1574 lines
52 KiB
Python
1574 lines
52 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Standard
|
|
from collections.abc import Iterator
|
|
from contextlib import contextmanager
|
|
from typing import TYPE_CHECKING, Any, Callable, Protocol
|
|
from unittest.mock import MagicMock, patch
|
|
import os
|
|
import pickle
|
|
import sys
|
|
|
|
# Third Party
|
|
import pytest
|
|
import torch
|
|
|
|
# First Party
|
|
from lmcache import torch_dev, torch_device_type
|
|
from lmcache.v1.distributed.api import MemoryLayoutDesc
|
|
from lmcache.v1.multiprocess.posix_shm import (
|
|
shm_create_readwrite,
|
|
shm_munmap,
|
|
shm_open_pool_as_mmap,
|
|
shm_unlink,
|
|
)
|
|
from lmcache.v1.multiprocess.protocol import RequestType
|
|
from lmcache.v1.multiprocess.protocols.engine import (
|
|
PrepareRetrieveResponse,
|
|
PrepareStoreResponse,
|
|
RegisterEngineDrivenContextResponse,
|
|
)
|
|
from lmcache.v1.multiprocess.transfer_context.base import (
|
|
EngineDrivenContextMetadata,
|
|
create_engine_driven_context,
|
|
)
|
|
from lmcache.v1.multiprocess.transfer_context.pickle import EngineDrivenContextPickle
|
|
from lmcache.v1.multiprocess.transfer_context.shm import EngineDrivenContextShm
|
|
|
|
if TYPE_CHECKING:
|
|
# First Party
|
|
from lmcache.v1.distributed.config import StorageManagerConfig
|
|
from lmcache.v1.gpu_connector.utils import LayoutHints
|
|
from lmcache.v1.multiprocess.custom_types import (
|
|
IPCCacheServerKey,
|
|
RegisterEngineDrivenContextPayload,
|
|
)
|
|
from lmcache.v1.multiprocess.engine_context import MPCacheServerContext
|
|
from lmcache.v1.multiprocess.modules.engine_driven_transfer import (
|
|
EngineDrivenTransferModule,
|
|
)
|
|
|
|
|
|
class ServerModuleFactory(Protocol):
|
|
"""Typed callable contract for creating patched server test modules.
|
|
|
|
Args:
|
|
storage_manager_config: Optional engine storage config override.
|
|
chunk_size: Engine chunk size used to initialize the context.
|
|
object_keys: Object keys returned by ``ipc_key_to_object_keys``.
|
|
mock_storage: Optional storage mock; defaults to a new ``MagicMock``.
|
|
mock_session: Optional session mock; defaults to a new ``MagicMock``.
|
|
|
|
Returns a tuple of ``(EngineDrivenTransferModule, storage MagicMock,
|
|
session MagicMock, MPCacheServerContext)``.
|
|
"""
|
|
|
|
def __call__(
|
|
self,
|
|
*,
|
|
storage_manager_config: "StorageManagerConfig | None" = None,
|
|
chunk_size: int = 8,
|
|
object_keys: list[str] | None = None,
|
|
mock_storage: MagicMock | None = None,
|
|
mock_session: MagicMock | None = None,
|
|
) -> tuple[
|
|
"EngineDrivenTransferModule", MagicMock, MagicMock, "MPCacheServerContext"
|
|
]: ...
|
|
|
|
|
|
def _make_kv_caches(
|
|
num_layers: int = 2,
|
|
num_blocks: int = 6,
|
|
block_size: int = 4,
|
|
num_heads: int = 2,
|
|
head_size: int = 8,
|
|
) -> dict[str, torch.Tensor]:
|
|
"""Build per-layer NHD KV tensors for non-CUDA data transfer tests."""
|
|
kv_caches = {}
|
|
for i in range(num_layers):
|
|
kv_caches[f"layer_{i}"] = torch.randn(
|
|
2, num_blocks, block_size, num_heads, head_size
|
|
)
|
|
return kv_caches
|
|
|
|
|
|
def _make_mla_kv_caches(
|
|
num_layers: int = 2,
|
|
num_blocks: int = 6,
|
|
block_size: int = 4,
|
|
hidden_size: int = 16,
|
|
) -> dict[str, torch.Tensor]:
|
|
"""Build per-layer MLA KV tensors for non-CUDA data transfer tests.
|
|
|
|
Args:
|
|
num_layers: Number of KV layers to generate.
|
|
num_blocks: Number of paged blocks per layer.
|
|
block_size: Number of tokens per block.
|
|
hidden_size: Hidden size per token.
|
|
|
|
Returns:
|
|
Mapping from layer name to MLA KV tensor with shape
|
|
``[num_blocks, block_size, hidden_size]``.
|
|
"""
|
|
kv_caches = {}
|
|
for i in range(num_layers):
|
|
kv_caches[f"layer_{i}"] = torch.randn(num_blocks, block_size, hidden_size)
|
|
return kv_caches
|
|
|
|
|
|
def _make_hnd_kv_caches(
|
|
num_layers: int = 2,
|
|
num_blocks: int = 6,
|
|
block_size: int = 4,
|
|
num_heads: int = 2,
|
|
head_size: int = 8,
|
|
) -> dict[str, torch.Tensor]:
|
|
"""Build per-layer HND KV tensors for non-CUDA data transfer tests."""
|
|
kv_caches = {}
|
|
for i in range(num_layers):
|
|
kv_caches[f"layer_{i}"] = torch.randn(
|
|
2, num_blocks, num_heads, block_size, head_size
|
|
)
|
|
return kv_caches
|
|
|
|
|
|
def _make_hnd_flashinfer_kv_caches(
|
|
num_layers: int = 2,
|
|
num_blocks: int = 6,
|
|
block_size: int = 4,
|
|
num_heads: int = 2,
|
|
head_size: int = 8,
|
|
) -> dict[str, torch.Tensor]:
|
|
"""Build per-layer HND flash-infer KV tensors for non-CUDA data transfer tests."""
|
|
kv_caches = {}
|
|
for i in range(num_layers):
|
|
kv_caches[f"layer_{i}"] = torch.randn(
|
|
num_blocks, 2, num_heads, block_size, head_size
|
|
)
|
|
return kv_caches
|
|
|
|
|
|
def _make_storage_manager_config(
|
|
*,
|
|
shm_name: str = "",
|
|
pool_size: int = 4096,
|
|
use_lazy: bool = False,
|
|
) -> Any:
|
|
"""Build a StorageManagerConfig for multiprocess engine-context tests."""
|
|
# First Party
|
|
from lmcache.v1.distributed.config import (
|
|
EvictionConfig,
|
|
L1ManagerConfig,
|
|
L1MemoryManagerConfig,
|
|
StorageManagerConfig,
|
|
)
|
|
|
|
return StorageManagerConfig(
|
|
l1_manager_config=L1ManagerConfig(
|
|
memory_config=L1MemoryManagerConfig(
|
|
size_in_bytes=pool_size,
|
|
use_lazy=use_lazy,
|
|
shm_name=shm_name,
|
|
),
|
|
),
|
|
eviction_config=EvictionConfig(eviction_policy="LRU"),
|
|
)
|
|
|
|
|
|
def _default_register_payload(
|
|
instance_id: int = 1,
|
|
) -> "RegisterEngineDrivenContextPayload":
|
|
"""Build a default non-GPU registration payload for server-side tests.
|
|
|
|
Args:
|
|
instance_id: Worker instance id to register. Defaults to ``1``.
|
|
|
|
Uses fixed values ``model_name="m"``, ``world_size=1``, ``block_size=4``,
|
|
``num_layers=2``, ``hidden_dim_size=16``, ``dtype_str="float32"``, and
|
|
``use_mla=False`` for a compact baseline scenario used by most tests.
|
|
"""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.custom_types import RegisterEngineDrivenContextPayload
|
|
|
|
return RegisterEngineDrivenContextPayload(
|
|
instance_id=instance_id,
|
|
model_name="m",
|
|
world_size=1,
|
|
block_size=4,
|
|
num_layers=2,
|
|
hidden_dim_size=16,
|
|
dtype_str="float32",
|
|
use_mla=False,
|
|
)
|
|
|
|
|
|
def _default_key(tokens: int = 8) -> "IPCCacheServerKey":
|
|
"""Build a default IPC cache key with ``tokens`` contiguous token IDs.
|
|
|
|
Args:
|
|
tokens: Total token count and key end offset. Defaults to ``8``.
|
|
|
|
Uses fixed values ``model_name="m"``, ``world_size=1``, ``rank=0``,
|
|
token IDs of ``[1] * tokens``, ``start=0``, ``end=tokens``,
|
|
and ``request_id="req"``.
|
|
"""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.custom_types import IPCCacheServerKey
|
|
|
|
return IPCCacheServerKey.from_token_ids(
|
|
"m",
|
|
1,
|
|
0,
|
|
[1] * tokens,
|
|
start=0,
|
|
end=tokens,
|
|
request_id="req",
|
|
)
|
|
|
|
|
|
def test_wrap_kv_caches_wraps_all_tensors() -> None:
|
|
"""Verify wrap_kv_caches wraps all provided KV tensors."""
|
|
# First Party
|
|
from lmcache.integration.vllm import vllm_multi_process_adapter as adapter_mod
|
|
from lmcache.v1.platform import _registry as platform_registry
|
|
|
|
kv_caches = _make_kv_caches()
|
|
# ``wrap_kv_caches`` dispatches through ``platform_registry``: each
|
|
# accelerator self-registers a wrapper factory keyed by
|
|
# ``tensor.device.type``. Override the relevant entries through the
|
|
# registry's documented API (snapshot + register + restore on
|
|
# teardown) instead of poking the adapter's private helper.
|
|
saved = platform_registry.snapshot()
|
|
|
|
def _fake_factory(tensor: Any) -> tuple[str, Any]:
|
|
return ("wrapped", tensor)
|
|
|
|
try:
|
|
for device_type in {t.device.type for t in kv_caches.values()}:
|
|
platform_registry.register_kv_wrapper(device_type, _fake_factory)
|
|
wrapped = adapter_mod.wrap_kv_caches(kv_caches)
|
|
finally:
|
|
platform_registry.restore(saved)
|
|
|
|
assert len(wrapped) == len(kv_caches)
|
|
|
|
|
|
def test_create_transfer_context_uses_non_cuda_context_on_cpu() -> None:
|
|
"""Ensure factory returns EngineDrivenTransferContext for CPU KV."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context.worker_transfer import (
|
|
EngineDrivenTransferContext,
|
|
create_transfer_context,
|
|
)
|
|
|
|
context = create_transfer_context({"layer_0": torch.randn(2, 2)})
|
|
assert isinstance(context, EngineDrivenTransferContext)
|
|
|
|
|
|
def test_resolve_extra_config_default_mp_transfer_mode_is_auto() -> None:
|
|
"""Without override the resolved mp_transfer_mode must be ``auto``."""
|
|
# First Party
|
|
from lmcache.integration.vllm.vllm_multi_process_adapter import (
|
|
ExtraConfigDefault,
|
|
_resolve_extra_config,
|
|
)
|
|
|
|
cfg = _resolve_extra_config(None)
|
|
assert cfg[ExtraConfigDefault.mp_transfer_mode.name] == "auto"
|
|
|
|
|
|
def test_resolve_extra_config_overrides_mp_transfer_mode() -> None:
|
|
"""``lmcache.mp.mp_transfer_mode`` override flows through unchanged."""
|
|
# First Party
|
|
from lmcache.integration.vllm.vllm_multi_process_adapter import (
|
|
ExtraConfigDefault,
|
|
_resolve_extra_config,
|
|
)
|
|
|
|
cfg = _resolve_extra_config({"lmcache.mp.mp_transfer_mode": "lmcache_driven"})
|
|
assert cfg[ExtraConfigDefault.mp_transfer_mode.name] == "lmcache_driven"
|
|
|
|
|
|
def test_extra_config_default_lets_env_var_select_mp_transfer_mode(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""When extra_config omits mp_transfer_mode, env var must still win.
|
|
|
|
The adapter detects the absence of ``lmcache.mp.mp_transfer_mode`` and
|
|
passes ``mode=None`` to ``create_transfer_context``, which then reads
|
|
the ``LMCACHE_MP_TRANSFER_MODE`` env var. Regression test for
|
|
buildkite k3-multiprocess CI ``cpu_e2e_validation (server-side copy)``.
|
|
"""
|
|
# First Party
|
|
from lmcache.integration.vllm.vllm_multi_process_adapter import (
|
|
_EXTRA_CONFIG_KEY_PREFIX,
|
|
ExtraConfigDefault,
|
|
)
|
|
from lmcache.v1.multiprocess.transfer_context import (
|
|
EngineDrivenTransferContext,
|
|
create_transfer_context,
|
|
)
|
|
from lmcache.v1.multiprocess.transfer_context.worker_transfer import (
|
|
ENV_MP_TRANSFER_MODE,
|
|
)
|
|
|
|
mp_mode_key = _EXTRA_CONFIG_KEY_PREFIX + ExtraConfigDefault.mp_transfer_mode.name
|
|
# Simulate adapter init: extra_config omits the mp_transfer_mode key.
|
|
extra_config: dict[str, Any] = {"lmcache.mp.mq_timeout": "1"}
|
|
resolved_mode = extra_config[mp_mode_key] if mp_mode_key in extra_config else None
|
|
assert resolved_mode is None
|
|
|
|
# With env=engine_driven and mode=None, CPU KV must pick
|
|
# EngineDrivenTransferContext.
|
|
monkeypatch.setenv(ENV_MP_TRANSFER_MODE, "engine_driven")
|
|
context = create_transfer_context(
|
|
{"layer_0": torch.randn(2, 2)}, mode=resolved_mode
|
|
)
|
|
assert isinstance(context, EngineDrivenTransferContext)
|
|
|
|
|
|
def test_create_transfer_context_force_lmcache_driven_mode() -> None:
|
|
"""``mode='lmcache_driven'`` must always pick
|
|
LMCacheDrivenTransferContext (handle path); CPU also works because the
|
|
CPU SHM wrapper factory is registered on import."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context import (
|
|
LMCacheDrivenTransferContext,
|
|
MPTransferMode,
|
|
create_transfer_context,
|
|
)
|
|
|
|
# Importing the CPU sub-package self-registers its KV-wrapper factory.
|
|
import lmcache.v1.platform.cpu # noqa: F401
|
|
|
|
context = create_transfer_context(
|
|
{"layer_0": torch.randn(2, 2)}, mode=MPTransferMode.LMCACHE_DRIVEN
|
|
)
|
|
assert isinstance(context, LMCacheDrivenTransferContext)
|
|
|
|
|
|
def test_create_transfer_context_force_engine_driven_mode_on_cpu() -> None:
|
|
"""``mode='engine_driven'`` on CPU returns EngineDrivenTransferContext
|
|
(data path; no wrapper-factory capability check is performed)."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context import (
|
|
EngineDrivenTransferContext,
|
|
create_transfer_context,
|
|
)
|
|
|
|
context = create_transfer_context(
|
|
{"layer_0": torch.randn(2, 2)}, mode="engine_driven"
|
|
)
|
|
assert isinstance(context, EngineDrivenTransferContext)
|
|
|
|
|
|
def test_create_transfer_context_invalid_mode_raises() -> None:
|
|
"""Unknown mode strings must raise a clear ValueError."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context import create_transfer_context
|
|
|
|
with pytest.raises(ValueError, match="Invalid MP transfer mode"):
|
|
create_transfer_context({"layer_0": torch.randn(2, 2)}, mode="bogus")
|
|
|
|
|
|
def test_create_transfer_context_handle_mode_unsupported_device_raises(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""``mode='lmcache_driven'`` must raise when no wrapper factory exists
|
|
for the device."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context import create_transfer_context
|
|
from lmcache.v1.platform import _registry as platform_registry
|
|
|
|
snapshot = platform_registry.snapshot()
|
|
try:
|
|
# Drop every registered factory so 'cpu' can never be resolved.
|
|
# Pass ``discovered=True`` so the lazy discovery pass does not
|
|
# immediately re-register the auto-discovered backends and
|
|
# defeat the empty-table fixture.
|
|
platform_registry.restore(
|
|
{"kv_wrapper": {}, "availability": {}, "discovered": True}
|
|
)
|
|
with pytest.raises(ValueError, match="not supported for device type"):
|
|
create_transfer_context(
|
|
{"layer_0": torch.randn(2, 2)}, mode="lmcache_driven"
|
|
)
|
|
finally:
|
|
platform_registry.restore(snapshot)
|
|
|
|
|
|
def test_musa_data_context_keeps_layout_validation_device_agnostic(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""MUSA MP data path must not put device layout gates in transfer context."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context import (
|
|
EngineDrivenTransferContext,
|
|
worker_transfer,
|
|
)
|
|
import lmcache.c_ops as lmc_ops
|
|
|
|
def _fake_compute_kv_layout(
|
|
*_args: Any, **_kwargs: Any
|
|
) -> tuple[int, int, int, str, Any]:
|
|
return (
|
|
4,
|
|
2,
|
|
16,
|
|
"float32",
|
|
lmc_ops.EngineKVFormat.NL_X_TWO_NB_NH_BS_HS,
|
|
)
|
|
|
|
monkeypatch.setattr(worker_transfer, "compute_kv_layout", _fake_compute_kv_layout)
|
|
monkeypatch.setattr(
|
|
worker_transfer,
|
|
"create_engine_driven_context",
|
|
lambda *_args, **_kwargs: MagicMock(),
|
|
)
|
|
future = MagicMock()
|
|
future.result.return_value = RegisterEngineDrivenContextResponse()
|
|
ctx = EngineDrivenTransferContext()
|
|
|
|
ctx.register(
|
|
instance_id=1,
|
|
kv_caches=_make_hnd_kv_caches(),
|
|
model_name="m",
|
|
world_size=1,
|
|
blocks_in_chunk=2,
|
|
mq_client=MagicMock(),
|
|
mq_timeout=1.0,
|
|
send_request=MagicMock(return_value=future),
|
|
)
|
|
|
|
|
|
def test_musa_data_context_store_uses_device_agnostic_gather(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Stage3 store keeps MUSA native details behind block-transfer entry."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context import (
|
|
EngineDrivenTransferContext,
|
|
worker_transfer,
|
|
)
|
|
import lmcache.c_ops as lmc_ops
|
|
|
|
class _FakeEngineDrivenContext:
|
|
def prepare_store(self, *_args: Any, **_kwargs: Any) -> None:
|
|
return None
|
|
|
|
def commit_store(self, *_args: Any, **_kwargs: Any) -> bool:
|
|
return True
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
captured_kwargs: dict[str, Any] = {}
|
|
future = MagicMock()
|
|
future.result.return_value = RegisterEngineDrivenContextResponse()
|
|
monkeypatch.setattr(
|
|
worker_transfer,
|
|
"compute_kv_layout",
|
|
lambda *_args, **_kwargs: (
|
|
4,
|
|
2,
|
|
16,
|
|
"float32",
|
|
lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS,
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
worker_transfer,
|
|
"create_engine_driven_context",
|
|
lambda *_args, **_kwargs: _FakeEngineDrivenContext(),
|
|
)
|
|
|
|
def _fake_gather(*_args: Any, **kwargs: Any) -> list[torch.Tensor]:
|
|
captured_kwargs.update(kwargs)
|
|
return [torch.zeros(2, 2, 8, 16)]
|
|
|
|
monkeypatch.setattr(worker_transfer, "gather_paged_kv_to_cpu", _fake_gather)
|
|
ctx = EngineDrivenTransferContext()
|
|
ctx.register(
|
|
instance_id=1,
|
|
kv_caches=_make_kv_caches(),
|
|
model_name="m",
|
|
world_size=1,
|
|
blocks_in_chunk=2,
|
|
mq_client=MagicMock(),
|
|
mq_timeout=1.0,
|
|
send_request=MagicMock(return_value=future),
|
|
)
|
|
|
|
result = ctx.submit_store(
|
|
"req",
|
|
_default_key(),
|
|
1,
|
|
_make_kv_caches(),
|
|
[[0, 1]],
|
|
MagicMock(),
|
|
2,
|
|
).result()
|
|
|
|
assert result is True
|
|
assert "prefer_musa_native" not in captured_kwargs
|
|
|
|
|
|
def test_musa_data_context_retrieve_uses_device_agnostic_scatter(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Stage3 retrieve keeps MUSA native details behind block-transfer entry."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context import (
|
|
EngineDrivenTransferContext,
|
|
worker_transfer,
|
|
)
|
|
import lmcache.c_ops as lmc_ops
|
|
|
|
class _FakeEngineDrivenContext:
|
|
def prepare_retrieve(self, *_args: Any, **_kwargs: Any) -> list[torch.Tensor]:
|
|
return [torch.zeros(2, 2, 8, 16)]
|
|
|
|
def commit_retrieve(self, *_args: Any, **_kwargs: Any) -> bool:
|
|
return True
|
|
|
|
def close(self) -> None:
|
|
return None
|
|
|
|
captured_kwargs: dict[str, Any] = {}
|
|
future = MagicMock()
|
|
future.result.return_value = RegisterEngineDrivenContextResponse()
|
|
monkeypatch.setattr(
|
|
worker_transfer,
|
|
"compute_kv_layout",
|
|
lambda *_args, **_kwargs: (
|
|
4,
|
|
2,
|
|
16,
|
|
"float32",
|
|
lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS,
|
|
),
|
|
)
|
|
monkeypatch.setattr(
|
|
worker_transfer,
|
|
"create_engine_driven_context",
|
|
lambda *_args, **_kwargs: _FakeEngineDrivenContext(),
|
|
)
|
|
|
|
def _fake_scatter(*_args: Any, **kwargs: Any) -> None:
|
|
captured_kwargs.update(kwargs)
|
|
|
|
monkeypatch.setattr(worker_transfer, "scatter_cpu_to_paged_kv", _fake_scatter)
|
|
ctx = EngineDrivenTransferContext()
|
|
ctx.register(
|
|
instance_id=1,
|
|
kv_caches=_make_kv_caches(),
|
|
model_name="m",
|
|
world_size=1,
|
|
blocks_in_chunk=2,
|
|
mq_client=MagicMock(),
|
|
mq_timeout=1.0,
|
|
send_request=MagicMock(return_value=future),
|
|
)
|
|
|
|
result = ctx.submit_retrieve(
|
|
"req",
|
|
_default_key(),
|
|
1,
|
|
_make_kv_caches(),
|
|
[[0, 1]],
|
|
MagicMock(),
|
|
2,
|
|
).result()
|
|
|
|
assert result is True
|
|
assert "prefer_musa_native" not in captured_kwargs
|
|
|
|
|
|
def test_create_transfer_context_env_var_overrides_default(
|
|
monkeypatch: Any,
|
|
) -> None:
|
|
"""``LMCACHE_MP_TRANSFER_MODE=lmcache_driven`` must force the
|
|
LMCache-driven path."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context import (
|
|
LMCacheDrivenTransferContext,
|
|
create_transfer_context,
|
|
)
|
|
from lmcache.v1.multiprocess.transfer_context.worker_transfer import (
|
|
ENV_MP_TRANSFER_MODE,
|
|
)
|
|
|
|
# Importing the CPU sub-package self-registers its KV-wrapper factory,
|
|
# which is required by the lmcache-driven (handle) path.
|
|
import lmcache.v1.platform.cpu # noqa: F401
|
|
|
|
monkeypatch.setenv(ENV_MP_TRANSFER_MODE, "lmcache_driven")
|
|
context = create_transfer_context({"layer_0": torch.randn(2, 2)})
|
|
assert isinstance(context, LMCacheDrivenTransferContext)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("builder_fn", "expected_block_size", "expected_hidden_dim", "layout_hints"),
|
|
[
|
|
pytest.param(
|
|
lambda: _make_kv_caches(
|
|
num_layers=2,
|
|
num_blocks=8,
|
|
block_size=4,
|
|
num_heads=4,
|
|
head_size=4,
|
|
),
|
|
4,
|
|
16,
|
|
None,
|
|
id="nhd",
|
|
),
|
|
pytest.param(
|
|
lambda: _make_mla_kv_caches(
|
|
num_layers=2, num_blocks=8, block_size=4, hidden_size=16
|
|
),
|
|
4,
|
|
16,
|
|
None,
|
|
id="mla",
|
|
),
|
|
],
|
|
)
|
|
def test_compute_kv_layout_and_gather_scatter_roundtrip(
|
|
builder_fn: Callable[[], dict[str, torch.Tensor]],
|
|
expected_block_size: int,
|
|
expected_hidden_dim: int,
|
|
layout_hints: "LayoutHints | None",
|
|
) -> None:
|
|
"""Validate layout extraction and gather/scatter round-trip on CPU tensors."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context.base import (
|
|
compute_kv_layout,
|
|
gather_paged_kv_to_cpu,
|
|
scatter_cpu_to_paged_kv,
|
|
)
|
|
|
|
source = {k: v.to(torch_device_type) for k, v in builder_fn().items()}
|
|
(
|
|
block_size,
|
|
num_layers,
|
|
hidden_dim,
|
|
dtype_str,
|
|
detected_kv_format,
|
|
) = compute_kv_layout(source, layout_hints=layout_hints)
|
|
assert block_size == expected_block_size
|
|
assert num_layers == 2
|
|
assert hidden_dim == expected_hidden_dim
|
|
assert dtype_str == "float32"
|
|
assert detected_kv_format is not None
|
|
|
|
blocks_per_chunk = 2
|
|
gathered = gather_paged_kv_to_cpu(source, [0, 1], blocks_per_chunk)
|
|
destination = {name: torch.zeros_like(tensor) for name, tensor in source.items()}
|
|
scatter_cpu_to_paged_kv(destination, [4, 5], gathered, blocks_per_chunk)
|
|
|
|
for name in source:
|
|
if source[name].dim() == 5:
|
|
assert torch.allclose(source[name][:, 0], destination[name][:, 4])
|
|
assert torch.allclose(source[name][:, 1], destination[name][:, 5])
|
|
else:
|
|
assert torch.allclose(source[name][0], destination[name][4])
|
|
assert torch.allclose(source[name][1], destination[name][5])
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("hnd_builder", "expected_format"),
|
|
[
|
|
(_make_hnd_kv_caches, "NL_X_TWO_NB_NH_BS_HS"),
|
|
(_make_hnd_flashinfer_kv_caches, "NL_X_NB_TWO_NH_BS_HS"),
|
|
],
|
|
)
|
|
def test_gather_scatter_roundtrip_hnd_layout(
|
|
hnd_builder: Callable[[int, int, int, int, int], dict[str, torch.Tensor]],
|
|
expected_format: str,
|
|
) -> None:
|
|
"""Validate gather/scatter round-trip for HND vLLM KV layout."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context.base import (
|
|
compute_kv_layout,
|
|
gather_paged_kv_to_cpu,
|
|
scatter_cpu_to_paged_kv,
|
|
)
|
|
import lmcache.c_ops as lmc_ops
|
|
|
|
source = {k: v.to(torch_device_type) for k, v in hnd_builder(2, 8, 4, 2, 8).items()}
|
|
layout_hints: LayoutHints = {"kv_layout": "HND"}
|
|
(
|
|
block_size,
|
|
num_layers,
|
|
hidden_dim,
|
|
dtype_str,
|
|
detected_kv_format,
|
|
) = compute_kv_layout(source, layout_hints=layout_hints)
|
|
assert block_size == 4
|
|
assert num_layers == 2
|
|
assert hidden_dim == 16
|
|
assert dtype_str == "float32"
|
|
assert detected_kv_format == getattr(lmc_ops.EngineKVFormat, expected_format)
|
|
|
|
blocks_per_chunk = 2
|
|
gathered = gather_paged_kv_to_cpu(
|
|
source,
|
|
[0, 1],
|
|
blocks_per_chunk,
|
|
layout_hints=layout_hints,
|
|
engine_kv_format=detected_kv_format,
|
|
)
|
|
destination = {name: torch.zeros_like(tensor) for name, tensor in source.items()}
|
|
scatter_cpu_to_paged_kv(
|
|
destination,
|
|
[4, 5],
|
|
gathered,
|
|
blocks_per_chunk,
|
|
layout_hints=layout_hints,
|
|
engine_kv_format=detected_kv_format,
|
|
)
|
|
|
|
for name in source:
|
|
if detected_kv_format == lmc_ops.EngineKVFormat.NL_X_TWO_NB_NH_BS_HS:
|
|
assert torch.allclose(source[name][:, 0], destination[name][:, 4])
|
|
assert torch.allclose(source[name][:, 1], destination[name][:, 5])
|
|
else:
|
|
assert torch.allclose(source[name][0], destination[name][4])
|
|
assert torch.allclose(source[name][1], destination[name][5])
|
|
|
|
|
|
def test_compute_kv_layout_empty_raises_value_error() -> None:
|
|
"""Ensure compute_kv_layout rejects empty KV cache input."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context.base import compute_kv_layout
|
|
|
|
with pytest.raises(ValueError, match="kv_caches is empty"):
|
|
compute_kv_layout({})
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
(
|
|
"builder_fn",
|
|
"skip_tokens",
|
|
"expected_unchanged_blocks",
|
|
"expected_copied_blocks",
|
|
),
|
|
[
|
|
pytest.param(
|
|
lambda: _make_kv_caches(
|
|
num_layers=2,
|
|
num_blocks=8,
|
|
block_size=4,
|
|
num_heads=4,
|
|
head_size=4,
|
|
),
|
|
8,
|
|
[0, 1],
|
|
[2, 3],
|
|
id="nhd-skip-two-blocks",
|
|
),
|
|
pytest.param(
|
|
lambda: _make_mla_kv_caches(
|
|
num_layers=2, num_blocks=8, block_size=4, hidden_size=16
|
|
),
|
|
8,
|
|
[0, 1],
|
|
[2, 3],
|
|
id="mla-skip-two-blocks",
|
|
),
|
|
pytest.param(
|
|
lambda: _make_mla_kv_caches(
|
|
num_layers=2, num_blocks=8, block_size=4, hidden_size=16
|
|
),
|
|
40,
|
|
[0, 1, 2, 3],
|
|
[],
|
|
id="mla-skip-past-chunk",
|
|
),
|
|
],
|
|
)
|
|
def test_scatter_respects_skip_first_n_tokens(
|
|
builder_fn: Callable[[], dict[str, torch.Tensor]],
|
|
skip_tokens: int,
|
|
expected_unchanged_blocks: list[int],
|
|
expected_copied_blocks: list[int],
|
|
) -> None:
|
|
"""Ensure scatter honors skip_first_n_tokens and preserves skipped blocks."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context.base import (
|
|
gather_paged_kv_to_cpu,
|
|
scatter_cpu_to_paged_kv,
|
|
)
|
|
|
|
source = {k: v.to(torch_device_type) for k, v in builder_fn().items()}
|
|
destination = {
|
|
name: torch.full_like(tensor, 999.0) for name, tensor in source.items()
|
|
}
|
|
gathered = gather_paged_kv_to_cpu(source, [0, 1, 2, 3], blocks_per_chunk=4)
|
|
scatter_cpu_to_paged_kv(
|
|
destination,
|
|
[0, 1, 2, 3],
|
|
gathered,
|
|
blocks_per_chunk=4,
|
|
skip_first_n_tokens=skip_tokens,
|
|
)
|
|
|
|
for name in destination:
|
|
for block_idx in expected_unchanged_blocks:
|
|
if destination[name].dim() == 5:
|
|
assert torch.all(destination[name][:, block_idx] == 999.0)
|
|
else:
|
|
assert torch.all(destination[name][block_idx] == 999.0)
|
|
for block_idx in expected_copied_blocks:
|
|
if destination[name].dim() == 5:
|
|
assert torch.allclose(
|
|
destination[name][:, block_idx], source[name][:, block_idx]
|
|
)
|
|
else:
|
|
assert torch.allclose(
|
|
destination[name][block_idx],
|
|
source[name][block_idx],
|
|
)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("builder_fn", "layout_hints"),
|
|
[
|
|
pytest.param(
|
|
lambda: _make_hnd_kv_caches(num_layers=2, num_blocks=4, block_size=4),
|
|
{"kv_layout": "HND"},
|
|
id="hnd",
|
|
),
|
|
pytest.param(
|
|
lambda: _make_mla_kv_caches(
|
|
num_layers=2, num_blocks=4, block_size=4, hidden_size=16
|
|
),
|
|
None,
|
|
id="mla",
|
|
),
|
|
],
|
|
)
|
|
def test_scatter_rounds_down_partial_block_skip_first_n_tokens(
|
|
builder_fn: Callable[[], dict[str, torch.Tensor]],
|
|
layout_hints: "LayoutHints | None",
|
|
) -> None:
|
|
"""Scatter rounds non-block-aligned prefix skips down to whole blocks."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context.base import (
|
|
gather_paged_kv_to_cpu,
|
|
scatter_cpu_to_paged_kv,
|
|
)
|
|
|
|
source = {k: v.to(torch_device_type) for k, v in builder_fn().items()}
|
|
destination = {
|
|
name: torch.full_like(tensor, 999.0) for name, tensor in source.items()
|
|
}
|
|
gathered = gather_paged_kv_to_cpu(
|
|
source,
|
|
[0, 1],
|
|
blocks_per_chunk=2,
|
|
layout_hints=layout_hints,
|
|
)
|
|
scatter_cpu_to_paged_kv(
|
|
destination,
|
|
[0, 1],
|
|
gathered,
|
|
blocks_per_chunk=2,
|
|
skip_first_n_tokens=2,
|
|
layout_hints=layout_hints,
|
|
)
|
|
|
|
for name in destination:
|
|
for block_idx in (0, 1):
|
|
if destination[name].dim() == 5:
|
|
assert torch.allclose(
|
|
destination[name][:, block_idx],
|
|
source[name][:, block_idx],
|
|
)
|
|
else:
|
|
assert torch.allclose(
|
|
destination[name][block_idx],
|
|
source[name][block_idx],
|
|
)
|
|
for block_idx in (2, 3):
|
|
if destination[name].dim() == 5:
|
|
assert torch.all(destination[name][:, block_idx] == 999.0)
|
|
else:
|
|
assert torch.all(destination[name][block_idx] == 999.0)
|
|
|
|
|
|
@pytest.fixture
|
|
def stub_native_storage_ops() -> Any:
|
|
"""Stub native modules so server imports work in source-only test runs."""
|
|
module = type(sys)("lmcache.native_storage_ops")
|
|
module.TTLLock = type("TTLLock", (), {}) # type: ignore[attr-defined]
|
|
module.Bitmap = type("Bitmap", (), {}) # type: ignore[attr-defined]
|
|
module.PeriodicEventNotifier = type( # type: ignore[attr-defined]
|
|
"PeriodicEventNotifier", (), {}
|
|
)
|
|
with patch.dict(
|
|
sys.modules,
|
|
{
|
|
"lmcache.native_storage_ops": module,
|
|
"cupy": MagicMock(),
|
|
},
|
|
):
|
|
yield
|
|
|
|
|
|
@pytest.fixture
|
|
def server_module_factory(
|
|
stub_native_storage_ops: Any,
|
|
) -> Iterator[ServerModuleFactory]:
|
|
"""Create a patched server module/context with configurable mocks."""
|
|
# Standard
|
|
from contextlib import ExitStack
|
|
|
|
# First Party
|
|
from lmcache.v1.multiprocess.engine_context import MPCacheServerContext
|
|
from lmcache.v1.multiprocess.modules.engine_driven_transfer import (
|
|
EngineDrivenTransferModule,
|
|
)
|
|
|
|
stack = ExitStack()
|
|
|
|
def _create(
|
|
*,
|
|
storage_manager_config: "StorageManagerConfig | None" = None,
|
|
chunk_size: int = 8,
|
|
object_keys: list[str] | None = None,
|
|
mock_storage: MagicMock | None = None,
|
|
mock_session: MagicMock | None = None,
|
|
) -> tuple[
|
|
"EngineDrivenTransferModule", MagicMock, MagicMock, "MPCacheServerContext"
|
|
]:
|
|
"""Create a patched module/context plus storage/session mocks.
|
|
|
|
Args:
|
|
storage_manager_config: Optional engine storage config override.
|
|
chunk_size: Engine chunk size passed to context construction.
|
|
object_keys: Keys returned from ``ipc_key_to_object_keys`` patch.
|
|
mock_storage: Optional storage mock instance to inject.
|
|
mock_session: Optional session mock instance to inject.
|
|
|
|
Returns ``(module, mock_storage, mock_session, ctx)``.
|
|
"""
|
|
mock_storage = mock_storage or MagicMock()
|
|
if mock_session is None:
|
|
mock_session = MagicMock()
|
|
mock_session.get_hashes.return_value = [b"h"]
|
|
|
|
stack.enter_context(
|
|
patch(
|
|
"lmcache.v1.multiprocess.engine_context.StorageManager",
|
|
return_value=mock_storage,
|
|
)
|
|
)
|
|
stack.enter_context(patch("lmcache.v1.multiprocess.engine_context.TokenHasher"))
|
|
session_cls = stack.enter_context(
|
|
patch("lmcache.v1.multiprocess.engine_context.SessionManager")
|
|
)
|
|
stack.enter_context(
|
|
patch("lmcache.v1.multiprocess.engine_context.get_event_bus")
|
|
)
|
|
stack.enter_context(
|
|
patch(
|
|
"lmcache.v1.multiprocess.engine_context.ipc_key_to_object_keys",
|
|
return_value=[object_keys or ["obj"]],
|
|
)
|
|
)
|
|
|
|
session_cls.return_value.get_or_create.return_value = mock_session
|
|
if storage_manager_config is None:
|
|
storage_manager_config = MagicMock()
|
|
# GDS L1 is off in these tests. A bare MagicMock would auto-vivify
|
|
# gds_l1_config to a truthy mock, making MPCacheServerContext attempt
|
|
# real cuFile init; pin it to None so GDS init stays a no-op.
|
|
storage_manager_config.l1_manager_config.gds_l1_config = None
|
|
ctx = MPCacheServerContext(
|
|
storage_manager_config=storage_manager_config,
|
|
chunk_size=chunk_size,
|
|
)
|
|
module = EngineDrivenTransferModule(ctx)
|
|
|
|
return module, mock_storage, mock_session, ctx
|
|
|
|
yield _create # type: ignore[misc]
|
|
stack.close()
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("config_kwargs", "expected_pool_info"),
|
|
[
|
|
pytest.param(
|
|
{"shm_name": "/test_pool", "pool_size": 1024},
|
|
{"shm_name": "lmcache_l1_pool_test_pool", "pool_size": 1024},
|
|
id="non-lazy",
|
|
),
|
|
pytest.param(
|
|
{
|
|
"shm_name": "lmcache_l1_pool_existing",
|
|
"pool_size": 2048,
|
|
"use_lazy": True,
|
|
},
|
|
{"shm_name": "", "pool_size": 0},
|
|
id="lazy",
|
|
),
|
|
],
|
|
)
|
|
def test_engine_context_shm_pool_info(
|
|
stub_native_storage_ops: Any,
|
|
config_kwargs: dict[str, Any],
|
|
expected_pool_info: dict[str, Any],
|
|
) -> None:
|
|
"""Ensure engine context computes SHM pool metadata for lazy and non-lazy modes."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.engine_context import MPCacheServerContext
|
|
|
|
with patch(
|
|
"lmcache.v1.distributed.config.current_device_spec",
|
|
MagicMock(is_pin_supported=True),
|
|
):
|
|
config = _make_storage_manager_config(**config_kwargs)
|
|
|
|
with (
|
|
patch("lmcache.v1.multiprocess.engine_context.StorageManager"),
|
|
patch("lmcache.v1.multiprocess.engine_context.TokenHasher"),
|
|
patch("lmcache.v1.multiprocess.engine_context.SessionManager"),
|
|
patch("lmcache.v1.multiprocess.engine_context.get_event_bus"),
|
|
):
|
|
ctx = MPCacheServerContext(storage_manager_config=config, chunk_size=16)
|
|
|
|
assert ctx.shm_pool_info == expected_pool_info
|
|
|
|
|
|
def test_server_register_and_find_non_cuda_context_layout(
|
|
stub_native_storage_ops: Any,
|
|
server_module_factory: ServerModuleFactory,
|
|
) -> None:
|
|
"""Ensure non-CUDA registration stores metadata and lookup finds layout."""
|
|
module, _, _, ctx = server_module_factory(chunk_size=16)
|
|
response = module.register_kv_cache_engine_driven_context(
|
|
_default_register_payload(instance_id=1)
|
|
)
|
|
assert response.shm_name == ""
|
|
assert response.pool_size == 0
|
|
|
|
layout = ctx.layout_desc_registry.find("m", 1)
|
|
assert layout is not None
|
|
assert layout.shapes[0] == torch.Size([2, 2, 16, 16])
|
|
|
|
|
|
def test_server_store_and_retrieve_cpu_chunks(
|
|
stub_native_storage_ops: Any,
|
|
server_module_factory: ServerModuleFactory,
|
|
) -> None:
|
|
"""Validate mocked server-side CPU chunk store and retrieve behavior."""
|
|
mock_storage = MagicMock()
|
|
target_tensor = torch.zeros(2, 2, 8, 16)
|
|
mock_memory_obj = MagicMock()
|
|
mock_memory_obj.tensor = target_tensor
|
|
mock_storage.reserve_write.return_value = {"obj": mock_memory_obj}
|
|
|
|
@contextmanager
|
|
def _read_prefetched_results(_keys: Any) -> Any:
|
|
yield [mock_memory_obj]
|
|
|
|
mock_storage.read_prefetched_results.side_effect = _read_prefetched_results
|
|
mock_session = MagicMock()
|
|
mock_session.get_hashes.return_value = [b"h"]
|
|
module, _, _, _ = server_module_factory(
|
|
mock_storage=mock_storage,
|
|
mock_session=mock_session,
|
|
)
|
|
module.register_kv_cache_engine_driven_context(
|
|
_default_register_payload(instance_id=2)
|
|
)
|
|
payload = torch.ones(2, 2, 8, 16)
|
|
key = _default_key()
|
|
store_ok = module.commit_store(key, 2, pickle.dumps([payload]))
|
|
response = module.prepare_retrieve(key, 2)
|
|
success = response.success
|
|
cpu_data = response.data
|
|
|
|
assert isinstance(store_ok, bool)
|
|
assert torch.allclose(mock_memory_obj.tensor, payload)
|
|
|
|
assert success is True
|
|
recovered_chunks: list[torch.Tensor] = pickle.loads(cpu_data)
|
|
assert len(recovered_chunks) == 1
|
|
assert torch.allclose(recovered_chunks[0], payload)
|
|
|
|
|
|
def test_server_shm_commit_store_allows_noop_when_all_keys_exist(
|
|
stub_native_storage_ops: Any,
|
|
server_module_factory: ServerModuleFactory,
|
|
) -> None:
|
|
"""Regression: repeated prompt after worker restart should no-op-store cleanly.
|
|
|
|
When all object keys already exist in cache, SHM ``prepare_store`` reserves
|
|
no new objects and returns empty slots (``{"slots": [], "chunk_indices": []}``).
|
|
The worker sees an empty chunk_indices list, skips gather and commit entirely,
|
|
so no entry leaks in ``_pending_shm_writes`` and no spurious error is logged.
|
|
"""
|
|
mock_storage = MagicMock()
|
|
# Empty reserve_write indicates all object keys already exist in cache.
|
|
mock_storage.reserve_write.return_value = {}
|
|
mock_session = MagicMock()
|
|
mock_session.get_hashes.return_value = [b"h"]
|
|
|
|
module, _, _, _ = server_module_factory(
|
|
storage_manager_config=_make_storage_manager_config(
|
|
shm_name="lmcache_test_pool", pool_size=1024
|
|
),
|
|
mock_storage=mock_storage,
|
|
mock_session=mock_session,
|
|
)
|
|
module.register_kv_cache_engine_driven_context(
|
|
_default_register_payload(instance_id=3)
|
|
)
|
|
key = _default_key()
|
|
prepare_response = module.prepare_store(key, 3)
|
|
# Server signals all-cached via empty slots list (not missing "slots" key).
|
|
assert prepare_response.context == {"slots": [], "chunk_indices": []}
|
|
|
|
# commit_store without a matching prepare must fail (no entry leaked).
|
|
assert module.commit_store(key, 3, b"") is False
|
|
|
|
|
|
def test_server_prepare_store_releases_unused_reserved_write_locks(
|
|
stub_native_storage_ops: Any,
|
|
server_module_factory: ServerModuleFactory,
|
|
) -> None:
|
|
"""Ensure SHM prepare_store releases reserved keys that have no writable tensor."""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.protocols.engine import PrepareStoreResponse
|
|
|
|
mock_storage = MagicMock()
|
|
memory_obj = MagicMock()
|
|
memory_obj.tensor = None
|
|
mock_storage.reserve_write.side_effect = lambda obj_keys, *_args, **_kwargs: {
|
|
obj_key: memory_obj for obj_key in obj_keys
|
|
}
|
|
mock_session = MagicMock()
|
|
mock_session.get_hashes.return_value = [b"h"]
|
|
|
|
module, _, _, _ = server_module_factory(
|
|
storage_manager_config=_make_storage_manager_config(
|
|
shm_name="lmcache_test_pool", pool_size=1024
|
|
),
|
|
mock_storage=mock_storage,
|
|
mock_session=mock_session,
|
|
)
|
|
module.register_kv_cache_engine_driven_context(
|
|
_default_register_payload(instance_id=5)
|
|
)
|
|
key = _default_key()
|
|
prepare_response = module.prepare_store(key, 5)
|
|
assert isinstance(prepare_response, PrepareStoreResponse)
|
|
assert prepare_response.context == {"slots": [], "chunk_indices": []}
|
|
reserved_keys = mock_storage.reserve_write.call_args[0][0]
|
|
mock_storage.finish_write.assert_called_once_with(reserved_keys)
|
|
|
|
|
|
def test_server_shm_transport_uses_engine_level_config(
|
|
stub_native_storage_ops: Any,
|
|
server_module_factory: ServerModuleFactory,
|
|
) -> None:
|
|
"""Ensure all instances share the same engine-level SHM transport setting."""
|
|
mock_storage = MagicMock()
|
|
mock_memory_obj = MagicMock()
|
|
mock_memory_obj.tensor = torch.zeros(2, 2, 8, 16)
|
|
mock_memory_obj.shm_offset = 0
|
|
mock_memory_obj.shm_byte_length = 2048
|
|
mock_storage.reserve_write.side_effect = lambda obj_keys, *_args, **_kwargs: {
|
|
obj_key: mock_memory_obj for obj_key in obj_keys
|
|
}
|
|
mock_session = MagicMock()
|
|
mock_session.get_hashes.return_value = [b"h"]
|
|
|
|
module, _, _, _ = server_module_factory(
|
|
storage_manager_config=_make_storage_manager_config(
|
|
shm_name="lmcache_test_pool", pool_size=1024
|
|
),
|
|
mock_storage=mock_storage,
|
|
mock_session=mock_session,
|
|
)
|
|
module.register_kv_cache_engine_driven_context(
|
|
_default_register_payload(instance_id=6)
|
|
)
|
|
module.register_kv_cache_engine_driven_context(
|
|
_default_register_payload(instance_id=7)
|
|
)
|
|
key = _default_key()
|
|
assert module.prepare_store(key, 6).context.get("slots")
|
|
assert module.prepare_store(key, 7).context.get("slots")
|
|
assert mock_storage.reserve_write.call_count == 2
|
|
|
|
|
|
def test_server_engine_driven_reregister_returns_existing_shm_response(
|
|
stub_native_storage_ops: Any,
|
|
server_module_factory: ServerModuleFactory,
|
|
) -> None:
|
|
"""Ensure duplicate non-GPU registration returns existing SHM response."""
|
|
module, _, _, _ = server_module_factory(
|
|
storage_manager_config=_make_storage_manager_config(
|
|
shm_name="lmcache_test_pool", pool_size=2048
|
|
),
|
|
)
|
|
payload = _default_register_payload(instance_id=8)
|
|
first_response = module.register_kv_cache_engine_driven_context(payload)
|
|
second_response = module.register_kv_cache_engine_driven_context(payload)
|
|
|
|
assert first_response.shm_name == "lmcache_l1_pool_lmcache_test_pool"
|
|
assert first_response.pool_size == 2048
|
|
assert second_response.shm_name == "lmcache_l1_pool_lmcache_test_pool"
|
|
assert second_response.pool_size == 2048
|
|
|
|
|
|
def test_server_unregister_engine_driven_context_releases_pending_shm_locks(
|
|
stub_native_storage_ops: Any,
|
|
server_module_factory: ServerModuleFactory,
|
|
) -> None:
|
|
"""Ensure unregister releases pending SHM read/write reservations."""
|
|
mock_storage = MagicMock()
|
|
mock_memory_obj = MagicMock()
|
|
mock_memory_obj.tensor = torch.zeros(2, 2, 8, 16)
|
|
mock_memory_obj.shm_offset = 0
|
|
mock_memory_obj.shm_byte_length = 2048
|
|
mock_storage.reserve_write.side_effect = lambda obj_keys, *_args, **_kwargs: {
|
|
obj_key: mock_memory_obj for obj_key in obj_keys
|
|
}
|
|
mock_storage.unsafe_read.side_effect = lambda obj_keys: (
|
|
obj_keys,
|
|
[mock_memory_obj for _ in obj_keys],
|
|
)
|
|
mock_session = MagicMock()
|
|
mock_session.get_hashes.return_value = [b"h"]
|
|
|
|
module, _, _, _ = server_module_factory(
|
|
storage_manager_config=_make_storage_manager_config(
|
|
shm_name="lmcache_test_pool", pool_size=4096
|
|
),
|
|
mock_storage=mock_storage,
|
|
mock_session=mock_session,
|
|
)
|
|
module.register_kv_cache_engine_driven_context(
|
|
_default_register_payload(instance_id=4)
|
|
)
|
|
key = _default_key()
|
|
assert module.prepare_store(key, 4).context.get("slots")
|
|
assert module.prepare_retrieve(key, 4).success is True
|
|
|
|
module.unregister_kv_cache(4)
|
|
|
|
mock_storage.finish_write.assert_called_once()
|
|
mock_storage.finish_read_prefetched.assert_called_once()
|
|
|
|
|
|
def test_gather_paged_kv_with_chunk_indices_subset() -> None:
|
|
"""gather_paged_kv_to_cpu with chunk_indices only gathers the specified chunks.
|
|
|
|
This tests the fix for the IndexError that occurred when SHM prepare_store
|
|
returned fewer slots than total chunks because some chunks already existed
|
|
in cache.
|
|
"""
|
|
# First Party
|
|
from lmcache.v1.multiprocess.transfer_context.base import gather_paged_kv_to_cpu
|
|
|
|
# 3 chunks (6 blocks, 2 blocks per chunk), but we only want chunks 0 and 2
|
|
source = {
|
|
k: v.to(torch_device_type)
|
|
for k, v in _make_kv_caches(
|
|
num_layers=2,
|
|
num_blocks=6,
|
|
block_size=4,
|
|
num_heads=4,
|
|
head_size=4,
|
|
).items()
|
|
}
|
|
blocks_per_chunk = 2
|
|
# Pre-allocate output buffers for chunks 0 and 2 only (2 tensors, not 3).
|
|
# Shape: [2, num_layers, chunk_tokens, hidden_dim] where
|
|
# chunk_tokens = blocks_per_chunk * block_size = 2 * 4 = 8.
|
|
out0 = torch.zeros(2, 2, 8, 16)
|
|
out2 = torch.zeros(2, 2, 8, 16)
|
|
out_buffers = [out0, out2]
|
|
|
|
# With chunk_indices=[0, 2], gather only chunks at positions 0 and 2
|
|
# block_ids has 6 entries: [0,1] for chunk 0, [2,3] for chunk 1, [4,5] for chunk 2
|
|
result = gather_paged_kv_to_cpu(
|
|
source,
|
|
block_ids=[0, 1, 2, 3, 4, 5],
|
|
blocks_per_chunk=blocks_per_chunk,
|
|
out=out_buffers,
|
|
chunk_indices=[0, 2],
|
|
)
|
|
torch_dev.synchronize()
|
|
# Result should be the same list as out_buffers (in-place fill)
|
|
assert result is out_buffers
|
|
|
|
# out_buffers[0] should contain chunk 0 (blocks 0,1) data
|
|
# out_buffers[1] should contain chunk 2 (blocks 4,5) data
|
|
# Verify by independently gathering all chunks and comparing
|
|
all_chunks = gather_paged_kv_to_cpu(source, [0, 1, 2, 3, 4, 5], blocks_per_chunk)
|
|
torch_dev.synchronize()
|
|
|
|
assert torch.allclose(out_buffers[0], all_chunks[0])
|
|
assert torch.allclose(out_buffers[1], all_chunks[2])
|
|
|
|
|
|
def test_server_prepare_store_includes_chunk_indices(
|
|
stub_native_storage_ops: Any,
|
|
server_module_factory: ServerModuleFactory,
|
|
) -> None:
|
|
"""prepare_store response context includes chunk_indices for SHM mode.
|
|
|
|
Regression test: the server must return the positional indices of the
|
|
reserved chunks so the client only gathers KV data for those chunks.
|
|
"""
|
|
mock_storage = MagicMock()
|
|
obj1 = "obj1"
|
|
obj2 = "obj2"
|
|
mock_memory_obj = MagicMock()
|
|
mock_memory_obj.tensor = torch.zeros(2, 2, 8, 16)
|
|
mock_memory_obj.shm_offset = 0
|
|
mock_memory_obj.shm_byte_length = 2048
|
|
# Only obj2 (index 1) is reserved; obj1 (index 0) already exists in cache.
|
|
mock_storage.reserve_write.return_value = {obj2: mock_memory_obj}
|
|
mock_session = MagicMock()
|
|
mock_session.get_hashes.return_value = [b"h1", b"h2"]
|
|
|
|
module, _, _, _ = server_module_factory(
|
|
storage_manager_config=_make_storage_manager_config(
|
|
shm_name="lmcache_test_pool", pool_size=4096
|
|
),
|
|
object_keys=[obj1, obj2],
|
|
mock_storage=mock_storage,
|
|
mock_session=mock_session,
|
|
)
|
|
module.register_kv_cache_engine_driven_context(
|
|
_default_register_payload(instance_id=10)
|
|
)
|
|
key = _default_key(tokens=16)
|
|
response = module.prepare_store(key, 10)
|
|
response_context = response.context
|
|
|
|
# slots should have 1 entry (only obj2 reserved)
|
|
assert len(response_context.get("slots", [])) == 1
|
|
# chunk_indices should be [1] (position of obj2 in [obj1, obj2])
|
|
assert response_context.get("chunk_indices") == [1]
|
|
|
|
|
|
class _CompletedFuture:
|
|
def __init__(self, value):
|
|
self._value = value
|
|
|
|
def wait(self, timeout=None): # noqa: ARG002
|
|
return True
|
|
|
|
def result(self, timeout=None): # noqa: ARG002
|
|
return self._value
|
|
|
|
|
|
def _create_shm_segment(shm_name: str, size: int) -> int:
|
|
"""Create a POSIX SHM segment via the project facade.
|
|
|
|
Returns the owner mmap address so the test can release the segment
|
|
with ``shm_munmap`` + ``shm_unlink`` regardless of platform
|
|
(Linux/macOS), instead of hard-coding ``/dev/shm`` paths.
|
|
"""
|
|
return shm_create_readwrite(shm_name, size)
|
|
|
|
|
|
def test_engine_driven_context_shm_tensor_view_from_buffer() -> None:
|
|
shm_name = f"lmcache_test_view_{os.getpid()}"
|
|
addr = _create_shm_segment(shm_name, 4096)
|
|
try:
|
|
mm = shm_open_pool_as_mmap(shm_name, 4096)
|
|
try:
|
|
src = torch.arange(8, dtype=torch.float32).reshape(2, 4)
|
|
mm[: src.numel() * src.element_size()] = src.numpy().tobytes()
|
|
finally:
|
|
mm.close()
|
|
|
|
context = EngineDrivenContextShm(
|
|
metadata=EngineDrivenContextMetadata(
|
|
layout_desc=MemoryLayoutDesc(
|
|
shapes=[torch.Size([2, 4])],
|
|
dtypes=[torch.float32],
|
|
),
|
|
block_size=1,
|
|
use_mla=False,
|
|
),
|
|
mq_client=MagicMock(),
|
|
mq_timeout=1.0,
|
|
shm_name=shm_name,
|
|
pool_size=4096,
|
|
)
|
|
try:
|
|
view = context._make_tensor_view(
|
|
offset=0,
|
|
length=src.numel() * src.element_size(),
|
|
shape=[2, 4],
|
|
dtype_str="float32",
|
|
)
|
|
assert torch.equal(view, src)
|
|
finally:
|
|
context.close()
|
|
finally:
|
|
shm_munmap(addr, 4096)
|
|
shm_unlink(shm_name)
|
|
|
|
|
|
def test_engine_driven_context_shm_store_retrieve_flow_with_mocked_mq() -> None:
|
|
shm_name = f"lmcache_test_flow_{os.getpid()}"
|
|
addr = _create_shm_segment(shm_name, 4096)
|
|
slots = [
|
|
{
|
|
"offset": 0,
|
|
"length": 16,
|
|
"shape": [2, 2],
|
|
"dtype": "float32",
|
|
}
|
|
]
|
|
|
|
mq_client = MagicMock()
|
|
|
|
def _submit_request(req_type, payload, response_cls): # noqa: ARG001
|
|
if req_type == RequestType.PREPARE_STORE:
|
|
return _CompletedFuture(
|
|
PrepareStoreResponse(context={"slots": slots, "chunk_indices": [0]})
|
|
)
|
|
if req_type == RequestType.COMMIT_STORE:
|
|
_, _, commit_cpu_data = payload
|
|
assert commit_cpu_data == b""
|
|
return _CompletedFuture(True)
|
|
if req_type == RequestType.PREPARE_RETRIEVE:
|
|
return _CompletedFuture(
|
|
PrepareRetrieveResponse(
|
|
success=True, data=b"", context={"slots": slots}
|
|
)
|
|
)
|
|
if req_type == RequestType.COMMIT_RETRIEVE:
|
|
return _CompletedFuture(True)
|
|
raise AssertionError(f"Unexpected request type: {req_type}")
|
|
|
|
mq_client.submit_request.side_effect = _submit_request
|
|
|
|
context = EngineDrivenContextShm(
|
|
metadata=EngineDrivenContextMetadata(
|
|
layout_desc=MemoryLayoutDesc(
|
|
shapes=[torch.Size([2, 2])],
|
|
dtypes=[torch.float32],
|
|
),
|
|
block_size=1,
|
|
use_mla=False,
|
|
),
|
|
mq_client=mq_client,
|
|
mq_timeout=1.0,
|
|
shm_name=shm_name,
|
|
pool_size=4096,
|
|
)
|
|
try:
|
|
key = _default_key()
|
|
store_result = context.prepare_store(key=key, instance_id=1)
|
|
assert store_result is not None
|
|
store_views, _ = store_result
|
|
store_views[0].copy_(
|
|
torch.tensor([[1.0, 2.0], [3.0, 4.0]], dtype=torch.float32)
|
|
)
|
|
assert context.commit_store(key, 1, store_views)
|
|
|
|
retrieve_views = context.prepare_retrieve(key=key, instance_id=1)
|
|
assert retrieve_views is not None
|
|
assert torch.equal(
|
|
retrieve_views[0],
|
|
torch.tensor([[1.0, 2.0], [3.0, 4.0]], dtype=torch.float32),
|
|
)
|
|
assert context.commit_retrieve(key, 1)
|
|
finally:
|
|
context.close()
|
|
shm_munmap(addr, 4096)
|
|
shm_unlink(shm_name)
|
|
|
|
|
|
def test_engine_driven_context_shm_init_raises_when_segment_missing() -> None:
|
|
with pytest.raises(FileNotFoundError, match="No such file or directory"):
|
|
EngineDrivenContextShm(
|
|
metadata=EngineDrivenContextMetadata(
|
|
layout_desc=MemoryLayoutDesc(
|
|
shapes=[torch.Size([2, 2])],
|
|
dtypes=[torch.float32],
|
|
),
|
|
block_size=1,
|
|
use_mla=False,
|
|
),
|
|
mq_client=MagicMock(),
|
|
mq_timeout=1.0,
|
|
shm_name="lmcache_missing_shm_segment",
|
|
pool_size=4096,
|
|
)
|
|
|
|
|
|
def test_create_engine_driven_context_falls_back_to_pickle_without_shm_info() -> None:
|
|
context = create_engine_driven_context(
|
|
metadata=EngineDrivenContextMetadata(
|
|
layout_desc=MemoryLayoutDesc(
|
|
shapes=[torch.Size([2, 2])],
|
|
dtypes=[torch.float32],
|
|
),
|
|
block_size=1,
|
|
use_mla=False,
|
|
),
|
|
mq_client=MagicMock(),
|
|
mq_timeout=1.0,
|
|
shm_name="",
|
|
pool_size=0,
|
|
)
|
|
assert isinstance(context, EngineDrivenContextPickle)
|
|
|
|
|
|
def test_create_engine_driven_context_use_pickle_ignores_valid_shm_info() -> None:
|
|
context = create_engine_driven_context(
|
|
metadata=EngineDrivenContextMetadata(
|
|
layout_desc=MemoryLayoutDesc(
|
|
shapes=[torch.Size([2, 2])],
|
|
dtypes=[torch.float32],
|
|
),
|
|
block_size=1,
|
|
use_mla=False,
|
|
),
|
|
mq_client=MagicMock(),
|
|
mq_timeout=1.0,
|
|
shm_name="lmcache_valid_shm",
|
|
pool_size=4096,
|
|
use_pickle=True,
|
|
)
|
|
assert isinstance(context, EngineDrivenContextPickle)
|
|
|
|
|
|
def test_engine_driven_context_shm_close_is_idempotent() -> None:
|
|
shm_name = f"lmcache_test_close_{os.getpid()}"
|
|
addr = _create_shm_segment(shm_name, 4096)
|
|
try:
|
|
context = EngineDrivenContextShm(
|
|
metadata=EngineDrivenContextMetadata(
|
|
layout_desc=MemoryLayoutDesc(
|
|
shapes=[torch.Size([2, 2])],
|
|
dtypes=[torch.float32],
|
|
),
|
|
block_size=1,
|
|
use_mla=False,
|
|
),
|
|
mq_client=MagicMock(),
|
|
mq_timeout=1.0,
|
|
shm_name=shm_name,
|
|
pool_size=4096,
|
|
)
|
|
context.close()
|
|
context.close()
|
|
finally:
|
|
shm_munmap(addr, 4096)
|
|
shm_unlink(shm_name)
|