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

1289 lines
50 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""LMCache-driven KV cache transfer operations for the MPCacheServer."""
# Standard
from dataclasses import dataclass
from itertools import islice
from typing import Generator, Sequence
import threading
import time
# Third Party
import torch
# First Party
from lmcache import torch_dev, torch_device_type
from lmcache.logging import init_logger
from lmcache.utils import (
EngineType,
_lmcache_nvtx_annotate,
check_interprocess_event_support,
)
from lmcache.v1.distributed.api import (
MemoryLayoutDesc,
ObjectKey,
)
from lmcache.v1.gpu_connector.gpu_ops import (
build_staging_copies,
lmcache_memcpy_async_d2h,
lmcache_memcpy_async_h2d,
)
from lmcache.v1.gpu_connector.utils import LayoutHints
from lmcache.v1.memory_allocators.lazy_memory_allocator import LazyMemoryAllocator
from lmcache.v1.memory_management import GDSMemoryObject, MemoryObj
from lmcache.v1.mp_observability.event import Event, EventType
from lmcache.v1.multiprocess.custom_types import (
IPCCacheServerKey,
KVCache,
)
from lmcache.v1.multiprocess.engine_context import MPCacheServerContext
from lmcache.v1.multiprocess.engine_module import (
HandlerSpec,
InstanceLivenessTarget,
ThreadPoolType,
)
from lmcache.v1.multiprocess.group_view import EngineGroupInfo
from lmcache.v1.multiprocess.native_completion import (
DeviceHostFuncDispatcher,
submit_callback_to_stream,
)
from lmcache.v1.multiprocess.protocols.base import RequestType
from lmcache.v1.platform.base_cache_context import BaseCacheContext
from lmcache.v1.platform.cache_context import create_cache_context
import lmcache.c_ops as lmc_ops
import lmcache.python_ops_fallback as _python_ops_fallback
logger = init_logger(__name__)
_HAS_NATIVE_OBJECT_GROUP_TRANSFER: bool = (
lmc_ops.execute_object_group_transfer
is not _python_ops_fallback.execute_object_group_transfer
)
def get_layout_desc(
cache_context: BaseCacheContext,
num_tokens: int,
object_group_id: int,
) -> MemoryLayoutDesc:
"""Get the memory layout description for a specific object group.
The returned layout describes the single memory object that backs
``object_group_id``: one (shape, dtype) entry per kernel group in that
object group, in the kernel groups' declared layout order. Kernel groups
may have different shapes and dtypes.
Args:
cache_context: The cache context containing the KV cache information.
num_tokens: The number of tokens to determine the layout for.
object_group_id: Index of the object group whose layout to build.
Returns:
MemoryLayoutDesc: The memory layout description containing shapes and
dtypes, one entry per kernel group in the object group.
"""
object_group = cache_context.kv_layer_groups_manager.object_groups[object_group_id]
shapes_and_dtypes = [
cache_context.get_kernel_group_shape_dtype(num_tokens, kernel_group_idx)
for kernel_group_idx in object_group.kernel_group_indices
]
shapes, dtypes = zip(*shapes_and_dtypes, strict=False)
return MemoryLayoutDesc(shapes=list(shapes), dtypes=list(dtypes))
def batched_iteration_with_skip(
lst: Sequence,
batch_size: int,
skip_count: int,
) -> Generator[tuple[int, tuple], None, None]:
"""Utility function to iterate over a list in batches with an initial skip.
Args:
lst: The list to iterate over.
batch_size: The size of each batch.
skip_count: The number of items to skip at the start of the list.
Yields:
Tuples of (batch_start_idx, batch) where batch is a tuple of items
from the list, and batch_start_idx is the "original" index of the first
item in the batch.
Raises:
ValueError: If batch_size is less than 1 or skip_count is negative.
Note:
Batch_idx is the index of the batch in the original list, accounting
for the skipped items. For example, if skip_count is 10 and batch_size
is 5, the first yielded batch will have batch_start_idx=10.
"""
if batch_size < 1:
raise ValueError("batch size must be at least one")
if skip_count < 0:
raise ValueError("skip_count must be non-negative")
it = iter(lst)
# Skip the initial items
for _ in range(skip_count):
next(it, None)
batch_start_idx = skip_count
while batch := tuple(islice(it, batch_size)):
yield batch_start_idx, batch
batch_start_idx += len(batch)
def downsample_and_stage_block_ids(
cache_context: BaseCacheContext,
block_ids: list[list[int]],
) -> list[torch.Tensor]:
"""Cut the block id lists to skip the unneeded blocks in a chunk and
stage it into GPU tensors for later use.
This mainly targets the case where a portion of the blocks are not
needed for every chunk, such as deepseek v4's swa cache.
Note that the we do NOT do any object-level skipping here.
Args:
cache_context: The cache context containing the KV cache information.
block_ids: The original block id lists, indexed by LMCache KV group index.
Returns:
The cut block id lists, indexed by LMCache KV group index.
Note:
This function has some coupled logic with transfer_kv_per_object_group below.
The caller need to make sure that the block ids seen by
transfer_kv_per_object_group are produced by this function.
Example:
If a model have 2 kernel groups, one is full attention with block size 32,
one is swa attention with block size 32 and sliding window size 64, and
LMCache has a chunk size of 128. And there are 2 chunks in total (256 tokens).
The input will be:
[
[1, 2, 3, 4, 5, 6, 7, 8], # block ids for the full attention group
[11, 12, 13, 14, 15, 16, 17, 18], # block ids for the swa attention group
]
The output will be
[
[1, 2, 3, 4, 5, 6, 7, 8], # full attention group still needs all block ids
[13, 14, 17, 18], # swa attention group only needs the last 2 block per chunk
]
"""
num_kernel_groups = cache_context.kv_layer_groups_manager.num_kernel_groups
for kernel_group_id in range(num_kernel_groups):
subchunk_sw_size_tokens = (
cache_context.kv_layer_groups_manager.get_subchunk_sw_size_tokens(
kernel_group_id
)
)
tokens_per_chunk = min(
cache_context.lmcache_tokens_per_chunk, subchunk_sw_size_tokens
)
keep_blocks_per_chunk = cache_context.calculate_num_blocks(
tokens_per_chunk, kernel_group_id
)
total_blocks_per_chunk = cache_context.calculate_num_blocks(
cache_context.lmcache_tokens_per_chunk, kernel_group_id
)
new_block_ids = []
old_block_ids = block_ids[kernel_group_id]
assert len(old_block_ids) % total_blocks_per_chunk == 0, (
f"len(block_ids[{kernel_group_id}]) should be a multiple "
f"of total_blocks_per_chunk ({total_blocks_per_chunk}), but got "
f"{len(old_block_ids)}"
)
for i in range(0, len(old_block_ids), total_blocks_per_chunk):
chunk_block_ids = old_block_ids[i : i + total_blocks_per_chunk]
new_block_ids.extend(chunk_block_ids[-keep_blocks_per_chunk:])
block_ids[kernel_group_id] = new_block_ids
# Stage the cut block ids into GPU tensors
block_ids_gpu = cache_context.stage_block_ids(block_ids)
return block_ids_gpu
def _recalculate_blocks_to_skip(
blocks_per_chunk: int,
blocks_per_window: int,
blocks_to_skip: int,
) -> int:
"""Re-calculate the number of blocks to skip for a batch of chunks based
on the blocks per chunk and blocks per sliding window WHEN the window
size is smaller than the lmcache chunk size.
Args:
blocks_per_chunk: The total number of blocks in one chunk for the
current group.
blocks_per_window: The number of blocks in the sliding window
for the current group. Should be less than or equal to
blocks_per_chunk.
blocks_to_skip: The number of blocks to skip.
Returns:
The re-calculated number of blocks to skip for the current batch of
chunks.
"""
if blocks_per_chunk == blocks_per_window:
return blocks_to_skip
full_windows_to_skip = blocks_to_skip // blocks_per_chunk
tail_blocks = blocks_to_skip % blocks_per_chunk
tail_blocks_to_skip = tail_blocks - (blocks_per_chunk - blocks_per_window)
return full_windows_to_skip * blocks_per_window + max(0, tail_blocks_to_skip)
def _run_object_group_transfer_plan(
cache_context: BaseCacheContext,
block_ids_gpu: list[torch.Tensor],
memory_objs: Sequence[MemoryObj | None],
object_group_id: int,
batch_size: int,
skip_first_n_tokens: int,
direction: "lmc_ops.TransferDirection",
) -> None:
"""Plan and execute one object group's transfer in a single native call.
This is the fast path of :func:`transfer_kv_per_object_group`: it runs the
same batched-iteration / skip logic, but instead of issuing each staging
copy and kernel launch immediately (each a GIL release/re-acquire), it
resolves every argument to plain pointers/scalars (the "planner", GIL held
throughout) and hands the whole plan to ``execute_object_group_transfer``,
which issues all of it on the stream within a single GIL release.
Requires every object to be non-GDS (staged through the lazy-allocator
path); the caller skips groups that contain any GDS-backed object.
Args:
cache_context: The GPU cache context containing the KV cache information.
block_ids_gpu: GPU block IDs, indexed by LMCache KV group index.
memory_objs: The MemoryObj instances to copy. None entries are only
valid for D2H (the batch is skipped); H2D raises.
object_group_id: Index of the object group being copied.
batch_size: Number of memory objects per batched copy.
skip_first_n_tokens: Tokens to skip writing at the start of the range.
direction: H2D (retrieve) or D2H (store).
Raises:
ValueError: If a None entry is found in memory_objs when direction is
H2D, or if an object's size does not match its GPU staging buffer.
"""
lmcache_chunk_size = cache_context.lmcache_tokens_per_chunk
kv_groups_manager = cache_context.kv_layer_groups_manager
object_group = kv_groups_manager.object_groups[object_group_id]
kernel_group_ids = object_group.kernel_group_indices
is_h2d = direction == lmc_ops.TransferDirection.H2D
max_batch_size = cache_context.max_batch_size
# --- Per-kernel-group invariants, resolved once (vs. every batch before) ---
kernel_group_specs: list["lmc_ops.KernelGroupSpec"] = []
spec_index_by_kg: dict[int, int] = {}
blocks_per_chunk_by_kg: dict[int, int] = {}
blocks_per_window_by_kg: dict[int, int] = {}
for kernel_group_id in kernel_group_ids:
blocks_per_chunk = cache_context.calculate_num_blocks(
lmcache_chunk_size, kernel_group_id
)
tokens_per_window = min(
lmcache_chunk_size,
kv_groups_manager.get_subchunk_sw_size_tokens(kernel_group_id),
)
blocks_per_window = cache_context.calculate_num_blocks(
tokens_per_window, kernel_group_id
)
blocks_per_chunk_by_kg[kernel_group_id] = blocks_per_chunk
blocks_per_window_by_kg[kernel_group_id] = blocks_per_window
paged_ptrs = cache_context.get_kernel_group_kv_pointers(kernel_group_id)
block_ids_tensor = block_ids_gpu[kernel_group_id]
temp_buffers = [
cache_context.get_temp_kernel_group_buffer(slot, kernel_group_id)
for slot in range(max_batch_size)
]
spec_index_by_kg[kernel_group_id] = len(kernel_group_specs)
kernel_group_specs.append(
lmc_ops.KernelGroupSpec(
paged_ptrs.data_ptr(),
[buffer.data_ptr() for buffer in temp_buffers],
cache_context.get_shape_desc(kernel_group_id),
cache_context.get_slots_per_chunk_in_sw(kernel_group_id),
cache_context.get_engine_kv_format(kernel_group_id),
block_ids_tensor.data_ptr(),
block_ids_tensor.numel(),
)
)
# Temp object-group staging buffers (reused per batch slot, like above).
object_group_buffers = [
cache_context.get_temp_object_group_buffer(slot, object_group_id)
for slot in range(max_batch_size)
]
attn_desc = kv_groups_manager.get_attn_desc()
num_objects_to_skip = 0
if not attn_desc.is_full_attention(object_group_id) and is_h2d:
sw_size_chunks = attn_desc.num_chunks_in_sw[object_group_id]
num_objects_to_skip = max(0, len(memory_objs) - sw_size_chunks)
logger.debug(
"Detected sliding window for object group %d: "
"skipping the first %d objects in the batch",
object_group_id,
num_objects_to_skip,
)
# --- Walk the batches in order, emitting staging + launch work per step ---
batch_steps: list["lmc_ops.BatchStep"] = []
for start_object_idx, memory_object_batch in batched_iteration_with_skip(
memory_objs, batch_size, skip_count=num_objects_to_skip
):
if any(mo is None for mo in memory_object_batch):
if is_h2d:
raise ValueError(
"MemoryObj is None for some objects in the batch, cannot "
"perform H2D copy. memory_object_batch: "
f"{memory_object_batch}"
)
else:
continue
batch_len = len(memory_object_batch)
batch_start_token = start_object_idx * lmcache_chunk_size
batch_end_token = batch_start_token + batch_len * lmcache_chunk_size
effective_start = max(batch_start_token, skip_first_n_tokens)
if effective_start >= batch_end_token:
continue
skip_tokens_in_chunk = effective_start - batch_start_token
staging = build_staging_copies(
memory_object_batch,
object_group_buffers[:batch_len],
is_h2d,
)
launches: list["lmc_ops.LaunchVar"] = []
for kernel_group_id in kernel_group_ids:
blocks_per_chunk = blocks_per_chunk_by_kg[kernel_group_id]
blocks_per_window = blocks_per_window_by_kg[kernel_group_id]
start_block_pos = start_object_idx * blocks_per_window
end_block_pos = (start_object_idx + batch_len) * blocks_per_window
orig_skip_blocks = cache_context.calculate_num_blocks(
skip_tokens_in_chunk, kernel_group_id
)
recalculated_skip_blocks = _recalculate_blocks_to_skip(
blocks_per_chunk,
blocks_per_window,
orig_skip_blocks,
)
launches.append(
lmc_ops.LaunchVar(
spec_index_by_kg[kernel_group_id],
start_block_pos,
end_block_pos - start_block_pos,
batch_len,
recalculated_skip_blocks,
)
)
batch_steps.append(lmc_ops.BatchStep(staging, launches))
if not batch_steps:
return
lmc_ops.execute_object_group_transfer(
direction,
cache_context.device,
LazyMemoryAllocator.PIN_CHUNK_SIZE,
kernel_group_specs,
batch_steps,
)
def transfer_kv_per_object_group(
cache_context: BaseCacheContext,
block_ids_gpu: list[torch.Tensor],
memory_objs: Sequence[MemoryObj | None],
object_group_id: int,
batch_size: int,
skip_first_n_tokens: int,
direction: "lmc_ops.TransferDirection",
) -> None:
"""Helper function to transfer memory objects of a single object group
to/from GPU, with batching support.
Args:
cache_context: The GPU cache context containing the KV cache information.
block_ids_gpu: GPU block IDs to retrieve into, indexed by LMCache KV group
index. It should satisfy `len(block_ids_gpu[i]) == len(memory_objs) *
blocks_per_chunk[i]` for each group `i`.
Note that the block IDs list are already on GPU.
memory_objs: The list of MemoryObj instances to copy from. It could be
None when allocation or retrieval fails. For store (D2H), it should
ignore the None entry and continue copying the rest. For retrieve
(H2D), it should raise the error and stop copying.
object_group_id: Index of the object group being copied.
batch_size: The number of memory objects to perform batched copy
skip_first_n_tokens: Number of tokens to skip writing at the start of
the retrieve range. This avoids overwriting APC-shared GPU blocks that
may be read concurrently by other requests.
direction: The transfer direction, H2D (retrieve) or D2H (store).
Raises:
ValueError: If it founds None entry in memory_objs when direction is H2D.
Note:
This function expects the caller to stage the block ids (list[list[int]])
into GPU tensors and pass them in as `block_ids_gpu`.
"""
if _HAS_NATIVE_OBJECT_GROUP_TRANSFER and not any(
isinstance(mo, GDSMemoryObject) for mo in memory_objs
):
_run_object_group_transfer_plan(
cache_context,
block_ids_gpu,
memory_objs,
object_group_id,
batch_size,
skip_first_n_tokens,
direction,
)
return
lmcache_chunk_size = cache_context.lmcache_tokens_per_chunk
kv_groups_manager = cache_context.kv_layer_groups_manager
object_group = kv_groups_manager.object_groups[object_group_id]
kernel_group_ids = object_group.kernel_group_indices
is_h2d = direction == lmc_ops.TransferDirection.H2D
attn_desc = kv_groups_manager.get_attn_desc()
num_objects_to_skip = 0
if not attn_desc.is_full_attention(object_group_id) and is_h2d:
sw_size_chunks = attn_desc.num_chunks_in_sw[object_group_id]
num_objects_to_skip = max(0, len(memory_objs) - sw_size_chunks)
logger.debug(
"Detected sliding window for object group %d: "
"skipping the first %d objects in the batch",
object_group_id,
num_objects_to_skip,
)
for start_object_idx, memory_object_batch in batched_iteration_with_skip(
memory_objs, batch_size, skip_count=num_objects_to_skip
):
if any(mo is None for mo in memory_object_batch):
if is_h2d:
raise ValueError(
"MemoryObj is None for some objects in the batch, cannot "
"perform H2D copy. memory_object_batch: "
f"{memory_object_batch}"
)
else:
continue
batch_len = len(memory_object_batch)
batch_start_token = start_object_idx * lmcache_chunk_size
batch_end_token = batch_start_token + batch_len * lmcache_chunk_size
effective_start = max(batch_start_token, skip_first_n_tokens)
if effective_start >= batch_end_token:
continue
skip_tokens_in_chunk = effective_start - batch_start_token
# For H2D, copy from CPU to GPU tmp buffers before the kernel launch
if is_h2d:
for chunk_idx, memory_obj in enumerate(memory_object_batch):
lmcache_memcpy_async_h2d(
memory_obj,
cache_context.get_temp_object_group_buffer(
chunk_idx, object_group_id
),
)
# Do paged KV copy
for kernel_group_id in kernel_group_ids:
blocks_per_chunk = cache_context.calculate_num_blocks(
lmcache_chunk_size, kernel_group_id
)
tokens_per_window = min(
lmcache_chunk_size,
kv_groups_manager.get_subchunk_sw_size_tokens(kernel_group_id),
)
blocks_per_window = cache_context.calculate_num_blocks(
tokens_per_window, kernel_group_id
)
# Get the block ids for this chunk
start_block_pos = start_object_idx * blocks_per_window
end_block_pos = (start_object_idx + batch_len) * blocks_per_window
block_ids_curr_batch = block_ids_gpu[kernel_group_id][
start_block_pos:end_block_pos
]
# Re-calculate the skip blocks for this kernel group
orig_skip_blocks = cache_context.calculate_num_blocks(
skip_tokens_in_chunk, kernel_group_id
)
recalculated_skip_blocks = _recalculate_blocks_to_skip(
blocks_per_chunk,
blocks_per_window,
orig_skip_blocks,
)
# Launch kernel
group_kv_pointers = cache_context.get_kernel_group_kv_pointers(
kernel_group_id
)
group_lmcache_chunk_size = cache_context.get_slots_per_chunk_in_sw(
kernel_group_id
)
tmp_gpu_buffers_batched = [
cache_context.get_temp_kernel_group_buffer(
i, kernel_group_id
).data_ptr()
for i in range(batch_len)
]
lmc_ops.multi_layer_block_kv_transfer(
group_kv_pointers,
tmp_gpu_buffers_batched,
block_ids_curr_batch,
cache_context.device,
direction,
cache_context.get_shape_desc(kernel_group_id),
group_lmcache_chunk_size,
cache_context.get_engine_kv_format(kernel_group_id),
recalculated_skip_blocks,
)
# For D2H, copy from GPU tmp buffers to CPU after the kernel launch
if not is_h2d:
for chunk_idx, memory_obj in enumerate(memory_object_batch):
lmcache_memcpy_async_d2h(
cache_context.get_temp_object_group_buffer(
chunk_idx, object_group_id
),
memory_obj,
)
@dataclass
class ContextEntry:
"""Registered cache context metadata for a single worker instance.
The concrete type is whatever :func:`create_cache_context` returned
for the wrapper list at registration time -- a
:class:`GPUCacheContext` for CUDA-IPC wrappers, a
:class:`CPUCacheContext` for POSIX-SHM wrappers. Both expose
the same ``kv_tensors`` / ``engine_kv_format`` / ``num_layers`` / ...
duck-typed surface, so downstream consumers stay agnostic.
Args:
cache_context: Platform cache context (GPU or CPU) managing
shape and pointers to the registered KV cache tensors.
model_name: The name of the model associated with this KV cache.
world_size: The world size associated with this KV cache.
last_seen: ``time.monotonic()`` of the most recent activity from
this instance (register, PING, store, or retrieve). Drives reaping.
has_liveness_signal: True once the instance has sent at least one
PING. Selects the reap window (timeout vs registration grace).
Latched only by PING, never by traffic.
"""
cache_context: BaseCacheContext
model_name: str
world_size: int
last_seen: float = 0.0
has_liveness_signal: bool = False
class LMCacheDrivenTransferModule(InstanceLivenessTarget):
"""Handles LMCache-driven KV cache transfer operations.
Owns GPU context registrations and provides handlers for
register, unregister, store, and retrieve of GPU KV caches.
Args:
ctx: The shared engine context.
"""
def __init__(self, ctx: MPCacheServerContext) -> None:
self._ctx = ctx
self._cache_contexts: dict[int, ContextEntry] = {}
# Guards all reads/writes of _cache_contexts. The reaper mutates it
# off the MQ main loop, so register/unregister/store/retrieve and
# report_status all serialize through this lock. Held only for dict
# ops -- never across context creation, layout-registry calls, or
# empty_cache (leaf-lock invariant: no thread holds two locks).
self._lock = threading.Lock()
# Route finish_write / finish_read_prefetched through a C++ host
# callback so the driver thread doesn't acquire the GIL.
self._device_host_func_dispatcher = DeviceHostFuncDispatcher()
self._device_host_func_dispatcher.register(
"finish_write",
self._ctx.storage_manager.finish_write,
payload_type=list[ObjectKey],
)
self._device_host_func_dispatcher.register(
"finish_read_prefetched",
self._ctx.storage_manager.finish_read_prefetched,
payload_type=list[ObjectKey],
)
self._device_host_func_dispatcher.start()
@property
def context(self) -> MPCacheServerContext:
"""Return the shared engine context. Exposed for testing only."""
return self._ctx
def get_and_touch_context_entry(self, instance_id: int) -> ContextEntry | None:
"""Return the entry for ``instance_id``, refreshing its last-seen time.
The refresh keeps an actively transferring worker from being reaped
even if its PINGs are briefly delayed. Does not latch the
ping-proven flag -- only PINGs do that.
Args:
instance_id: The worker instance ID.
Returns:
The entry, or None if the instance is not (or no longer) tracked.
"""
now = time.monotonic()
with self._lock:
entry = self._cache_contexts.get(instance_id)
if entry is not None:
entry.last_seen = now
return entry
def context_entries_snapshot(self) -> dict[int, ContextEntry]:
"""Return a shallow copy of the registry for iteration or status.
Returns:
A new dict mapping instance ID to entry; does not refresh
last-seen times.
"""
with self._lock:
return dict(self._cache_contexts)
def touch_instance(self, instance_id: int) -> None:
"""Refresh the worker's last-seen time and mark it ping-proven.
A no-op if the instance is not tracked.
Args:
instance_id: The worker instance ID.
"""
now = time.monotonic()
with self._lock:
entry = self._cache_contexts.get(instance_id)
if entry is not None:
entry.last_seen = now
entry.has_liveness_signal = True
def tracked_instance_count(self) -> int:
"""Return the number of currently registered instances."""
with self._lock:
return len(self._cache_contexts)
def reap_stale_instances(
self, reap_timeout_s: float, registration_grace_s: float
) -> list[int]:
"""Reap GPU registrations that have gone silent.
A ping-proven instance is judged against ``reap_timeout_s``; one
that has never pinged against the larger ``registration_grace_s``.
Args:
reap_timeout_s: Silence budget for ping-proven instances.
registration_grace_s: Silence budget for never-pinged instances.
Returns:
The instance IDs reaped this scan.
"""
now = time.monotonic()
reaped: list[tuple[int, ContextEntry]] = []
with self._lock:
stale_ids = [
iid
for iid, entry in self._cache_contexts.items()
if now - entry.last_seen
> (
reap_timeout_s
if entry.has_liveness_signal
else registration_grace_s
)
]
for iid in stale_ids:
reaped.append((iid, self._cache_contexts.pop(iid)))
for iid, entry in reaped:
self._release_entry(entry)
logger.warning(
"Reaped GPU instance %d: silent for %.1fs (pinged=%s)",
iid,
now - entry.last_seen,
entry.has_liveness_signal,
)
return [iid for iid, _ in reaped]
def _release_entry(self, entry: ContextEntry) -> None:
"""Release resources for a popped entry (run outside the lock).
Args:
entry: The entry removed from the registry.
"""
entry.cache_context.close()
self._ctx.layout_desc_registry.unregister(entry.model_name, entry.world_size)
torch_dev.empty_cache()
def get_handlers(self) -> list[HandlerSpec]:
"""Return handler specs for all request types this module serves.
Returns:
A list of HandlerSpec entries mapping request types to
their handler callables and thread pool assignments.
"""
return [
HandlerSpec(
RequestType.REGISTER_KV_CACHE,
self.register_kv_cache,
ThreadPoolType.SYNC,
),
HandlerSpec(
RequestType.UNREGISTER_KV_CACHE,
self.unregister_kv_cache,
ThreadPoolType.SYNC,
),
HandlerSpec(
RequestType.STORE,
self.store,
ThreadPoolType.AFFINITY,
),
HandlerSpec(
RequestType.RETRIEVE,
self.retrieve,
ThreadPoolType.AFFINITY,
),
]
def report_status(self) -> dict:
"""Return GPU transfer module status information.
Returns:
A dict containing registered GPU instance IDs and
per-instance KV cache layout metadata.
"""
registered_gpu_ids: list[int] = []
cache_context_meta: dict[str, dict] = {}
for instance_id, entry in self.context_entries_snapshot().items():
registered_gpu_ids.append(instance_id)
ctx = entry.cache_context
cache_context_meta[str(instance_id)] = {
"model_name": entry.model_name,
"world_size": entry.world_size,
"kv_cache_layout": ctx.report_status(),
}
return {
"registered_gpu_ids": registered_gpu_ids,
"cache_context_meta": cache_context_meta,
}
def close(self) -> None:
"""Release GPU resources owned by this module."""
# Stop the drain thread before storage_manager.close() so any
# in-flight completions reach a live storage manager.
self._device_host_func_dispatcher.stop()
with self._lock:
entries = list(self._cache_contexts.values())
self._cache_contexts.clear()
for entry in entries:
entry.cache_context.close()
if entries:
torch_dev.empty_cache()
def register_kv_cache(
self,
instance_id: int,
kv_caches: KVCache,
model_name: str,
world_size: int,
engine_type: EngineType,
layout_hints: LayoutHints,
engine_group_infos: list[EngineGroupInfo],
) -> None:
"""Register the KV cache tensors for a given GPU instance ID.
Args:
instance_id: The GPU instance ID (such as PID).
kv_caches: The KV cache tensor wrappers from the
serving engine.
model_name: The name of the model associated with this KV cache.
world_size: The world size associated with this KV cache.
engine_type: Which serving engine produced the caches.
Forwarded to GPUCacheContext for format detection.
layout_hints: See LayoutHints. Forwarded to
GPUCacheContext for GPU KV format detection.
engine_group_infos: Engine-neutral KV cache group metadata
(already msgspec-decoded by the message queue).
"""
now = time.monotonic()
# NOOP-register: an already-registered instance (e.g. a recovering
# worker re-registering on its first ping) refreshes its last-seen
# time so a stale entry is not reaped right after recovery. REGISTER
# is SYNC-serialized on the MQ main loop, so it is the sole inserter.
with self._lock:
existing = self._cache_contexts.get(instance_id)
if existing is not None:
existing.last_seen = now
logger.info(
"Instance %d already registered; refreshing liveness",
instance_id,
)
return
# Build the context and layout descriptor outside the lock.
cache_context = create_cache_context(
kv_caches,
self._ctx.chunk_size,
layout_hints=layout_hints or None,
engine_group_infos=engine_group_infos,
engine_type=engine_type,
separate_object_groups=self._ctx.separate_object_groups,
full_sw_kv=self._ctx.full_sw_kv,
)
layout_desc = get_layout_desc(
cache_context, self._ctx.chunk_size, object_group_id=0
)
kv_groups_manager = cache_context.kv_layer_groups_manager
attn_desc = kv_groups_manager.get_attn_desc()
self._ctx.layout_desc_registry.register(
model_name, world_size, layout_desc, attn_desc
)
with self._lock:
self._cache_contexts[instance_id] = ContextEntry(
cache_context=cache_context,
model_name=model_name,
world_size=world_size,
last_seen=now,
has_liveness_signal=False,
)
logger.info(
"Registered KV cache for GPU ID %d with %d layers",
instance_id,
cache_context.num_layers,
)
def unregister_kv_cache(self, instance_id: int) -> None:
"""Unregister the KV cache tensors for a given GPU instance ID.
Args:
instance_id: The GPU instance ID (such as PID).
"""
with self._lock:
entry = self._cache_contexts.pop(instance_id, None)
if entry is None:
logger.warning(
"No registered GPU context found for instance ID %d", instance_id
)
return
self._release_entry(entry)
logger.info("Unregistered KV cache for GPU ID %d", instance_id)
@_lmcache_nvtx_annotate
def store(
self,
key: IPCCacheServerKey,
instance_id: int,
gpu_block_ids: list[list[int]],
event_ipc_handle: bytes,
) -> tuple[bytes, bool]:
"""Store the GPU KV cache blocks to CPU.
Args:
key: The IPC key for the KV cache blocks.
Must have worker_id != None (worker store operation).
instance_id: The GPU instance ID (such as PID).
gpu_block_ids: GPU block IDs to store, indexed by LMCache KV
group index.
event_ipc_handle: The IPC handle of the event to wait on.
Returns:
A tuple where the first element is the IPC handle of the event
that signals the completion of the store operation, and the second
element indicates whether the store operation completed without a
fatal error (not whether every requested chunk was stored; see
Notes).
Raises:
ValueError: If no GPU context is registered for the given instance ID.
RuntimeError: If the backend does not support IPC event handles.
Notes:
All-or-nothing. If ``gpu_block_ids`` do not fully cover every chunk
``key`` resolves to for every LMCache group (e.g. a caller/protocol
bug), or a copy fails, the whole store is skipped and nothing is
committed (logged at WARNING); a subsequent retrieve simply misses
and the engine recomputes. The boolean result reports whether the
store completed without such a failure.
"""
st = time.perf_counter()
entry = self.get_and_touch_context_entry(instance_id)
if entry is None:
raise ValueError(f"No GPU context registered for instance ID {instance_id}")
cache_context = entry.cache_context
model_name = entry.model_name
num_object_groups = cache_context.kv_layer_groups_manager.num_object_groups
obj_keys_per_obj_group = self._ctx.resolve_obj_keys(
key, list(range(num_object_groups))
)
num_chunks = len(obj_keys_per_obj_group[0])
# NOTE: different engine groups may have different block sizes, so
# ``blocks_per_chunk[i]`` is the number of blocks in one chunk for
# group ``i``.
blocks_per_chunk = [
cache_context.calculate_num_blocks(self._ctx.chunk_size, group_idx)
for group_idx in range(
cache_context.kv_layer_groups_manager.num_kernel_groups
)
]
with (
torch_dev.device(cache_context.device),
torch_dev.stream(cache_context.stream),
):
check_interprocess_event_support()
event = torch_dev.Event(interprocess=True)
# Fail closed: every LMCache group must have block IDs covering all
# chunks. A short list (e.g. a caller/protocol bug) would otherwise
# drive the transfer kernel to read out-of-bounds GPU memory, so skip
# the whole store and commit nothing rather than caching a partial or
# garbage entry. A later request can store it once the block IDs are
# complete. Checked on the raw block ids, before cutting drops the
# per-chunk blocks that sliding-window groups do not need.
if any(
len(group_block_ids) < num_chunks * bpc
for group_block_ids, bpc in zip(
gpu_block_ids, blocks_per_chunk, strict=True
)
):
logger.warning(
"STORE block ID underflow for request_id=%s: each group needs "
"num_chunks * blocks_per_chunk block IDs for %d chunks "
"(per-group blocks_per_chunk=%s); skipping the store.",
key.request_id,
num_chunks,
blocks_per_chunk,
)
event.record()
return event.ipc_handle(), False
block_ids_per_group_gpu = downsample_and_stage_block_ids(
cache_context, gpu_block_ids
)
if not hasattr(torch_dev.Event, "from_ipc_handle"):
raise RuntimeError(
f"Backend '{torch_device_type}' does not support IPC event "
"handles (Event.from_ipc_handle not available). "
"Multiprocess IPC requires CUDA."
)
vllm_event = torch_dev.Event.from_ipc_handle(
cache_context.device, event_ipc_handle
)
vllm_event.wait(stream=cache_context.stream)
# CPU-synchronous sentinel: a GPU store is about to be enqueued.
# Must be published via publish() (not publish_on_stream) so the
# drain thread sees it before MP_REQUEST_END can race MP_STORE_END.
self._ctx.event_bus.publish(
Event(
event_type=EventType.MP_STORE_SUBMITTED,
session_id=key.request_id,
metadata={"device": str(cache_context.device)},
)
)
self._ctx.event_bus.publish_on_stream(
cache_context.cupy_stream,
Event(
event_type=EventType.MP_STORE_START,
session_id=key.request_id,
metadata={
"device": str(cache_context.device),
"engine_id": instance_id,
"model_name": model_name,
},
),
)
reserved_dict: dict[ObjectKey, MemoryObj] = {}
all_dict: dict[ObjectKey, MemoryObj] = {}
total_bytes: int = 0
store_succeeded = False
try:
for obj_group_id in range(num_object_groups):
obj_keys = obj_keys_per_obj_group[obj_group_id]
layout_desc = get_layout_desc(
cache_context,
self._ctx.chunk_size,
object_group_id=obj_group_id,
)
reserved_dict = self._ctx.storage_manager.reserve_write(
obj_keys, layout_desc, "new"
)
all_dict.update(reserved_dict)
if reserved_dict:
total_bytes += next(
iter(reserved_dict.values())
).get_size() * len(reserved_dict)
# Keys not in reserved_dict (skipped by the storage manager)
# become None entries; the helper skips them for D2H.
memory_objs: list[MemoryObj | None] = [
reserved_dict.get(obj_key) for obj_key in obj_keys
]
# NOTE: batch_size must stay 1 for store.
transfer_kv_per_object_group(
cache_context,
block_ids_per_group_gpu,
memory_objs,
object_group_id=obj_group_id,
batch_size=1,
skip_first_n_tokens=0,
direction=lmc_ops.TransferDirection.D2H,
)
store_succeeded = True
except Exception:
logger.exception("Cannot store keys due to exception")
return event.ipc_handle(), False
finally:
event.record()
# Fail closed: commit the reserved objects only when every chunk
# copied successfully; otherwise the whole store is skipped.
stored_count = len(all_dict) if store_succeeded else 0
if stored_count:
submit_callback_to_stream(
cache_context.cupy_stream,
"finish_write",
list(all_dict.keys()),
)
else:
total_bytes = 0
self._ctx.event_bus.publish_on_stream(
cache_context.cupy_stream,
Event(
event_type=EventType.MP_STORE_END,
session_id=key.request_id,
metadata={
"stored_count": stored_count,
"device": str(cache_context.device),
"engine_id": instance_id,
"model_name": model_name,
"total_bytes": total_bytes,
},
),
)
ed = time.perf_counter()
if stored_count:
logger.info(
"Stored %d tokens in %.3f seconds",
num_chunks * self._ctx.chunk_size,
ed - st,
)
return event.ipc_handle(), True
@_lmcache_nvtx_annotate
def retrieve(
self,
key: IPCCacheServerKey,
instance_id: int,
gpu_block_ids: list[list[int]],
event_ipc_handle: bytes,
skip_first_n_tokens: int = 0,
) -> tuple[bytes, bool]:
"""Retrieve the CPU KV cache and put into GPU blocks.
Args:
key: The IPC key for the KV cache blocks.
Must have worker_id != None (worker retrieve operation).
instance_id: The GPU instance ID (such as PID).
gpu_block_ids: GPU block IDs to retrieve into, indexed by LMCache
KV group index.
event_ipc_handle: The IPC handle of the event to wait on.
skip_first_n_tokens: Number of tokens to skip writing at
the start of the retrieve range. This avoids overwriting
APC-shared GPU blocks that may be read concurrently by other
requests.
Returns:
A tuple where the first element is the IPC handle of the event
that signals the completion of the retrieve operation, and the
second element indicates whether the key was successfully retrieved.
Raises:
ValueError: If no GPU context is registered for the given instance ID.
"""
st = time.perf_counter()
entry = self.get_and_touch_context_entry(instance_id)
if entry is None:
raise ValueError(f"No GPU context registered for instance ID {instance_id}")
cache_context = entry.cache_context
model_name = entry.model_name
num_object_groups = cache_context.kv_layer_groups_manager.num_object_groups
obj_keys_per_obj_group = self._ctx.resolve_obj_keys(
key, list(range(num_object_groups))
)
num_chunks = len(obj_keys_per_obj_group[0])
# CPU-synchronous sentinel: a GPU retrieve is about to be enqueued.
# Must be published via publish() (not publish_on_stream) so the
# drain thread sees it before MP_REQUEST_END can race MP_RETRIEVE_END.
self._ctx.event_bus.publish(
Event(
event_type=EventType.MP_RETRIEVE_SUBMITTED,
session_id=key.request_id,
metadata={"device": str(cache_context.device)},
)
)
self._ctx.event_bus.publish_on_stream(
cache_context.cupy_stream,
Event(
event_type=EventType.MP_RETRIEVE_START,
session_id=key.request_id,
metadata={
"device": str(cache_context.device),
"engine_id": instance_id,
"model_name": model_name,
},
),
)
blocks_per_chunk = [
cache_context.calculate_num_blocks(self._ctx.chunk_size, group_idx)
for group_idx in range(
cache_context.kv_layer_groups_manager.num_kernel_groups
)
]
with (
torch_dev.device(cache_context.device),
torch_dev.stream(cache_context.stream),
):
check_interprocess_event_support()
event = torch_dev.Event(interprocess=True)
# Fail closed: a short block-id list would drive the transfer
# kernel to write out-of-bounds GPU memory. Checked on the raw
# block ids, before cutting drops the per-chunk blocks that
# sliding-window groups do not need.
if any(
len(group_block_ids) < num_chunks * bpc
for group_block_ids, bpc in zip(
gpu_block_ids, blocks_per_chunk, strict=True
)
):
logger.error(
"RETRIEVE block ID underflow for request_id=%s: each group "
"needs num_chunks * blocks_per_chunk block IDs for %d "
"chunks (per-group blocks_per_chunk=%s); skipping the "
"retrieve.",
key.request_id,
num_chunks,
blocks_per_chunk,
)
event.record()
return event.ipc_handle(), False
# Cut and stage all block_ids to GPU once before the transfer
block_ids_per_group_gpu = downsample_and_stage_block_ids(
cache_context, gpu_block_ids
)
prefetched_keys: list[ObjectKey] = []
total_bytes = 0
try:
for obj_group_id in range(num_object_groups):
obj_keys = obj_keys_per_obj_group[obj_group_id]
with self._ctx.storage_manager.read_prefetched_results(
obj_keys
) as memory_objs:
if not memory_objs or len(memory_objs) != len(obj_keys):
logger.error("Some keys not found during retrieve!")
return event.ipc_handle(), False
total_bytes += sum(mo.get_size() for mo in memory_objs)
transfer_kv_per_object_group(
cache_context,
block_ids_per_group_gpu,
memory_objs,
object_group_id=obj_group_id,
batch_size=cache_context.max_batch_size,
skip_first_n_tokens=skip_first_n_tokens,
direction=lmc_ops.TransferDirection.H2D,
)
# Extend only after the copy is enqueued: on exception,
# read_prefetched_results releases this group's locks
# itself, and a key must not be released twice.
prefetched_keys.extend(obj_keys)
except Exception:
logger.exception("Cannot retrieve keys due to exception")
return event.ipc_handle(), False
finally:
event.record()
if prefetched_keys:
submit_callback_to_stream(
cache_context.cupy_stream,
"finish_read_prefetched",
prefetched_keys,
)
self._ctx.event_bus.publish_on_stream(
cache_context.cupy_stream,
Event(
event_type=EventType.MP_RETRIEVE_END,
session_id=key.request_id,
metadata={
"retrieved_count": len(prefetched_keys),
"device": str(cache_context.device),
"engine_id": instance_id,
"model_name": model_name,
"cache_salt": key.cache_salt,
"total_bytes": total_bytes,
},
),
)
tokens_retrieved = num_chunks * self._ctx.chunk_size
ed = time.perf_counter()
logger.info(
"Retrieved %d tokens in %.3f seconds",
tokens_retrieved,
ed - st,
)
return event.ipc_handle(), True