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

536 lines
19 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Engine-driven KV cache transfer operations for the MPCacheServer."""
# Standard
from dataclasses import dataclass
import threading
import time
# Third Party
import torch
# First Party
from lmcache.logging import init_logger
from lmcache.utils import _lmcache_nvtx_annotate
from lmcache.v1.distributed.api import (
MemoryLayoutDesc,
ObjectKey,
)
from lmcache.v1.multiprocess.custom_types import (
IPCCacheServerKey,
RegisterEngineDrivenContextPayload,
)
from lmcache.v1.multiprocess.engine_context import MPCacheServerContext, ShmPoolInfo
from lmcache.v1.multiprocess.engine_module import (
HandlerSpec,
InstanceLivenessTarget,
ThreadPoolType,
)
from lmcache.v1.multiprocess.protocols.base import RequestType
from lmcache.v1.multiprocess.protocols.engine import (
PrepareRetrieveResponse,
PrepareStoreResponse,
RegisterEngineDrivenContextResponse,
)
from lmcache.v1.multiprocess.transfer_context.base import EngineDrivenContextMetadata
# Local
from .server_transfer import (
TransferStrategy,
create_transfer_strategy,
)
logger = init_logger(__name__)
@dataclass
class EngineDrivenContextEntry:
"""Registered non-GPU context metadata for a single worker instance.
Attributes:
metadata: Layout metadata describing the non-CUDA chunk format.
model_name: The name of the model associated with this context.
world_size: The world size associated with this context.
last_seen: ``time.monotonic()`` of the most recent activity from this
instance (register, PING, prepare/commit). Drives reaping.
has_liveness_signal: True once the instance has sent at least one
PING. Selects the reap window. Latched only by PING.
"""
metadata: EngineDrivenContextMetadata
model_name: str
world_size: int
last_seen: float = 0.0
has_liveness_signal: bool = False
class EngineDrivenTransferModule(InstanceLivenessTarget):
"""Handles Engine-driven KV cache transfer operations.
Owns non-GPU context registrations and provides handlers for
register, unregister, prepare/commit store, and prepare/commit retrieve
of CPU-serialized KV caches.
Args:
ctx: The shared engine context.
"""
def __init__(self, ctx: MPCacheServerContext) -> None:
self._ctx = ctx
self._engine_driven_contexts: dict[int, EngineDrivenContextEntry] = {}
self._strategies: dict[int, TransferStrategy] = {}
# Guards _engine_driven_contexts and _strategies together (the reaper
# mutates them off the MQ main loop). Leaf lock, never held with
# _pending_shm_lock.
self._lock = threading.Lock()
self._pending_shm_writes: dict[
tuple[int, IPCCacheServerKey], list[ObjectKey]
] = {}
self._pending_shm_reads: dict[
tuple[int, IPCCacheServerKey], list[ObjectKey]
] = {}
self._pending_shm_lock = threading.Lock()
self._shm_pool_info: ShmPoolInfo = self._ctx.shm_pool_info
@property
def context(self) -> MPCacheServerContext:
"""Return the shared engine context. Exposed for testing only."""
return self._ctx
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_ENGINE_DRIVEN_CONTEXT,
self.register_kv_cache_engine_driven_context,
ThreadPoolType.SYNC,
),
HandlerSpec(
RequestType.UNREGISTER_KV_CACHE_ENGINE_DRIVEN_CONTEXT,
self.unregister_kv_cache,
ThreadPoolType.SYNC,
),
HandlerSpec(
RequestType.PREPARE_STORE,
self.prepare_store,
ThreadPoolType.AFFINITY,
),
HandlerSpec(
RequestType.COMMIT_STORE,
self.commit_store,
ThreadPoolType.AFFINITY,
),
HandlerSpec(
RequestType.PREPARE_RETRIEVE,
self.prepare_retrieve,
ThreadPoolType.AFFINITY,
),
HandlerSpec(
RequestType.COMMIT_RETRIEVE,
self.commit_retrieve,
ThreadPoolType.AFFINITY,
),
]
def report_status(self) -> dict:
"""Return non-GPU transfer module status information.
Returns:
A dict containing registered non-CUDA instance IDs and
per-instance context metadata.
"""
registered_non_cuda_ids: list[int] = []
non_cuda_context_meta: dict[str, dict] = {}
with self._lock:
entries = dict(self._engine_driven_contexts)
for instance_id, entry in entries.items():
registered_non_cuda_ids.append(instance_id)
non_cuda_context_meta[str(instance_id)] = {
"model_name": entry.model_name,
"world_size": entry.world_size,
"block_size": entry.metadata.block_size,
"use_mla": entry.metadata.use_mla,
}
return {
"registered_non_cuda_instance_ids": registered_non_cuda_ids,
"non_cuda_context_meta": non_cuda_context_meta,
}
def close(self) -> None:
"""Release resources owned by this module."""
with self._lock:
self._engine_driven_contexts.clear()
self._strategies.clear()
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._engine_driven_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 non-GPU instances."""
with self._lock:
return len(self._engine_driven_contexts)
def reap_stale_instances(
self, reap_timeout_s: float, registration_grace_s: float
) -> list[int]:
"""Reap non-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, EngineDrivenContextEntry]] = []
with self._lock:
stale_ids = [
iid
for iid, entry in self._engine_driven_contexts.items()
if now - entry.last_seen
> (
reap_timeout_s
if entry.has_liveness_signal
else registration_grace_s
)
]
for iid in stale_ids:
entry = self._engine_driven_contexts.pop(iid)
self._strategies.pop(iid, None)
reaped.append((iid, entry))
for iid, entry in reaped:
self._release_entry(iid, entry)
logger.warning(
"Reaped non-GPU instance %d: silent for %.1fs (pinged=%s)",
iid,
now - entry.last_seen,
entry.has_liveness_signal,
)
return [iid for iid, _ in reaped]
def _resolve_for_transfer(
self, instance_id: int
) -> tuple[EngineDrivenContextEntry, TransferStrategy]:
"""Return (entry, strategy) for a transfer, refreshing last_seen.
Pair-atomicity guarantees the entry exists whenever the strategy
does. Refreshes last_seen (no latch) so an active worker is not
reaped mid-transfer.
Args:
instance_id: The worker instance ID.
Returns:
The entry and its transfer strategy.
Raises:
ValueError: If the instance is not registered (or was reaped).
"""
now = time.monotonic()
with self._lock:
entry = self._engine_driven_contexts.get(instance_id)
strategy = self._strategies.get(instance_id)
if entry is None or strategy is None:
raise ValueError(
"non-GPU context not registered (or reaped) for "
f"instance ID {instance_id}"
)
entry.last_seen = now
return entry, strategy
def _release_entry(self, instance_id: int, entry: EngineDrivenContextEntry) -> None:
"""Release resources for a popped entry (run outside the lock).
Sweeps the instance's pending SHM transfers and unregisters its
layout descriptor.
Args:
instance_id: The popped instance ID.
entry: The popped entry.
"""
with self._pending_shm_lock:
stale_writes = [k for k in self._pending_shm_writes if k[0] == instance_id]
stale_reads = [k for k in self._pending_shm_reads if k[0] == instance_id]
write_obj_keys = [self._pending_shm_writes.pop(k) for k in stale_writes]
read_obj_keys = [self._pending_shm_reads.pop(k) for k in stale_reads]
for obj_keys in write_obj_keys:
if obj_keys:
self._ctx.storage_manager.finish_write(obj_keys)
for obj_keys in read_obj_keys:
if obj_keys:
self._ctx.storage_manager.finish_read_prefetched(obj_keys)
self._ctx.layout_desc_registry.unregister(entry.model_name, entry.world_size)
@staticmethod
def _make_transfer_key(
key: IPCCacheServerKey, instance_id: int
) -> tuple[int, IPCCacheServerKey]:
return (instance_id, key)
def _resolve_single_group_obj_keys(self, key: IPCCacheServerKey) -> list[ObjectKey]:
"""Resolve object keys for the single object group used by
non-GPU transfers."""
return self._ctx.resolve_obj_keys(key, [0])[0]
def register_kv_cache_engine_driven_context(
self,
payload: RegisterEngineDrivenContextPayload,
) -> RegisterEngineDrivenContextResponse:
"""Register non-CUDA KV layout metadata for non-GPU context mode.
Args:
payload: Struct containing all registration fields
(instance_id, model_name, world_size, block_size,
num_layers, hidden_dim_size, dtype_str, use_mla).
Raises:
ValueError: If ``payload.dtype_str`` is not a valid torch dtype name.
"""
shm_name = self._shm_pool_info["shm_name"]
pool_size = self._shm_pool_info["pool_size"]
now = time.monotonic()
with self._lock:
existing = self._engine_driven_contexts.get(payload.instance_id)
if existing is not None:
existing.last_seen = now
logger.info(
"Instance %d already registered (non-GPU); refreshing liveness",
payload.instance_id,
)
return RegisterEngineDrivenContextResponse(
shm_name=shm_name, pool_size=pool_size
)
dtype = getattr(torch, payload.dtype_str, None)
if dtype is None or not isinstance(dtype, torch.dtype):
raise ValueError(
f"Invalid dtype_str '{payload.dtype_str}': must be a valid torch dtype "
"attribute name (e.g. 'float16' for torch.float16, "
"'bfloat16' for torch.bfloat16, 'float32' for torch.float32)."
)
shape = (
torch.Size(
[payload.num_layers, self._ctx.chunk_size, payload.hidden_dim_size]
)
if payload.use_mla
else torch.Size(
[2, payload.num_layers, self._ctx.chunk_size, payload.hidden_dim_size]
)
)
layout_desc = MemoryLayoutDesc(shapes=[shape], dtypes=[dtype])
metadata = EngineDrivenContextMetadata(
layout_desc=layout_desc,
block_size=payload.block_size,
use_mla=payload.use_mla,
)
# Build the entry and strategy outside the lock, then insert the pair
# atomically so a concurrent reap can never strand one without the
# other. REGISTER is SYNC-serialized, so it is the sole inserter.
entry = EngineDrivenContextEntry(
metadata=metadata,
model_name=payload.model_name,
world_size=payload.world_size,
last_seen=now,
has_liveness_signal=False,
)
strategy: TransferStrategy = create_transfer_strategy(
self._ctx.storage_manager,
shm_name=shm_name,
pool_size=pool_size,
pending_writes=self._pending_shm_writes,
pending_reads=self._pending_shm_reads,
pending_lock=self._pending_shm_lock,
transfer_key_factory=self._make_transfer_key,
)
with self._lock:
self._engine_driven_contexts[payload.instance_id] = entry
self._strategies[payload.instance_id] = strategy
logger.info(
"Registered non-GPU context for instance %d (model=%s, world_size=%d)",
payload.instance_id,
payload.model_name,
payload.world_size,
)
self._ctx.layout_desc_registry.register(
payload.model_name, payload.world_size, layout_desc
)
return RegisterEngineDrivenContextResponse(
shm_name=shm_name, pool_size=pool_size
)
def unregister_kv_cache(self, instance_id: int) -> None:
"""Unregister a non-GPU KV cache context for the given instance ID.
Args:
instance_id: The worker instance identifier.
"""
with self._lock:
entry = self._engine_driven_contexts.pop(instance_id, None)
if entry is not None:
self._strategies.pop(instance_id, None)
if entry is None:
logger.warning(
"No registered non-GPU context found for instance ID %d",
instance_id,
)
return
self._release_entry(instance_id, entry)
logger.info("Unregistered non-CUDA context for instance ID %d", instance_id)
@_lmcache_nvtx_annotate
def prepare_store(
self,
key: IPCCacheServerKey,
instance_id: int,
) -> PrepareStoreResponse:
"""Prepare a store operation.
Args:
key: Cache key for the token range to store.
instance_id: Worker instance identifier.
Returns:
PrepareStoreResponse with empty slots for pickle mode.
"""
entry, strategy = self._resolve_for_transfer(instance_id)
response = strategy.prepare_store(
key=key,
instance_id=instance_id,
context=entry.metadata,
resolve_obj_keys=self._resolve_single_group_obj_keys,
)
session = self._ctx.session_manager.get_or_create(key.request_id)
session.extras["store_start_time"] = time.perf_counter()
return response
@_lmcache_nvtx_annotate
def commit_store(
self,
key: IPCCacheServerKey,
instance_id: int,
cpu_data: bytes,
) -> bool:
"""Commit serialized CPU chunks to storage.
Args:
key: Cache key for the token range to store.
instance_id: Worker instance identifier.
cpu_data: Pickled list of CPU tensors produced by the worker.
Returns:
``True`` when all reserved objects are written, otherwise ``False``.
Raises:
ValueError: If no non-GPU context is registered for the given
instance ID.
"""
entry, strategy = self._resolve_for_transfer(instance_id)
session = self._ctx.session_manager.get_or_create(key.request_id)
st = session.extras.pop("store_start_time", None)
result = strategy.commit_store(
key=key,
instance_id=instance_id,
cpu_data=cpu_data,
context=entry.metadata,
resolve_obj_keys=self._resolve_single_group_obj_keys,
)
if st is not None and result:
num_tokens = (
len(self._resolve_single_group_obj_keys(key)) * self._ctx.chunk_size
)
logger.info(
"Stored %d tokens in %.3f seconds",
num_tokens,
time.perf_counter() - st,
)
return result
@_lmcache_nvtx_annotate
def prepare_retrieve(
self,
key: IPCCacheServerKey,
instance_id: int,
) -> PrepareRetrieveResponse:
"""Retrieve prefetched chunks and return serialized CPU tensors.
Args:
key: Cache key for the token range to retrieve.
instance_id: Worker instance identifier.
Returns:
PrepareRetrieveResponse with serialized data on hit.
Raises:
ValueError: If no non-GPU context is registered for the given
instance ID.
"""
_, strategy = self._resolve_for_transfer(instance_id)
response = strategy.prepare_retrieve(
key=key,
instance_id=instance_id,
resolve_obj_keys=self._resolve_single_group_obj_keys,
)
session = self._ctx.session_manager.get_or_create(key.request_id)
session.extras["retrieve_start_time"] = time.perf_counter()
return response
@_lmcache_nvtx_annotate
def commit_retrieve(
self,
key: IPCCacheServerKey,
instance_id: int,
) -> bool:
"""Finalize a retrieve operation.
Args:
key: Cache key (unused for pickle).
instance_id: Worker instance identifier (unused for pickle).
Returns:
Always ``True``.
"""
_, strategy = self._resolve_for_transfer(instance_id)
session = self._ctx.session_manager.get_or_create(key.request_id)
st = session.extras.pop("retrieve_start_time", None)
result = strategy.commit_retrieve(key=key, instance_id=instance_id)
if st is not None:
num_tokens = (
len(self._resolve_single_group_obj_keys(key)) * self._ctx.chunk_size
)
logger.info(
"Retrieved %d tokens in %.3f seconds",
num_tokens,
time.perf_counter() - st,
)
return result