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

1159 lines
41 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Internal helpers for ``lmcache bench server``.
This module owns the heavy runtime imports (``torch`` / ``zmq`` /
``lmcache.v1.*``) and all pure / low-level helper functions used by
the ``server`` bench target. The CLI registration and execute
orchestration live in :mod:`lmcache.cli.commands.bench.server_bench.command`.
Splitting the module this way keeps the public command surface in line
with the ``engine_bench`` and ``l2_adapter_bench`` siblings, while
still quarantining the heavy imports behind a single guarded block so
the slim ``lmcache-cli`` install can load the bench parser without
torch / zmq.
"""
# Future
from __future__ import annotations
# Standard
from dataclasses import dataclass
from typing import Any
import ctypes
import hashlib
import json
import mmap
import sys
import time
import urllib.error
import urllib.request
# First Party
from lmcache import torch_dev, torch_device_type
# ``lmcache bench server`` allocates real CUDA tensors and talks to
# the MP server via ZMQ, both of which are absent from the thin
# ``lmcache-cli`` distribution (no torch, no zmq, no lmcache.v1.*).
# Importing them unconditionally would kill the *entire* ``lmcache``
# CLI at registry load time with an opaque ImportError. Wrap the
# heavy imports and remember the error so ``add_arguments`` /
# ``execute`` can bail out with an actionable install hint.
_IMPORT_ERROR: ImportError | None = None
try:
# Third Party
import torch
import zmq # noqa: F401 # availability probe; used by command.py
# First Party
from lmcache.utils import (
EngineType,
check_interprocess_event_support,
)
from lmcache.v1.kv_layer_groups import (
DTYPE_MAP,
KVLayerGroupInfo,
)
from lmcache.v1.multiprocess.custom_types import (
IPCCacheServerKey,
KVCache,
RegisterEngineDrivenContextPayload,
)
from lmcache.v1.multiprocess.futures import MessagingFuture
from lmcache.v1.multiprocess.group_view import EngineGroupInfo
from lmcache.v1.multiprocess.mq import MessageQueueClient
from lmcache.v1.multiprocess.posix_shm import shm_open_pool_as_mmap
from lmcache.v1.multiprocess.protocols.base import RequestType
from lmcache.v1.multiprocess.protocols.engine import (
RegisterEngineDrivenContextResponse,
)
from lmcache.v1.multiprocess.transfer_context.shm import ShmSlotDescriptor
from lmcache.v1.platform.cpu.shm import (
CpuShmTensorWrapper,
shm_create_readwrite,
)
except ImportError as _exc:
_IMPORT_ERROR = _exc
# Fallback placeholder so ``add_arguments`` can still build its
# help text without crashing on a CLI-only install.
DTYPE_MAP = {} # type: ignore[assignment]
# Stubs so other modules (notably ``command.py``) can still import
# the SHM helpers on a slim install; ``_require_full_install`` is
# the gate that prevents them from ever being invoked there.
def shm_open_pool_as_mmap(name: str, nbytes: int) -> Any: # type: ignore[misc]
raise RuntimeError(
"shm_open_pool_as_mmap unavailable on slim lmcache-cli install"
)
def _require_full_install() -> None:
"""Exit with an install hint if the full LMCache runtime is missing.
``lmcache bench server`` needs torch, zmq and ``lmcache.v1.*``
(MP client, KV layer-group parser). When those imports failed at
module load — almost always because the user installed
``lmcache-cli`` instead of the full package — print the shortest
actionable message to stderr and exit with status ``2`` so
scripts can detect the install gap programmatically.
"""
if _IMPORT_ERROR is None:
return
print(
"ERROR: `lmcache bench server` needs the full LMCache package "
"(torch, zmq, MP runtime), but only the `lmcache-cli` shell "
"appears to be installed.\n"
" Install the full package with `pip install lmcache` and try "
"again.\n"
f" Original import error: {_IMPORT_ERROR}",
file=sys.stderr,
)
sys.exit(2)
# ------------------------------------------------------------------ #
# Constants #
# ------------------------------------------------------------------ #
_HELLO_TOKEN_ID = 9906
_MODEL_NAME = "test-model"
_WORLD_SIZE = 1
_INSTANCE_ID = 0
# Default KV shape spec matching the original defaults:
# 32 layers, (2, num_blocks=1024, block_size=16, 8 heads, 128 head_size)
_DEFAULT_SHAPE_SPEC = "(2,1024,16,8,128):float16:32"
# ------------------------------------------------------------------ #
# Low-level helpers #
# ------------------------------------------------------------------ #
# Default RPC call timeout (seconds) for blocking request/reply
# round-trips.
_DEFAULT_RPC_TIMEOUT_S = 10.0
# Unique sentinel returned by :func:`_call` on RPC timeout so callers
# can disambiguate it from a legitimate ``None`` (void) reply.
_TIMEOUT = object()
def _call(
client: MessageQueueClient,
request_type: RequestType,
payloads: list,
timeout_s: float = _DEFAULT_RPC_TIMEOUT_S,
) -> Any:
"""Submit a request through ``MessageQueueClient`` and block.
Returns the decoded response (possibly ``None`` for void replies)
on success, or the sentinel ``_TIMEOUT`` on RPC timeout.
"""
future: MessagingFuture[Any] = client.submit_request(request_type, payloads)
try:
return future.result(timeout=timeout_s)
except TimeoutError:
return _TIMEOUT
# ------------------------------------------------------------------ #
# Token / key helpers #
# ------------------------------------------------------------------ #
def _build_token_ids(
seq_no: int,
num_tokens: int,
) -> tuple[int, ...]:
"""Build token sequence: ``(seq_no, hello, hello, ...)``."""
return (seq_no,) + (_HELLO_TOKEN_ID,) * num_tokens
def _make_key(
token_ids: tuple[int, ...],
request_id: str,
start: int = 0,
end: int = 0,
worker_id: int | None = None,
) -> IPCCacheServerKey:
"""Build an IPCCacheServerKey."""
return IPCCacheServerKey(
model_name=_MODEL_NAME,
world_size=_WORLD_SIZE,
worker_id=worker_id,
token_ids=token_ids,
start=start,
end=end if end > 0 else len(token_ids),
request_id=request_id,
)
# ------------------------------------------------------------------ #
# Protocol operations #
# ------------------------------------------------------------------ #
# ------------------------------------------------------------------ #
# GPU KV cache allocation #
# ------------------------------------------------------------------ #
def _allocate_gpu_kv_cache(
num_layers: int = 32,
num_heads: int = 8,
head_size: int = 128,
num_blocks: int = 1024,
block_size: int = 16,
dtype: torch.dtype | None = None,
device: str | torch.device | None = None,
kv_size: int = 2,
groups: list[KVLayerGroupInfo] | None = None,
) -> list[torch.Tensor]:
"""Allocate paged GPU KV cache tensors.
Each layer is a tensor of shape
``(kv_size, num_blocks, block_size, num_heads, head_size)``
matching the vLLM NHD layout. ``kv_size`` is 2 for standard
K/V attention; override via the ``--kvcache-shape-spec``
first dimension for architectures that need a different
leading dimension (e.g. MLA).
When ``groups`` is provided, tensors are allocated per-group
using each group's own ``(kv_size, NB, BS, NH, HS)`` / ``dtype``
(for heterogeneous multi-group specs). In that mode the flat
``num_heads`` / ``head_size`` / ``dtype`` / ``kv_size`` kwargs
are ignored, and ``num_layers`` is derived from the groups.
"""
# ``torch.float16`` cannot be used as a default value because the
# module must load on ``lmcache-cli`` (no torch) installs.
if dtype is None:
dtype = torch.float16
torch.random.manual_seed(42)
dev = (
torch.device(device)
if device
else torch.device(torch_device_type, torch_dev.current_device())
)
def _alloc(
shape: tuple[int, ...],
a_dtype: torch.dtype,
) -> torch.Tensor:
if a_dtype.is_floating_point:
return torch.randn(shape, dtype=a_dtype, device=dev)
# ``torch.randn`` only supports floating-point dtypes; fall
# back to ``randint`` for integer dtypes (e.g. ``uint8``
# used by FP8 quantized KV cache layouts).
iinfo = torch.iinfo(a_dtype)
return torch.randint(iinfo.min, iinfo.max + 1, shape, dtype=a_dtype, device=dev)
if groups:
tensors: list[torch.Tensor] = []
for g in groups:
sd = g.shape_desc
g_shape = (sd.kv_size, sd.nb, sd.bs, sd.nh, sd.hs)
tensors.extend(_alloc(g_shape, g.dtype) for _ in range(sd.nl))
return tensors
shape = (kv_size, num_blocks, block_size, num_heads, head_size)
return [_alloc(shape, dtype) for _ in range(num_layers)]
# Backward-compatible alias used by tests and older callers.
_allocate_kv_cache = _allocate_gpu_kv_cache
def _allocate_cpu_shm_kv_cache(
groups: list[KVLayerGroupInfo],
shm_prefix: str,
) -> tuple[list[torch.Tensor], list[CpuShmTensorWrapper], list[str]]:
"""Allocate paged CPU KV cache tensors backed by POSIX SHM.
For each (group, layer) we ``shm_open`` a fresh segment and
``mmap`` it into the client process. The returned tensors share
storage with the SHM mapping, and the matching
:class:`CpuShmTensorWrapper` instances tell the LMCache mp
server how to map the very same physical pages -- i.e. true
zero-copy across processes (matching the GPU CUDA-IPC path).
Returns:
``(tensors, wrappers, shm_names)``. ``shm_names`` is kept
so the caller can ``shm_unlink`` on shutdown.
"""
# Fixed seed so the deterministic random fill below produces
# reproducible checksums across cold/warm bench iterations.
torch.random.manual_seed(42)
tensors: list[torch.Tensor] = []
wrappers: list[CpuShmTensorWrapper] = []
shm_names: list[str] = []
layer_idx = 0
for g_idx, g in enumerate(groups):
sd = g.shape_desc
g_shape = (sd.kv_size, sd.nb, sd.bs, sd.nh, sd.hs)
for _ in range(sd.nl):
n_elems = 1
for d in g_shape:
n_elems *= d
nbytes = n_elems * g.dtype.itemsize
name = "%s_%d_%d" % (shm_prefix, g_idx, layer_idx)
addr = shm_create_readwrite(name, nbytes)
buf_type = ctypes.c_uint8 * nbytes
buf = buf_type.from_address(addr)
flat = torch.frombuffer(buf, dtype=torch.uint8)
t = flat.view(g.dtype).reshape(g_shape)
# Initialise with deterministic random data so the
# cold/warm checksum compare in the bench loop is
# meaningful.
if g.dtype.is_floating_point:
t.copy_(torch.randn(g_shape, dtype=g.dtype))
else:
iinfo = torch.iinfo(g.dtype)
t.copy_(torch.randint(iinfo.min, iinfo.max + 1, g_shape, dtype=g.dtype))
tensors.append(t)
wrappers.append(CpuShmTensorWrapper(t, name))
shm_names.append(name)
layer_idx += 1
return tensors, wrappers, shm_names
def _send_register_kv_cache(
client: MessageQueueClient,
instance_id: int = 0,
model_name: str = _MODEL_NAME,
world_size: int = _WORLD_SIZE,
layout_hints: dict | None = None,
kv_caches: KVCache | None = None,
use_gpu: bool = True,
use_handle: bool | None = None,
engine_group_infos: "list[EngineGroupInfo] | None" = None,
) -> "bool | RegisterEngineDrivenContextResponse":
"""Register a KV cache context with the MP server.
Dispatches to the correct protocol based on ``use_handle``:
* Handle mode: ``REGISTER_KV_CACHE`` with a wrapper list
(``CudaIPCWrapper`` for GPU, ``CpuShmTensorWrapper`` for CPU).
* Data mode: ``REGISTER_KV_CACHE_ENGINE_DRIVEN_CONTEXT`` with a
``RegisterEngineDrivenContextPayload`` derived from ``layout_hints``.
``use_handle`` defaults to ``use_gpu`` for backwards compatibility:
GPU always goes through the handle path, CPU defaults to data.
``engine_group_infos`` (handle mode only) carries the per-group
metadata — including each group's true ``tokens_per_block`` — so the
server does not have to trust the block size discovered from the
tensors (which the HND layout can swap with ``num_heads``). ``None``
sends an empty list (single non-hybrid group, geometry discovered
from the tensors).
"""
if use_handle is None:
use_handle = use_gpu
if use_handle:
if not kv_caches:
raise ValueError(
"kv_caches must be a non-empty list of wrappers "
"(CudaIPCWrapper for GPU, CpuShmTensorWrapper for CPU)"
)
hints: dict = {"kv_layout": "NHD"}
if layout_hints:
hints.update(layout_hints)
# TODO(maobaolong): Make the engine type configurable
payloads = [
instance_id,
kv_caches,
model_name,
world_size,
EngineType.VLLM,
hints,
list(engine_group_infos or ()),
]
result = _call(client, RequestType.REGISTER_KV_CACHE, payloads)
return result is not _TIMEOUT
# CPU mode: use the non-GPU context registration protocol.
# layout_hints carries num_layers, num_heads, head_size, block_size,
# dtype. hidden_dim_size = num_heads * head_size (NHD layout).
hints_d: dict = layout_hints or {}
num_layers = int(hints_d.get("num_layers", 32))
num_heads = hints_d.get("num_heads", 8)
head_size = hints_d.get("head_size", 128)
block_size = int(hints_d.get("block_size", 16))
dtype_str = str(hints_d.get("dtype", "float16"))
# "mixed" can appear for heterogeneous specs; fall back to first group.
if not isinstance(num_heads, int):
num_heads = 8
if not isinstance(head_size, int):
head_size = 128
hidden_dim_size = int(num_heads) * int(head_size)
payload = RegisterEngineDrivenContextPayload(
instance_id=instance_id,
model_name=model_name,
world_size=world_size,
block_size=block_size,
num_layers=num_layers,
hidden_dim_size=hidden_dim_size,
dtype_str=dtype_str,
use_mla=False,
)
result = _call(
client, RequestType.REGISTER_KV_CACHE_ENGINE_DRIVEN_CONTEXT, [payload]
)
if result is _TIMEOUT:
return False
# The data-mode register reply carries the server's SHM pool name
# and size; the bench keeps it on the side so STORE / RETRIEVE
# can mmap the same pool and exchange tensor data without going
# through pickle.
return result
def _send_unregister_kv_cache(
client: MessageQueueClient,
instance_id: int = 0,
use_handle: bool = True,
) -> bool:
"""Deregister a KV cache context from the MP server.
The inverse of :func:`_send_register_kv_cache`. Without this call
the server keeps the bench's registration (and the CUDA-IPC / POSIX
SHM mappings it holds) alive forever, leaking one context entry per
bench run.
Dispatches to the correct protocol based on ``use_handle``, mirroring
the register path:
* Handle mode: ``UNREGISTER_KV_CACHE``.
* Data mode: ``UNREGISTER_KV_CACHE_ENGINE_DRIVEN_CONTEXT``.
Both protocols take a single ``instance_id`` payload and return a void
reply, so success is distinguished from an RPC timeout only.
Args:
client: The MP message-queue client.
instance_id: The instance ID used at registration time. Must match
the ``instance_id`` passed to :func:`_send_register_kv_cache`.
use_handle: ``True`` for the handle path (GPU CUDA-IPC / CPU SHM),
``False`` for the engine-driven data path.
Returns:
``True`` if the server acknowledged the call, ``False`` on RPC
timeout.
"""
request_type = (
RequestType.UNREGISTER_KV_CACHE
if use_handle
else RequestType.UNREGISTER_KV_CACHE_ENGINE_DRIVEN_CONTEXT
)
result = _call(client, request_type, [instance_id])
return result is not _TIMEOUT
def _send_lookup(
client: MessageQueueClient,
key: IPCCacheServerKey,
) -> bool:
"""LOOKUP — submit a prefix lookup.
The server-side handler returns ``None`` (void) on success, so
we only distinguish RPC timeout from a completed call.
"""
result = _call(client, RequestType.LOOKUP, [key, 1])
return result is not _TIMEOUT
def _poll_prefetch_status(
client: MessageQueueClient,
request_id: str,
max_polls: int = 50,
poll_interval: float = 0.05,
) -> int | None:
"""QUERY_PREFETCH_STATUS — poll until done.
Returns the hit chunk count, or ``None`` if the polling budget
is exhausted. The server keys prefetch jobs by ``request_id``
(str), not an integer job handle.
"""
for _ in range(max_polls):
result = _call(
client,
RequestType.QUERY_PREFETCH_STATUS,
[request_id],
)
if result is _TIMEOUT:
# RPC timeout — treat as giving up on this poll cycle.
return None
if result is not None:
return result
time.sleep(poll_interval)
return None
def _make_event_handle(use_gpu: bool = True) -> bytes:
"""Create a CUDA event IPC handle for GPU mode.
CPU mode does not need a cross-process event (SHM mappings are
coherent without device-side sync), so an empty handle is
returned and the server treats it as a no-op.
"""
if not use_gpu:
return b""
check_interprocess_event_support()
event = torch_dev.Event(interprocess=True)
event.record()
return event.ipc_handle()
def _build_server_slot_views(
server_pool: "mmap.mmap",
slots: list[dict[str, Any]],
) -> list["torch.Tensor"]:
"""Build zero-copy tensor views over server SHM slot descriptors.
Each ``ShmSlotDescriptor`` carries the ``(offset, length, shape,
dtype)`` of one chunk inside the server-owned SHM pool; we wrap
them with ``torch.frombuffer`` so the bench can read or overwrite
that chunk without going through pickle.
"""
views: list[torch.Tensor] = []
for raw in slots:
desc = ShmSlotDescriptor.from_dict(raw)
dtype = getattr(torch, desc.dtype, None)
if not isinstance(dtype, torch.dtype):
raise ValueError("invalid torch dtype string: %s" % desc.dtype)
itemsize = torch.empty((), dtype=dtype).element_size()
if itemsize <= 0:
raise ValueError("invalid dtype size for %s" % desc.dtype)
count = desc.length // itemsize
flat = torch.frombuffer(
server_pool, dtype=dtype, count=count, offset=desc.offset
)
views.append(flat.view(torch.Size(desc.shape)))
return views
def _gather_paged_to_flat_chunks(
tensors: list["torch.Tensor"],
block_offset: int,
num_blocks: int,
block_size: int,
chunk_size: int,
) -> list["torch.Tensor"]:
"""Gather paged client tensors into flat per-chunk CPU tensors.
Output layout matches the server's expected ``commit_store``
payload (set up at register time by
``register_kv_cache_engine_driven_context``):
each chunk is ``[2, num_layers, chunk_size, hidden_dim]``,
where ``hidden_dim = NH * HS``. Assumes a homogeneous group
(same NH/HS/dtype across all layers); heterogeneous specs
fall outside the bench scope.
"""
if chunk_size % block_size != 0:
raise ValueError(
"chunk_size %d must be a multiple of block_size %d"
% (chunk_size, block_size)
)
blocks_per_chunk = chunk_size // block_size
num_chunks = num_blocks // blocks_per_chunk
num_layers = len(tensors)
chunks: list[torch.Tensor] = []
for c in range(num_chunks):
start_b = block_offset + c * blocks_per_chunk
per_layer: list[torch.Tensor] = []
for t in tensors:
# paged: (2, NB, BS, NH, HS) -> slice block range ->
# (2, blocks_per_chunk, BS, NH, HS) -> flatten to
# (2, chunk_size, NH*HS).
sliced = t.narrow(1, start_b, blocks_per_chunk)
kv, _, bs, nh, hs = sliced.shape
flat = sliced.contiguous().view(kv, blocks_per_chunk * bs, nh * hs)
per_layer.append(flat)
# Stack along a new layer dim -> (2, NL, chunk_size, hidden).
chunk = torch.stack(per_layer, dim=1).contiguous()
if chunk.shape[1] != num_layers:
raise RuntimeError(
"unexpected chunk shape %s (NL mismatch)" % (chunk.shape,)
)
chunks.append(chunk)
return chunks
def _scatter_flat_chunks_to_paged(
tensors: list["torch.Tensor"],
chunks: list["torch.Tensor"],
block_offset: int,
block_size: int,
chunk_size: int,
) -> None:
"""Inverse of :func:`_gather_paged_to_flat_chunks`.
Writes each ``[2, NL, chunk_size, hidden]`` flat chunk back into
the paged client tensors at the matching block range. Used by
the data-mode RETRIEVE path so the bench's client-side checksum
can compare cold ground truth with what the server returned.
"""
if chunk_size % block_size != 0:
raise ValueError(
"chunk_size %d must be a multiple of block_size %d"
% (chunk_size, block_size)
)
blocks_per_chunk = chunk_size // block_size
for c, chunk in enumerate(chunks):
start_b = block_offset + c * blocks_per_chunk
for layer_idx, t in enumerate(tensors):
kv, _, bs, nh, hs = t.shape
target = t.narrow(1, start_b, blocks_per_chunk)
# chunk[:, layer_idx] is (chunk_size, hidden); reshape
# back to (2, blocks_per_chunk, BS, NH, HS).
flat = chunk[:, layer_idx]
reshaped = flat.reshape(kv, blocks_per_chunk, bs, nh, hs)
target.copy_(reshaped)
# ------------------------------------------------------------------ #
# Client-side checksum / zero-fill (data-mode self-check) #
# ------------------------------------------------------------------ #
def _compute_client_checksums(
tensors: list["torch.Tensor"],
block_offset: int,
num_blocks: int,
block_size: int,
chunk_size: int,
) -> list[str]:
"""Hash a paged block range from client-side KV tensors.
For each chunk (``chunk_size // block_size`` consecutive blocks),
feed every layer's bytes for that block range into a single MD5
digest. The returned list maps 1:1 to the chunks the bench loop
expects, so a cold-pass digest can be compared with a warm-pass
digest to verify that ``RETRIEVE`` actually wrote back the data
we wrote during ``STORE`` -- without relying on a server-side
``/cache/checksums`` endpoint (which only exists in handle mode).
"""
if chunk_size % block_size != 0:
raise ValueError(
"chunk_size %d must be a multiple of block_size %d"
% (chunk_size, block_size)
)
blocks_per_chunk = chunk_size // block_size
num_chunks = num_blocks // blocks_per_chunk
checksums: list[str] = []
for c in range(num_chunks):
start_b = block_offset + c * blocks_per_chunk
end_b = start_b + blocks_per_chunk
h = hashlib.md5()
for t in tensors:
# Paged layout: dim 1 is the block dim for both kv-major
# ``(kv, NB, BS, NH, HS)`` and MLA ``(NB, BS, NH, HS)``
# tensors. ``contiguous().numpy().tobytes()`` survives
# non-contiguous slices and dtype quirks (bfloat16 has no
# numpy view, but uint8 reinterpret works after slice).
view = t.narrow(1, start_b, end_b - start_b).contiguous()
h.update(view.view(torch.uint8).numpy().tobytes())
checksums.append(h.hexdigest())
return checksums
def _zero_fill_client_blocks(
tensors: list["torch.Tensor"],
block_offset: int,
num_blocks: int,
) -> None:
"""Zero out a paged block range across all client tensors.
Used right before a warm-pass ``RETRIEVE`` so that any non-zero
bytes observed afterwards must have been written by the server.
Without this, a warm checksum equal to the cold checksum could
still happen even if ``RETRIEVE`` was a silent no-op (the SHM
pages were never overwritten in the first place).
"""
for t in tensors:
t.narrow(1, block_offset, num_blocks).zero_()
def _send_store(
client: MessageQueueClient,
key: IPCCacheServerKey,
block_offset: int = 0,
block_size: int = 16,
num_engine_group_infos: int = 1,
use_gpu: bool = True,
use_handle: bool | None = None,
client_tensors: list["torch.Tensor"] | None = None,
chunk_size: int = 0,
server_pool: "mmap.mmap | None" = None,
) -> str:
"""Store KV cache blocks. Returns status string.
Handle mode uses the single-shot ``STORE`` RPC (GPU CUDA-IPC, or
CPU SHM with an empty event handle).
Data mode uses the two-phase ``PREPARE_STORE`` + ``COMMIT_STORE``.
When ``server_pool`` and ``client_tensors`` are both supplied the
bench gathers the paged block range into flat per-chunk CPU
tensors and writes them straight into the server-owned SHM pool
via the slot descriptors returned by ``PREPARE_STORE``, so the
follow-up ``COMMIT_STORE`` carries an empty payload and the
server stays on its zero-copy SHM path.
"""
if use_handle is None:
use_handle = use_gpu
if use_handle:
num_tokens = key.end - key.start
num_blocks = num_tokens // block_size
block_ids = list(range(block_offset, block_offset + num_blocks))
payloads = [
key,
_INSTANCE_ID,
[block_ids] * num_engine_group_infos,
_make_event_handle(),
]
result = _call(client, RequestType.STORE, payloads)
if result is _TIMEOUT:
return "timeout"
return "stored" if result[1] else "store_failed"
# CPU mode: PREPARE_STORE -> COMMIT_STORE
prep = _call(client, RequestType.PREPARE_STORE, [key, _INSTANCE_ID])
if prep is _TIMEOUT:
return "timeout"
if server_pool is not None and client_tensors is not None and chunk_size > 0:
ctx = prep.context if isinstance(prep.context, dict) else {}
slots = ctx.get("slots", []) or []
chunk_indices = ctx.get("chunk_indices", []) or []
if slots and chunk_indices:
num_blocks = (key.end - key.start) // block_size
full_chunks = _gather_paged_to_flat_chunks(
client_tensors,
block_offset,
num_blocks,
block_size,
chunk_size,
)
slot_views = _build_server_slot_views(server_pool, slots)
for slot_view, chunk_idx in zip(slot_views, chunk_indices, strict=False):
if 0 <= chunk_idx < len(full_chunks):
slot_view.copy_(full_chunks[chunk_idx].view(slot_view.shape))
commit = _call(client, RequestType.COMMIT_STORE, [key, _INSTANCE_ID, b""])
if commit is _TIMEOUT:
return "timeout"
return "stored" if commit else "store_failed"
def _send_retrieve(
client: MessageQueueClient,
key: IPCCacheServerKey,
chunk_size: int,
hit_chunks: int,
block_offset: int = 0,
block_size: int = 16,
num_engine_group_infos: int = 1,
use_gpu: bool = True,
use_handle: bool | None = None,
client_tensors: list["torch.Tensor"] | None = None,
server_pool: "mmap.mmap | None" = None,
) -> str:
"""Retrieve KV cache blocks. Returns status.
Handle mode uses the single-shot ``RETRIEVE`` RPC (GPU CUDA-IPC, or
CPU SHM with an empty event handle).
Data mode uses the two-phase ``PREPARE_RETRIEVE`` +
``COMMIT_RETRIEVE``. When ``server_pool`` and ``client_tensors``
are both supplied the bench builds zero-copy tensor views over
the slot descriptors returned by ``PREPARE_RETRIEVE`` and
scatters them back into the paged client SHM, so the round-trip
self-check can run without ``PREPARE_RETRIEVE`` having to ship a
pickled copy of the chunks.
"""
if use_handle is None:
use_handle = use_gpu
if use_handle:
hit_tokens = hit_chunks * chunk_size
num_blocks = hit_tokens // block_size
block_ids = list(range(block_offset, block_offset + num_blocks))
payloads = [
key,
_INSTANCE_ID,
[block_ids] * num_engine_group_infos,
_make_event_handle(),
0, # skip_first_n_tokens
]
result = _call(client, RequestType.RETRIEVE, payloads)
if result is _TIMEOUT:
return "timeout"
return "retrieved" if result[1] else "retrieve_failed"
# CPU mode: PREPARE_RETRIEVE -> COMMIT_RETRIEVE
prep = _call(client, RequestType.PREPARE_RETRIEVE, [key, _INSTANCE_ID])
if prep is _TIMEOUT:
return "timeout"
if not prep.success:
return "retrieve_failed"
if server_pool is not None and client_tensors is not None:
ctx = prep.context if isinstance(prep.context, dict) else {}
slots = ctx.get("slots", []) or []
if slots:
try:
slot_views = _build_server_slot_views(server_pool, slots)
_scatter_flat_chunks_to_paged(
client_tensors,
slot_views,
block_offset,
block_size,
chunk_size,
)
except (RuntimeError, ValueError) as exc:
print(" [WARNING] retrieve scatter failed: %s" % exc)
commit = _call(client, RequestType.COMMIT_RETRIEVE, [key, _INSTANCE_ID])
if commit is _TIMEOUT:
return "timeout"
return "retrieved" if commit else "retrieve_failed"
def _send_end_session(
client: MessageQueueClient,
request_id: str,
) -> None:
"""END_SESSION — clean up server-side session state."""
_call(client, RequestType.END_SESSION, [request_id])
# ------------------------------------------------------------------ #
# Checksum query #
# ------------------------------------------------------------------ #
def _query_checksum(
http_base: str,
block_offset: int,
num_blocks: int,
block_size: int,
chunk_size: int,
) -> list[str] | None:
"""Query KV cache checksums via the HTTP API.
Uses the MP-native ``block_ids`` + ``block_size`` addressing
scheme so the query matches the same block-level semantics
as ``STORE`` / ``RETRIEVE``. This CLI pins ``layerwise=false``
so the server always returns ``chunk_checksums`` as a flat
``list[str]``. We still defensively validate the response
type — if a future endpoint variant returns a per-layer
``dict`` we log and skip the comparison rather than letting
``str.join`` crash.
"""
blocks = list(range(block_offset, block_offset + num_blocks))
# The MP /cache/checksums endpoint is block-native: its chunk_size counts
# blocks per chunk, while our caller passes in the server-side token-level
# chunk_size. Convert here.
if chunk_size % block_size != 0:
print(
" [WARNING] chunk_size %d not a multiple of block_size %d; "
"skipping checksum query" % (chunk_size, block_size)
)
return None
chunk_size_blocks = chunk_size // block_size
url = "%s/cache/checksums" % http_base
payload = json.dumps(
{"block_ids": blocks, "chunk_size": chunk_size_blocks, "layerwise": False}
).encode()
try:
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(req, timeout=5) as resp:
data = json.loads(resp.read().decode())
if data.get("status") != "success":
return None
checksums = data.get("chunk_checksums", [])
if not isinstance(checksums, list) or not all(
isinstance(c, str) for c in checksums
):
print(
" [WARNING] unexpected chunk_checksums "
"type=%s; expected list[str]" % type(checksums).__name__
)
return None
return checksums
except (urllib.error.URLError, OSError) as exc:
print(" [WARNING] Checksum query failed: %s" % exc)
return None
# ------------------------------------------------------------------ #
# Per-request flow #
# ------------------------------------------------------------------ #
@dataclass
class RequestResult:
"""Result of a single request pass (cold or warm).
Carries both the checksum list (for correctness verification) and
per-operation latency measurements (for metrics aggregation).
"""
checksums: list[str] | None = None
lookup_ms: float | None = None
retrieve_ms: float | None = None
store_ms: float | None = None
hit_chunks: int = 0
total_chunks: int = 0
store_tokens: int = 0
retrieve_tokens: int = 0
def _process_request(
client: MessageQueueClient,
seq_no: int,
num_tokens: int,
chunk_size: int,
pass_label: str,
http_base: str = "",
block_size: int = 16,
total_blocks: int = 1024,
num_engine_group_infos: int = 1,
use_gpu: bool = True,
use_handle: bool | None = None,
client_tensors: list["torch.Tensor"] | None = None,
server_pool: "mmap.mmap | None" = None,
) -> RequestResult | None:
"""Run the full lookup -> retrieve/store flow.
When ``client_tensors`` is provided (data-mode self-check), the
flow gains two extra steps:
* cold pass: hash the paged block range *before* ``STORE``, so
the digest captures the ground-truth KV bytes.
* warm pass: zero-fill the same block range *before*
``RETRIEVE``, then hash *after* ``RETRIEVE``. cold == warm
proves the server returned the exact bytes we sent.
Handle mode keeps the historical server-side
``/cache/checksums`` path; client tensors are not consulted (in
handle mode the client and server share the same SHM/IPC
pages, so a client-side hash equals itself by construction).
"""
token_ids = _build_token_ids(seq_no, num_tokens)
request_id = "req-%d-%s" % (seq_no, pass_label)
# Align end to chunk_size (only full chunks)
num_full_tokens = (len(token_ids) // chunk_size) * chunk_size
if num_full_tokens == 0:
print(
" [seq %d/%s] SKIP: %d tokens < chunk_size %d"
% (seq_no, pass_label, len(token_ids), chunk_size)
)
return None
# Key for lookup (worker_id=None)
lookup_key = _make_key(
token_ids,
request_id,
start=0,
end=num_full_tokens,
)
# 1. LOOKUP
t0 = time.monotonic()
if not _send_lookup(client, lookup_key):
print(" [seq %d/%s] LOOKUP timeout" % (seq_no, pass_label))
return None
# 2. QUERY_PREFETCH_STATUS (poll by request_id)
hit_chunks = _poll_prefetch_status(client, lookup_key.request_id)
if hit_chunks is None:
hit_chunks = 0
total_chunks = num_full_tokens // chunk_size
miss_chunks = total_chunks - hit_chunks
hit_tokens = hit_chunks * chunk_size
lookup_ms = (time.monotonic() - t0) * 1000
print(
" [seq %d/%s] LOOKUP: %d/%d chunks hit "
"(%.1f ms)"
% (
seq_no,
pass_label,
hit_chunks,
total_chunks,
lookup_ms,
)
)
# Block offset: each request uses a different block
# range so that different requests touch different data.
# Wrap with modulo and clamp so the entire range
# [block_offset, block_offset + num_blocks) stays
# within [0, total_blocks).
num_blocks = num_full_tokens // block_size
usable = max(total_blocks - num_blocks, 1)
block_offset = (seq_no * num_blocks) % usable
# Client-side self-check (data mode only). cold pass: snapshot
# ground truth before STORE. warm pass: zero out the slice so
# a successful RETRIEVE must overwrite every byte.
cold_ground_truth: list[str] | None = None
if client_tensors is not None:
if pass_label == "cold" and miss_chunks > 0:
store_block_off = block_offset + (hit_tokens // block_size)
store_num_blocks = (num_full_tokens - hit_tokens) // block_size
cold_ground_truth = _compute_client_checksums(
client_tensors,
store_block_off,
store_num_blocks,
block_size,
chunk_size,
)
if pass_label == "warm" and hit_chunks > 0:
retr_num_blocks = hit_tokens // block_size
_zero_fill_client_blocks(
client_tensors,
block_offset,
retr_num_blocks,
)
# 3. RETRIEVE hit portion
retrieve_ms: float = 0.0
store_ms: float = 0.0
if hit_chunks > 0:
retrieve_key = _make_key(
token_ids,
request_id,
start=0,
end=hit_tokens,
worker_id=0,
)
t1 = time.monotonic()
status = _send_retrieve(
client,
retrieve_key,
chunk_size,
hit_chunks,
block_offset=block_offset,
block_size=block_size,
num_engine_group_infos=num_engine_group_infos,
use_gpu=use_gpu,
use_handle=use_handle,
client_tensors=client_tensors,
server_pool=server_pool,
)
retrieve_ms = (time.monotonic() - t1) * 1000
print(
" [seq %d/%s] RETRIEVE: %s "
"(%d tokens, %.1f ms)"
% (
seq_no,
pass_label,
status,
hit_tokens,
retrieve_ms,
)
)
# 4. STORE miss portion
if miss_chunks > 0:
store_start = hit_tokens
store_end = num_full_tokens
store_key = _make_key(
token_ids,
request_id,
start=store_start,
end=store_end,
worker_id=0,
)
t2 = time.monotonic()
store_block_off = block_offset + (hit_tokens // block_size)
status = _send_store(
client,
store_key,
block_offset=store_block_off,
block_size=block_size,
num_engine_group_infos=num_engine_group_infos,
use_gpu=use_gpu,
use_handle=use_handle,
client_tensors=client_tensors,
chunk_size=chunk_size,
server_pool=server_pool,
)
store_ms = (time.monotonic() - t2) * 1000
print(
" [seq %d/%s] STORE: %s "
"(%d tokens, %.1f ms)"
% (
seq_no,
pass_label,
status,
store_end - store_start,
store_ms,
)
)
# 5. Compute checksums.
# * data mode (client_tensors set):
# cold -> ground truth captured pre-STORE
# warm -> hash post-RETRIEVE; cold == warm proves the
# server returned the exact bytes we wrote.
# * handle mode: query /cache/checksums on the server, which
# reads the shared SHM/IPC pages directly.
checksums: list[str] | None = None
if client_tensors is not None and num_full_tokens > 0:
if pass_label == "cold":
checksums = cold_ground_truth
elif pass_label == "warm" and hit_chunks > 0:
retr_num_blocks = hit_tokens // block_size
checksums = _compute_client_checksums(
client_tensors,
block_offset,
retr_num_blocks,
block_size,
chunk_size,
)
elif http_base and num_full_tokens > 0:
checksums = _query_checksum(
http_base,
block_offset,
num_blocks,
block_size,
chunk_size,
)
if checksums:
digest = hashlib.md5("".join(checksums).encode()).hexdigest()[:16]
print(
" [seq %d/%s] CHECKSUM: %s (%d chunks)"
% (
seq_no,
pass_label,
digest,
len(checksums),
)
)
# 6. END_SESSION
_send_end_session(client, request_id)
return RequestResult(
checksums=checksums,
lookup_ms=lookup_ms,
retrieve_ms=retrieve_ms if hit_chunks > 0 else None,
store_ms=store_ms if miss_chunks > 0 else None,
hit_chunks=hit_chunks,
total_chunks=total_chunks,
store_tokens=(num_full_tokens - hit_tokens) if miss_chunks > 0 else 0,
retrieve_tokens=hit_tokens if hit_chunks > 0 else 0,
)
# ------------------------------------------------------------------ #
# Server query helper #
# ------------------------------------------------------------------ #
def _get_chunk_size(client: MessageQueueClient) -> int:
"""Query the server's chunk size."""
result = _call(client, RequestType.GET_CHUNK_SIZE, [])
if result is _TIMEOUT or result is None:
return 256 # fallback
return int(result)