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

203 lines
7.5 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Encoder Cache (EC) engine.
Mirrors the KV cache engine's layering, but each EC entry is keyed by one
multimodal hash and stores a single tensor of shape ``[num_tokens,
hidden_size]``. EC does not require token chunking, layerwise operations, or
paged gather/scatter.
See ``docs/design/v1/encoder-cache.md`` for the broader design.
"""
# Future
from __future__ import annotations
# Standard
from typing import Optional
import hashlib
# Third Party
import torch
# First Party
from lmcache.logging import init_logger
from lmcache.utils import CacheEngineKey
from lmcache.v1.config import LMCacheEngineConfig
from lmcache.v1.event_manager import EventManager
from lmcache.v1.memory_management import MemoryFormat
from lmcache.v1.metadata import LMCacheMetadata
from lmcache.v1.storage_backend.storage_manager import StorageManager
logger = init_logger(__name__)
# Sentinel ``world_size``/``worker_id`` used in EC cache keys. Encoder outputs
# are replicated across tensor-parallel ranks (every rank produces the same
# tensor for a given mm_hash), so EC entries are deduplicated to a single
# logical "rank" on disk. Concurrent puts from multiple ranks land on the same
# key and are idempotent (identical contents).
_EC_KEY_WORLD_SIZE = 1
_EC_KEY_WORKER_ID = 0
def _stable_u64_from_str(s: str) -> int:
"""Hash an arbitrary string into a stable 64-bit unsigned int.
Used to project arbitrary multimodal-hash strings (which may be opaque,
not hex) into the ``chunk_hash: int`` field of :class:`CacheEngineKey`.
"""
digest = hashlib.sha256(str(s).encode("utf-8")).digest()
return int.from_bytes(digest[:8], byteorder="big", signed=False)
class ECCacheEngine:
"""LMCache-backed engine for vLLM encoder cache (EC) tensors.
The engine speaks tensors, not container objects: the vLLM-aware adapter
is responsible for reading from / writing to vLLM's ``encoder_cache``
dict. The engine itself only needs ``mm_hash`` and a tensor.
"""
def __init__(
self,
config: LMCacheEngineConfig,
metadata: LMCacheMetadata,
encoder_dtype: torch.dtype,
) -> None:
"""Initialize the EC cache engine.
Args:
config: LMCache engine configuration; supplies storage backends.
metadata: LMCache metadata describing model identity. Only
``model_name`` is used in the EC cache key; ``world_size``
and ``worker_id`` are intentionally ignored (see module
comment) to keep EC entries shared across TP ranks.
encoder_dtype: dtype of the encoder output tensors. Used as the
dtype field of the on-disk cache key, so it must be stable
across processes that share the same EC cache. Decoupled
from ``metadata.kv_dtype`` so that changing KV quantization
does not invalidate EC entries.
Raises:
ValueError: if no non-allocator storage backend is configured.
"""
self.config = config
self.metadata = metadata
self._model_name = metadata.model_name
self._dtype = encoder_dtype
self._event_manager = EventManager()
self._storage_manager = StorageManager(
config=config,
metadata=metadata,
event_manager=self._event_manager,
lmcache_worker=None,
async_lookup_server=None,
)
available_backends = self._storage_manager.get_non_allocator_backends()
if len(available_backends) == 0:
raise ValueError(
"EC cache engine found no storage backends. Configure at least one "
"backend (e.g. local_disk, remote_url, gds_path, nixl storage plugin)."
)
logger.info(
"Initialized EC cache engine with storage backends=%s",
available_backends,
)
def close(self) -> None:
"""Close EC storage resources and background workers."""
if hasattr(self, "_storage_manager") and self._storage_manager is not None:
self._storage_manager.close()
def _make_cache_key(self, mm_hash: str) -> CacheEngineKey:
return CacheEngineKey(
model_name=self._model_name,
world_size=_EC_KEY_WORLD_SIZE,
worker_id=_EC_KEY_WORKER_ID,
chunk_hash=_stable_u64_from_str(mm_hash),
dtype=self._dtype,
request_configs={},
)
def contains(self, mm_hash: str) -> bool:
"""Return whether encoder cache exists for the given multimodal hash."""
key = self._make_cache_key(mm_hash)
return self._storage_manager.contains(key) is not None
def put(self, mm_hash: str, tensor: torch.Tensor) -> bool:
"""Store one encoder output tensor under ``mm_hash``.
Args:
mm_hash: multimodal-input identifier produced by vLLM.
tensor: encoder output, shape ``[num_tokens, hidden_size]``,
on any device. The engine copies it into a pinned CPU
buffer; the caller's tensor is not retained.
Returns:
``True`` if a store task was submitted to the storage manager;
``False`` if the underlying allocator could not provide a buffer
(transient resource pressure). Never returns ``False`` for
caller misuse — pass a real tensor.
"""
key = self._make_cache_key(mm_hash)
# Allocate via LMCache allocator (LocalCPUBackend) through StorageManager.
# Preserve the source tensor dtype to avoid precision loss.
mem_obj = self._storage_manager.allocate(
shapes=tensor.shape,
dtypes=tensor.dtype,
fmt=MemoryFormat.EC_TD,
eviction=True,
busy_loop=False,
)
if mem_obj is None or mem_obj.tensor is None:
logger.warning("EC allocate failed; skipping put for key %s", key)
return False
# Single copy: src -> pinned CPU buffer, handles device transfer + dtype cast.
mem_obj.tensor.copy_(tensor)
self._storage_manager.batched_put(
[key],
[mem_obj],
)
nbytes = tensor.element_size() * tensor.numel()
logger.info("EC put: stored %d bytes for mm_hash=%s", nbytes, mm_hash)
return True
def get(self, mm_hash: str, device: str) -> Optional[torch.Tensor]:
"""Load the encoder output tensor for ``mm_hash`` if present.
Args:
mm_hash: multimodal-input identifier produced by vLLM.
device: torch device string (e.g. ``"cuda"``, ``"cpu"``) onto
which the returned tensor should reside.
Returns:
The encoder tensor on the requested device, or ``None`` on a
cache miss. The returned tensor never aliases an LMCache-managed
buffer — callers may keep it indefinitely.
"""
key = self._make_cache_key(mm_hash)
mem_objs = self._storage_manager.batched_get([key])
mem_obj = mem_objs[0]
if mem_obj is None:
return None
if mem_obj.tensor is None:
mem_obj.ref_count_down()
return None
try:
out = mem_obj.tensor.to(device=device)
# Ensure the returned tensor doesn't alias the buffer we're about
# to release: ``.to(same_device)`` is a no-op view, so clone.
if out.data_ptr() == mem_obj.tensor.data_ptr():
out = out.clone()
return out
finally:
mem_obj.ref_count_down()