Files
lmcache--lmcache/tests/v1/platform/test_gpu_cache_context.py
T
2026-07-13 12:24:33 +08:00

551 lines
21 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Unit tests for the temp-GPU-buffer machinery in
``lmcache.v1.platform.cuda.cache_context``.
Two layers are exercised:
* ``_TempGPUBuffer`` -- the standalone buffer manager. It is built directly
from a real :class:`KVLayerGroupsManager` (its constructor is fully public),
so the layout invariants (per-kernel-group shape/dtype, per-object-group flat
views, non-overlap, write isolation, byte sizing) are tested in isolation.
* ``GPUCacheContext`` -- the higher-level context that owns a ``_TempGPUBuffer``
and exposes the per-kernel-group / per-object-group buffer accessors plus
``get_kernel_group_kv_pointers``, ``calculate_num_blocks``,
``kv_layer_groups_manager`` and ``report_status``. It is built through its
real public constructor using a lightweight ``to_tensor`` test double in place
of ``CudaIPCWrapper`` (same-process CUDA IPC cannot reimport its own handle).
"""
# Standard
from collections.abc import Sequence
# Third Party
import pytest
import torch
pytestmark = pytest.mark.skipif(
not torch.cuda.is_available(), reason="CUDA not available"
)
# First Party
from lmcache.v1.kv_layer_groups import KVLayerGroupsManager # noqa: E402
from lmcache.v1.multiprocess.group_view import EngineGroupInfo # noqa: E402
from lmcache.v1.platform.cuda.cache_context import ( # noqa: E402
GPUCacheContext,
_TempGPUBuffer,
)
import lmcache.c_ops as lmc_ops # noqa: E402
_DEVICE = torch.device("cuda")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
class _GroupSpec:
"""Description of one homogeneous block of KV layers used to build the
synthetic ``[2, NB, BS, NH, HS]`` (non-MLA) tensors fed to the manager."""
def __init__(
self,
num_layers: int,
num_heads: int = 8,
head_size: int = 64,
block_size: int = 16,
dtype: torch.dtype = torch.bfloat16,
) -> None:
self.num_layers = num_layers
self.num_heads = num_heads
self.head_size = head_size
self.block_size = block_size
self.dtype = dtype
def _make_kv_tensors(
specs: Sequence[_GroupSpec],
num_blocks: int = 4,
) -> list[torch.Tensor]:
"""Build non-MLA per-layer KV tensors shaped ``[2, NB, BS, NH, HS]``."""
tensors: list[torch.Tensor] = []
for spec in specs:
for _ in range(spec.num_layers):
tensors.append(
torch.empty(
2,
num_blocks,
spec.block_size,
spec.num_heads,
spec.head_size,
dtype=spec.dtype,
device=_DEVICE,
)
)
return tensors
def _build_manager(
tensors: list[torch.Tensor],
engine_kv_format: "lmc_ops.EngineKVFormat" = (
lmc_ops.EngineKVFormat.NL_X_TWO_NB_BS_NH_HS
),
engine_group_infos: Sequence[EngineGroupInfo] = (),
lmcache_tokens_per_chunk: int = 256,
) -> KVLayerGroupsManager:
"""Build a real :class:`KVLayerGroupsManager` from synthetic tensors."""
return KVLayerGroupsManager(
tensors,
engine_kv_formats=[engine_kv_format] * len(tensors),
engine_group_infos=engine_group_infos,
lmcache_tokens_per_chunk=lmcache_tokens_per_chunk,
)
def _make_temp_buffer(
specs: Sequence[_GroupSpec],
chunk_size: int = 256,
max_batch_size: int = 4,
num_blocks: int = 4,
engine_group_infos: Sequence[EngineGroupInfo] = (),
) -> _TempGPUBuffer:
"""Build a ``_TempGPUBuffer`` backed by a real manager."""
tensors = _make_kv_tensors(specs, num_blocks=num_blocks)
manager = _build_manager(
tensors,
engine_group_infos=engine_group_infos,
lmcache_tokens_per_chunk=chunk_size,
)
return _TempGPUBuffer(
kv_layer_groups_manager=manager,
lmcache_tokens_per_chunk=chunk_size,
device=_DEVICE,
max_batch_size=max_batch_size,
)
def _expected_kernel_group_shape(
manager: KVLayerGroupsManager, num_tokens: int, kernel_group_idx: int
) -> torch.Size:
"""Compute the expected kernel-group buffer shape from the manager's
public metadata (kv_size, num_layers, slots, hidden_dim)."""
group = manager.kernel_groups[kernel_group_idx]
num_slots = num_tokens * group.slots_per_block // group.tokens_per_block
return torch.Size(
(
group.shape_desc.kv_size,
group.num_layers,
num_slots,
group.hidden_dim_size,
)
)
def _expected_kernel_group_bytes(
manager: KVLayerGroupsManager, chunk_size: int, kernel_group_idx: int
) -> int:
"""Byte size of one kernel group's per-chunk buffer."""
group = manager.kernel_groups[kernel_group_idx]
shape = _expected_kernel_group_shape(manager, chunk_size, kernel_group_idx)
return shape.numel() * group.dtype.itemsize
def _byte_region(buf: torch.Tensor) -> tuple[int, int]:
"""Return ``(start_ptr, end_ptr)`` covering a tensor's bytes."""
start = buf.data_ptr()
return start, start + buf.nelement() * buf.element_size()
def _assert_disjoint(regions: list[tuple[int, int, str]]) -> None:
"""Assert that no two ``(start, end, label)`` byte ranges overlap."""
for i in range(len(regions)):
for j in range(i + 1, len(regions)):
s_i, e_i, label_i = regions[i]
s_j, e_j, label_j = regions[j]
assert e_i <= s_j or e_j <= s_i, f"Overlap between {label_i} and {label_j}"
class _FakeIPCWrapper:
"""Test-only stand-in for ``CudaIPCWrapper``.
``GPUCacheContext`` only needs ``to_tensor()`` from each entry of its
``kv_caches`` argument. Same-process CUDA IPC cannot reopen its own handle,
so this test double simply hands back a locally allocated CUDA tensor,
letting the real ``GPUCacheContext`` constructor run end to end.
"""
def __init__(self, tensor: torch.Tensor) -> None:
self._tensor = tensor
def to_tensor(self) -> torch.Tensor:
"""Return the wrapped local CUDA tensor (test-only)."""
return self._tensor
def _make_context(
specs: Sequence[_GroupSpec],
chunk_size: int = 256,
num_blocks: int = 4,
engine_group_infos: Sequence[EngineGroupInfo] = (),
) -> GPUCacheContext:
"""Build a real ``GPUCacheContext`` via its public constructor."""
tensors = _make_kv_tensors(specs, num_blocks=num_blocks)
kv_caches = [_FakeIPCWrapper(t) for t in tensors]
return GPUCacheContext(
kv_caches, # type: ignore
lmcache_tokens_per_chunk=chunk_size,
engine_group_infos=engine_group_infos,
)
# Common group layouts reused across tests.
_SINGLE_GROUP = [_GroupSpec(num_layers=4, num_heads=8, head_size=64)]
_MULTI_GROUP = [
_GroupSpec(num_layers=4, num_heads=8, head_size=64, dtype=torch.bfloat16),
_GroupSpec(num_layers=2, num_heads=16, head_size=64, dtype=torch.float16),
]
# ---------------------------------------------------------------------------
# _TempGPUBuffer tests
# ---------------------------------------------------------------------------
class TestTempGPUBufferConstruction:
def test_max_batch_size_property(self) -> None:
buf = _make_temp_buffer(_SINGLE_GROUP, max_batch_size=3)
assert buf.max_batch_size == 3
class TestTempGPUBufferKernelGroupBuffer:
def test_shape_and_dtype(self) -> None:
tensors = _make_kv_tensors(_MULTI_GROUP)
manager = _build_manager(tensors)
buf = _TempGPUBuffer(manager, 256, _DEVICE)
for kg in range(manager.num_kernel_groups):
tensor = buf.get_temp_kernel_group_buffer(0, kg)
assert tensor.shape == _expected_kernel_group_shape(manager, 256, kg)
assert tensor.dtype == manager.kernel_groups[kg].dtype
def test_contiguous(self) -> None:
buf = _make_temp_buffer(_SINGLE_GROUP)
assert buf.get_temp_kernel_group_buffer(0, 0).is_contiguous()
def test_repeated_calls_same_ptr(self) -> None:
buf = _make_temp_buffer(_SINGLE_GROUP)
first = buf.get_temp_kernel_group_buffer(1, 0)
second = buf.get_temp_kernel_group_buffer(1, 0)
assert first.data_ptr() == second.data_ptr()
def test_invalid_batch_idx_raises(self) -> None:
buf = _make_temp_buffer(_SINGLE_GROUP, max_batch_size=4)
with pytest.raises(ValueError, match="Invalid batch_idx"):
buf.get_temp_kernel_group_buffer(4, 0)
def test_invalid_kernel_group_idx_raises(self) -> None:
buf = _make_temp_buffer(_SINGLE_GROUP)
with pytest.raises(ValueError, match="kernel_group_idx"):
buf.get_temp_kernel_group_buffer(0, 99)
def test_buffers_non_overlapping(self) -> None:
"""Every (batch, kernel_group) buffer occupies disjoint memory."""
tensors = _make_kv_tensors(_MULTI_GROUP)
manager = _build_manager(tensors)
max_batch_size = 4
buf = _TempGPUBuffer(manager, 256, _DEVICE, max_batch_size=max_batch_size)
regions: list[tuple[int, int, str]] = []
for batch in range(max_batch_size):
for kg in range(manager.num_kernel_groups):
tensor = buf.get_temp_kernel_group_buffer(batch, kg)
start, end = _byte_region(tensor)
regions.append((start, end, f"batch={batch},kg={kg}"))
_assert_disjoint(regions)
def test_write_isolation(self) -> None:
"""Writing to one batch slot must not corrupt another."""
buf = _make_temp_buffer(
[_GroupSpec(num_layers=2, num_heads=2, head_size=16)],
chunk_size=32,
max_batch_size=4,
)
for batch in range(4):
buf.get_temp_kernel_group_buffer(batch, 0).fill_(float(batch + 1))
for batch in range(4):
tensor = buf.get_temp_kernel_group_buffer(batch, 0).to(torch.float32)
assert tensor.min().item() == pytest.approx(batch + 1, rel=1e-3)
assert tensor.max().item() == pytest.approx(batch + 1, rel=1e-3)
class TestTempGPUBufferObjectGroupBuffer:
def test_flat_uint8(self) -> None:
buf = _make_temp_buffer(_MULTI_GROUP)
tensor = buf.get_temp_object_group_buffer(0, 0)
assert tensor.dtype == torch.uint8
assert tensor.dim() == 1
assert tensor.is_contiguous()
def test_size_covers_all_kernel_groups(self) -> None:
"""The single object group's flat buffer spans every kernel group's
bytes for one chunk."""
tensors = _make_kv_tensors(_MULTI_GROUP)
manager = _build_manager(tensors)
chunk_size = 256
buf = _TempGPUBuffer(manager, chunk_size, _DEVICE)
obj_group = manager.object_groups[0]
expected_bytes = sum(
_expected_kernel_group_bytes(manager, chunk_size, kg)
for kg in obj_group.kernel_group_indices
)
assert buf.get_temp_object_group_buffer(0, 0).numel() == expected_bytes
def test_starts_at_first_kernel_group(self) -> None:
"""The object-group flat view aliases the same memory as its first
kernel group's buffer."""
tensors = _make_kv_tensors(_MULTI_GROUP)
manager = _build_manager(tensors)
buf = _TempGPUBuffer(manager, 256, _DEVICE)
first_kg = manager.object_groups[0].kernel_group_indices[0]
obj_buf = buf.get_temp_object_group_buffer(0, 0)
kg_buf = buf.get_temp_kernel_group_buffer(0, first_kg)
assert obj_buf.data_ptr() == kg_buf.data_ptr()
def test_invalid_indices_raise(self) -> None:
buf = _make_temp_buffer(_SINGLE_GROUP, max_batch_size=4)
with pytest.raises(ValueError, match="object_group_idx"):
buf.get_temp_object_group_buffer(0, 99)
with pytest.raises(ValueError, match="batch_idx"):
buf.get_temp_object_group_buffer(4, 0)
def test_contains_kernel_group_data(self) -> None:
"""Bytes written through kernel-group views are visible through the
object-group flat view at matching offsets."""
tensors = _make_kv_tensors(_MULTI_GROUP)
chunk_size = 64
manager = _build_manager(tensors, lmcache_tokens_per_chunk=chunk_size)
buf = _TempGPUBuffer(manager, chunk_size, _DEVICE)
obj_group = manager.object_groups[0]
for offset_kg, kg in enumerate(obj_group.kernel_group_indices):
buf.get_temp_kernel_group_buffer(0, kg).view(torch.uint8).fill_(
offset_kg + 1
)
flat = buf.get_temp_object_group_buffer(0, 0)
cursor = 0
for offset_kg, kg in enumerate(obj_group.kernel_group_indices):
size = _expected_kernel_group_bytes(manager, chunk_size, kg)
region = flat[cursor : cursor + size]
assert region.min().item() == offset_kg + 1
assert region.max().item() == offset_kg + 1
cursor += size
def test_object_groups_non_overlapping(self) -> None:
"""Object-group buffers across batch slots occupy disjoint memory."""
tensors = _make_kv_tensors(_MULTI_GROUP)
manager = _build_manager(tensors)
max_batch_size = 4
buf = _TempGPUBuffer(manager, 256, _DEVICE, max_batch_size=max_batch_size)
regions: list[tuple[int, int, str]] = []
for batch in range(max_batch_size):
for og in range(manager.num_object_groups):
start, end = _byte_region(buf.get_temp_object_group_buffer(batch, og))
regions.append((start, end, f"batch={batch},og={og}"))
_assert_disjoint(regions)
class TestTempGPUBufferShapeDtype:
def test_shape_scales_with_num_tokens(self) -> None:
# num_tokens must be a whole number of chunks; the shape scales
# linearly with the chunk count.
tensors = _make_kv_tensors(_SINGLE_GROUP)
manager = _build_manager(tensors)
buf = _TempGPUBuffer(manager, 256, _DEVICE)
for num_tokens in (256, 512, 768):
shape, dtype = buf.get_kernel_group_shape_dtype(num_tokens, 0)
assert shape == _expected_kernel_group_shape(manager, num_tokens, 0)
assert dtype == manager.kernel_groups[0].dtype
def test_shape_compressed_group(self) -> None:
"""For a compressed group, the token dim is divided by compress_ratio."""
tensors = _make_kv_tensors([_GroupSpec(num_layers=2, block_size=8)])
manager = _build_manager(
tensors,
engine_group_infos=[EngineGroupInfo(0, (0, 1), tokens_per_block=16)],
)
kg = manager.kernel_groups[0]
assert kg.tokens_per_block // kg.slots_per_block == 2
buf = _TempGPUBuffer(manager, 256, _DEVICE)
shape, _ = buf.get_kernel_group_shape_dtype(256, 0)
assert shape[2] == 256 // 2
def test_not_whole_chunks_raises(self) -> None:
tensors = _make_kv_tensors([_GroupSpec(num_layers=2, block_size=8)])
manager = _build_manager(
tensors,
engine_group_infos=[EngineGroupInfo(0, (0, 1), tokens_per_block=16)],
)
buf = _TempGPUBuffer(manager, 256, _DEVICE)
with pytest.raises(ValueError, match="must be a multiple of"):
buf.get_kernel_group_shape_dtype(255, 0)
class TestTempGPUBufferCacheSize:
def test_cache_size_per_token(self) -> None:
tensors = _make_kv_tensors(_MULTI_GROUP)
manager = _build_manager(tensors)
chunk_size = 256
buf = _TempGPUBuffer(manager, chunk_size, _DEVICE)
expected = (
sum(
_expected_kernel_group_bytes(manager, chunk_size, kg)
for kg in range(manager.num_kernel_groups)
)
// chunk_size
)
assert buf.get_cache_size_per_token() == expected
def test_cache_size_per_token_compressed(self) -> None:
"""Compression halves per-physical-slot bytes, so the per-logical-token
size of a 2x-compressed group is half its uncompressed counterpart."""
uncompressed = _make_temp_buffer([_GroupSpec(num_layers=2, block_size=16)])
compressed = _make_temp_buffer(
[_GroupSpec(num_layers=2, block_size=8)],
engine_group_infos=[EngineGroupInfo(0, (0, 1), tokens_per_block=16)],
)
assert (
compressed.get_cache_size_per_token() * 2
== uncompressed.get_cache_size_per_token()
)
# ---------------------------------------------------------------------------
# GPUCacheContext tests
# ---------------------------------------------------------------------------
class TestGPUCacheContextBuffers:
def test_max_batch_size(self) -> None:
ctx = _make_context(_SINGLE_GROUP)
assert ctx.max_batch_size == 4
def test_kv_layer_groups_manager(self) -> None:
ctx = _make_context(_MULTI_GROUP)
manager = ctx.kv_layer_groups_manager
assert isinstance(manager, KVLayerGroupsManager)
assert manager.num_kernel_groups == 2
def test_get_temp_kernel_group_buffer(self) -> None:
ctx = _make_context(_MULTI_GROUP)
manager = ctx.kv_layer_groups_manager
for kg in range(manager.num_kernel_groups):
tensor = ctx.get_temp_kernel_group_buffer(0, kg)
assert tensor.shape == _expected_kernel_group_shape(manager, 256, kg)
assert tensor.dtype == manager.kernel_groups[kg].dtype
def test_get_temp_object_group_buffer(self) -> None:
ctx = _make_context(_MULTI_GROUP)
tensor = ctx.get_temp_object_group_buffer(0, 0)
assert tensor.dtype == torch.uint8
assert tensor.dim() == 1
def test_get_kernel_group_shape_dtype(self) -> None:
ctx = _make_context(_SINGLE_GROUP)
manager = ctx.kv_layer_groups_manager
shape, dtype = ctx.get_kernel_group_shape_dtype(256, 0)
assert shape == _expected_kernel_group_shape(manager, 256, 0)
assert dtype == manager.kernel_groups[0].dtype
class TestGPUCacheContextPointers:
def test_get_kernel_group_kv_pointers(self) -> None:
ctx = _make_context(_MULTI_GROUP)
manager = ctx.kv_layer_groups_manager
for kg in range(manager.num_kernel_groups):
pointers = ctx.get_kernel_group_kv_pointers(kg)
assert pointers.dtype == torch.long
# One pointer per layer in the group.
assert pointers.numel() == manager.kernel_groups[kg].num_layers
class TestGPUCacheContextBlocks:
def test_calculate_num_blocks_uncompressed(self) -> None:
# block_size=16, compress_ratio=1 -> 256 tokens span 16 blocks.
ctx = _make_context([_GroupSpec(num_layers=2, block_size=16)])
assert ctx.calculate_num_blocks(256, 0) == 16
def test_calculate_num_blocks_matches_manager(self) -> None:
ctx = _make_context(_MULTI_GROUP)
manager = ctx.kv_layer_groups_manager
for kg in range(manager.num_kernel_groups):
assert ctx.calculate_num_blocks(256, kg) == manager.calculate_num_blocks(
kg, 256
)
class TestGPUCacheContextReportStatus:
_TOP_LEVEL_KEYS = {
"num_layers",
"num_blocks",
"cache_size_per_token",
"kernel_groups",
}
_GROUP_KEYS = {
"kernel_group_idx",
"engine_group_idx",
"object_group_idx",
"num_layers",
"layer_indices",
"tokens_per_block",
"slots_per_block",
"dtype",
"engine_kv_concrete_shape",
"is_mla",
"engine_kv_format",
"engine_kv_shape",
"attention_backend",
}
def test_report_status_fields(self) -> None:
ctx = _make_context(_SINGLE_GROUP)
status = ctx.report_status()
assert set(status.keys()) == self._TOP_LEVEL_KEYS
assert status["num_layers"] == 4
assert status["cache_size_per_token"] == ctx.cache_size_per_token()
assert len(status["kernel_groups"]) == 1
group = status["kernel_groups"][0]
assert set(group.keys()) == self._GROUP_KEYS
assert group["kernel_group_idx"] == 0
assert group["num_layers"] == 4
assert group["layer_indices"] == [0, 1, 2, 3]
assert group["is_mla"] is False
assert group["engine_kv_format"] == "NL_X_TWO_NB_BS_NH_HS"
assert group["dtype"] == str(ctx.kv_tensors[0].dtype)
def test_report_status_multi_group(self) -> None:
ctx = _make_context(_MULTI_GROUP)
manager = ctx.kv_layer_groups_manager
status = ctx.report_status()
assert status["num_layers"] == 6
assert len(status["kernel_groups"]) == manager.num_kernel_groups
# Group reports enumerate in order and stay self-consistent with the
# manager's kernel groups.
for kg_idx, (group, kernel_group) in enumerate(
zip(status["kernel_groups"], manager.kernel_groups, strict=False)
):
assert set(group.keys()) == self._GROUP_KEYS
assert group["kernel_group_idx"] == kg_idx
assert group["engine_group_idx"] == kernel_group.engine_group_idx
assert group["num_layers"] == kernel_group.num_layers
assert group["slots_per_block"] == kernel_group.slots_per_block
assert group["tokens_per_block"] == kernel_group.tokens_per_block
assert 0 <= group["object_group_idx"] < manager.num_object_groups
if __name__ == "__main__":
pytest.main([__file__, "-v"])