# SPDX-License-Identifier: Apache-2.0 """Tests for VisionFeatureSSDCache (memory LRU + SSD persistence).""" import time from types import SimpleNamespace from unittest.mock import MagicMock import mlx.core as mx import pytest from omlx.cache.vision_feature_cache import ( VisionFeatureSSDCache, _composite_hash, _composite_key, ) @pytest.fixture def tmp_cache_dir(tmp_path): """Provide a temporary directory for SSD cache tests.""" return tmp_path / "vision_cache" @pytest.fixture def memory_only_cache(): """Create a memory-only cache (no SSD).""" cache = VisionFeatureSSDCache(cache_dir=None, max_memory_entries=3) yield cache cache.close() @pytest.fixture def ssd_cache(tmp_cache_dir): """Create a cache with SSD persistence.""" cache = VisionFeatureSSDCache( cache_dir=tmp_cache_dir, max_size_bytes=10 * 1024 * 1024, # 10MB for testing max_memory_entries=3, ) yield cache cache.close() class TestCompositeKey: def test_composite_key_format(self): key = _composite_key("model-a", "hash123") assert key == "model-a:hash123" def test_composite_hash_deterministic(self): h1 = _composite_hash("model", "abc") h2 = _composite_hash("model", "abc") assert h1 == h2 def test_composite_hash_differs_for_different_models(self): h1 = _composite_hash("model-a", "same_hash") h2 = _composite_hash("model-b", "same_hash") assert h1 != h2 class TestMemoryCache: def test_put_get(self, memory_only_cache): features = mx.ones((4, 8)) memory_only_cache.put("img_hash", "model_a", features) result = memory_only_cache.get("img_hash", "model_a") assert result is not None assert mx.array_equal(result, features) def test_miss_returns_none(self, memory_only_cache): result = memory_only_cache.get("nonexistent", "model") assert result is None def test_lru_eviction(self, memory_only_cache): # max_memory_entries=3, insert 4 → first should be evicted for i in range(4): memory_only_cache.put(f"img_{i}", "model", mx.ones((2, 2)) * i) # img_0 should be evicted assert memory_only_cache.get("img_0", "model") is None # img_1, img_2, img_3 should remain assert memory_only_cache.get("img_1", "model") is not None assert memory_only_cache.get("img_2", "model") is not None assert memory_only_cache.get("img_3", "model") is not None def test_lru_access_refreshes(self, memory_only_cache): # Insert 3 items for i in range(3): memory_only_cache.put(f"img_{i}", "model", mx.ones((2, 2)) * i) # Access img_0 to refresh it memory_only_cache.get("img_0", "model") # Insert 1 more → img_1 should be evicted (oldest non-accessed) memory_only_cache.put("img_3", "model", mx.ones((2, 2)) * 3) assert memory_only_cache.get("img_0", "model") is not None # refreshed assert memory_only_cache.get("img_1", "model") is None # evicted assert memory_only_cache.get("img_2", "model") is not None assert memory_only_cache.get("img_3", "model") is not None def test_composite_key_isolation(self, memory_only_cache): features_a = mx.ones((2, 2)) * 1 features_b = mx.ones((2, 2)) * 2 memory_only_cache.put("same_hash", "model_a", features_a) memory_only_cache.put("same_hash", "model_b", features_b) result_a = memory_only_cache.get("same_hash", "model_a") result_b = memory_only_cache.get("same_hash", "model_b") assert mx.array_equal(result_a, features_a) assert mx.array_equal(result_b, features_b) def test_overwrite_same_key(self, memory_only_cache): memory_only_cache.put("img", "model", mx.ones((2, 2))) memory_only_cache.put("img", "model", mx.zeros((2, 2))) result = memory_only_cache.get("img", "model") assert mx.array_equal(result, mx.zeros((2, 2))) def test_stats_tracking(self, memory_only_cache): memory_only_cache.put("img", "model", mx.ones((2, 2))) memory_only_cache.get("img", "model") # hit memory_only_cache.get("missing", "model") # miss stats = memory_only_cache.stats assert stats["saves"] == 1 assert stats["hits"] == 1 assert stats["misses"] == 1 def test_close_clears_memory_lru(self): cache = VisionFeatureSSDCache(cache_dir=None, max_memory_entries=3) cache.put("img", "model", mx.ones((2, 2))) with cache._memory_lock: assert cache._memory_cache cache.close() with cache._memory_lock: assert cache._memory_cache == {} class TestSSDCache: def test_ssd_write_and_load(self, ssd_cache): features = mx.random.normal((10, 16)) mx.eval(features) ssd_cache.put("img_hash", "model_a", features) # Wait for background writer time.sleep(0.5) # Clear memory cache to force SSD read with ssd_cache._memory_lock: ssd_cache._memory_cache.clear() result = ssd_cache.get("img_hash", "model_a") assert result is not None assert mx.allclose(result, features, atol=1e-5) def test_ssd_file_exists(self, ssd_cache, tmp_cache_dir): features = mx.ones((4, 8)) mx.eval(features) ssd_cache.put("img_hash", "model_a", features) time.sleep(0.5) # Check safetensors file exists safetensors_files = list(tmp_cache_dir.rglob("*.safetensors")) assert len(safetensors_files) == 1 def test_ssd_startup_scan(self, tmp_cache_dir): # Phase 1: create cache and store features cache1 = VisionFeatureSSDCache(cache_dir=tmp_cache_dir, max_memory_entries=3) features = mx.ones((4, 8)) mx.eval(features) cache1.put("img_hash", "model_a", features) time.sleep(0.5) cache1.close() # Phase 2: create new cache instance — should scan existing files cache2 = VisionFeatureSSDCache(cache_dir=tmp_cache_dir, max_memory_entries=3) # Memory cache is empty, but SSD index should have the entry result = cache2.get("img_hash", "model_a") assert result is not None assert mx.allclose(result, features, atol=1e-5) cache2.close() def test_ssd_eviction(self, tmp_cache_dir): # Very small max_size to trigger eviction cache = VisionFeatureSSDCache( cache_dir=tmp_cache_dir, max_size_bytes=100, # 100 bytes — any real tensor will exceed this max_memory_entries=10, ) # Store multiple features that exceed max_size for i in range(3): f = mx.ones((4, 8)) * i mx.eval(f) cache.put(f"img_{i}", "model", f) time.sleep(0.5) # SSD index should have evicted older entries assert cache._ssd_total_size <= 100 or len(cache._ssd_index) <= 1 cache.close() def test_corrupted_file_recovery(self, ssd_cache, tmp_cache_dir): features = mx.ones((4, 8)) mx.eval(features) ssd_cache.put("img_hash", "model_a", features) time.sleep(0.5) # Clear memory cache with ssd_cache._memory_lock: ssd_cache._memory_cache.clear() # Corrupt the file safetensors_files = list(tmp_cache_dir.rglob("*.safetensors")) assert len(safetensors_files) == 1 with open(safetensors_files[0], "wb") as f: f.write(b"corrupted data") # Should return None and remove from index result = ssd_cache.get("img_hash", "model_a") assert result is None def test_close_flushes_writes(self, tmp_cache_dir): cache = VisionFeatureSSDCache(cache_dir=tmp_cache_dir, max_memory_entries=3) features = mx.ones((4, 8)) mx.eval(features) cache.put("img_hash", "model_a", features) # Close immediately — should flush pending writes cache.close() # Verify file was written safetensors_files = list(tmp_cache_dir.rglob("*.safetensors")) assert len(safetensors_files) == 1 def test_memory_only_mode_no_ssd(self, memory_only_cache): features = mx.ones((4, 8)) memory_only_cache.put("img", "model", features) result = memory_only_cache.get("img", "model") assert result is not None assert mx.array_equal(result, features) # No SSD directory should exist assert memory_only_cache._cache_dir is None class TestMultiTensorFeatures: def test_multi_tensor_put_get_memory(self, memory_only_cache): features = [mx.ones((2, 4)), mx.ones((3, 4)) * 2] memory_only_cache.put("multi_img", "model", features) result = memory_only_cache.get("multi_img", "model") assert isinstance(result, list) assert len(result) == 2 assert mx.array_equal(result[0], features[0]) assert mx.array_equal(result[1], features[1]) def test_multi_tensor_ssd_roundtrip(self, ssd_cache): features = [mx.ones((2, 4)), mx.ones((3, 4)) * 2] for f in features: mx.eval(f) ssd_cache.put("multi_img", "model", features) time.sleep(0.5) # Clear memory to force SSD load with ssd_cache._memory_lock: ssd_cache._memory_cache.clear() result = ssd_cache.get("multi_img", "model") assert isinstance(result, list) assert len(result) == 2 assert mx.allclose(result[0], features[0], atol=1e-5) assert mx.allclose(result[1], features[1], atol=1e-5) class TestVLMEngineIntegration: """Integration tests for vision cache in VLMBatchedEngine using mocks.""" def test_compute_vision_features_encode_image(self): """Model with encode_image should receive image_position_ids when available.""" from omlx.engine.vlm import VLMBatchedEngine engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = MagicMock() engine._vlm_model.config.model_type = "gemma4" expected = mx.ones((10, 16)) engine._vlm_model.encode_image.return_value = expected pixel_values = mx.zeros((1, 3, 224, 224)) image_position_ids = mx.zeros((1, 10, 2)) result = engine._compute_vision_features( pixel_values, {"image_position_ids": image_position_ids} ) assert result is expected engine._vlm_model.encode_image.assert_called_once_with( pixel_values, image_position_ids=image_position_ids ) def test_compute_vision_features_encode_image_with_grid_thw(self): """MiniMax-style encode_image should receive image_grid_thw.""" from omlx.engine.vlm import VLMBatchedEngine expected = mx.ones((10, 16)) class GridModel: config = SimpleNamespace(model_type="minimax_m3_vl") def __init__(self): self.calls = [] def encode_image(self, pixel_values, image_grid_thw=None): self.calls.append((pixel_values, image_grid_thw)) if image_grid_thw is None: raise ValueError("image_grid_thw required") return expected engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = GridModel() pixel_values = mx.zeros((1, 3, 224, 224)) image_grid_thw = mx.array([[1, 4, 4]]) result = engine._compute_vision_features( pixel_values, {"image_grid_thw": image_grid_thw} ) assert result is expected assert engine._vlm_model.calls == [(pixel_values, image_grid_thw)] def test_compute_vision_features_encode_image_without_position_support(self): """Models with a pixel-only encode_image signature should still work.""" from omlx.engine.vlm import VLMBatchedEngine expected = mx.ones((10, 16)) class PixelOnlyModel: config = SimpleNamespace(model_type="pixel_only") def __init__(self): self.calls = [] def encode_image(self, pixel_values): self.calls.append(pixel_values) return expected engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = PixelOnlyModel() pixel_values = mx.zeros((1, 3, 224, 224)) result = engine._compute_vision_features( pixel_values, {"image_position_ids": mx.zeros((1, 10, 2))} ) assert result is expected assert engine._vlm_model.calls == [pixel_values] def test_compute_vision_features_qwen_style(self): """Qwen-style model should call vision_tower(pv, grid_thw) directly.""" from omlx.engine.vlm import VLMBatchedEngine engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = MagicMock( spec=[ "vision_tower", "config", ] ) engine._vlm_model.config.model_type = "qwen3_5_moe" expected = mx.ones((10, 16)) engine._vlm_model.vision_tower.return_value = (expected, None) engine._vlm_model.vision_tower.patch_embed.proj.weight.dtype = mx.float16 pixel_values = mx.zeros((1, 3, 224, 224)) grid_thw = mx.array([[1, 14, 14]]) result = engine._compute_vision_features( pixel_values, {"image_grid_thw": grid_thw} ) assert result is expected engine._vlm_model.vision_tower.assert_called_once() def test_compute_vision_features_unsupported(self): """Unsupported model should return None.""" from omlx.engine.vlm import VLMBatchedEngine engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = MagicMock(spec=["config"]) engine._vlm_model.config.model_type = "deepseekocr_2" result = engine._compute_vision_features(mx.zeros((1, 3, 224, 224)), {}) assert result is None def test_compute_vision_features_qwen_no_grid_thw(self): """Qwen model without grid_thw in extras should return None.""" from omlx.engine.vlm import VLMBatchedEngine engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = MagicMock(spec=["vision_tower", "config"]) engine._vlm_model.config.model_type = "qwen2_vl" result = engine._compute_vision_features(mx.zeros((1, 3, 224, 224)), {}) assert result is None def test_compute_vision_features_llava_style(self): """LLaVA model should use vision_tower → select → projector.""" from omlx.engine.vlm import VLMBatchedEngine engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = MagicMock( spec=[ "vision_tower", "multi_modal_projector", "vision_feature_layer", "vision_feature_select_strategy", "config", ] ) engine._vlm_model.config.model_type = "llava" engine._vlm_model.vision_feature_layer = -2 engine._vlm_model.vision_feature_select_strategy = "default" # vision_tower returns (_, _, hidden_states) hidden_state = mx.ones((1, 257, 1024)) # 256 patches + 1 CLS engine._vlm_model.vision_tower.return_value = ( None, None, [ mx.zeros((1, 257, 1024)), # layer -3 hidden_state, # layer -2 (selected) mx.zeros((1, 257, 1024)), # layer -1 ], ) projected = mx.ones((1, 256, 4096)) engine._vlm_model.multi_modal_projector.return_value = projected pixel_values = mx.zeros((1, 3, 336, 336)) result = engine._compute_vision_features(pixel_values, {}) assert result is projected engine._vlm_model.vision_tower.assert_called_once() engine._vlm_model.multi_modal_projector.assert_called_once() def test_split_vision_features_with_soft_token_counts(self): """Flat compacted features should split by num_soft_tokens_per_image.""" from omlx.engine.vlm import VLMBatchedEngine engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = MagicMock() engine._vlm_model.config.model_type = "gemma4_unified" features = mx.array(list(range(20))).reshape(5, 4) result = engine._split_vision_features( features, 2, {"num_soft_tokens_per_image": [2, 3]}, ) assert result is not None assert len(result) == 2 assert result[0].shape == (2, 4) assert result[1].shape == (3, 4) assert mx.array_equal(result[0], features[:2]) assert mx.array_equal(result[1], features[2:]) def test_split_vision_features_rejects_bad_soft_token_total(self): """Mismatched soft-token totals should fall back to whole-request cache.""" from omlx.engine.vlm import VLMBatchedEngine engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = MagicMock() engine._vlm_model.config.model_type = "gemma4_unified" result = engine._split_vision_features( mx.ones((5, 4)), 2, {"num_soft_tokens_per_image": [2, 2]}, ) assert result is None def test_vision_features_match_image_tokens(self): """Cached features should be ignored when token counts do not match.""" from omlx.engine.vlm import VLMBatchedEngine engine = VLMBatchedEngine.__new__(VLMBatchedEngine) engine._vlm_model = MagicMock() engine._vlm_model.config.image_token_id = 42 input_ids = mx.array([[1, 42, 2, 42, 3]]) image_token_count = engine._image_token_count(input_ids) assert image_token_count == 2 assert engine._vision_features_match_image_tokens( mx.ones((2, 8)), image_token_count ) assert engine._vision_features_match_image_tokens( mx.ones((1, 2, 8)), image_token_count ) assert not engine._vision_features_match_image_tokens( mx.ones((3, 8)), image_token_count ) def test_language_prompt_kwargs_preserves_token_type_ids(self): """Gemma4 unified needs multimodal token types during language prefill.""" from omlx.engine.vlm import VLMBatchedEngine mm_token_type_ids = mx.array([[0, 1, 1, 0]]) token_type_ids = mx.array([[0, 1, 1, 0]]) result = VLMBatchedEngine._language_prompt_kwargs( { "mm_token_type_ids": mm_token_type_ids, "token_type_ids": token_type_ids, "image_position_ids": mx.zeros((1, 2, 2)), "num_soft_tokens_per_image": [2], "ignored_none": None, } ) assert result == { "mm_token_type_ids": mm_token_type_ids, "token_type_ids": token_type_ids, }