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

540 lines
18 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
SDK for retrieving and storing KV cache tensors.
"""
# Future
from __future__ import annotations
# Standard
from collections.abc import Sequence
import os
import time
import uuid
# Third Party
import requests
import torch
import zmq
# First Party
from lmcache.integration.vllm.vllm_multi_process_adapter import send_lmcache_request
from lmcache.logging import init_logger
from lmcache.sdk.wrapper.contiguous import ContiguousTransferWrapper
from lmcache.v1.gpu_connector.utils import (
DiscoverableKVCache,
LayoutHints,
get_block_size,
get_num_heads,
)
from lmcache.v1.multiprocess.custom_types import IPCCacheServerKey
from lmcache.v1.multiprocess.mq import MessageQueueClient
from lmcache.v1.multiprocess.protocol import RequestType, get_response_class
from lmcache.v1.multiprocess.transfer_context.worker_transfer import (
EngineDrivenTransferContext,
create_transfer_context,
)
import lmcache.c_ops as lmc_ops
logger = init_logger(__name__)
class KVCacheSDKError(RuntimeError):
"""Raised when an SDK KV-cache operation fails."""
class LMCacheKVCacheContext:
"""
Retrieve and store KV cache tensors via LMCache's MQ endpoints.
The model layout must already be registered in the running LMCache server
(e.g. by a vllm instance that called REGISTER_KV_CACHE).
Getting the layout information from server requires inference engine running
with GPU so that SDK can derive the shapes.
SDK is running on CPU, so the allocated paged KV cache is on CPU, and the
geometry is always in HND order regardless of the inference engine's.
"""
def __init__(
self,
url: str,
http_url: str,
model_name: str,
timeout: float = 60.0,
) -> None:
"""
Initialize the SDK context and register the SDK transfer strategy.
Args:
url: ZMQ endpoint URL for the LMCache message queue.
http_url: HTTP endpoint URL for fetching information.
model_name: Model name used by the running LMCache server instance.
timeout: Timeout in seconds for blocking MQ calls. Defaults to 60.
Returns:
LMCacheKVCacheContext instance.
"""
self._zmq_context = zmq.Context()
self._mq_client = MessageQueueClient(url, self._zmq_context)
self._mq_timeout = timeout
self._model_name = model_name
self.instance_id = os.getpid()
self._http_url = http_url
mp_conf = {}
try:
response = requests.get(f"{self._http_url}/config", timeout=timeout)
response.raise_for_status()
mp_conf = response.json()["mp"]
except (requests.RequestException, KeyError, ValueError) as err:
raise KVCacheSDKError(
f"failed to fetch server config from {self._http_url}/config"
) from err
self._chunk_size: int = int(mp_conf["chunk_size"])
self.shm_name: str = str(mp_conf.get("shm_name", "")).lstrip("/")
if self.shm_name and not self.shm_name.startswith("lmcache_l1_pool_"):
self.shm_name = f"lmcache_l1_pool_{self.shm_name}"
self._cache_context_meta_conf = {}
try:
response = requests.get(f"{self._http_url}/status", timeout=timeout)
response.raise_for_status()
self._cache_context_meta_conf = response.json().get(
"cache_context_meta", {}
)
except (requests.RequestException, KeyError, ValueError) as err:
raise KVCacheSDKError(
f"failed to fetch server config from {self._http_url}/status"
) from err
self._pending_lookups: set[str] = set()
self._finished_lookups: dict[str, int] = {}
logger.info(
f"Initialized LMCacheKVCacheContext with instance_id={self.instance_id}, "
f"model_name={self._model_name}, chunk_size={self._chunk_size}, "
f"shm_name={self.shm_name}"
)
def register_kv_caches(
self,
) -> None:
"""Register the KV cache layout for the model with the SDK context."""
entry = None
for e in self._cache_context_meta_conf.values():
if e.get("model_name") == self._model_name:
entry = e
break
if not entry:
raise KVCacheSDKError(
f"no registered GPU layout for model_name={self._model_name!r}; "
"MP mode cannot derive geometry from model_name alone — "
"register from a vLLM instance first, or pass the geometry explicitly."
)
try:
self._world_size = int(entry.get("world_size", 1))
kv_cache_layout = entry.get("kv_cache_layout", {})
if not kv_cache_layout:
raise KVCacheSDKError(
f"no registered KV cache layout for {self._model_name!r}."
)
num_layers = kv_cache_layout.get("num_layers", 0)
kernel_groups = kv_cache_layout.get("kernel_groups", [])
if len(kernel_groups) != 1:
raise KVCacheSDKError(
"Currently not supporting hybrid models with multiple "
f"kernel groups; found {len(kernel_groups)} "
f"for model_name={self._model_name!r}."
)
kernel_group = kernel_groups[0]
dtype = getattr(torch, kernel_group["dtype"].replace("torch.", ""))
tokens_per_block = kernel_group.get("tokens_per_block", 0)
inner = [
int(x)
for x in kernel_group["engine_kv_concrete_shape"]
.split("[")[1]
.rstrip("] ")
.split(",")
]
fmt = getattr(lmc_ops.EngineKVFormat, kernel_group["engine_kv_format"])
probe: list[DiscoverableKVCache] = [torch.empty(inner, device="meta")]
num_kv_heads = get_num_heads(probe, fmt)
block_size = get_block_size(probe, fmt)
head_dim = inner[-1]
if block_size != tokens_per_block:
raise KVCacheSDKError(
f"decoded block_size {block_size} != tokens_per_block "
f"{tokens_per_block} for model_name={self._model_name!r}"
)
except Exception as err:
raise KVCacheSDKError(
f"failed to decode KV cache layout for model_name={self._model_name!r}"
) from err
# SDK runs on CPU, LMCache's detects HND (gpu_connector/utils.py:663).
# Build in HND-physical order [NB, 2, NH, BS, HS] (flip from GPU order)
# So, no matter the inference engine runs on CPU or GPU, SDK will always
# use HND.
# Hardcode number of blocks to 1 (dummy shape) only for registering
num_blocks = 1
shape = (num_blocks, 2, num_kv_heads, block_size, head_dim)
self._kv_caches = {
f"layer.{i}": torch.zeros(shape, dtype=dtype, device="cpu")
for i in range(num_layers)
}
transfer_ctx = create_transfer_context(self._kv_caches)
self.blocks_in_chunk = self._chunk_size // block_size
layout_hints = LayoutHints(
kv_layout="HND",
num_kv_heads=num_kv_heads,
tokens_per_block=block_size,
head_dim=head_dim,
)
transfer_ctx.register(
self.instance_id,
self._kv_caches,
self._model_name,
self._world_size,
self.blocks_in_chunk,
self._mq_client,
self._mq_timeout,
send_request=send_lmcache_request,
layout_hints=layout_hints,
)
if not isinstance(transfer_ctx, EngineDrivenTransferContext):
raise KVCacheSDKError(
"SDK requires an engine-driven transfer context, got "
f"{type(transfer_ctx).__name__}."
)
self._transfer_ctx = ContiguousTransferWrapper(
transfer_ctx.engine_driven_context, self._chunk_size
)
@property
def chunk_size(self) -> int:
"""Return the chunk size of the context."""
return self._chunk_size
@property
def mq_timeout(self) -> float:
"""Return the message queue timeout of the context."""
return self._mq_timeout
@property
def transfer_ctx(self) -> ContiguousTransferWrapper:
"""Return the contiguous transfer context."""
return self._transfer_ctx
def close(self) -> None:
"""Close the MQ client and ZMQ context."""
self._mq_client.close()
def maybe_submit_lookup_request(
self,
request_id: str,
token_ids: list[int],
cache_salt: str = "",
) -> None:
"""Submit a LOOKUP request for the given token IDs.
Modification from lmcache/integration/vllm/vllm_multi_process_adapter.py.
Need duplicate since SDK has TransferContext, not Adapter, but still need
lookup and end_session functionality.
Args:
request_id: Unique ID for this lookup request.
token_ids: List of token IDs to look up.
cache_salt: Optional cache salt string for the lookup.
"""
if request_id in self._pending_lookups:
# Skip if there is already a lookup request
return
aligned_end = (len(token_ids) // self._chunk_size) * self._chunk_size
key = self._create_key(
token_ids,
start=0,
end=aligned_end,
request_id=request_id,
cache_salt=cache_salt,
).no_worker_id_version()
future = self._mq_client.submit_request(
RequestType.LOOKUP,
[key, self._world_size],
get_response_class(RequestType.LOOKUP),
)
try:
future.result(timeout=self._mq_timeout)
except TimeoutError:
logger.warning(
"LOOKUP request timed out after %ss.",
self._mq_timeout,
)
return
self._pending_lookups.add(request_id)
def check_lookup_result(self, request_id: str) -> int | None:
"""Check the result of a LOOKUP request.
Modification from lmcache/integration/vllm/vllm_multi_process_adapter.py.
Need duplicate since SDK has TransferContext, not Adapter, but still need
lookup and end_session functionality.
Args:
request_id: The request ID of the LOOKUP to check.
Returns:
The number of prefetched tokens if the LOOKUP is finished,
0 if not finished, or None if the request ID is not found.
"""
if request_id not in self._pending_lookups:
# No job — either unhealthy at submit time or already cleaned up.
# If we have a cached result, return it to handle repeated calls.
return self._finished_lookups.get(request_id, 0)
if request_id in self._finished_lookups:
# Return cached result if the job is already finished
return self._finished_lookups[request_id]
try:
result = self._mq_client.submit_request(
RequestType.QUERY_PREFETCH_STATUS,
[request_id],
get_response_class(RequestType.QUERY_PREFETCH_STATUS),
).result(timeout=self._mq_timeout)
except TimeoutError:
logger.warning(
"QUERY_PREFETCH_STATUS timed out after %ss.",
self._mq_timeout,
)
return 0
if result is None:
return None
token_count = result * self._chunk_size
self._finished_lookups[request_id] = token_count
return token_count
def end_session(self, request_id: str, block_ids: list[int] | None = None) -> None:
"""End a session and clean up associated resources on the server.
Args:
request_id: The request ID of the session to end.
block_ids: Optional list of block IDs to free.
"""
self._pending_lookups.discard(request_id)
self._finished_lookups.pop(request_id, None)
try:
self._mq_client.submit_request(
RequestType.END_SESSION,
[request_id],
get_response_class(RequestType.END_SESSION),
).result(timeout=self._mq_timeout)
except TimeoutError:
logger.warning(
"END_SESSION timed out after %ss for request_id=%s.",
self._mq_timeout,
request_id,
)
# Helper functions
def _create_key(
self,
token_ids: list[int],
start: int,
end: int,
request_id: str,
cache_salt: str = "",
worker_id: int | None = None,
) -> IPCCacheServerKey:
"""Convert token IDs to an IPC cache engine key.
Args:
token_ids: The token IDs.
start: Start token index.
end: End token index.
request_id: The request ID.
cache_salt: Per-user isolation salt.
worker_id: Optional worker ID for the key.
If None, the key will be created without a worker ID (for lookups).
Returns:
IPCCacheServerKey: The constructed key.
"""
return IPCCacheServerKey(
model_name=self._model_name,
world_size=self._world_size,
worker_id=worker_id,
token_ids=tuple(token_ids),
start=start,
end=end,
request_id=request_id,
cache_salt=cache_salt,
)
def connect(
url: str,
http_url: str,
model_name: str,
timeout: float = 60.0,
) -> "LMCacheKVCacheContext":
"""Create and initialize the LMCache SDK context.
Args:
url: ZMQ endpoint URL for the LMCache message queue.
http_url: HTTP endpoint URL for fetching server configuration.
model_name: Model name used by the running LMCache server instance.
timeout: Timeout in seconds for blocking MQ calls. Defaults to 60.
Returns:
An initialized LMCacheKVCacheContext instance.
Ready to be passed to close(), retrieve(), and store() functions.
"""
ctx = LMCacheKVCacheContext(
url=url, http_url=http_url, model_name=model_name, timeout=timeout
)
ctx.register_kv_caches()
return ctx
def close(ctx: "LMCacheKVCacheContext") -> None:
"""Close the LMCache SDK context and release resources.
Args:
ctx: The LMCacheKVCacheContext instance to close.
"""
ctx.close()
def retrieve(
ctx: "LMCacheKVCacheContext",
tokens: Sequence[int],
cache_salt: str = "",
) -> torch.Tensor | None:
"""Retrieve KV cache tensors for the given token IDs.
Args:
ctx: The LMCacheKVCacheContext instance to use for retrieval.
tokens: The list of token IDs to retrieve KV cache for.
cache_salt: Optional cache salt string for the lookup.
Returns:
A contiguous CPU tensor containing the retrieved KV cache for
the requested tokens.
None if retrieval fails or there are no tokens to retrieve.
"""
if not tokens:
return None
# Drop tokens not fit into a whole chunk
total_tokens = (len(tokens) // ctx.chunk_size) * ctx.chunk_size
if total_tokens == 0:
return None
# Assign request ID to this request
request_id = f"retrieve-{uuid.uuid4().hex}"
key = ctx._create_key(
token_ids=list(tokens[:total_tokens]),
start=0,
end=total_tokens,
request_id=request_id,
cache_salt=cache_salt,
worker_id=0,
)
# Phase 0: Trigger lookup
ctx.maybe_submit_lookup_request(
request_id,
token_ids=list(tokens[:total_tokens]),
cache_salt=cache_salt,
)
start_time = time.time()
num_prefetched_tokens = ctx.check_lookup_result(request_id)
while num_prefetched_tokens is None:
if time.time() - start_time > ctx.mq_timeout:
raise KVCacheSDKError(
f"LOOKUP request timed out after {ctx.mq_timeout}s "
f"for request_id={request_id}"
)
logger.info(
"Waiting for LOOKUP result for request_id=%s...",
request_id,
)
time.sleep(0.01)
num_prefetched_tokens = ctx.check_lookup_result(request_id)
if num_prefetched_tokens <= 0:
ctx.end_session(request_id)
return None
# Phase 1: retrieve the cached prefix as one contiguous tensor
key = ctx._create_key(
token_ids=list(tokens[:num_prefetched_tokens]),
start=0,
end=num_prefetched_tokens,
request_id=request_id,
cache_salt=cache_salt,
worker_id=0,
)
try:
return ctx.transfer_ctx.retrieve(key, ctx.instance_id)
finally:
ctx.end_session(request_id)
def store(
ctx: "LMCacheKVCacheContext",
kv: torch.Tensor,
tokens: Sequence[int],
cache_salt: str = "",
) -> bool:
"""Store KV cache tensors for the given token IDs.
Args:
ctx: The LMCacheKVCacheContext instance to use for storage.
kv: The KV cache tensor to store, of shape [2, L, T, D].
tokens: The list of token IDs corresponding to the KV cache tensor.
cache_salt: Optional cache salt string for the store.
Returns:
True if the store operation is successful, False otherwise.
"""
if len(tokens) != kv.shape[2]:
raise KVCacheSDKError(
f"Number of tokens ({len(tokens)}) does not match KV tensor's "
f"token dimension ({kv.shape[2]})."
)
token_ids = list(tokens)
total_tokens = (len(token_ids) // ctx.chunk_size) * ctx.chunk_size
token_ids = token_ids[:total_tokens]
kv_cpu = kv[:, :, :total_tokens, :].detach().cpu().contiguous()
# Phase 0: assign request ID to this request
request_id = f"store-{uuid.uuid4().hex}"
key = ctx._create_key(
token_ids=token_ids,
start=0,
end=total_tokens,
request_id=request_id,
cache_salt=cache_salt,
worker_id=0,
)
# Phase 1: store the KV cache tensor
try:
return ctx.transfer_ctx.store(key, ctx.instance_id, kv_cpu)
finally:
ctx.end_session(request_id)