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

320 lines
10 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""End-to-end tests for :class:`StorageReplayDriver`.
Each test records a short sequence of StorageManager operations into a
binary trace file via the real ``EventBus`` + ``StorageTraceRecorder``
stack, then constructs a fresh StorageManager and drives replay over
that trace. These tests avoid any GPU/vLLM dependencies — the whole
flow runs in-process on CPU memory.
"""
# Future
from __future__ import annotations
# Standard
from typing import Callable
import time
# Third Party
import pytest
import torch
# First Party
from lmcache.cli.commands.trace._dispatch import (
CallDispatcher,
ReplayContext,
build_default_dispatcher,
)
from lmcache.cli.commands.trace._driver import StorageReplayDriver
from lmcache.v1.distributed.api import MemoryLayoutDesc, ObjectKey
from lmcache.v1.distributed.config import (
EvictionConfig,
L1ManagerConfig,
L1MemoryManagerConfig,
StorageManagerConfig,
)
from lmcache.v1.distributed.storage_manager import StorageManager
from lmcache.v1.mp_observability.event_bus import EventBus, EventBusConfig
from lmcache.v1.mp_observability.trace.decorator import set_tracing_enabled
from lmcache.v1.mp_observability.trace.recorder import StorageTraceRecorder
import lmcache.v1.mp_observability.event_bus as _bus_module
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
def _should_use_lazy() -> bool:
"""Lazy allocator requires CUDA. CPU-only hosts (our primary replay
target) must use eager allocation."""
return torch.cuda.is_available()
def _make_sm_config() -> StorageManagerConfig:
"""Build a small StorageManagerConfig suitable for CPU testing."""
memory = L1MemoryManagerConfig(
size_in_bytes=64 * 1024 * 1024,
use_lazy=_should_use_lazy(),
init_size_in_bytes=32 * 1024 * 1024,
align_bytes=0x1000,
)
l1 = L1ManagerConfig(
memory_config=memory,
write_ttl_seconds=600,
read_ttl_seconds=300,
)
return StorageManagerConfig(
l1_manager_config=l1,
eviction_config=EvictionConfig(eviction_policy="LRU"),
)
def _make_key(i: int) -> ObjectKey:
return ObjectKey(
chunk_hash=i.to_bytes(4, "big"),
model_name="test",
kv_rank=0,
)
def _make_layout() -> MemoryLayoutDesc:
return MemoryLayoutDesc(
shapes=[torch.Size([16, 16])],
dtypes=[torch.float16],
)
@pytest.fixture(autouse=True)
def restore_global_bus():
saved = _bus_module._global_bus
yield
_bus_module._global_bus = saved
set_tracing_enabled(False)
@pytest.fixture
def trace_path(tmp_path):
return str(tmp_path / "run.lct")
def _flush(bus: EventBus) -> None:
time.sleep(0.25)
bus._drain_all()
# ---------------------------------------------------------------------------
# Helpers: record a scripted sequence into a trace file
# ---------------------------------------------------------------------------
def _record_sequence(
trace_path: str,
sm_config: StorageManagerConfig,
script: Callable[[StorageManager], None],
) -> None:
"""Record whatever ``script(sm)`` does into *trace_path*.
``script`` receives a live StorageManager and should call traced
methods on it. This helper handles the bus / recorder lifecycle.
"""
bus = EventBus(EventBusConfig(enabled=True))
_bus_module._global_bus = bus
bus.start()
sm = StorageManager(sm_config)
rec = StorageTraceRecorder(trace_path)
rec.attach_storage_config(sm_config)
bus.register_subscriber(rec)
try:
script(sm)
_flush(bus)
finally:
bus.stop()
sm.close()
# ---------------------------------------------------------------------------
# Tests
# ---------------------------------------------------------------------------
class TestRecordReplayRoundtrip:
def test_reserve_and_finish_write_replay(self, trace_path):
sm_config = _make_sm_config()
layout = _make_layout()
keys = [_make_key(i) for i in range(3)]
def script(sm: StorageManager) -> None:
reserved = sm.reserve_write(keys, layout, mode="new")
assert len(reserved) == 3
sm.finish_write(keys)
_record_sequence(trace_path, sm_config, script)
with StorageReplayDriver(_make_sm_config(), trace_path) as driver:
result = driver.run()
assert result.records_failed == 0
assert result.records_replayed >= 2 # reserve_write + finish_write
assert result.header_level == "storage"
# Same config used on both sides → digest matches.
assert result.header_digest == result.replay_config_digest
def test_full_prefetch_cycle_replay(self, trace_path):
sm_config = _make_sm_config()
layout = _make_layout()
keys = [_make_key(i) for i in range(3)]
def script(sm: StorageManager) -> None:
sm.reserve_write(keys, layout, mode="new")
sm.finish_write(keys)
handle = sm.submit_prefetch_task(keys, layout)
assert handle is not None
with sm.read_prefetched_results(keys) as objs:
assert objs is not None
assert len(objs) == 3
_record_sequence(trace_path, sm_config, script)
with StorageReplayDriver(_make_sm_config(), trace_path) as driver:
result = driver.run()
# Everything replayed, no failures.
assert result.records_failed == 0
assert result.records_skipped == 0
qns = result.stats.summary().keys()
# Every op from the script appears in stats.
expected_substrings = [
"reserve_write",
"finish_write",
"submit_prefetch_task",
"read_prefetched_results.__enter__",
"read_prefetched_results.__exit__",
]
for sub in expected_substrings:
assert any(sub in qn for qn in qns), (
f"missing qualname containing {sub!r}: saw {sorted(qns)}"
)
def test_on_record_callback_fires_per_record(self, trace_path):
sm_config = _make_sm_config()
layout = _make_layout()
keys = [_make_key(0)]
def script(sm: StorageManager) -> None:
sm.reserve_write(keys, layout, mode="new")
sm.finish_write(keys)
_record_sequence(trace_path, sm_config, script)
seen: list[tuple[str, bool]] = []
def on_record(qualname: str, latency_s: float, failed: bool) -> None:
seen.append((qualname, failed))
with StorageReplayDriver(_make_sm_config(), trace_path) as driver:
result = driver.run(on_record=on_record)
assert len(seen) == result.records_replayed
assert all(not failed for _, failed in seen)
class TestMismatchHandling:
def test_unknown_qualname_is_skipped(self, trace_path):
"""An empty dispatcher with no matching handlers skips every
record without raising."""
sm_config = _make_sm_config()
layout = _make_layout()
keys = [_make_key(0)]
def script(sm: StorageManager) -> None:
sm.reserve_write(keys, layout, mode="new")
_record_sequence(trace_path, sm_config, script)
empty = CallDispatcher()
with StorageReplayDriver(
_make_sm_config(), trace_path, dispatcher=empty
) as driver:
result = driver.run()
assert result.records_replayed == 0
assert result.records_skipped >= 1
def test_handler_failure_counted(self, trace_path):
sm_config = _make_sm_config()
layout = _make_layout()
keys = [_make_key(0)]
def script(sm: StorageManager) -> None:
sm.reserve_write(keys, layout, mode="new")
_record_sequence(trace_path, sm_config, script)
d = build_default_dispatcher()
# Replace the reserve_write handler with one that raises.
failing = CallDispatcher()
for qn in d.registered_qualnames():
if qn.endswith(".reserve_write"):
def _boom(_ctx: ReplayContext, _a: dict) -> None:
raise RuntimeError("boom")
failing.register(qn, _boom)
else:
# Reuse the default handler for anything else; none
# is expected in this script but registering keeps
# the test robust against future decorator additions.
# First Party
from lmcache.cli.commands.trace._dispatch import (
_call_sm_method,
_enter_read_prefetched,
_exit_read_prefetched,
)
if qn.endswith(".__enter__"):
failing.register(qn, _enter_read_prefetched)
elif qn.endswith(".__exit__"):
failing.register(qn, _exit_read_prefetched)
else:
failing.register(qn, _call_sm_method(qn.split(".")[-1]))
with StorageReplayDriver(
_make_sm_config(), trace_path, dispatcher=failing
) as driver:
result = driver.run()
assert result.records_failed >= 1
class TestPacing:
def test_replay_does_not_regress_past_monotonic(self, trace_path):
"""Replay never runs *before* the recorded offset.
Records have t_mono=0 and a positive value; the driver always
honors the recorded gap — there is no as-fast-as-possible
mode, because async read/write dependencies inside
``StorageManager`` make it unsafe to collapse the recorded
inter-call intervals.
"""
sm_config = _make_sm_config()
layout = _make_layout()
keys = [_make_key(0)]
def script(sm: StorageManager) -> None:
sm.reserve_write(keys, layout, mode="new")
time.sleep(0.05) # force a gap
sm.finish_write(keys)
_record_sequence(trace_path, sm_config, script)
start = time.monotonic()
with StorageReplayDriver(_make_sm_config(), trace_path) as driver:
result = driver.run()
elapsed = time.monotonic() - start
assert result.records_failed == 0
# Replay should have slept ≈ 50ms at minimum. Use a generous
# bound to avoid flakes under load.
assert elapsed >= 0.04