Files
2026-07-13 12:24:33 +08:00

624 lines
20 KiB
Python

# 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)