# SPDX-License-Identifier: Apache-2.0 """TensorRT-LLM KV Cache Connector adapter for LMCache (multi-process mode). Implements ``LMCacheMPKvConnectorScheduler`` and ``LMCacheMPKvConnectorWorker`` — the two classes TRT-LLM's ``kv_connector_config`` requires — backed by a standalone LMCache server reached over ZMQ. Provides process isolation and shared caching across multiple TRT-LLM instances on the same node. The KV pool tensor is shared with the server via :class:`RawCudaIPCWrapper` because TRT-LLM's pool is allocated outside PyTorch's caching allocator (``at::for_blob`` over ``cudaMalloc``), which makes ``UntypedStorage._share_cuda_()`` raise. The wrapper bypasses that path. """ # Standard from dataclasses import dataclass, field from typing import List, Optional, Tuple import os import time # Third Party from tensorrt_llm._torch.pyexecutor.connectors.kv_cache_connector import ( KvCacheConnectorScheduler, KvCacheConnectorWorker, SchedulerOutput, ) from tensorrt_llm.bindings.internal.batch_manager import LlmRequest from tensorrt_llm.llmapi.llm_args import TorchLlmArgs import torch import zmq # First Party from lmcache import torch_dev from lmcache.logging import init_logger from lmcache.utils import EngineType, check_interprocess_event_support from lmcache.v1.multiprocess.custom_types import ( IPCCacheServerKey, ) from lmcache.v1.multiprocess.mq import MessageQueueClient, MessagingFuture from lmcache.v1.multiprocess.protocol import RequestType, get_response_class from lmcache.v1.platform.cuda.ipc_wrapper import RawCudaIPCWrapper logger = init_logger(__name__) DEFAULT_SERVER_URL = "ipc:///tmp/lmcache.sock" DEFAULT_MQ_TIMEOUT: float = 300.0 def _get_server_url(llm_args: "TorchLlmArgs") -> str: """Resolve the server URL: connector-config field > env var > default.""" cfg = llm_args.kv_connector_config if cfg is not None and cfg.server_url is not None: return cfg.server_url return os.environ.get("LMCACHE_SERVER_URL", DEFAULT_SERVER_URL) def _send_request( mq_client: MessageQueueClient, request_type: RequestType, payloads: list, ) -> MessagingFuture: return mq_client.submit_request( request_type, payloads, get_response_class(request_type) ) @dataclass class _BlockSpec: tokens: List[int] block_ids: List[int] @dataclass class LMCacheMPConnectorMetadata: loads: dict = field(default_factory=dict) saves: dict = field(default_factory=dict) class LMCacheMPKvConnectorScheduler(KvCacheConnectorScheduler): """TRT-LLM scheduler that routes lookup requests to an LMCache MP server.""" def __init__(self, llm_args: TorchLlmArgs) -> None: super().__init__(llm_args) self._block_size: int = self._llm_args.kv_cache_config.tokens_per_block # request_id -> (all_tokens, num_matched). self._pending: dict = {} self._zmq_context = zmq.Context() self._mq_client = MessageQueueClient( _get_server_url(self._llm_args), self._zmq_context ) self._mq_timeout = float( os.environ.get("LMCACHE_MQ_TIMEOUT", DEFAULT_MQ_TIMEOUT) ) future = _send_request(self._mq_client, RequestType.GET_CHUNK_SIZE, []) self._chunk_size = future.result(timeout=self._mq_timeout) logger.info( "LMCache MP scheduler: connected to server at %s (chunk_size=%d)", _get_server_url(self._llm_args), self._chunk_size, ) # Third Party import tensorrt_llm self._rank = tensorrt_llm.mpi_rank() tp_size = llm_args.tensor_parallel_size pp_size = llm_args.pipeline_parallel_size self._world_size = tp_size * pp_size self._model_name = str(getattr(llm_args, "model", "unknown_model")) def _create_key( self, token_ids: List[int], start: int, end: int, request_id: int, ) -> IPCCacheServerKey: return IPCCacheServerKey( model_name=self._model_name, world_size=self._world_size, worker_id=None, token_ids=tuple(token_ids), start=start, end=end, request_id=str(request_id), ) def get_num_new_matched_tokens( self, request: LlmRequest, num_computed_tokens: int, ) -> Tuple[int, bool]: """Return how many additional tokens the LMCache server can provide. Submits a ``LOOKUP`` to the server and queries ``QUERY_PREFETCH_STATUS`` by ``request_id`` to read the result. ``LOOKUP`` returns ``None`` on the server protocol — the prefetch is tracked server-side keyed by ``request_id``. """ t0 = time.perf_counter() if num_computed_tokens % self._block_size != 0: self._pending[request.request_id] = ([], 0) return 0, False all_tokens = list(request.get_tokens(0)) max_block_aligned = (len(all_tokens) // self._block_size) * self._block_size if num_computed_tokens >= max_block_aligned: self._pending[request.request_id] = (all_tokens, 0) return 0, False aligned_end = (len(all_tokens) // self._chunk_size) * self._chunk_size key = self._create_key( all_tokens, start=0, end=aligned_end, request_id=request.request_id ).no_worker_id_version() t1 = time.perf_counter() try: _send_request(self._mq_client, RequestType.LOOKUP, [key, 1]).result( timeout=self._mq_timeout ) result = _send_request( self._mq_client, RequestType.QUERY_PREFETCH_STATUS, [str(request.request_id)], ).result(timeout=self._mq_timeout) cached_tokens = result * self._chunk_size if result is not None else 0 except Exception as e: logger.warning("LMCache MP scheduler: lookup failed: %s", e) self._pending[request.request_id] = (all_tokens, 0) return 0, False t2 = time.perf_counter() new_matched = max(0, cached_tokens - num_computed_tokens) new_matched = (new_matched // self._block_size) * self._block_size # ``LOOKUP`` acquires read locks on chunks in [0, cached_tokens). # TRT-LLM already has [0, num_computed_tokens), so those chunks # will never be retrieved — release their locks (chunk-aligned) # to avoid holding them until TTL expiry. overlap_end = min(cached_tokens, num_computed_tokens) overlap_end = (overlap_end // self._chunk_size) * self._chunk_size if overlap_end > 0: free_key = self._create_key( all_tokens, start=0, end=overlap_end, request_id=request.request_id, ).no_worker_id_version() try: _send_request( self._mq_client, RequestType.FREE_LOOKUP_LOCKS, [free_key, 1], ) except Exception as e: logger.warning("LMCache MP scheduler: free_lookup_locks failed: %s", e) self._pending[request.request_id] = (all_tokens, new_matched) logger.debug( "LMCache MP scheduler: req %d lookup=%.3fms total=%.3fms " "trt_matched=%d lmcache_cached=%d new_matched=%d", request.request_id, (t2 - t1) * 1000, (time.perf_counter() - t0) * 1000, num_computed_tokens, cached_tokens, new_matched, ) return new_matched, False def build_connector_meta( self, scheduler_output: SchedulerOutput ) -> LMCacheMPConnectorMetadata: """Build per-request load/save specs from pending lookup results.""" meta = LMCacheMPConnectorMetadata() for req in scheduler_output.new_requests: if req.request_id not in self._pending: continue all_tokens, num_matched = self._pending[req.request_id] block_ids: List[int] = list(req.new_block_ids) num_computed_blocks = req.computed_position // self._block_size if num_matched > 0: meta.loads[req.request_id] = _BlockSpec( tokens=all_tokens, block_ids=block_ids ) save_start = max(num_computed_blocks, num_matched // self._block_size) num_full_new_blocks = len(req.new_tokens) // self._block_size if ( save_start < len(block_ids) and num_full_new_blocks > 0 and save_start < num_computed_blocks + num_full_new_blocks ): meta.saves[req.request_id] = _BlockSpec( tokens=all_tokens, block_ids=block_ids ) self._pending.clear() return meta def request_finished(self, request: LlmRequest, cache_block_ids: List[int]) -> bool: """Notify the server so it can clean up per-request state. Saves are synchronous in this adapter, so we never need to defer deallocation — return ``False``. We still call ``END_SESSION`` to release the server-side token-hash/session state for the request. """ try: _send_request( self._mq_client, RequestType.END_SESSION, [str(request.request_id)], ) except Exception as e: logger.warning("LMCache MP scheduler: end_session failed: %s", e) return False def update_state_after_alloc( self, request: LlmRequest, block_ids: List[int] ) -> None: """No-op — block IDs are captured in :meth:`build_connector_meta`.""" pass class LMCacheMPKvConnectorWorker(KvCacheConnectorWorker): """TRT-LLM worker that routes store/retrieve to an LMCache MP server.""" def __init__(self, llm_args: TorchLlmArgs) -> None: super().__init__(llm_args) self._block_size: int = self._llm_args.kv_cache_config.tokens_per_block self._zmq_context = zmq.Context() self._mq_client = MessageQueueClient( _get_server_url(self._llm_args), self._zmq_context ) self._mq_timeout = float( os.environ.get("LMCACHE_MQ_TIMEOUT", DEFAULT_MQ_TIMEOUT) ) self._instance_id = os.getpid() self._registered = False # Third Party import tensorrt_llm self._rank = tensorrt_llm.mpi_rank() tp_size = llm_args.tensor_parallel_size pp_size = llm_args.pipeline_parallel_size self._world_size = tp_size * pp_size self._model_name = str(getattr(llm_args, "model", "unknown_model")) future = _send_request(self._mq_client, RequestType.GET_CHUNK_SIZE, []) self._chunk_size = future.result(timeout=self._mq_timeout) def _create_key( self, token_ids: List[int], request_id: int, ) -> IPCCacheServerKey: aligned_end = (len(token_ids) // self._chunk_size) * self._chunk_size return IPCCacheServerKey( model_name=self._model_name, world_size=self._world_size, worker_id=self._rank, token_ids=tuple(token_ids), start=0, end=aligned_end, request_id=str(request_id), ) def register_kv_caches(self, kv_cache_tensor: torch.Tensor) -> None: """Register the KV pool with the LMCache server via raw CUDA IPC. TRT-LLM provides a 4-D pool tensor ``[NB, NL, 2, NH * BS * HS]``. The server reshapes it to 6-D ``[NB, NL, 2, NH, BS, HS]`` from the ``layout_hints`` so format detection lands on ``NB_NL_TWO_NH_BS_HS``. """ if self._registered: logger.info("LMCache MP worker: KV caches already registered") return # Third Party from transformers import AutoConfig hf_config = AutoConfig.from_pretrained(self._model_name) head_dim = getattr( hf_config, "head_dim", hf_config.hidden_size // hf_config.num_attention_heads, ) num_kv_heads = getattr( hf_config, "num_key_value_heads", hf_config.num_attention_heads ) tp_size = self._llm_args.tensor_parallel_size num_kv_heads = num_kv_heads // tp_size _, _, _, block_size_flat = kv_cache_tensor.shape tokens_per_block = block_size_flat // (num_kv_heads * head_dim) wrapped = [RawCudaIPCWrapper(kv_cache_tensor)] layout_hints = { "kv_layout": "HND", "num_kv_heads": num_kv_heads, "tokens_per_block": tokens_per_block, "head_dim": head_dim, } future = _send_request( self._mq_client, RequestType.REGISTER_KV_CACHE, [ self._instance_id, wrapped, self._model_name, self._world_size, EngineType.TRTLLM, layout_hints, [], ], ) try: future.result(timeout=self._mq_timeout) self._registered = True logger.info( "LMCache MP worker: registered KV caches " "(tensor_shape=%s, NH=%d, BS=%d, HS=%d)", list(kv_cache_tensor.shape), num_kv_heads, tokens_per_block, head_dim, ) except TimeoutError: logger.error( "LMCache MP worker: KV cache registration timed out after %ss", self._mq_timeout, ) def start_load_kv(self, stream: torch_dev.Stream) -> None: """Send ``RETRIEVE`` requests for each pending load.""" meta: Optional[LMCacheMPConnectorMetadata] = self._metadata if meta is None or not meta.loads: return t0 = time.perf_counter() # Not all backends support interprocess Events (CUDA IPC specific) check_interprocess_event_support() event = torch_dev.Event(interprocess=True) event.record(stream) for req_id, spec in meta.loads.items(): if not spec.tokens or not spec.block_ids: continue key = self._create_key(spec.tokens, req_id) try: _send_request( self._mq_client, RequestType.RETRIEVE, [ key, self._instance_id, [spec.block_ids], event.ipc_handle(), 0, # skip_first_n_tokens ], ).result(timeout=self._mq_timeout) except Exception as e: logger.warning( "LMCache MP worker: retrieve failed for req %d: %s", req_id, e, ) logger.debug( "LMCache MP worker: start_load_kv retrieve=%.3fms num_loads=%d", (time.perf_counter() - t0) * 1000, len(meta.loads), ) def wait_for_layer_load(self, layer_idx: int, stream: torch_dev.Stream) -> None: """No-op — server synchronizes via CUDA IPC events.""" pass def save_kv_layer(self, layer_idx: int, stream: torch_dev.Stream) -> None: """No-op — saves are batched in :meth:`wait_for_save`.""" pass def wait_for_save(self, stream: torch_dev.Stream) -> None: """Send ``STORE`` requests for each pending save.""" meta: Optional[LMCacheMPConnectorMetadata] = self._metadata if meta is None or not meta.saves: return t0 = time.perf_counter() # Not all backends support interprocess Events (CUDA IPC specific) check_interprocess_event_support() event = torch_dev.Event(interprocess=True) event.record(stream) for req_id, spec in meta.saves.items(): if not spec.tokens or not spec.block_ids: continue key = self._create_key(spec.tokens, req_id) try: _send_request( self._mq_client, RequestType.STORE, [ key, self._instance_id, [spec.block_ids], event.ipc_handle(), ], ).result(timeout=self._mq_timeout) except Exception as e: logger.warning( "LMCache MP worker: store failed for req %d: %s", req_id, e, ) logger.debug( "LMCache MP worker: wait_for_save store=%.3fms num_saves=%d", (time.perf_counter() - t0) * 1000, len(meta.saves), ) def get_finished( self, finished_gen_req_ids: List[int], started_loading_req_ids: List[int], ) -> Tuple[List[int], List[int]]: """All operations are synchronous — nothing is ever pending.""" return [], []