583 lines
19 KiB
Python
583 lines
19 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
"""
|
|
Tests for cache factory, recovery manager, and hybrid cache configuration.
|
|
|
|
This module tests the factory pattern for creating cache instances,
|
|
recovery from cache corruption, and hybrid model cache configurations.
|
|
"""
|
|
|
|
from pathlib import Path
|
|
from typing import Any, Dict, List, Optional
|
|
from unittest.mock import MagicMock, patch, PropertyMock
|
|
|
|
import pytest
|
|
|
|
from omlx.cache.factory import CacheConfig, CacheFactory
|
|
from omlx.cache.recovery import CacheRecoveryManager
|
|
from omlx.cache.hybrid_cache import (
|
|
LayerCacheConfig,
|
|
ModelCacheConfig,
|
|
create_default_kvcache_config,
|
|
)
|
|
from omlx.cache.type_handlers import CacheType
|
|
|
|
|
|
class TestCacheConfig:
|
|
"""Tests for CacheConfig dataclass."""
|
|
|
|
def test_default_values(self):
|
|
"""Test default values."""
|
|
config = CacheConfig()
|
|
|
|
assert config.block_size == 64
|
|
assert config.max_num_blocks == 1024
|
|
assert config.initial_blocks == 256
|
|
assert config.paged_ssd_cache_dir is None
|
|
assert config.max_paged_ssd_cache_size == 100 * 1024 * 1024 * 1024 # 100GB
|
|
assert config.max_kv_cache_memory is None
|
|
assert config.model_name == ""
|
|
|
|
def test_with_custom_values(self):
|
|
"""Test with custom values."""
|
|
config = CacheConfig(
|
|
block_size=128,
|
|
max_num_blocks=2048,
|
|
initial_blocks=512,
|
|
paged_ssd_cache_dir=Path("/tmp/cache"),
|
|
max_paged_ssd_cache_size=50 * 1024**3,
|
|
max_kv_cache_memory=8 * 1024**3,
|
|
model_name="test-model",
|
|
)
|
|
|
|
assert config.block_size == 128
|
|
assert config.max_num_blocks == 2048
|
|
assert config.initial_blocks == 512
|
|
assert config.paged_ssd_cache_dir == Path("/tmp/cache")
|
|
assert config.max_paged_ssd_cache_size == 50 * 1024**3
|
|
assert config.max_kv_cache_memory == 8 * 1024**3
|
|
assert config.model_name == "test-model"
|
|
|
|
|
|
class TestCacheFactory:
|
|
"""Tests for CacheFactory."""
|
|
|
|
def test_create_paged_cache_disabled(self):
|
|
"""Test create_paged_cache returns None when SSD cache disabled."""
|
|
config = CacheConfig(paged_ssd_cache_dir=None)
|
|
|
|
result = CacheFactory.create_paged_cache(config)
|
|
|
|
assert result is None
|
|
|
|
def test_create_paged_cache_enabled(self, tmp_path: Path):
|
|
"""Test create_paged_cache creates manager when enabled."""
|
|
config = CacheConfig(
|
|
paged_ssd_cache_dir=tmp_path / "cache",
|
|
block_size=64,
|
|
max_num_blocks=100,
|
|
initial_blocks=50,
|
|
model_name="test-model",
|
|
)
|
|
|
|
result = CacheFactory.create_paged_cache(config)
|
|
|
|
assert result is not None
|
|
assert result.block_size == 64
|
|
assert result.max_blocks == 100
|
|
assert result.model_name == "test-model"
|
|
|
|
def test_create_paged_ssd_cache_disabled(self):
|
|
"""Test create_paged_ssd_cache returns None when disabled."""
|
|
config = CacheConfig(paged_ssd_cache_dir=None)
|
|
|
|
result = CacheFactory.create_paged_ssd_cache(config)
|
|
|
|
assert result is None
|
|
|
|
def test_create_paged_ssd_cache_enabled(self, tmp_path: Path):
|
|
"""Test create_paged_ssd_cache creates manager."""
|
|
config = CacheConfig(
|
|
paged_ssd_cache_dir=tmp_path / "cache",
|
|
max_paged_ssd_cache_size=1024**3,
|
|
)
|
|
|
|
result = CacheFactory.create_paged_ssd_cache(config, model_name="test-model")
|
|
|
|
assert result is not None
|
|
# Check that model_name is used in path
|
|
assert "test-model" in str(result._cache_dir)
|
|
|
|
def test_create_prefix_cache_disabled(self):
|
|
"""Test create_prefix_cache returns None when disabled."""
|
|
config = CacheConfig(paged_ssd_cache_dir=None)
|
|
|
|
result = CacheFactory.create_prefix_cache(config)
|
|
|
|
assert result is None
|
|
|
|
def test_create_prefix_cache_no_paged_cache(self, tmp_path: Path):
|
|
"""Test create_prefix_cache returns None without paged cache."""
|
|
config = CacheConfig(paged_ssd_cache_dir=tmp_path / "cache")
|
|
|
|
result = CacheFactory.create_prefix_cache(config, paged_cache=None)
|
|
|
|
assert result is None
|
|
|
|
def test_create_prefix_cache_enabled(self, tmp_path: Path):
|
|
"""Test create_prefix_cache creates cache."""
|
|
config = CacheConfig(paged_ssd_cache_dir=tmp_path / "cache")
|
|
|
|
paged_cache = CacheFactory.create_paged_cache(config)
|
|
mock_model = MagicMock()
|
|
mock_model.layers = [MagicMock() for _ in range(4)]
|
|
|
|
result = CacheFactory.create_prefix_cache(
|
|
config,
|
|
model=mock_model,
|
|
paged_cache=paged_cache,
|
|
)
|
|
|
|
assert result is not None
|
|
|
|
def test_create_memory_monitor(self, tmp_path: Path):
|
|
"""Test create_memory_monitor creates monitor."""
|
|
config = CacheConfig(
|
|
paged_ssd_cache_dir=tmp_path / "cache",
|
|
max_kv_cache_memory=8 * 1024**3,
|
|
)
|
|
|
|
paged_cache = CacheFactory.create_paged_cache(config)
|
|
|
|
result = CacheFactory.create_memory_monitor(config, paged_cache)
|
|
|
|
assert result is not None
|
|
|
|
def test_create_memory_monitor_default_memory(self, tmp_path: Path):
|
|
"""Test create_memory_monitor with default memory limit."""
|
|
config = CacheConfig(
|
|
paged_ssd_cache_dir=tmp_path / "cache",
|
|
max_kv_cache_memory=None, # Should use default
|
|
)
|
|
|
|
result = CacheFactory.create_memory_monitor(config)
|
|
|
|
assert result is not None
|
|
|
|
def test_create_full_cache_stack_disabled(self):
|
|
"""Test create_full_cache_stack returns Nones when disabled."""
|
|
config = CacheConfig(paged_ssd_cache_dir=None)
|
|
|
|
result = CacheFactory.create_full_cache_stack(config)
|
|
|
|
assert result["paged_cache"] is None
|
|
assert result["paged_ssd_cache"] is None
|
|
assert result["prefix_cache"] is None
|
|
assert result["memory_monitor"] is None
|
|
|
|
def test_create_full_cache_stack_enabled(self, tmp_path: Path):
|
|
"""Test create_full_cache_stack creates all components."""
|
|
config = CacheConfig(
|
|
paged_ssd_cache_dir=tmp_path / "cache",
|
|
model_name="test-model",
|
|
)
|
|
|
|
mock_model = MagicMock()
|
|
mock_model.layers = [MagicMock() for _ in range(4)]
|
|
|
|
result = CacheFactory.create_full_cache_stack(
|
|
config,
|
|
model=mock_model,
|
|
)
|
|
|
|
assert result["paged_cache"] is not None
|
|
assert result["paged_ssd_cache"] is not None
|
|
assert result["prefix_cache"] is not None
|
|
assert result["memory_monitor"] is not None
|
|
|
|
|
|
class TestCacheRecoveryManager:
|
|
"""Tests for CacheRecoveryManager."""
|
|
|
|
@pytest.fixture
|
|
def mock_prefix_cache(self):
|
|
"""Create a mock prefix cache."""
|
|
mock = MagicMock()
|
|
mock.clear.return_value = 10
|
|
return mock
|
|
|
|
@pytest.fixture
|
|
def recovery_manager(self, mock_prefix_cache):
|
|
"""Create a recovery manager."""
|
|
return CacheRecoveryManager(block_aware_cache=mock_prefix_cache)
|
|
|
|
def test_is_cache_corruption_shape_mismatch(self, recovery_manager):
|
|
"""Test detecting shape mismatch errors."""
|
|
error = ValueError("Shapes (1, 32, 64) and (1, 32, 128) do not match")
|
|
|
|
# This depends on is_cache_corruption_error implementation
|
|
# Just verify method exists and returns bool
|
|
result = recovery_manager.is_cache_corruption(error)
|
|
assert isinstance(result, bool)
|
|
|
|
def test_is_cache_corruption_dimension_error(self, recovery_manager):
|
|
"""Test detecting dimension errors."""
|
|
error = ValueError("Dimension mismatch in concatenate")
|
|
|
|
result = recovery_manager.is_cache_corruption(error)
|
|
assert isinstance(result, bool)
|
|
|
|
def test_recover(self, recovery_manager, mock_prefix_cache):
|
|
"""Test recovery process."""
|
|
# Setup mock objects
|
|
mock_holder = MagicMock()
|
|
mock_holder.batch_generator = MagicMock()
|
|
mock_holder._current_sampler_params = MagicMock()
|
|
|
|
request_id_to_uid = {"req-001": 1, "req-002": 2}
|
|
uid_to_request_id = {1: "req-001", 2: "req-002"}
|
|
request_detokenizers = {"req-001": MagicMock()}
|
|
|
|
# Perform recovery
|
|
recovery_manager.recover(
|
|
mock_holder,
|
|
request_id_to_uid,
|
|
uid_to_request_id,
|
|
request_detokenizers,
|
|
)
|
|
|
|
# Verify batch_generator cleared
|
|
assert mock_holder.batch_generator is None
|
|
assert mock_holder._current_sampler_params is None
|
|
|
|
# Verify cache cleared
|
|
mock_prefix_cache.clear.assert_called_once()
|
|
|
|
# Verify mappings cleared
|
|
assert len(request_id_to_uid) == 0
|
|
assert len(uid_to_request_id) == 0
|
|
assert len(request_detokenizers) == 0
|
|
|
|
def test_recover_without_cache(self):
|
|
"""Test recovery without prefix cache."""
|
|
manager = CacheRecoveryManager(block_aware_cache=None)
|
|
|
|
mock_holder = MagicMock()
|
|
request_id_to_uid = {"req-001": 1}
|
|
uid_to_request_id = {1: "req-001"}
|
|
request_detokenizers = {}
|
|
|
|
# Should not raise
|
|
manager.recover(
|
|
mock_holder,
|
|
request_id_to_uid,
|
|
uid_to_request_id,
|
|
request_detokenizers,
|
|
)
|
|
|
|
assert mock_holder.batch_generator is None
|
|
|
|
def test_reschedule_running_requests(self, recovery_manager):
|
|
"""Test rescheduling running requests."""
|
|
from collections import deque
|
|
|
|
# Create mock requests
|
|
mock_request1 = MagicMock()
|
|
mock_request1.prompt_token_ids = [1, 2, 3, 4]
|
|
mock_request2 = MagicMock()
|
|
mock_request2.prompt_token_ids = [5, 6, 7, 8]
|
|
|
|
running = {
|
|
"req-001": mock_request1,
|
|
"req-002": mock_request2,
|
|
}
|
|
waiting = deque()
|
|
mock_waiting_status = MagicMock()
|
|
|
|
count = recovery_manager.reschedule_running_requests(
|
|
running,
|
|
waiting,
|
|
mock_waiting_status,
|
|
)
|
|
|
|
assert count == 2
|
|
assert len(running) == 0
|
|
assert len(waiting) == 2
|
|
|
|
# Verify requests were reset
|
|
assert mock_request1.status is mock_waiting_status
|
|
assert mock_request1.batch_uid is None
|
|
assert mock_request1.prompt_cache is None
|
|
|
|
def test_reschedule_empty_running(self, recovery_manager):
|
|
"""Test rescheduling with no running requests."""
|
|
from collections import deque
|
|
|
|
running = {}
|
|
waiting = deque()
|
|
mock_waiting_status = MagicMock()
|
|
|
|
count = recovery_manager.reschedule_running_requests(
|
|
running,
|
|
waiting,
|
|
mock_waiting_status,
|
|
)
|
|
|
|
assert count == 0
|
|
|
|
|
|
class TestLayerCacheConfig:
|
|
"""Tests for LayerCacheConfig."""
|
|
|
|
def test_creation(self):
|
|
"""Test creating layer config."""
|
|
config = LayerCacheConfig(
|
|
layer_idx=5,
|
|
cache_type=CacheType.KVCACHE,
|
|
supports_block_slicing=True,
|
|
class_name="KVCache",
|
|
)
|
|
|
|
assert config.layer_idx == 5
|
|
assert config.cache_type == CacheType.KVCACHE
|
|
assert config.supports_block_slicing is True
|
|
assert config.class_name == "KVCache"
|
|
|
|
def test_handler_property(self):
|
|
"""Test handler property returns correct handler."""
|
|
config = LayerCacheConfig(
|
|
layer_idx=0,
|
|
cache_type=CacheType.KVCACHE,
|
|
supports_block_slicing=True,
|
|
class_name="KVCache",
|
|
)
|
|
|
|
handler = config.handler
|
|
|
|
assert handler is not None
|
|
assert handler.cache_type == CacheType.KVCACHE
|
|
|
|
def test_arrays_cache_layer(self):
|
|
"""Test config for ArraysCache layer."""
|
|
config = LayerCacheConfig(
|
|
layer_idx=1,
|
|
cache_type=CacheType.ARRAYS_CACHE,
|
|
supports_block_slicing=False,
|
|
class_name="ArraysCache",
|
|
)
|
|
|
|
assert config.supports_block_slicing is False
|
|
assert config.handler.cache_type == CacheType.ARRAYS_CACHE
|
|
|
|
|
|
class TestModelCacheConfig:
|
|
"""Tests for ModelCacheConfig."""
|
|
|
|
def test_default_values(self):
|
|
"""Test default values."""
|
|
config = ModelCacheConfig()
|
|
|
|
assert config.model_name == ""
|
|
assert config.num_layers == 0
|
|
assert config.layer_configs == []
|
|
assert config.is_hybrid is False
|
|
assert config.sliceable_layer_count == 0
|
|
|
|
def test_from_cache_list_empty(self):
|
|
"""Test from_cache_list with empty list."""
|
|
config = ModelCacheConfig.from_cache_list([], model_name="test")
|
|
|
|
assert config.model_name == "test"
|
|
assert config.num_layers == 0
|
|
|
|
def test_from_cache_list_kvcache_only(self):
|
|
"""Test from_cache_list with KVCache only."""
|
|
mock_caches = []
|
|
for i in range(4):
|
|
mock = MagicMock()
|
|
mock.__class__.__name__ = "KVCache"
|
|
mock_caches.append(mock)
|
|
|
|
config = ModelCacheConfig.from_cache_list(mock_caches, model_name="test-model")
|
|
|
|
assert config.num_layers == 4
|
|
assert config.is_hybrid is False
|
|
assert config.sliceable_layer_count == 4
|
|
|
|
def test_from_cache_list_hybrid(self):
|
|
"""Test from_cache_list with mixed cache types."""
|
|
mock_caches = []
|
|
|
|
# KVCache layer
|
|
mock_kv = MagicMock()
|
|
mock_kv.__class__.__name__ = "KVCache"
|
|
mock_caches.append(mock_kv)
|
|
|
|
# ArraysCache layer
|
|
mock_arrays = MagicMock()
|
|
mock_arrays.__class__.__name__ = "ArraysCache"
|
|
mock_arrays.cache = [MagicMock(), MagicMock()]
|
|
mock_caches.append(mock_arrays)
|
|
|
|
config = ModelCacheConfig.from_cache_list(mock_caches)
|
|
|
|
assert config.num_layers == 2
|
|
assert config.is_hybrid is True
|
|
assert config.sliceable_layer_count == 1
|
|
|
|
def test_from_type_list(self):
|
|
"""Test from_type_list."""
|
|
type_names = ["KVCache", "KVCache", "ArraysCache", "KVCache"]
|
|
|
|
config = ModelCacheConfig.from_type_list(type_names, model_name="test")
|
|
|
|
assert config.num_layers == 4
|
|
assert config.is_hybrid is True
|
|
assert config.sliceable_layer_count == 3
|
|
|
|
def test_from_type_list_empty(self):
|
|
"""Test from_type_list with empty list."""
|
|
config = ModelCacheConfig.from_type_list([])
|
|
|
|
assert config.num_layers == 0
|
|
|
|
def test_get_sliceable_layers(self):
|
|
"""Test get_sliceable_layers."""
|
|
type_names = ["KVCache", "ArraysCache", "KVCache", "RotatingKVCache"]
|
|
config = ModelCacheConfig.from_type_list(type_names)
|
|
|
|
sliceable = config.get_sliceable_layers()
|
|
|
|
# KVCache layers only (indices 0 and 2)
|
|
assert 0 in sliceable
|
|
assert 2 in sliceable
|
|
assert 1 not in sliceable # ArraysCache
|
|
assert 3 not in sliceable # RotatingKVCache
|
|
|
|
def test_get_non_sliceable_layers(self):
|
|
"""Test get_non_sliceable_layers."""
|
|
type_names = ["KVCache", "ArraysCache", "KVCache", "ArraysCache"]
|
|
config = ModelCacheConfig.from_type_list(type_names)
|
|
|
|
non_sliceable = config.get_non_sliceable_layers()
|
|
|
|
assert 1 in non_sliceable
|
|
assert 3 in non_sliceable
|
|
assert len(non_sliceable) == 2
|
|
|
|
def test_get_layer_type(self):
|
|
"""Test get_layer_type."""
|
|
type_names = ["KVCache", "ArraysCache"]
|
|
config = ModelCacheConfig.from_type_list(type_names)
|
|
|
|
assert config.get_layer_type(0) == CacheType.KVCACHE
|
|
assert config.get_layer_type(1) == CacheType.ARRAYS_CACHE
|
|
# Out of bounds returns default
|
|
assert config.get_layer_type(99) == CacheType.KVCACHE
|
|
|
|
def test_get_layer_handler(self):
|
|
"""Test get_layer_handler."""
|
|
type_names = ["KVCache", "ArraysCache"]
|
|
config = ModelCacheConfig.from_type_list(type_names)
|
|
|
|
handler0 = config.get_layer_handler(0)
|
|
handler1 = config.get_layer_handler(1)
|
|
|
|
assert handler0.cache_type == CacheType.KVCACHE
|
|
assert handler1.cache_type == CacheType.ARRAYS_CACHE
|
|
|
|
def test_get_type_names(self):
|
|
"""Test get_type_names."""
|
|
type_names = ["KVCache", "ArraysCache", "RotatingKVCache"]
|
|
config = ModelCacheConfig.from_type_list(type_names)
|
|
|
|
names = config.get_type_names()
|
|
|
|
assert names == ["KVCache", "ArraysCache", "RotatingKVCache"]
|
|
|
|
def test_get_meta_states(self):
|
|
"""Test get_meta_states."""
|
|
mock_caches = []
|
|
for i in range(2):
|
|
mock = MagicMock()
|
|
mock.__class__.__name__ = "KVCache"
|
|
mock_keys = MagicMock()
|
|
mock_keys.shape = (1, 8, 64, 64)
|
|
mock.state = (mock_keys, MagicMock())
|
|
mock.offset = 64 + i * 10
|
|
mock_caches.append(mock)
|
|
|
|
config = ModelCacheConfig.from_cache_list(mock_caches)
|
|
meta_states = config.get_meta_states(mock_caches)
|
|
|
|
assert len(meta_states) == 2
|
|
# Each should have at least offset
|
|
for meta in meta_states:
|
|
assert len(meta) > 0
|
|
|
|
def test_supports_full_block_slicing(self):
|
|
"""Test supports_full_block_slicing."""
|
|
# All KVCache
|
|
config1 = ModelCacheConfig.from_type_list(["KVCache", "KVCache"])
|
|
assert config1.supports_full_block_slicing() is True
|
|
|
|
# Mixed
|
|
config2 = ModelCacheConfig.from_type_list(["KVCache", "ArraysCache"])
|
|
assert config2.supports_full_block_slicing() is False
|
|
|
|
def test_has_rotating_layers(self):
|
|
"""Test has_rotating_layers."""
|
|
config1 = ModelCacheConfig.from_type_list(["KVCache", "KVCache"])
|
|
assert config1.has_rotating_layers() is False
|
|
|
|
config2 = ModelCacheConfig.from_type_list(["KVCache", "RotatingKVCache"])
|
|
assert config2.has_rotating_layers() is True
|
|
|
|
def test_repr(self):
|
|
"""Test string representation."""
|
|
config = ModelCacheConfig.from_type_list(
|
|
["KVCache", "ArraysCache", "KVCache"],
|
|
model_name="test-model",
|
|
)
|
|
|
|
repr_str = repr(config)
|
|
|
|
assert "test-model" in repr_str
|
|
assert "layers=3" in repr_str
|
|
assert "hybrid=True" in repr_str
|
|
|
|
|
|
class TestCreateDefaultKVCacheConfig:
|
|
"""Tests for create_default_kvcache_config helper."""
|
|
|
|
def test_creates_all_kvcache(self):
|
|
"""Test creating all-KVCache config."""
|
|
config = create_default_kvcache_config(num_layers=32, model_name="llama")
|
|
|
|
assert config.num_layers == 32
|
|
assert config.model_name == "llama"
|
|
assert config.is_hybrid is False
|
|
assert config.sliceable_layer_count == 32
|
|
|
|
def test_all_layers_kvcache(self):
|
|
"""Test all layers are KVCache type."""
|
|
config = create_default_kvcache_config(num_layers=4)
|
|
|
|
for layer_config in config.layer_configs:
|
|
assert layer_config.cache_type == CacheType.KVCACHE
|
|
assert layer_config.supports_block_slicing is True
|
|
assert layer_config.class_name == "KVCache"
|
|
|
|
def test_layer_indices_correct(self):
|
|
"""Test layer indices are sequential."""
|
|
config = create_default_kvcache_config(num_layers=4)
|
|
|
|
for i, layer_config in enumerate(config.layer_configs):
|
|
assert layer_config.layer_idx == i
|
|
|
|
def test_zero_layers(self):
|
|
"""Test with zero layers."""
|
|
config = create_default_kvcache_config(num_layers=0)
|
|
|
|
assert config.num_layers == 0
|
|
assert len(config.layer_configs) == 0
|