# SPDX-License-Identifier: Apache-2.0 """ Unit tests for TurboQuant serde skeleton. """ # Standard from pathlib import Path from typing import cast import shutil import tempfile import time # Third Party import pytest import torch # First Party from lmcache.native_storage_ops import Bitmap from lmcache.v1.distributed.api import MemoryLayoutDesc, ObjectKey from lmcache.v1.distributed.config import ( EvictionConfig, L1ManagerConfig, L1MemoryManagerConfig, StorageManagerConfig, ) from lmcache.v1.distributed.l2_adapters.config import L2AdaptersConfig from lmcache.v1.distributed.l2_adapters.fs_l2_adapter import FSL2AdapterConfig from lmcache.v1.distributed.l2_adapters.mock_l2_adapter import MockL2AdapterConfig from lmcache.v1.distributed.serde import ( SerdeConfig, create_serde_processor, get_registered_serde_types, ) from lmcache.v1.distributed.serde.turboquant import ( TurboQuantSerdeConfig, TurboQuantSerializer, ) from lmcache.v1.distributed.storage_manager import StorageManager from lmcache.v1.memory_management import MemoryObj def test_turboquant_registered() -> None: assert "turboquant" in get_registered_serde_types() def test_create_turboquant_serde_processor() -> None: processor = create_serde_processor( SerdeConfig( type="turboquant", kwargs={ "preset": "turboquant_k8v4", "head_dim": 128, "block_size": 16, "max_workers": 1, }, ) ) processor.close() @pytest.mark.parametrize( ( "preset", "key_fp8", "key_quant_bits", "key_mse_bits", "value_quant_bits", "norm_correction", "key_packed_size", "value_packed_size", "slot_size", "slot_size_aligned", ), [ ("turboquant_k8v4", True, 8, 0, 4, False, 128, 68, 196, 196), ("turboquant_4bit_nc", False, 4, 4, 4, True, 66, 68, 134, 134), ("turboquant_k3v4_nc", False, 3, 3, 4, True, 50, 68, 118, 118), ("turboquant_3bit_nc", False, 3, 3, 3, True, 50, 52, 102, 102), ], ) def test_turboquant_config_sizes_head_dim_128( preset: str, key_fp8: bool, key_quant_bits: int, key_mse_bits: int, value_quant_bits: int, norm_correction: bool, key_packed_size: int, value_packed_size: int, slot_size: int, slot_size_aligned: int, ) -> None: cfg = TurboQuantSerdeConfig( preset=preset, head_dim=128, block_size=16, ) assert cfg.key_fp8 is key_fp8 assert cfg.key_quant_bits == key_quant_bits assert cfg.key_mse_bits == key_mse_bits assert cfg.value_quant_bits == value_quant_bits assert cfg.norm_correction is norm_correction assert cfg.key_packed_size == key_packed_size assert cfg.value_packed_size == value_packed_size assert cfg.slot_size == slot_size assert cfg.slot_size_aligned == slot_size_aligned def test_turboquant_config_rejects_invalid_preset() -> None: cfg = TurboQuantSerdeConfig( preset="turboquant_invalid", head_dim=128, block_size=16, ) with pytest.raises(ValueError, match="Unsupported TurboQuant preset"): _ = cfg.key_quant_bits def test_estimate_serialized_size_k8v4() -> None: cfg = TurboQuantSerdeConfig( preset="turboquant_k8v4", head_dim=128, block_size=16, ) serializer = TurboQuantSerializer(cfg) # LMCache KV layout: [2, num_layers, num_tokens, hidden_dim] # hidden_dim = num_heads * head_dim = 4 * 128 = 512 layout = MemoryLayoutDesc( shapes=[torch.Size([2, 3, 20, 512])], dtypes=[torch.bfloat16], ) # num_layers = 3 # default skip_first_layers=2 and skip_last_layers=2 leaves no middle # layers to compress, so all layers are stored as raw bfloat16 KV. expected = 2 * 3 * 20 * 512 * torch.bfloat16.itemsize assert serializer.estimate_serialized_size(layout) == expected def test_estimate_serialized_size_rejects_invalid_kv_size() -> None: cfg = TurboQuantSerdeConfig( preset="turboquant_k8v4", head_dim=128, block_size=16, ) serializer = TurboQuantSerializer(cfg) layout = MemoryLayoutDesc( shapes=[torch.Size([1, 3, 20, 512])], dtypes=[torch.bfloat16], ) with pytest.raises(ValueError, match="kv_size=2"): serializer.estimate_serialized_size(layout) def test_estimate_serialized_size_rejects_bad_head_dim() -> None: cfg = TurboQuantSerdeConfig( preset="turboquant_k8v4", head_dim=128, block_size=16, ) serializer = TurboQuantSerializer(cfg) layout = MemoryLayoutDesc( shapes=[torch.Size([2, 3, 20, 500])], dtypes=[torch.bfloat16], ) with pytest.raises(ValueError, match="must be divisible"): serializer.estimate_serialized_size(layout) # ============================================================================= # GPU E2E test: StorageManager + SerdeL2AdapterWrapper + MockL2Adapter # ============================================================================= def _make_turboquant_object_key(chunk_id: int) -> ObjectKey: return ObjectKey( chunk_hash=ObjectKey.IntHash2Bytes(chunk_id), model_name="turboquant_test_model", kv_rank=0, ) def _make_turboquant_layout() -> MemoryLayoutDesc: # TurboQuant serde currently expects [2, num_layers, num_tokens, hidden_dim]. # hidden_dim = num_heads * head_dim = 4 * 128 = 512. return MemoryLayoutDesc( shapes=[torch.Size([2, 2, 32, 512])], dtypes=[torch.bfloat16], ) def _wait_for_condition( predicate, timeout: float = 20.0, poll_interval: float = 0.05, ) -> bool: deadline = time.monotonic() + timeout while time.monotonic() < deadline: if predicate(): return True time.sleep(poll_interval) return False def _wait_for_prefetch_status( sm: StorageManager, handle, timeout: float = 20.0, poll_interval: float = 0.05, ) -> Bitmap | None: deadline = time.monotonic() + timeout while time.monotonic() < deadline: result = sm.query_prefetch_status(handle) if result is not None: return result time.sleep(poll_interval) return None def _finish_read_prefetched_until_clean( sm: StorageManager, keys: list[ObjectKey], timeout: float = 30.0, ) -> None: """Release prefetched temporary objects and wait for L1 cleanup. StorageManager / serde wrapper paths may hold more than one read lock on temporary prefetched objects. Release repeatedly until L1 is clean. """ for _ in range(4): sm.finish_read_prefetched(keys) ok = _wait_for_condition( lambda: ( sm.report_status()["l1_manager"]["memory_used_bytes"] == 0 and sm.report_status()["l1_manager"]["total_object_count"] == 0 and sm.report_status()["l1_manager"]["read_locked_count"] == 0 and sm.report_status()["l1_manager"]["write_locked_count"] == 0 and sm.report_status()["l1_manager"]["temporary_count"] == 0 ), timeout=timeout, ) if ok: return raise AssertionError(f"L1 memory not released: {sm.report_status()['l1_manager']}") def _make_turboquant_storage_manager(preset: str) -> StorageManager: adapter_cfg = MockL2AdapterConfig( max_size_gb=0.1, mock_bandwidth_gb=10.0, ) adapter_cfg.serde_config = SerdeConfig( type="turboquant", kwargs={ "preset": preset, "head_dim": 128, "block_size": 16, "max_workers": 1, }, ) cfg = StorageManagerConfig( l1_manager_config=L1ManagerConfig( memory_config=L1MemoryManagerConfig( size_in_bytes=256 * 1024 * 1024, use_lazy=torch.cuda.is_available(), init_size_in_bytes=64 * 1024 * 1024, ), ), eviction_config=EvictionConfig(eviction_policy="LRU"), l2_adapter_config=L2AdaptersConfig(adapters=[adapter_cfg]), ) return StorageManager(cfg) @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") @pytest.mark.parametrize( ("preset", "corr_lower_bound"), [ ("turboquant_k8v4", 0.95), ("turboquant_4bit_nc", 0.90), ("turboquant_k3v4_nc", 0.85), ("turboquant_3bit_nc", 0.80), ], ) def test_turboquant_storage_manager_roundtrip( preset: str, corr_lower_bound: float, ) -> None: """Store/load through StorageManager with TurboQuant serde. This verifies: reserve_write -> finish_write -> StoreController -> SerdeL2AdapterWrapper serialize -> MockL2Adapter store -> clear L1 -> prefetch -> MockL2Adapter load -> SerdeL2AdapterWrapper deserialize -> read_prefetched_results. """ sm = _make_turboquant_storage_manager(preset) layout = _make_turboquant_layout() keys = [_make_turboquant_object_key(i) for i in range(3)] try: ret = sm.reserve_write(keys, layout, mode="new") assert len(ret) == len(keys), f"reserve_write got {len(ret)} / {len(keys)}" original_by_key = {} for i, key in enumerate(keys): obj = ret[key] assert obj.tensor is not None torch.manual_seed(1234 + i) data = torch.randn( obj.tensor.shape, dtype=obj.tensor.dtype, device=obj.tensor.device, ) data = data + float(i) obj.tensor.copy_(data) original_by_key[key] = data.detach().clone() sm.finish_write(list(ret.keys())) # Wait until both the store controller and the serde wrapper cleanup # have released temporary objects and locks. ok = _wait_for_condition( lambda: ( sm.report_status()["store_controller"]["in_flight_task_count"] == 0 and sm.report_status()["store_controller"]["pending_keys_count"] == 0 and sm.report_status()["l1_manager"]["write_locked_count"] == 0 and sm.report_status()["l1_manager"]["read_locked_count"] == 0 and sm.report_status()["l1_manager"]["temporary_count"] == 0 ), timeout=120.0, ) assert ok, "Store to L2 did not fully complete" sm.clear() ok = _wait_for_condition( lambda: ( sm.report_status()["l1_manager"]["total_object_count"] == 0 and sm.report_status()["l1_manager"]["memory_used_bytes"] == 0 and sm.report_status()["l1_manager"]["read_locked_count"] == 0 and sm.report_status()["l1_manager"]["write_locked_count"] == 0 and sm.report_status()["l1_manager"]["temporary_count"] == 0 ), timeout=120.0, ) assert ok, f"L1 not cleared: {sm.report_status()['l1_manager']}" handle = sm.submit_prefetch_task(keys, layout) hit_bitmap = _wait_for_prefetch_status(sm, handle, timeout=120.0) assert hit_bitmap is not None hits = hit_bitmap.count_leading_ones() assert hits == len(keys), f"Expected {len(keys)} hits, got {hits}" with sm.read_prefetched_results(keys) as objs: assert objs is not None assert len(objs) == len(keys) for key, obj in zip(keys, objs, strict=True): assert obj.tensor is not None recovered = obj.tensor original = original_by_key[key] orig_f = original.float().flatten() rec_f = recovered.float().flatten() corr = torch.corrcoef(torch.stack([orig_f, rec_f]))[0, 1].item() mae = torch.mean(torch.abs(orig_f - rec_f)).item() mse = torch.mean((orig_f - rec_f) ** 2).item() assert corr > corr_lower_bound, ( f"low corr for preset={preset}, key={key}: " f"corr={corr}, mae={mae}, mse={mse}" ) _finish_read_prefetched_until_clean(sm, keys) finally: sm.close() # ============================================================================= # GPU direct test: TurboQuantSerializer + TurboQuantDeserializer # ============================================================================= class _FakeMemoryObj: def __init__(self, tensor: torch.Tensor): self.tensor = tensor @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") @pytest.mark.parametrize( ("preset", "expected_ratio_lower_bound", "corr_lower_bound"), [ ("turboquant_k8v4", 2.60, 0.95), ("turboquant_4bit_nc", 3.75, 0.90), ("turboquant_k3v4_nc", 4.20, 0.85), ("turboquant_3bit_nc", 4.85, 0.80), ], ) def test_turboquant_direct_roundtrip_cuda( preset: str, expected_ratio_lower_bound: float, corr_lower_bound: float, ) -> None: """Direct GPU round-trip through TurboQuant serializer/deserializer.""" # First Party from lmcache.v1.distributed.serde.turboquant import TurboQuantDeserializer device = torch.device("cuda:0") dtype = torch.float16 # LMCache KV layout: [2, num_layers, num_tokens, hidden_dim] num_layers = 4 num_tokens = 128 num_heads = 8 head_dim = 128 hidden_dim = num_heads * head_dim cfg = TurboQuantSerdeConfig( preset=preset, head_dim=head_dim, block_size=16, skip_first_layers=0, skip_last_layers=0, ) torch.manual_seed(2026) shape = torch.Size([2, num_layers, num_tokens, hidden_dim]) original = torch.randn(shape, dtype=dtype, device=device) serializer = TurboQuantSerializer(cfg) deserializer = TurboQuantDeserializer(cfg) layout = MemoryLayoutDesc(shapes=[shape], dtypes=[dtype]) n_bytes = serializer.estimate_serialized_size(layout) compressed = torch.empty(n_bytes, dtype=torch.uint8, device=device) recovered = torch.empty_like(original) written = serializer.serialize( cast(MemoryObj, _FakeMemoryObj(original)), cast(MemoryObj, _FakeMemoryObj(compressed)), ) assert written == n_bytes deserializer.deserialize( cast(MemoryObj, _FakeMemoryObj(compressed)), cast(MemoryObj, _FakeMemoryObj(recovered)), ) orig_f = original.float().flatten() rec_f = recovered.float().flatten() corr = torch.corrcoef(torch.stack([orig_f, rec_f]))[0, 1].item() mae = torch.mean(torch.abs(orig_f - rec_f)).item() mse = torch.mean((orig_f - rec_f) ** 2).item() original_bytes = original.numel() * original.element_size() ratio = original_bytes / n_bytes assert ratio >= expected_ratio_lower_bound assert corr > corr_lower_bound, ( f"low corr for preset={preset}: corr={corr}, mae={mae}, mse={mse}" ) # ============================================================================= # GPU E2E test: StorageManager + SerdeL2AdapterWrapper + FSL2Adapter # ============================================================================= def _make_turboquant_fs_storage_manager( base_path: str, preset: str, ) -> StorageManager: adapter_cfg = FSL2AdapterConfig( base_path=base_path, relative_tmp_dir="tmp", use_odirect=False, ) adapter_cfg.serde_config = SerdeConfig( type="turboquant", kwargs={ "preset": preset, "head_dim": 128, "block_size": 16, "max_workers": 1, }, ) cfg = StorageManagerConfig( l1_manager_config=L1ManagerConfig( memory_config=L1MemoryManagerConfig( size_in_bytes=256 * 1024 * 1024, use_lazy=torch.cuda.is_available(), init_size_in_bytes=64 * 1024 * 1024, ), ), eviction_config=EvictionConfig(eviction_policy="LRU"), l2_adapter_config=L2AdaptersConfig(adapters=[adapter_cfg]), ) return StorageManager(cfg) @pytest.mark.skipif(not torch.cuda.is_available(), reason="CUDA is not available") @pytest.mark.parametrize( ("preset", "corr_lower_bound"), [ ("turboquant_k8v4", 0.95), ("turboquant_4bit_nc", 0.90), ("turboquant_k3v4_nc", 0.85), ("turboquant_3bit_nc", 0.80), ], ) def test_turboquant_fs_storage_manager_roundtrip( preset: str, corr_lower_bound: float, ) -> None: """Store/load through FSL2Adapter with TurboQuant serde.""" base_dir = tempfile.mkdtemp(prefix="lmcache_turboquant_fs_") sm = None try: sm = _make_turboquant_fs_storage_manager(base_dir, preset) layout = _make_turboquant_layout() keys = [_make_turboquant_object_key(i) for i in range(3)] ret = sm.reserve_write(keys, layout, mode="new") assert len(ret) == len(keys), f"reserve_write got {len(ret)} / {len(keys)}" original_by_key = {} for i, key in enumerate(keys): obj = ret[key] assert obj.tensor is not None torch.manual_seed(5678 + i) data = torch.randn( obj.tensor.shape, dtype=obj.tensor.dtype, device=obj.tensor.device, ) data = data + float(i) obj.tensor.copy_(data) original_by_key[key] = data.detach().clone() sm.finish_write(list(ret.keys())) ok = _wait_for_condition( lambda: ( sm.report_status()["store_controller"]["in_flight_task_count"] == 0 and sm.report_status()["store_controller"]["pending_keys_count"] == 0 and sm.report_status()["l1_manager"]["write_locked_count"] == 0 and sm.report_status()["l1_manager"]["read_locked_count"] == 0 and sm.report_status()["l1_manager"]["temporary_count"] == 0 ), timeout=120.0, ) assert ok, "Store to FS L2 did not fully complete" stored_files = [p for p in Path(base_dir).rglob("*") if p.is_file()] assert len(stored_files) >= len(keys) sm.clear() ok = _wait_for_condition( lambda: ( sm.report_status()["l1_manager"]["total_object_count"] == 0 and sm.report_status()["l1_manager"]["memory_used_bytes"] == 0 and sm.report_status()["l1_manager"]["read_locked_count"] == 0 and sm.report_status()["l1_manager"]["write_locked_count"] == 0 and sm.report_status()["l1_manager"]["temporary_count"] == 0 ), timeout=120.0, ) assert ok, f"L1 not cleared: {sm.report_status()['l1_manager']}" handle = sm.submit_prefetch_task(keys, layout) hit_bitmap = _wait_for_prefetch_status(sm, handle, timeout=120.0) assert hit_bitmap is not None hits = hit_bitmap.count_leading_ones() assert hits == len(keys), f"Expected {len(keys)} hits, got {hits}" with sm.read_prefetched_results(keys) as objs: assert objs is not None assert len(objs) == len(keys) for key, obj in zip(keys, objs, strict=True): assert obj.tensor is not None recovered = obj.tensor original = original_by_key[key] orig_f = original.float().flatten() rec_f = recovered.float().flatten() corr = torch.corrcoef(torch.stack([orig_f, rec_f]))[0, 1].item() mae = torch.mean(torch.abs(orig_f - rec_f)).item() mse = torch.mean((orig_f - rec_f) ** 2).item() assert corr > corr_lower_bound, ( f"low corr for preset={preset}, key={key}: " f"corr={corr}, mae={mae}, mse={mse}" ) _finish_read_prefetched_until_clean(sm, keys) finally: if sm is not None: sm.close() shutil.rmtree(base_dir, ignore_errors=True)