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

715 lines
27 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Non-GPU context abstractions and utilities for multiprocess transport.
This module provides:
- ``EngineDrivenContextMetadata``: layout metadata dataclass for non-CUDA workers.
- ``EngineDrivenContext``: abstract base class with a two-phase prepare/commit
interface for CPU-side KV data transfer. Concrete implementations (e.g.
``EngineDrivenContextPickle``) each decide *how* data is serialised and transported.
- ``create_engine_driven_context()``: factory that returns the appropriate
``EngineDrivenContext`` subclass.
- ``compute_kv_layout``, ``gather_paged_kv_to_cpu``, ``scatter_cpu_to_paged_kv``:
shared gather/scatter utilities used by all concrete implementations.
"""
# Future
from __future__ import annotations
# Standard
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import TYPE_CHECKING
import inspect
# Third Party
import numpy as np
import torch
# First Party
from lmcache import torch_dev
from lmcache.logging import init_logger
from lmcache.utils import EngineType
from lmcache.v1.distributed.api import MemoryLayoutDesc
from lmcache.v1.gpu_connector.utils import LayoutHints
from lmcache.v1.multiprocess.custom_types import IPCCacheServerKey
from lmcache.v1.multiprocess.mq import MessageQueueClient
if TYPE_CHECKING:
# First Party
import lmcache.c_ops as lmc_ops
logger = init_logger(__name__)
# ---------------------------------------------------------------------------
# Global capability flag: does lmc_ops.multi_layer_block_kv_transfer accept
# list[torch.Tensor] directly for lmcache_objects_ptrs, or only list[int]?
#
# We inspect the function signature once at import time. If the annotation
# for ``lmcache_objects_ptrs`` includes ``Tensor``, the op can handle tensors
# natively and we pass them through. Otherwise (annotation is list[int], or
# inspect fails entirely) we must convert tensors to data pointers before
# calling.
# ---------------------------------------------------------------------------
def _detect_block_transfer_accepts_tensor() -> bool:
"""Return True if lmc_ops.multi_layer_block_kv_transfer accepts
list[torch.Tensor] for its lmcache_objects_ptrs parameter."""
try:
# First Party
import lmcache.c_ops as _lmc_ops
fn = _lmc_ops.multi_layer_block_kv_transfer
# Attempt: use inspect.signature (works on newer pybind11 builds)
# Assumptions: if lmcache_objects_ptrs accepts tensors,
# it's fallback path, and we do not convert tensors to ptrs explicitly.
# TODO: String matching on annotations is fragile. Wait for lmc_ops to
# expose a direct version flag (e.g., lmc_ops.__version__) or
# an explicit capability boolean.
try:
sig = inspect.signature(fn)
param = sig.parameters.get("lmcache_objects_ptrs")
if param is not None and param.annotation is not inspect.Parameter.empty:
ann_str = str(param.annotation)
if "Tensor" in ann_str:
return True
# Annotation exists but no Tensor mention → ptr-only
return False
except (ValueError, TypeError):
pass
except Exception:
# Import failed or any other error → conservative: assume ptr-only
pass
# Default: inspect failed or lmc_ops not available → assume ptr-only
return False
_LMC_OPS_BLOCK_TRANSFER_ACCEPTS_TENSOR: bool = _detect_block_transfer_accepts_tensor()
"""If True, ``lmc_ops.multi_layer_block_kv_transfer`` accepts
``list[torch.Tensor]`` directly for ``lmcache_objects_ptrs``.
If False, callers must convert tensors to ``list[int]`` data pointers."""
logger.info(
"multi_layer_block_kv_transfer mode: %s",
"tensor" if _LMC_OPS_BLOCK_TRANSFER_ACCEPTS_TENSOR else "ptr",
)
def _tensors_to_ptrs(tensors: list[torch.Tensor]) -> list[int]:
"""Convert a list of tensors to a list of their data_ptr() values."""
return [t.data_ptr() for t in tensors]
# ---------------------------------------------------------------------------
@dataclass
class EngineDrivenContextMetadata:
"""Non-GPU context layout metadata for non-CUDA workers.
Attributes:
layout_desc: Memory layout descriptor used to interpret chunk payloads.
block_size: Number of tokens per paged block.
use_mla: Whether the worker KV format is MLA.
"""
layout_desc: MemoryLayoutDesc
block_size: int
use_mla: bool
class EngineDrivenContext(ABC):
"""Abstract base class for CPU-side KV data transfer contexts.
All concrete implementations share a common message-queue client and
expose a uniform two-phase ``prepare/commit`` interface so that the
worker adapter is implementation-agnostic.
Args:
metadata: Layout metadata describing the chunk format.
mq_client: Message-queue client used for server communication.
mq_timeout: Timeout in seconds for blocking MQ requests.
"""
def __init__(
self,
metadata: EngineDrivenContextMetadata,
mq_client: MessageQueueClient,
mq_timeout: float,
) -> None:
self.metadata = metadata
self.mq_client = mq_client
self.mq_timeout = mq_timeout
@property
def layout_desc(self) -> MemoryLayoutDesc:
"""The memory layout descriptor for this context."""
return self.metadata.layout_desc
@abstractmethod
def prepare_store(
self, key: IPCCacheServerKey, instance_id: int
) -> tuple[list[torch.Tensor], list[int]] | None:
"""Prepare SHM buffers for a store operation.
Returns:
None: pickle mode — no pre-allocated buffers. Caller gathers all
chunks to CPU itself and sends the serialized data via
commit_store.
([], []): SHM mode but all chunks already cached. Caller should
skip gather and commit entirely.
(tensors, chunk_indices): SHM mode with new chunks to write.
- tensors[i] is a writable SHM-backed buffer for one chunk.
- chunk_indices[i] is the position of that chunk in the full
block_ids sequence (e.g. [0, 2] means only chunks 0 and 2
need writing; chunk 1 is already cached).
Caller gathers only these chunks into the provided tensors,
then calls commit_store with empty payload.
"""
...
@abstractmethod
def commit_store(
self, key: IPCCacheServerKey, instance_id: int, chunks: list[torch.Tensor]
) -> bool:
"""Commit store. Pickle: serialize and send. Shm: notify server."""
...
@abstractmethod
def prepare_retrieve(
self, key: IPCCacheServerKey, instance_id: int
) -> list[torch.Tensor] | None:
"""Prepare retrieve. Returns chunks or shm views, or None on miss."""
...
@abstractmethod
def commit_retrieve(self, key: IPCCacheServerKey, instance_id: int) -> bool:
"""Commit retrieve. Pickle: no-op. Shm: release read locks."""
...
@abstractmethod
def close(self) -> None:
"""Release any resources held by this context."""
...
def create_engine_driven_context(
metadata: EngineDrivenContextMetadata,
mq_client: MessageQueueClient,
mq_timeout: float,
shm_name: str,
pool_size: int,
*,
use_pickle: bool = False,
) -> EngineDrivenContext:
"""Factory that returns the appropriate :class:`EngineDrivenContext` implementation.
Returns SHM-based implementation when shared-memory pool information is
available; otherwise falls back to the pickle-based implementation.
If SHM initialization fails for any reason (e.g. segment not found,
permission error), gracefully falls back to pickle transport.
Args:
metadata: Layout metadata for the non-GPU context.
mq_client: Message-queue client for server communication.
mq_timeout: Timeout in seconds for blocking MQ requests.
shm_name: Shared-memory segment name. Empty values force pickle mode.
pool_size: Shared-memory pool size in bytes. Non-positive values force
pickle mode.
use_pickle: Explicitly use pickle transport even when SHM info is
available.
Returns:
A concrete :class:`EngineDrivenContext` instance.
"""
if not shm_name or pool_size <= 0:
use_pickle = True
if not use_pickle:
# Local
from .shm import EngineDrivenContextShm
try:
logger.info(
"Creating EngineDrivenContextShm (shm_name=%s, pool_size=%d)",
shm_name,
pool_size,
)
return EngineDrivenContextShm(
metadata, mq_client, mq_timeout, shm_name, pool_size
)
except Exception:
logger.warning(
"Failed to initialize SHM context (shm_name=%s), "
"falling back to pickle transport",
shm_name,
exc_info=True,
)
# Local
from .pickle import EngineDrivenContextPickle
logger.info("Creating EngineDrivenContextPickle (pickle transport)")
return EngineDrivenContextPickle(metadata, mq_client, mq_timeout)
# ---------------------------------------------------------------------------
# Shared gather / scatter utilities
# ---------------------------------------------------------------------------
def compute_kv_layout(
kv_caches: dict[str, torch.Tensor],
layout_hints: LayoutHints | None = None,
) -> tuple[int, int, int, str, "lmc_ops.EngineKVFormat"]:
"""Compute KV layout metadata from KV tensors.
Args:
kv_caches: Per-layer KV tensor mapping.
layout_hints: Optional engine layout hints.
Returns:
Tuple of ``(block_size, num_layers, hidden_dim_size, dtype_str,``
``engine_kv_format)``.
Raises:
ValueError: If ``kv_caches`` is empty.
"""
# First Party
from lmcache.v1.gpu_connector.utils import (
get_block_size,
get_hidden_dim_size,
get_num_layers,
normalize_kv_and_discover_format,
)
tensors = list(kv_caches.values())
if not tensors:
raise ValueError("kv_caches is empty. Cannot compute KV layout.")
engine_kv_format, normalized = normalize_kv_and_discover_format(
tensors, EngineType.VLLM, layout_hints=layout_hints
)
block_size = get_block_size(normalized, engine_kv_format)
num_layers = get_num_layers(normalized, engine_kv_format)
hidden_dim_size = get_hidden_dim_size(normalized, engine_kv_format)
dtype_str = str(tensors[0].dtype).replace("torch.", "")
return block_size, num_layers, hidden_dim_size, dtype_str, engine_kv_format
def gather_paged_kv_to_cpu(
kv_caches: dict[str, torch.Tensor],
block_ids: list[int],
blocks_per_chunk: int,
layout_hints: LayoutHints | None = None,
engine_kv_format: "lmc_ops.EngineKVFormat" | None = None,
out: list[torch.Tensor] | None = None,
chunk_indices: list[int] | None = None,
) -> list[torch.Tensor]:
"""Gather paged KV blocks into CPU chunk tensors.
Args:
kv_caches: Per-layer KV tensor mapping.
block_ids: Flattened block IDs for all chunks.
blocks_per_chunk: Number of paged blocks in one LMCache chunk.
layout_hints: Optional engine layout hints.
engine_kv_format: Optional pre-detected KV format.
out: Optional pre-allocated output tensors. If provided, length
must be at least ``len(chunk_indices)`` when ``chunk_indices``
is given, or the total number of chunks otherwise. Any extra
buffers beyond the number of gathered chunks are ignored.
chunk_indices: Optional list of chunk positions (into the full
``block_ids`` sequence) to gather. When provided together with
``out``, only those chunks are gathered and written into
``out[i]`` in order. When ``None``, all chunks are gathered
(backward-compatible behaviour).
Returns:
List of CPU tensors, one per chunk. For non-MLA each chunk has shape
``[2, num_layers, chunk_tokens, hidden_dim]`` where dimension ``0``
stores ``(K, V)``. For MLA (multi-head latent attention) each chunk
has shape ``[num_layers, chunk_tokens, hidden_dim]``.
Raises:
ValueError: If ``out`` is provided with fewer buffers than the number
of gathered chunks.
"""
# First Party
from lmcache.v1.gpu_connector.utils import (
get_block_size,
get_hidden_dim_size,
get_num_blocks,
get_num_layers,
is_mla,
make_page_buffer_shape_desc,
normalize_kv_and_discover_format,
)
import lmcache.c_ops as lmc_ops
tensors = list(kv_caches.values())
fmt, normalized = normalize_kv_and_discover_format(
tensors, EngineType.VLLM, layout_hints=layout_hints
)
if engine_kv_format is None:
engine_kv_format = fmt
block_size = get_block_size(normalized, engine_kv_format)
num_layers = get_num_layers(normalized, engine_kv_format)
hidden_dim_size = get_hidden_dim_size(normalized, engine_kv_format)
num_blocks = get_num_blocks(normalized, engine_kv_format)
num_chunks = len(block_ids) // blocks_per_chunk
chunk_tokens = blocks_per_chunk * block_size
shape_desc = make_page_buffer_shape_desc(
normalized,
engine_kv_format,
layer_idx=0,
num_layers_in_group=num_layers,
num_blocks=num_blocks,
block_size=block_size,
)
iter_indices = (
list(chunk_indices) if chunk_indices is not None else list(range(num_chunks))
)
# Require at least one output buffer per gathered chunk. Extra trailing
# buffers are ignored (see ``chunks = out[: len(iter_indices)]`` below),
# mirroring the scatter-side length check for consistency.
if out is not None and len(out) < len(iter_indices):
raise ValueError(
f"out length ({len(out)}) must be at least the number of "
f"gathered chunks ({len(iter_indices)})"
)
# Determine if pinned memory is strictly required
# (only for the compiled C++ path which does not accept tensor)
requires_pinned = not _LMC_OPS_BLOCK_TRANSFER_ACCEPTS_TENSOR
needs_staging = False
staged_chunks = []
if out is None:
use_mla = is_mla(engine_kv_format)
if use_mla:
chunks = [
torch.empty(
(num_layers, chunk_tokens, hidden_dim_size),
dtype=tensors[0].dtype,
device=torch.device("cpu"),
pin_memory=requires_pinned,
)
for _ in iter_indices
]
else:
chunks = [
torch.empty(
(2, num_layers, chunk_tokens, hidden_dim_size),
dtype=tensors[0].dtype,
device=torch.device("cpu"),
pin_memory=requires_pinned,
)
for _ in iter_indices
]
else:
_target_out = out[: len(iter_indices)]
if requires_pinned and not all(t.is_pinned() for t in _target_out):
# Core fallback: Unpinned memory (e.g., IPC Shared Memory) detected.
# We cannot dynamically call `.pin_memory()` on `out` because it
# would allocate new tensors, breaking the caller's expectation
# of an in-place update. Instead, we allocate a temporary pinned
# staging buffer for the C++ kernel to write to safely.
logger.warning(
"Unpinned memory detected in 'out' during "
"gather_paged_kv_to_cpu (likely Shared Memory). "
"Using an internal pinned staging buffer, which "
"adds a CPU memory copy overhead."
)
needs_staging = True
staged_chunks = [torch.empty_like(t, pin_memory=True) for t in _target_out]
chunks = (
staged_chunks # Point to the safe staging buffer for the H2D transfer
)
else:
# Ideal case: Memory is pinned, or we are using Python fallback.
# Ignore any extra trailing buffers beyond what we actually gather so
# the kernel's ``total_blocks % num_objects`` invariant still holds.
# Return ``out`` unchanged when no trimming is needed so the in-place
# fill contract (result is out) is preserved.
if len(out) == len(iter_indices):
chunks = out
else:
chunks = out[: len(iter_indices)]
selected_block_ids: list[int] = []
for chunk_idx in iter_indices:
selected_block_ids.extend(
block_ids[chunk_idx * blocks_per_chunk : (chunk_idx + 1) * blocks_per_chunk]
)
if selected_block_ids:
if _LMC_OPS_BLOCK_TRANSFER_ACCEPTS_TENSOR:
# Python fallback: accepts tensor list directly for all params.
paged_arg = normalized
objs_arg = chunks
block_ids_arg = selected_block_ids
# call kernel in one shot
lmc_ops.multi_layer_block_kv_transfer(
paged_arg,
objs_arg,
block_ids_arg,
tensors[0].device,
lmc_ops.TransferDirection.D2H,
shape_desc,
chunk_tokens,
engine_kv_format,
0,
)
else:
# Compiled C++/CUDA/XPU: requires int64 pointer tensor and list[int].
_ptrs_np = np.array(
[t.data_ptr() for t in normalized], # type: ignore[union-attr]
dtype=np.uint64,
).view(np.int64)
paged_arg = torch.from_numpy(_ptrs_np).to(device=tensors[0].device)
# This safely points to either the pre-pinned chunks
# OR the temporary staged_chunks
objs_arg = _tensors_to_ptrs(chunks)
block_ids_arg = torch.tensor(
selected_block_ids, dtype=torch.int64, device=tensors[0].device
)
# Split transfer to respect CUDA kernel's object count limitation
MAX_OBJECTS = 4
req_blocks_per_obj = blocks_per_chunk
total_objects = len(objs_arg)
for i in range(0, total_objects, MAX_OBJECTS):
# Slice object pointers and corresponding block IDs
batch_objs_ptrs = objs_arg[i : i + MAX_OBJECTS]
start_block = i * req_blocks_per_obj
end_block = min(
(i + MAX_OBJECTS) * req_blocks_per_obj, len(selected_block_ids)
)
batch_blocks = block_ids_arg[start_block:end_block]
# Execute batched transfer
lmc_ops.multi_layer_block_kv_transfer(
paged_arg,
batch_objs_ptrs,
batch_blocks,
tensors[0].device,
lmc_ops.TransferDirection.D2H,
shape_desc,
chunk_tokens,
engine_kv_format,
0,
)
# --- Final reconciliation ---
# If we used a staging buffer to protect unpinned shared memory,
# we now copy the gathered data back into the caller's original tensors.
if needs_staging:
assert out is not None
# The CPU MUST block and wait for the GPU ONLY when a temporary
# staging buffer is used. This is because the CPU needs to immediately
# read this data for the memory copy below.
torch_dev.synchronize()
for dst, src in zip(_target_out, staged_chunks, strict=False):
dst.copy_(src) # High-speed CPU-to-CPU memory copy
if len(out) == len(iter_indices):
chunks = out
else:
chunks = _target_out
# Fast path: The async GPU copy might still be in progress.
# We intentionally omit synchronization here for performance.
# WARNING: The caller MUST explicitly call `torch_dev.synchronize()`
# before consuming these chunks to ensure data validity.
return chunks
def scatter_cpu_to_paged_kv(
kv_caches: dict[str, torch.Tensor],
block_ids: list[int],
chunks: list[torch.Tensor],
blocks_per_chunk: int,
skip_first_n_tokens: int = 0,
layout_hints: LayoutHints | None = None,
engine_kv_format: "lmc_ops.EngineKVFormat" | None = None,
) -> None:
"""Scatter CPU chunk tensors back into paged KV tensors.
Args:
kv_caches: Per-layer KV tensor mapping to write into.
block_ids: Flattened destination block IDs for all chunks. Length
must be at least ``len(chunks) * blocks_per_chunk``; any extra
trailing block IDs are ignored.
chunks: List of CPU chunk tensors (as returned by
:func:`gather_paged_kv_to_cpu`).
blocks_per_chunk: Number of paged blocks in one LMCache chunk.
skip_first_n_tokens: Token prefix to skip when scattering. Must be a
multiple of ``block_size``; non-aligned values are rounded down
to the nearest whole block and an error is logged (matching the
GPU transfer path).
layout_hints: Optional engine layout hints.
engine_kv_format: Optional pre-detected KV format.
Raises:
ValueError: If ``block_ids`` is shorter than
``len(chunks) * blocks_per_chunk``.
"""
# First Party
from lmcache.v1.gpu_connector.utils import (
get_block_size,
get_num_blocks,
get_num_layers,
make_page_buffer_shape_desc,
normalize_kv_and_discover_format,
)
import lmcache.c_ops as lmc_ops
if not chunks:
return
# Require enough block IDs to cover every chunk. Extra trailing block IDs
# are ignored by the per-chunk slicing below, mirroring the gather-side
# ``out`` length check for consistency.
if len(block_ids) < len(chunks) * blocks_per_chunk:
raise ValueError(
f"block_ids length ({len(block_ids)}) must be at least "
f"len(chunks) ({len(chunks)}) * blocks_per_chunk "
f"({blocks_per_chunk})"
)
tensors = list(kv_caches.values())
fmt, normalized = normalize_kv_and_discover_format(
tensors, EngineType.VLLM, layout_hints=layout_hints
)
if engine_kv_format is None:
engine_kv_format = fmt
block_size = get_block_size(normalized, engine_kv_format)
num_layers = get_num_layers(normalized, engine_kv_format)
num_blocks = get_num_blocks(normalized, engine_kv_format)
chunk_tokens = blocks_per_chunk * block_size
# Block-level transfer can only skip whole blocks. A non-aligned prefix is
# rounded down to the nearest block (matching the lmcache-driven path in
# lmcache_driven_transfer.py) rather than raising, so a slightly misaligned skip
# degrades gracefully instead of failing the whole retrieve.
if skip_first_n_tokens % block_size != 0:
logger.error(
"skip_first_n_tokens (%d) is not block-aligned (block_size=%d); "
"rounding down to %d blocks",
skip_first_n_tokens,
block_size,
skip_first_n_tokens // block_size,
)
skip_prefix_n_blocks = skip_first_n_tokens // block_size
shape_desc = make_page_buffer_shape_desc(
normalized,
engine_kv_format,
layer_idx=0,
num_layers_in_group=num_layers,
num_blocks=num_blocks,
block_size=block_size,
)
selected_block_ids: list[int] = []
for chunk_idx in range(len(chunks)):
selected_block_ids.extend(
block_ids[chunk_idx * blocks_per_chunk : (chunk_idx + 1) * blocks_per_chunk]
)
if not selected_block_ids:
return
if _LMC_OPS_BLOCK_TRANSFER_ACCEPTS_TENSOR:
# Python fallback: accepts tensor list directly for all params.
paged_arg = normalized
objs_arg = chunks
block_ids_arg = selected_block_ids
lmc_ops.multi_layer_block_kv_transfer(
paged_arg,
objs_arg,
block_ids_arg,
tensors[0].device,
lmc_ops.TransferDirection.H2D,
shape_desc,
chunk_tokens,
engine_kv_format,
skip_prefix_n_blocks,
)
else:
# assuming this is c ops path which requires pin memory
# TODO: may have a better approach here
# Defensive check: Ensure all incoming CPU chunks are pinned memory.
# Otherwise, the underlying CUDA kernel may throw an Illegal
# Memory Access error during H2D transfer.
if not all(chunk.is_pinned() for chunk in chunks):
logger.warning(
"Received unpinned CPU tensors in scatter_cpu_to_paged_kv. "
"Dynamically pinning memory now, which may incur additional"
"synchronization overhead."
)
chunks = [
chunk.pin_memory() if not chunk.is_pinned() else chunk
for chunk in chunks
]
# Compiled C++/CUDA/XPU: requires int64 pointer tensor and list[int].
_ptrs_np = np.array(
[t.data_ptr() for t in normalized], # type: ignore[union-attr]
dtype=np.uint64,
).view(np.int64)
paged_arg = torch.from_numpy(_ptrs_np).to(device=tensors[0].device)
objs_arg = _tensors_to_ptrs(chunks)
block_ids_arg = torch.tensor(
selected_block_ids, dtype=torch.int64, device=tensors[0].device
)
# Batched transfer to satisfy cuda's limitation (max 4 objects)
MAX_OBJECTS = 4
req_blocks_per_obj = (
blocks_per_chunk # Each chunk corresponds to one object's blocks
)
total_chunks = len(chunks)
for i in range(0, total_chunks, MAX_OBJECTS):
# Slice objects and block IDs for this batch
batch_objs_ptrs = objs_arg[i : i + MAX_OBJECTS]
start_block = i * req_blocks_per_obj
end_block = min(
(i + MAX_OBJECTS) * req_blocks_per_obj, len(selected_block_ids)
)
batch_blocks = block_ids_arg[start_block:end_block]
# Execute transfer for this batch
lmc_ops.multi_layer_block_kv_transfer(
paged_arg,
batch_objs_ptrs,
batch_blocks,
tensors[0].device,
lmc_ops.TransferDirection.H2D,
shape_desc,
chunk_tokens,
engine_kv_format,
skip_prefix_n_blocks if i == 0 else 0,
)
# Fast path: The async GPU copy might still be in progress.
# We intentionally omit synchronization here for performance.
# WARNING: The caller MUST explicitly call `torch_dev.synchronize()`
# before consuming these chunks to ensure data validity.