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

689 lines
26 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Tests for NixlStorageBackend shared-pool (CPU mode) constructor-based init.
Verifies that local_cpu_backend is wired up at construction time (no post_init),
get_allocator_backend() returns the correct backend, and error cases are caught
early. No real NIXL hardware needed — agent constructors are stubbed per test,
and when the nixl package is not installed it is shadowed at sys.modules level
only for the duration of this module's import (see _install_nixl_mock_if_absent).
"""
# Standard
from unittest.mock import MagicMock
import asyncio
import sys
import types
# Third Party
import pytest
import torch
# ---------------------------------------------------------------------------
# Mock the nixl package before any import of nixl_storage_backend
# ---------------------------------------------------------------------------
def _install_nixl_mock_if_absent() -> list:
"""Install a fake ``nixl`` package so this module can import
``nixl_storage_backend`` and exercise its construction paths without a real
NIXL build. Returns the list of ``sys.modules`` keys it inserted.
The fake is removed again immediately after the imports below — it must NOT
outlive this module's import. Other nixl test modules gate on
``pytest.importorskip("nixl")`` and import ``nixl`` at module top; a fake
left in ``sys.modules`` during collection would make those modules run
against the mock instead of skipping (or, with real nixl present, shadow it)
and fail. Removing it at import time — rather than in a teardown — closes
that window, because pytest imports each module to completion during
collection before collecting the next one (a teardown runs far too late).
When real nixl is installed we install nothing and use it as-is; the
per-test agent ``__init__`` stubs keep the tests off any real transport.
"""
try:
# Third Party
import nixl # noqa: F401
import nixl._api # noqa: F401
return []
except ImportError:
pass
nixlBind_mock = MagicMock()
nixlBind_mock.nixlRegDList = object
nixlBind_mock.nixlXferDList = object
nixlBind_mock.nixlBackendError = Exception
sync_t_mock = MagicMock()
sync_t_mock.NIXL_THREAD_SYNC_STRICT = "NIXL_THREAD_SYNC_STRICT"
api_mock = types.ModuleType("nixl._api")
api_mock.nixl_agent = MagicMock # type: ignore[attr-defined]
api_mock.nixl_agent_config = MagicMock # type: ignore[attr-defined]
api_mock.nixl_prepped_dlist_handle = MagicMock # type: ignore[attr-defined]
api_mock.nixl_xfer_handle = MagicMock # type: ignore[attr-defined]
api_mock.nixlBind = nixlBind_mock # type: ignore[attr-defined]
api_mock.nixl_thread_sync_t = sync_t_mock # type: ignore[attr-defined]
nixl_mock = types.ModuleType("nixl")
nixl_mock._api = api_mock # type: ignore[attr-defined]
inserted = []
for name, mod in (("nixl", nixl_mock), ("nixl._api", api_mock)):
if name not in sys.modules:
sys.modules[name] = mod
inserted.append(name)
return inserted
_NIXL_MOCK_KEYS = _install_nixl_mock_if_absent()
# First Party
from lmcache.utils import CacheEngineKey # noqa: E402
from lmcache.v1.config import LMCacheEngineConfig # noqa: E402
from lmcache.v1.memory_allocators.paged_tensor_memory_allocator import ( # noqa: E402
PagedTensorMemoryAllocator,
)
from lmcache.v1.memory_management import MemoryFormat # noqa: E402
from lmcache.v1.metadata import LMCacheMetadata # noqa: E402
from lmcache.v1.storage_backend import CreateStorageBackends # noqa: E402
from lmcache.v1.storage_backend.local_cpu_backend import LocalCPUBackend # noqa: E402
import lmcache.v1.memory_management as memory_management_module # noqa: E402
import lmcache.v1.storage_backend.nixl_storage_backend as nixl_module # noqa: E402
# nixl_module has now captured whatever it needs from the (possibly fake) nixl
# package. Drop the fake from sys.modules so it cannot leak into other test
# modules' pytest.importorskip("nixl") during collection. No-op when real nixl
# is installed (nothing was inserted).
for _name in _NIXL_MOCK_KEYS:
sys.modules.pop(_name, None)
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _make_metadata() -> LMCacheMetadata:
return LMCacheMetadata(
model_name="test_model",
world_size=1,
local_world_size=1,
worker_id=0,
local_worker_id=0,
kv_dtype=torch.bfloat16,
kv_shape=(4, 2, 256, 8, 128),
)
def _nixl_cpu_config(pool_size: int = 0) -> LMCacheEngineConfig:
config = LMCacheEngineConfig.from_defaults(
chunk_size=256,
local_cpu=True,
lmcache_instance_id="test_nixl_shared",
)
# In CPU mode nixl_buffer_size is rejected by config validation; the pool
# is sized by max_local_cpu_size via LocalCPUBackend.
config.nixl_buffer_device = "cpu"
config.save_unfull_chunk = False
config.extra_config = {
"enable_nixl_storage": True,
"nixl_backend": "OBJ",
"nixl_pool_size": pool_size,
"nixl_backend_params": {},
"nixl_presence_cache": False,
"nixl_async_put": False,
"use_direct_io": False,
"nixl_path": None,
"nixl_enable_prog_thread": True,
}
return config
def _nixl_gpu_config(pool_size: int = 0) -> LMCacheEngineConfig:
config = _nixl_cpu_config(pool_size)
config.nixl_buffer_device = "cuda"
config.nixl_buffer_size = 1024 * 1024
return config
def _make_paged_allocator(metadata: LMCacheMetadata) -> PagedTensorMemoryAllocator:
shapes = metadata.get_shapes()
dtypes = metadata.get_dtypes()
chunk_bytes = sum(
s.numel() * d.itemsize for s, d in zip(shapes, dtypes, strict=True)
)
buffer = torch.zeros(chunk_bytes * 4, dtype=torch.uint8)
return PagedTensorMemoryAllocator(
buffer,
[torch.Size(metadata.kv_shape)],
[metadata.kv_dtype],
MemoryFormat.KV_2LTD,
)
def _make_local_cpu_paged(
monkeypatch, metadata: LMCacheMetadata, local_cpu: bool = True
) -> LocalCPUBackend:
"""LocalCPUBackend with memory_allocator = MixedMemoryAllocator(use_paging=True).
Sized to exactly 4 chunks so tests that need to drive the pool to
saturation know the page count without inspecting allocator internals.
"""
config = LMCacheEngineConfig.from_defaults(
chunk_size=256, local_cpu=local_cpu, lmcache_instance_id="test_paged"
)
shapes = metadata.get_shapes()
dtypes = metadata.get_dtypes()
chunk_bytes = sum(
s.numel() * d.itemsize for s, d in zip(shapes, dtypes, strict=True)
)
aligned = chunk_bytes * 4
config.max_local_cpu_size = aligned / (1024**3)
config.nixl_buffer_device = "cpu"
config.extra_config = {"enable_nixl_storage": True}
config.save_unfull_chunk = False
real_buf = torch.zeros(aligned, dtype=torch.uint8)
monkeypatch.setattr(
memory_management_module,
"_allocate_cpu_memory",
lambda size, *a, **kw: real_buf,
)
# real_buf is a plain (pageable) tensor, not cudaHostAlloc'd. The real
# _free_cpu_memory would call cudaFreeHost on it on a CUDA build and raise
# "cudaFreeHost failed" — so neutralize free symmetrically with alloc; the
# tensor is reclaimed by normal Python GC.
monkeypatch.setattr(
memory_management_module, "_free_cpu_memory", lambda *a, **kw: None
)
return LocalCPUBackend(config=config, metadata=metadata, dst_device="cpu")
def _make_local_cpu_flat(metadata: LMCacheMetadata) -> LocalCPUBackend:
"""LocalCPUBackend memory_allocator = MixedMemoryAllocator(use_paging=False)."""
config = LMCacheEngineConfig.from_defaults(
chunk_size=256, local_cpu=True, lmcache_instance_id="test_flat"
)
config.max_local_cpu_size = 0.01
return LocalCPUBackend(config=config, metadata=metadata, dst_device="cpu")
def _stub_dynamic_agent(monkeypatch) -> None:
monkeypatch.setattr(
nixl_module.NixlDynamicStorageAgent, "__init__", lambda self, *a, **kw: None
)
monkeypatch.setattr(nixl_module.NixlDynamicStorageAgent, "close", lambda self: None)
monkeypatch.setattr(
nixl_module.NixlDynamicStorageAgent, "mem_type", "OBJ", raising=False
)
def _stub_static_agent(monkeypatch) -> None:
monkeypatch.setattr(
nixl_module.NixlStaticStorageAgent, "__init__", lambda self, *a, **kw: None
)
monkeypatch.setattr(nixl_module.NixlStaticStorageAgent, "close", lambda self: None)
def _stub_gpu_allocator(monkeypatch, allocator) -> None:
monkeypatch.setattr(
nixl_module.NixlStorageBackend,
"initialize_allocator",
lambda self, config, metadata: allocator,
)
def _build_dynamic(monkeypatch, metadata, config, local_cpu_backend=None):
_stub_dynamic_agent(monkeypatch)
loop = asyncio.new_event_loop()
nixl_config = nixl_module.NixlStorageConfig.from_cache_engine_config(
config, metadata
)
backend = nixl_module.NixlDynamicStorageBackend(
nixl_config, config, metadata, loop, local_cpu_backend=local_cpu_backend
)
return backend, loop
def _build_static(monkeypatch, metadata, config, local_cpu_backend=None):
_stub_static_agent(monkeypatch)
loop = asyncio.new_event_loop()
nixl_config = nixl_module.NixlStorageConfig.from_cache_engine_config(
config, metadata
)
backend = nixl_module.NixlStaticStorageBackend(
nixl_config, config, metadata, loop, local_cpu_backend=local_cpu_backend
)
return backend, loop
def _make_key(idx: int) -> CacheEngineKey:
"""Unique CacheEngineKey for tests."""
return CacheEngineKey(
model_name="test_model",
world_size=1,
worker_id=0,
chunk_hash=idx,
dtype=torch.bfloat16,
)
def _stub_dynamic_agent_runtime(backend) -> None:
"""Stub the NixlDynamicStorageAgent runtime methods invoked by storage_to_mem.
The constructor was already stubbed by _stub_dynamic_agent; this fills in
the transport surface so storage_to_mem can run end-to-end without real NIXL.
"""
backend.agent.mem_type = "OBJ"
backend.agent.create_batched_storage_handler = MagicMock(
return_value=(MagicMock(), MagicMock())
)
backend.agent.get_storage_to_mem_handle = MagicMock(return_value=MagicMock())
backend.agent.post_blocking = MagicMock()
backend.agent.release_handle = MagicMock()
backend.agent.release_storage_handler = MagicMock()
# ---------------------------------------------------------------------------
# Tests: Dynamic CPU mode
# ---------------------------------------------------------------------------
class TestDynamicCpuMode:
def test_cpu_mode_raises_without_local_cpu(self, monkeypatch):
"""Constructor raises RuntimeError if local_cpu_backend is None in CPU mode."""
metadata = _make_metadata()
_stub_dynamic_agent(monkeypatch)
loop = asyncio.new_event_loop()
try:
nixl_config = nixl_module.NixlStorageConfig.from_cache_engine_config(
_nixl_cpu_config(), metadata
)
with pytest.raises(RuntimeError, match="max_local_cpu_size"):
nixl_module.NixlDynamicStorageBackend(
nixl_config,
_nixl_cpu_config(),
metadata,
loop,
local_cpu_backend=None,
)
finally:
loop.close()
def test_cpu_mode_raises_if_wrong_allocator_type(self, monkeypatch):
"""RuntimeError when LocalCPUBackend uses non-paged MixedMemoryAllocator."""
metadata = _make_metadata()
local_cpu_flat = _make_local_cpu_flat(metadata)
_stub_dynamic_agent(monkeypatch)
loop = asyncio.new_event_loop()
try:
nixl_config = nixl_module.NixlStorageConfig.from_cache_engine_config(
_nixl_cpu_config(), metadata
)
with pytest.raises(
RuntimeError, match="MixedMemoryAllocator\\(use_paging=True\\)"
):
nixl_module.NixlDynamicStorageBackend(
nixl_config,
_nixl_cpu_config(),
metadata,
loop,
local_cpu_backend=local_cpu_flat,
)
finally:
loop.close()
def test_get_allocator_backend_returns_local_cpu(self, monkeypatch):
"""get_allocator_backend() returns local_cpu_backend in CPU mode."""
metadata = _make_metadata()
local_cpu = _make_local_cpu_paged(monkeypatch, metadata)
backend, loop = _build_dynamic(
monkeypatch, metadata, _nixl_cpu_config(), local_cpu_backend=local_cpu
)
try:
assert backend.get_allocator_backend() is local_cpu
finally:
loop.close()
local_cpu.memory_allocator.close()
# ---------------------------------------------------------------------------
# Tests: Dynamic GPU mode
# ---------------------------------------------------------------------------
class TestDynamicGpuMode:
def test_get_allocator_backend_returns_self(self, monkeypatch):
"""In GPU mode, get_allocator_backend() returns self."""
metadata = _make_metadata()
fake_alloc = _make_paged_allocator(metadata)
_stub_gpu_allocator(monkeypatch, fake_alloc)
monkeypatch.setattr(torch.cuda, "current_device", lambda: 0, raising=False)
monkeypatch.setattr(torch.cuda, "set_device", lambda x: None, raising=False)
backend, loop = _build_dynamic(monkeypatch, metadata, _nixl_gpu_config())
try:
assert backend.get_allocator_backend() is backend
finally:
loop.close()
fake_alloc.close()
# ---------------------------------------------------------------------------
# Tests: Static CPU mode
# ---------------------------------------------------------------------------
class TestStaticCpuMode:
def test_cpu_mode_raises_if_wrong_allocator_type(self, monkeypatch):
"""Static backend: RuntimeError when LocalCPUBackend uses flat allocator."""
metadata = _make_metadata()
local_cpu_flat = _make_local_cpu_flat(metadata)
_stub_static_agent(monkeypatch)
loop = asyncio.new_event_loop()
try:
nixl_config = nixl_module.NixlStorageConfig.from_cache_engine_config(
_nixl_cpu_config(pool_size=4), metadata
)
with pytest.raises(
RuntimeError, match="MixedMemoryAllocator\\(use_paging=True\\)"
):
nixl_module.NixlStaticStorageBackend(
nixl_config,
_nixl_cpu_config(pool_size=4),
metadata,
loop,
local_cpu_backend=local_cpu_flat,
)
finally:
loop.close()
# ---------------------------------------------------------------------------
# Tests: cross-pool ownership bug fix (red->green demonstration)
# ---------------------------------------------------------------------------
class TestCrossPoolOwnershipFix:
"""Red->green test for the cross-pool ownership bug.
Pre-fix: _allocate_for_read called self.memory_allocator.allocate()
directly. Once NIXL-hit promotions filled LocalCPUBackend's hot cache
with buffers from the shared pool, allocate() returned None and
batched_get_blocking returned [None].
Post-fix: _allocate_for_read routes through
LocalCPUBackend.allocate(eviction=True), so eviction fires on the
shared pool and frees a page from hot_cache to satisfy the read.
"""
def test_get_succeeds_after_pool_saturation_via_eviction(self, monkeypatch):
"""After N promotions saturate the shared pool, the (N+1)th
batched_get_blocking still returns a valid MemoryObj (eviction fires
via the shared allocator)."""
metadata = _make_metadata()
# The fixture sizes the pool to exactly 4 chunks; drive exactly that
# many promotions to saturate it without peeking at allocator state.
num_pages = 4
local_cpu = _make_local_cpu_paged(monkeypatch, metadata)
backend, loop = _build_dynamic(
monkeypatch, metadata, _nixl_cpu_config(), local_cpu_backend=local_cpu
)
try:
backend.init_chunk_meta(metadata)
_stub_dynamic_agent_runtime(backend)
for i in range(num_pages):
key = _make_key(i)
objs = backend.batched_get_blocking([key])
assert objs[0] is not None, (
f"batched_get_blocking returned None at promotion "
f"{i}/{num_pages}; pool exhausted earlier than expected"
)
# Promote into hot_cache and drop the caller's ref so hot_cache
# holds the only reference to the page.
local_cpu.batched_submit_put_task([key], objs)
objs[0].ref_count_down()
# Verify the pool is saturated via the public allocator API: a
# no-eviction allocation must fail when every page is pinned in
# hot_cache.
shapes = metadata.get_shapes()
dtypes = metadata.get_dtypes()
sat_obj = local_cpu.allocate(
shapes, dtypes, MemoryFormat.KV_2LTD, eviction=False
)
assert sat_obj is None, (
"Test fixture: shared pool should be saturated after "
f"{num_pages} promotions"
)
# The (N+1)th read must still succeed — _allocate_for_read routes
# through LocalCPUBackend.allocate(eviction=True), which evicts
# one hot_cache entry to free a page.
extra_key = _make_key(num_pages)
objs = backend.batched_get_blocking([extra_key])
assert objs[0] is not None, (
"Shared-pool eviction did not fire: pre-fix, _allocate_for_read "
"called the inner allocator directly with no eviction and "
"returned None."
)
objs[0].ref_count_down()
finally:
loop.close()
local_cpu.memory_allocator.close()
# ---------------------------------------------------------------------------
# Tests: shared-pool contract (close ownership + staging-only mode)
# ---------------------------------------------------------------------------
class TestSharedPoolContract:
"""Behavioral contracts for the CPU-mode shared pool."""
def test_close_does_not_close_borrowed_allocator(self, monkeypatch):
"""NixlStorageBackend.close() must not close the LocalCPUBackend
allocator it borrowed — that allocator is owned by LocalCPUBackend."""
metadata = _make_metadata()
local_cpu = _make_local_cpu_paged(monkeypatch, metadata)
backend, loop = _build_dynamic(
monkeypatch, metadata, _nixl_cpu_config(), local_cpu_backend=local_cpu
)
close_calls: list[None] = []
original_close = local_cpu.memory_allocator.close
def spy() -> None:
close_calls.append(None)
original_close()
monkeypatch.setattr(local_cpu.memory_allocator, "close", spy)
try:
backend.close()
assert close_calls == [], (
"NIXL.close() must not invoke close() on the borrowed allocator"
)
# And the allocator is still usable after NIXL has shut down.
shapes = metadata.get_shapes()
dtypes = metadata.get_dtypes()
obj = local_cpu.allocate(shapes, dtypes, MemoryFormat.KV_2LTD)
assert obj is not None, (
"Borrowed allocator is no longer usable after NIXL close()"
)
obj.ref_count_down()
finally:
loop.close()
local_cpu.memory_allocator.close()
def test_local_cpu_false_does_not_promote_to_hot_cache(self, monkeypatch):
"""With local_cpu=False the LocalCPUBackend is a NIXL staging buffer
only: batched_submit_put_task is a no-op, so a subsequent
get_blocking returns None. Documents the contract stated in the
NIXL docs and exercised by the CreateStorageBackends path that
force-creates LocalCPUBackend in CPU mode."""
metadata = _make_metadata()
local_cpu = _make_local_cpu_paged(monkeypatch, metadata, local_cpu=False)
try:
shapes = metadata.get_shapes()
dtypes = metadata.get_dtypes()
obj = local_cpu.allocate(shapes, dtypes, MemoryFormat.KV_2LTD)
assert obj is not None, "Staging-only LocalCPUBackend must still allocate"
key = _make_key(0)
local_cpu.batched_submit_put_task([key], [obj])
assert local_cpu.get_blocking(key) is None, (
"local_cpu=False must suppress hot-cache promotion"
)
obj.ref_count_down()
finally:
local_cpu.memory_allocator.close()
class TestAllocatorMethodsRouteThroughLocalCpu:
"""In CPU mode the AllocatorBackendInterface methods on NixlStorageBackend
must delegate to LocalCPUBackend's public API, not call the inner
allocator directly. Matches P2P's pattern (allocations via
local_cpu_backend.allocate, allocator only peeked for buffer info)."""
def test_allocate_routes_through_local_cpu_backend(self, monkeypatch):
metadata = _make_metadata()
local_cpu = _make_local_cpu_paged(monkeypatch, metadata)
backend, loop = _build_dynamic(
monkeypatch, metadata, _nixl_cpu_config(), local_cpu_backend=local_cpu
)
calls: list[dict] = []
original_allocate = local_cpu.allocate
def spy(shapes, dtypes, fmt=None, eviction=True, busy_loop=True):
calls.append(
{"shapes": shapes, "eviction": eviction, "busy_loop": busy_loop}
)
return original_allocate(
shapes, dtypes, fmt, eviction=eviction, busy_loop=busy_loop
)
monkeypatch.setattr(local_cpu, "allocate", spy)
try:
shapes = metadata.get_shapes()
dtypes = metadata.get_dtypes()
obj = backend.allocate(shapes, dtypes, MemoryFormat.KV_2LTD)
assert obj is not None
assert len(calls) == 1, (
"NixlStorageBackend.allocate must delegate to LocalCPUBackend"
)
assert calls[0]["busy_loop"] is False, (
"Delegated allocate must force busy_loop=False (NIXL semantics)"
)
obj.ref_count_down()
finally:
backend.close()
loop.close()
local_cpu.memory_allocator.close()
def test_batched_allocate_routes_through_local_cpu_backend(self, monkeypatch):
metadata = _make_metadata()
local_cpu = _make_local_cpu_paged(monkeypatch, metadata)
backend, loop = _build_dynamic(
monkeypatch, metadata, _nixl_cpu_config(), local_cpu_backend=local_cpu
)
calls: list[dict] = []
original_batched = local_cpu.batched_allocate
def spy(shapes, dtypes, batch_size, fmt=None, eviction=True, busy_loop=True):
calls.append({"batch_size": batch_size, "busy_loop": busy_loop})
return original_batched(
shapes,
dtypes,
batch_size,
fmt,
eviction=eviction,
busy_loop=busy_loop,
)
monkeypatch.setattr(local_cpu, "batched_allocate", spy)
try:
shapes = metadata.get_shapes()
dtypes = metadata.get_dtypes()
objs = backend.batched_allocate(shapes, dtypes, 2, MemoryFormat.KV_2LTD)
assert objs is not None
assert len(calls) == 1, (
"NixlStorageBackend.batched_allocate must delegate to LocalCPUBackend"
)
assert calls[0]["batch_size"] == 2
assert calls[0]["busy_loop"] is False
for obj in objs:
obj.ref_count_down()
finally:
backend.close()
loop.close()
local_cpu.memory_allocator.close()
def test_get_memory_allocator_returns_local_cpu_allocator(self, monkeypatch):
"""storage_manager.memcheck() iterates AllocatorBackendInterface
backends and calls get_memory_allocator().memcheck(). In CPU mode the
NIXL backend shares LocalCPUBackend's pool — returning the same
allocator avoids double-counting the pool in memcheck."""
metadata = _make_metadata()
local_cpu = _make_local_cpu_paged(monkeypatch, metadata)
backend, loop = _build_dynamic(
monkeypatch, metadata, _nixl_cpu_config(), local_cpu_backend=local_cpu
)
try:
assert backend.get_memory_allocator() is local_cpu.get_memory_allocator()
finally:
backend.close()
loop.close()
local_cpu.memory_allocator.close()
def _scheduler_metadata() -> LMCacheMetadata:
return LMCacheMetadata(
model_name="test_model",
world_size=1,
local_world_size=1,
worker_id=0,
local_worker_id=0,
kv_dtype=torch.bfloat16,
kv_shape=(4, 2, 256, 8, 128),
role="scheduler",
)
class TestSchedulerRoleRejection:
"""The scheduler role never creates a LocalCPUBackend, so NIXL CPU mode —
which shares that backend's pool — cannot run there. CreateStorageBackends
rejects the combo with a clear ValueError instead of letting it crash deep
in the NIXL constructor. See plans/pr-nixl-cpu-shared-pool-review."""
def test_scheduler_role_nixl_cpu_rejected(self):
config = _nixl_cpu_config()
loop = asyncio.new_event_loop()
try:
with pytest.raises(ValueError, match="scheduler"):
CreateStorageBackends(config, _scheduler_metadata(), loop)
finally:
loop.close()
def test_scheduler_role_without_nixl_ok(self):
# The guard is specific to NIXL CPU mode: a scheduler with no NIXL
# storage must still build (no LocalCPUBackend, no rejection).
config = LMCacheEngineConfig.from_defaults(
chunk_size=256,
local_cpu=True,
lmcache_instance_id="test_sched_no_nixl",
)
loop = asyncio.new_event_loop()
try:
backends = CreateStorageBackends(config, _scheduler_metadata(), loop)
assert "LocalCPUBackend" not in backends
finally:
loop.close()