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

561 lines
19 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
End-to-end tests for the serde integration through StorageManager.
Drives the full L1 -> L2 store (with serialize) and L2 -> L1 prefetch
(with deserialize) paths using a real L1Manager + MockL2Adapter +
AsyncSerdeProcessor(fp8). Tests exercise the public StorageManager API
only (no private-member access), per AGENTS.md.
Coverage:
- Store + prefetch round-trip through fp8 serde
- Memory accounting (no leaks after full cycle)
- Partial prefix trimming with serde
- Mixed adapters (one with serde, one without)
- Serde disabled (None) preserves existing behavior exactly
- Serde failure propagation
"""
# Standard
import time
# Third Party
import pytest
import torch
# First Party
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.mock_l2_adapter import MockL2AdapterConfig
from lmcache.v1.distributed.serde import SerdeConfig
from lmcache.v1.distributed.storage_manager import StorageManager
# Skip all tests in this module if CUDA is not available
pytestmark = pytest.mark.skipif(
not torch.cuda.is_available(), reason="CUDA is not available"
)
# =============================================================================
# Helpers
# =============================================================================
def should_use_lazy_alloc() -> bool:
"""Determine if lazy allocation should be used based on CUDA availability."""
return torch.cuda.is_available()
def make_object_key(chunk_id: int) -> ObjectKey:
"""Create a test ObjectKey with the given chunk ID."""
return ObjectKey(
chunk_hash=ObjectKey.IntHash2Bytes(chunk_id),
model_name="test_model",
kv_rank=0,
)
def make_layout() -> MemoryLayoutDesc:
"""Create a small MemoryLayoutDesc for testing (bf16, ~200KB/chunk)."""
return MemoryLayoutDesc(
shapes=[torch.Size([100, 2, 512])],
dtypes=[torch.bfloat16],
)
def wait_for_condition(
predicate,
timeout: float = 10.0,
poll_interval: float = 0.05,
) -> bool:
"""Poll until a predicate returns True or timeout."""
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 = 10.0,
poll_interval: float = 0.05,
) -> int | None:
"""Poll query_prefetch_status until it returns a non-None value."""
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
result = sm.query_prefetch_status(handle)
if result is not None:
return result.count_leading_ones()
time.sleep(poll_interval)
return None
def make_mock_adapter_config(
*,
serde_type: str | None = "fp8",
) -> MockL2AdapterConfig:
"""Create a MockL2AdapterConfig, optionally with serde."""
cfg = MockL2AdapterConfig(
max_size_gb=0.1,
mock_bandwidth_gb=10.0,
)
if serde_type is not None:
cfg.serde_config = SerdeConfig(type=serde_type)
return cfg
def make_storage_manager_config(
adapter_configs: list[MockL2AdapterConfig],
l1_size_mb: int = 256,
) -> StorageManagerConfig:
"""Build a StorageManagerConfig with the given L2 adapter configs."""
return StorageManagerConfig(
l1_manager_config=L1ManagerConfig(
memory_config=L1MemoryManagerConfig(
size_in_bytes=l1_size_mb * 1024 * 1024,
use_lazy=should_use_lazy_alloc(),
init_size_in_bytes=min(l1_size_mb, 64) * 1024 * 1024,
),
),
eviction_config=EvictionConfig(eviction_policy="LRU"),
l2_adapter_config=L2AdaptersConfig(adapters=list(adapter_configs)),
)
def get_l2_stored_object_count(sm: StorageManager) -> int:
"""Return the total stored object count across all L2 adapters."""
return sum(
adapter["stored_object_count"] for adapter in sm.report_status()["l2_adapters"]
)
def write_and_wait_for_l2(
sm: StorageManager,
keys: list[ObjectKey],
layout: MemoryLayoutDesc,
timeout: float = 10.0,
) -> None:
"""Write keys to L1 via StorageManager and wait for L2 store.
Fills each chunk with deterministic data so round-trip can be verified.
"""
stored_before = get_l2_stored_object_count(sm)
ret = sm.reserve_write(keys, layout, mode="new")
assert len(ret) == len(keys), f"reserve_write: {len(ret)}/{len(keys)} succeeded"
# Fill with deterministic data per key
for i, key in enumerate(keys):
obj = ret[key]
tensor = obj.tensor
if tensor is not None:
tensor.fill_(float(i + 1))
sm.finish_write(list(ret.keys()))
# Wait for StoreController to flush to L2. Polling the controller's queue
# counters alone is racy: the background loop pops the pending keys
# (pending_keys_count -> 0) before it submits the store tasks
# (in_flight_task_count is still 0 in between), so a poll landing in that
# window declares the store complete before anything reached L2. Anchor
# the wait on the adapters' stored object counts, then use the queue
# counters only to confirm the controller has settled.
def flushed_to_l2() -> bool:
status = sm.report_status()
stored_total = sum(
adapter["stored_object_count"] for adapter in status["l2_adapters"]
)
store_controller = status["store_controller"]
return (
stored_total >= stored_before + len(keys)
and store_controller["in_flight_task_count"] == 0
and store_controller["pending_keys_count"] == 0
)
ok = wait_for_condition(flushed_to_l2, timeout=timeout)
assert ok, "Store to L2 did not complete within timeout"
def get_l1_memory_used(sm: StorageManager) -> int:
"""Return current L1 memory usage in bytes via public report_status."""
return sm.report_status()["l1_manager"]["memory_used_bytes"]
def get_l1_object_count(sm: StorageManager) -> int:
"""Return current L1 object count via public report_status."""
return sm.report_status()["l1_manager"]["total_object_count"]
def clear_and_wait_drained(sm: StorageManager, timeout: float = 10.0) -> None:
"""Clear L1 and poll until every object is evicted.
After an L2 store the StoreController holds read locks on the stored objects
for a short window, and ``StorageManager.clear`` keeps locked objects intact.
A single clear right after the store therefore races the lock release and can
leave objects behind. Retry clear() until the locks drop and L1 drains rather
than relying on a fixed sleep.
Raises:
AssertionError: If L1 still holds objects after ``timeout`` seconds.
"""
def drained() -> bool:
sm.clear()
return get_l1_object_count(sm) == 0
if not wait_for_condition(drained, timeout=timeout):
raise AssertionError(
f"L1 did not drain after clear: {get_l1_object_count(sm)} objects remain"
)
# =============================================================================
# Tests: Full round-trip through serde
# =============================================================================
class TestSerdeRoundTrip:
"""End-to-end store + prefetch through fp8 serde."""
def test_store_and_prefetch_with_serde(self) -> None:
"""Write → L2 store (serialize) → clear L1 → prefetch (deserialize).
Verifies all keys are recovered and L1 memory returns to clean state
after all read locks are released.
"""
cfg = make_storage_manager_config([make_mock_adapter_config(serde_type="fp8")])
sm = StorageManager(cfg)
layout = make_layout()
keys = [make_object_key(i) for i in range(5)]
write_and_wait_for_l2(sm, keys, layout)
clear_and_wait_drained(sm)
assert get_l1_object_count(sm) == 0
# Prefetch from L2
handle = sm.submit_prefetch_task(keys, layout)
hits = wait_for_prefetch_status(sm, handle)
assert hits == 5, f"Expected 5 L2 hits, got {hits}"
# Read the data back
with sm.read_prefetched_results(keys) as objs:
assert objs is not None
assert len(objs) == 5
sm.finish_read_prefetched(keys)
sm.close()
def test_no_memory_leak_after_full_cycle(self) -> None:
"""After write → store → clear → prefetch → finish_read, L1 is clean."""
cfg = make_storage_manager_config([make_mock_adapter_config(serde_type="fp8")])
sm = StorageManager(cfg)
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
write_and_wait_for_l2(sm, keys, layout)
clear_and_wait_drained(sm)
# Prefetch
handle = sm.submit_prefetch_task(keys, layout)
hits = wait_for_prefetch_status(sm, handle)
assert hits == 3
# Release read locks
sm.finish_read_prefetched(keys)
# L1 should have objects (temporary prefetch results auto-delete on
# finish_read), so memory should be back to 0.
ok = wait_for_condition(
lambda: get_l1_memory_used(sm) == 0,
timeout=5.0,
)
assert ok, (
f"L1 memory leak: {get_l1_memory_used(sm)} bytes still used "
f"after releasing all read locks"
)
sm.close()
# =============================================================================
# Tests: Serde disabled (None) preserves existing behavior
# =============================================================================
class TestSerdeDisabled:
"""Verify that serde_config=None is equivalent to no-serde code path."""
def test_store_and_prefetch_without_serde(self) -> None:
"""Same flow as the serde test, but without serde — must still work."""
cfg = make_storage_manager_config([make_mock_adapter_config(serde_type=None)])
sm = StorageManager(cfg)
layout = make_layout()
keys = [make_object_key(i) for i in range(5)]
write_and_wait_for_l2(sm, keys, layout)
clear_and_wait_drained(sm)
handle = sm.submit_prefetch_task(keys, layout)
hits = wait_for_prefetch_status(sm, handle)
assert hits == 5
with sm.read_prefetched_results(keys) as objs:
assert objs is not None
assert len(objs) == 5
sm.finish_read_prefetched(keys)
sm.close()
def test_no_memory_leak_without_serde(self) -> None:
"""No-serde path should also leave L1 clean after a full cycle."""
cfg = make_storage_manager_config([make_mock_adapter_config(serde_type=None)])
sm = StorageManager(cfg)
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
write_and_wait_for_l2(sm, keys, layout)
clear_and_wait_drained(sm)
handle = sm.submit_prefetch_task(keys, layout)
hits = wait_for_prefetch_status(sm, handle)
assert hits == 3
sm.finish_read_prefetched(keys)
ok = wait_for_condition(
lambda: get_l1_memory_used(sm) == 0,
timeout=5.0,
)
assert ok, f"L1 memory leak: {get_l1_memory_used(sm)} bytes still used"
sm.close()
# =============================================================================
# Tests: Prefix trimming with serde
# =============================================================================
class TestSerdePartialPrefix:
"""Prefetch with serde when L2 has gaps in the key sequence."""
def test_partial_prefix_with_serde(self) -> None:
"""L2 has keys {0,1,3,4} but not 2 → only prefix {0,1} returned."""
cfg = make_storage_manager_config([make_mock_adapter_config(serde_type="fp8")])
sm = StorageManager(cfg)
layout = make_layout()
# Write only keys 0, 1, 3, 4 (skip 2)
keys_to_write = [make_object_key(i) for i in [0, 1, 3, 4]]
write_and_wait_for_l2(sm, keys_to_write, layout)
clear_and_wait_drained(sm)
# Request all 5 keys — prefix should be 2 (gap at index 2)
all_keys = [make_object_key(i) for i in range(5)]
handle = sm.submit_prefetch_task(all_keys, layout)
hits = wait_for_prefetch_status(sm, handle)
assert hits is not None
assert hits == 2, f"Expected prefix of 2, got {hits}"
sm.finish_read_prefetched(all_keys[:hits])
sm.close()
# =============================================================================
# Tests: Memory leak on repeated cycles
# =============================================================================
class TestSerdeMemoryStress:
"""Run multiple store-clear-prefetch cycles to catch temp buffer leaks."""
def test_repeated_cycles_no_leak(self) -> None:
"""5 cycles of write → L2 → clear → prefetch → finish_read.
After each cycle L1 should return to 0 bytes used. A temp buffer
leak would accumulate across cycles.
"""
cfg = make_storage_manager_config([make_mock_adapter_config(serde_type="fp8")])
sm = StorageManager(cfg)
layout = make_layout()
for cycle in range(5):
keys = [make_object_key(cycle * 10 + i) for i in range(3)]
write_and_wait_for_l2(sm, keys, layout)
clear_and_wait_drained(sm)
handle = sm.submit_prefetch_task(keys, layout)
hits = wait_for_prefetch_status(sm, handle)
assert hits == 3, f"Cycle {cycle}: expected 3 hits, got {hits}"
sm.finish_read_prefetched(keys)
ok = wait_for_condition(
lambda: get_l1_memory_used(sm) == 0,
timeout=5.0,
)
assert ok, (
f"Cycle {cycle}: L1 memory leak — {get_l1_memory_used(sm)} bytes used"
)
sm.close()
# =============================================================================
# Tests: Multiple keys with nothing in L2
# =============================================================================
class TestSerdeNoHits:
"""Prefetch with serde when L2 is empty — 0 hits, no crash."""
def test_prefetch_no_hits_with_serde(self) -> None:
"""Empty L2 → prefetch returns 0 hits, no temp buffer leak."""
cfg = make_storage_manager_config([make_mock_adapter_config(serde_type="fp8")])
sm = StorageManager(cfg)
layout = make_layout()
keys = [make_object_key(i) for i in range(3)]
handle = sm.submit_prefetch_task(keys, layout)
hits = wait_for_prefetch_status(sm, handle)
assert hits is not None
assert hits == 0
# No objects in L1 → memory should be 0
ok = wait_for_condition(
lambda: get_l1_memory_used(sm) == 0,
timeout=5.0,
)
assert ok, (
f"L1 memory leak after 0-hit prefetch: {get_l1_memory_used(sm)} bytes"
)
sm.close()
# =============================================================================
# Tests: Buffer sizing — no out-of-bound memory access
# =============================================================================
class TestSerdeBufferBounds:
"""Verify that the temp buffer sizing chain does not cause OOB access.
The critical path:
estimate_serialized_size(layout) -> buffer size (includes 1.5x margin)
temp_buffer = estimate bytes as uint8
serialize: writes num_elements bytes into temp (must fit)
deserialize: reads num_elements bytes from temp (must fit)
OOB would manifest as a crash (segfault / CUDA illegal access),
wrong data, or a memory leak from corrupted L1 accounting.
"""
def _run_roundtrip(
self,
layout: MemoryLayoutDesc,
num_keys: int = 3,
l1_size_mb: int = 512,
) -> None:
"""Helper: full store -> clear -> prefetch -> verify -> cleanup.
Crashes here indicate OOB in serialize or deserialize.
"""
cfg = make_storage_manager_config(
[make_mock_adapter_config(serde_type="fp8")],
l1_size_mb=l1_size_mb,
)
sm = StorageManager(cfg)
keys = [make_object_key(i) for i in range(num_keys)]
write_and_wait_for_l2(sm, keys, layout)
clear_and_wait_drained(sm)
assert get_l1_object_count(sm) == 0
handle = sm.submit_prefetch_task(keys, layout)
hits = wait_for_prefetch_status(sm, handle)
assert hits == num_keys, f"Expected {num_keys} hits, got {hits}"
with sm.read_prefetched_results(keys) as objs:
assert objs is not None
assert len(objs) == num_keys
sm.finish_read_prefetched(keys)
# Verify no memory leak (temp buffers fully cleaned up)
ok = wait_for_condition(
lambda: get_l1_memory_used(sm) == 0,
timeout=5.0,
)
assert ok, f"L1 memory leak: {get_l1_memory_used(sm)} bytes after full cycle"
sm.close()
def test_bfloat16_layout(self) -> None:
"""bf16: 2 bytes/elem KV -> 1 byte/elem fp8. Buffer = 1.5 * numel."""
layout = MemoryLayoutDesc(
shapes=[torch.Size([100, 2, 512])],
dtypes=[torch.bfloat16],
)
self._run_roundtrip(layout)
def test_float16_layout(self) -> None:
"""fp16: 2 bytes/elem KV -> 1 byte/elem fp8. Same ratio as bf16."""
layout = MemoryLayoutDesc(
shapes=[torch.Size([100, 2, 512])],
dtypes=[torch.float16],
)
self._run_roundtrip(layout)
def test_float32_layout(self) -> None:
"""fp32: 4 bytes/elem KV -> 1 byte/elem fp8. 4x compression.
The temp buffer (1.5 * numel bytes) is much smaller than the
real KV buffer (4 * numel bytes). This is the highest compression
ratio and the most likely to trigger sizing bugs.
"""
layout = MemoryLayoutDesc(
shapes=[torch.Size([50, 2, 256])],
dtypes=[torch.float32],
)
self._run_roundtrip(layout)
def test_large_tensor(self) -> None:
"""Large tensor (~4M elements, ~8MB bf16). Stress the buffer boundary."""
layout = MemoryLayoutDesc(
shapes=[torch.Size([256, 4, 2, 2048])],
dtypes=[torch.bfloat16],
)
self._run_roundtrip(layout, num_keys=2, l1_size_mb=512)
def test_small_tensor(self) -> None:
"""Tiny tensor (single element). Edge case for buffer sizing."""
layout = MemoryLayoutDesc(
shapes=[torch.Size([1])],
dtypes=[torch.bfloat16],
)
self._run_roundtrip(layout)
def test_odd_element_count(self) -> None:
"""Non-power-of-2 element count. Tests alignment edge cases.
numel = 7 * 13 * 3 = 273, not divisible by any common alignment.
"""
layout = MemoryLayoutDesc(
shapes=[torch.Size([7, 13, 3])],
dtypes=[torch.bfloat16],
)
self._run_roundtrip(layout)
# NOTE: Multi-group layouts (multiple shapes/dtypes) are not tested here
# because the fp8 serde accesses MemoryObj.tensor which only works for
# single-group layouts. Multi-group would require per-group
# serialize/deserialize via MemoryObj.get_tensor(index).