2194 lines
83 KiB
Python
2194 lines
83 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Standard
|
|
from collections import defaultdict
|
|
from collections.abc import Iterable
|
|
from typing import (
|
|
TYPE_CHECKING,
|
|
Any,
|
|
Callable,
|
|
Dict,
|
|
Generator,
|
|
List,
|
|
Optional,
|
|
Tuple,
|
|
Union,
|
|
)
|
|
|
|
if TYPE_CHECKING:
|
|
# First Party
|
|
from lmcache.v1.health_monitor.base import HealthMonitor
|
|
|
|
# Standard
|
|
import asyncio
|
|
import gc
|
|
import multiprocessing
|
|
import time
|
|
|
|
# Third Party
|
|
import torch
|
|
|
|
# First Party
|
|
from lmcache import torch_dev, torch_device_type
|
|
from lmcache.logging import init_logger
|
|
from lmcache.observability import LMCacheStatsLogger, LMCStatsMonitor
|
|
from lmcache.usage_telemetry import InitializeUsageContext
|
|
from lmcache.utils import (
|
|
CacheEngineKey,
|
|
CacheStoreEvent,
|
|
_lmcache_nvtx_annotate,
|
|
compress_slot_mapping,
|
|
convert_tokens_to_list,
|
|
)
|
|
from lmcache.v1.config import LMCacheEngineConfig
|
|
from lmcache.v1.event_manager import EventManager, EventStatus, EventType
|
|
from lmcache.v1.gpu_connector.gpu_connectors import GPUConnectorInterface
|
|
from lmcache.v1.gpu_connector.utils import assert_layerwise_gpu_connector
|
|
from lmcache.v1.hidden_state_store import HiddenStateStore
|
|
from lmcache.v1.memory_allocators.cu_file_memory_allocator import CuFileMemoryAllocator
|
|
from lmcache.v1.memory_allocators.mixed_memory_allocator import MixedMemoryAllocator
|
|
from lmcache.v1.memory_allocators.paged_tensor_memory_allocator import (
|
|
PagedTensorMemoryAllocator,
|
|
)
|
|
from lmcache.v1.memory_management import (
|
|
MemoryAllocatorInterface,
|
|
MemoryFormat,
|
|
MemoryObj,
|
|
MemoryObjMetadata,
|
|
TensorMemoryObj,
|
|
)
|
|
from lmcache.v1.metadata import LMCacheMetadata
|
|
from lmcache.v1.pin_monitor import PinMonitor
|
|
from lmcache.v1.platform import current_device_spec
|
|
from lmcache.v1.storage_backend.storage_manager import StorageManager
|
|
from lmcache.v1.system_detection import NUMADetector, NUMAMapping
|
|
from lmcache.v1.token_database import (
|
|
ChunkedTokenDatabase,
|
|
SegmentTokenDatabase,
|
|
TokenDatabase,
|
|
)
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
# Type aliases for processed chunks
|
|
# (cache_key, memory_obj, start_index, end_index)
|
|
ProcessedChunk = Tuple[CacheEngineKey, MemoryObj, int, int]
|
|
# (list of processed chunks, total kv size)
|
|
ProcessTokensInternalResult = Tuple[List[ProcessedChunk], int]
|
|
|
|
|
|
class CacheEngineEndSignal:
|
|
pass
|
|
|
|
|
|
class LMCacheEngine:
|
|
"""The main class for the cache engine.
|
|
|
|
When storing the KV caches into the cache engine, it takes GPU KV
|
|
caches from the serving engine and convert them into MemoryObjs that
|
|
resides in the CPU. The MemoryObjs are then being stored into the
|
|
StorageBackends in an asynchronous manner.
|
|
|
|
When retrieving the KV caches from the cache engine, it fetches the
|
|
MemoryObjs from the StorageBackends and convert them into GPU KV caches
|
|
by GPUConnectors specialized for the serving engine.
|
|
|
|
It also supports prefetching the KV caches from the StorageBackends.
|
|
It relies on the StorageBackends to manage the requests of prefetching
|
|
and real retrieval and avoid the conflicts.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
config: LMCacheEngineConfig,
|
|
metadata: LMCacheMetadata,
|
|
token_database: TokenDatabase,
|
|
gpu_connector: Optional[GPUConnectorInterface],
|
|
broadcast_fn: Callable[[torch.Tensor, int], None],
|
|
broadcast_object_fn: Callable[[Any, int], Any],
|
|
):
|
|
logger.info("Creating LMCacheEngine with config: %s", config)
|
|
self.config = config
|
|
self.metadata = metadata
|
|
self.token_database = token_database
|
|
self.gpu_connector = gpu_connector
|
|
self.broadcast_fn = broadcast_fn
|
|
self.broadcast_object_fn = broadcast_object_fn
|
|
# save_only_first_rank only works when use mla
|
|
self.save_only_first_rank = (
|
|
self.config.get_extra_config_value("save_only_first_rank", metadata.use_mla)
|
|
and metadata.use_mla
|
|
)
|
|
|
|
if self.save_only_first_rank and self.gpu_connector is not None:
|
|
self.broadcast_stream = (
|
|
self.gpu_connector.load_stream
|
|
if hasattr(self.gpu_connector, "load_stream")
|
|
else torch_dev.Stream()
|
|
)
|
|
|
|
# Holds GPU-resident copies of the broadcast send buffers on the
|
|
# leader rank so the subsequent batched_to_gpu can read from HBM
|
|
# rather than re-reading the same L1 bytes over PCIe. Always empty
|
|
# on non-leader ranks and outside the broadcast critical section.
|
|
# Typed as List[MemoryObj] (the supertype) so the list can be
|
|
# passed directly to batched_to_gpu without an invariance cast.
|
|
self._leader_gpu_substitute_objs: List[MemoryObj] = []
|
|
|
|
self.enable_controller = config.enable_controller
|
|
|
|
# NOTE: Unix systems use fork by default
|
|
multiprocessing.set_start_method("spawn", force=True)
|
|
|
|
# avoid circular import
|
|
# First Party
|
|
from lmcache.v1.cache_controller import LMCacheWorker
|
|
|
|
self.lmcache_worker: Optional[LMCacheWorker] = None
|
|
lmcache_worker_ids = config.get_lmcache_worker_ids(
|
|
metadata.use_mla, metadata.world_size
|
|
)
|
|
# lmcache_worker_ids is empty means start on all workers
|
|
if (
|
|
self.enable_controller
|
|
and self.metadata.role != "scheduler"
|
|
and (not lmcache_worker_ids or metadata.worker_id in lmcache_worker_ids)
|
|
):
|
|
self.lmcache_worker = LMCacheWorker(config, metadata, self)
|
|
else:
|
|
self.lmcache_worker = None
|
|
logger.info(
|
|
"LMCacheWorker is not initialized (related configs: "
|
|
"enable_controller: %s, role: %s, worker_id: %d, worker_ids: %s).",
|
|
self.enable_controller,
|
|
self.metadata.role,
|
|
self.metadata.worker_id,
|
|
lmcache_worker_ids,
|
|
)
|
|
|
|
self.async_loading = config.enable_async_loading
|
|
self.event_manager = EventManager()
|
|
|
|
self.use_layerwise = config.use_layerwise
|
|
|
|
# TODO: support save_only_first_rank when use layerwise
|
|
# if use_layerwise is True, all ranks will initialize the storage_manager
|
|
# if save_only_first_rank is False, all ranks will initialize
|
|
# the storage_manager
|
|
# if save_only_first_rank is True, only the first rank and
|
|
# lookup server workers will initialize the storage_manager
|
|
self.storage_manager: Optional[StorageManager] = None
|
|
|
|
# KV events
|
|
self.kv_events_enabled = False
|
|
self.kv_events_enabled = config.enable_kv_events
|
|
if self.kv_events_enabled:
|
|
self.kv_events: List[CacheStoreEvent] = []
|
|
logger.info("KV events are enabled.")
|
|
else:
|
|
logger.info("KV events are disabled.")
|
|
|
|
# HACK: remove this in the future
|
|
# NOTE (Jiayi): This is currently used to support
|
|
# dropping the kv cache from the buffer in PD backend
|
|
# at decoder.
|
|
self.remove_after_retrieve = config.enable_pd and config.pd_role == "receiver"
|
|
|
|
# asymmetric store/retrieve location can be specified
|
|
# this is typically used (but not limited) in PD system
|
|
self.store_location = config.store_location
|
|
self.retrieve_locations = config.retrieve_locations
|
|
|
|
self.num_layers = metadata.kv_shape[0]
|
|
self.fmt = None
|
|
if self.use_layerwise:
|
|
if metadata.use_mla:
|
|
self.fmt = MemoryFormat.KV_MLA_FMT
|
|
elif config.enable_blending:
|
|
self.fmt = MemoryFormat.KV_2TD
|
|
else:
|
|
self.fmt = MemoryFormat.KV_T2D
|
|
if metadata.use_mla:
|
|
self.fmt = MemoryFormat.KV_MLA_FMT
|
|
|
|
# NOTE(ApostaC): we haven't support lookup-cache yet
|
|
self.lookup_cache: dict[CacheEngineKey, Any] = {}
|
|
|
|
# lookup_id -> {location -> [pinned keys]}
|
|
self.lookup_pins: dict[str, dict[str, list]] = defaultdict(
|
|
lambda: defaultdict(list)
|
|
)
|
|
|
|
InitializeUsageContext(config, metadata)
|
|
self.stats_monitor = LMCStatsMonitor.GetOrCreate()
|
|
# Initialize PinMonitor singleton with config
|
|
PinMonitor.GetOrCreate(config, metadata)
|
|
|
|
self.post_inited = False
|
|
|
|
# Flag to control KVCache Check logging (can be toggled via API)
|
|
self.kvcache_check_log_enabled = False
|
|
|
|
gc.collect()
|
|
if not config.py_enable_gc:
|
|
gc.disable()
|
|
|
|
# Health monitor reference (injected by LMCacheManager)
|
|
self._health_monitor: Optional["HealthMonitor"] = None
|
|
|
|
# Flag to indicate if initialization failed (irrecoverable error)
|
|
self._init_failed = False
|
|
|
|
# Hidden-state cache (logically separate from KV; lives on its own
|
|
# pinned pool). Bound to storage_manager in post_init for coupled
|
|
# eviction. None when disabled in config.
|
|
self.hidden_state_store: Optional[HiddenStateStore] = None
|
|
if config.enable_hidden_state_cache:
|
|
self.hidden_state_store = HiddenStateStore(config, token_database)
|
|
|
|
def set_health_monitor(self, health_monitor: "HealthMonitor") -> None:
|
|
"""
|
|
Set the health monitor reference.
|
|
|
|
This is called by LMCacheManager after creating the HealthMonitor
|
|
to inject the reference into the engine.
|
|
|
|
Args:
|
|
health_monitor: The HealthMonitor instance from LMCacheManager
|
|
"""
|
|
self._health_monitor = health_monitor
|
|
|
|
def is_healthy(self) -> bool:
|
|
"""
|
|
Check if the LMCache system is healthy.
|
|
|
|
This method returns False if:
|
|
- Initialization failed (irrecoverable error)
|
|
- HealthMonitor reports unhealthy
|
|
|
|
If no health monitor is set and initialization succeeded,
|
|
it returns True (assume healthy).
|
|
|
|
Returns:
|
|
bool: True if healthy, False otherwise
|
|
"""
|
|
if self._init_failed:
|
|
return False
|
|
if self._health_monitor is not None:
|
|
return self._health_monitor.is_healthy()
|
|
return True
|
|
|
|
def _get_req_id(self, kwargs: dict) -> str:
|
|
"""Extracts request ID from kwargs for logging."""
|
|
return kwargs.get("req_id", "unspecified")
|
|
|
|
def mark_init_failed(self, reason: str = "") -> None:
|
|
"""
|
|
Mark the engine as having failed initialization.
|
|
|
|
This is called by LMCacheManager when an irrecoverable error occurs
|
|
during initialization or post_init. Once marked, is_healthy() will
|
|
always return False, causing the system to fall back to recomputation.
|
|
|
|
Args:
|
|
reason: Optional reason string for logging
|
|
"""
|
|
self._init_failed = True
|
|
if reason:
|
|
logger.error("LMCacheEngine marked as init failed: %s", reason)
|
|
else:
|
|
logger.error("LMCacheEngine marked as init failed")
|
|
|
|
def post_init(self, **kwargs) -> None:
|
|
if not self.post_inited:
|
|
logger.info("Post initializing LMCacheEngine")
|
|
lookup_server_worker_ids = self.config.get_lookup_server_worker_ids(
|
|
self.metadata.use_mla, self.metadata.world_size
|
|
)
|
|
if (
|
|
self.lmcache_worker is not None
|
|
or self.use_layerwise
|
|
or not self.save_only_first_rank
|
|
or self.metadata.is_first_rank()
|
|
or len(lookup_server_worker_ids) == 0
|
|
or self.metadata.worker_id in lookup_server_worker_ids
|
|
):
|
|
logger.info(
|
|
"Initialize storage manager on rank %d, "
|
|
"use layerwise: %s,"
|
|
"save only first rank: %s",
|
|
self.metadata.worker_id,
|
|
self.use_layerwise,
|
|
self.save_only_first_rank,
|
|
)
|
|
async_lookup_server = kwargs.get("async_lookup_server", None)
|
|
self.storage_manager = StorageManager(
|
|
self.config,
|
|
self.metadata,
|
|
event_manager=self.event_manager,
|
|
lmcache_worker=self.lmcache_worker,
|
|
async_lookup_server=async_lookup_server,
|
|
)
|
|
if self.hidden_state_store is not None:
|
|
self.hidden_state_store.bind_storage_manager(self.storage_manager)
|
|
self.post_inited = True
|
|
|
|
def freeze(self, enabled: bool) -> None:
|
|
"""
|
|
Set the freeze mode for the cache engine.
|
|
|
|
When freeze mode is enabled:
|
|
- All store operations will be skipped (no new data stored)
|
|
- Only local_cpu backend will be used for retrieval
|
|
- No admit/evict messages will be generated
|
|
This protects the local_cpu hot cache from changes.
|
|
|
|
Args:
|
|
enabled (bool): Whether to enable freeze mode
|
|
"""
|
|
if self.storage_manager is not None:
|
|
self.storage_manager.set_freeze(enabled)
|
|
|
|
def is_frozen(self) -> bool:
|
|
"""
|
|
Get the current freeze mode status.
|
|
|
|
Returns:
|
|
bool: True if freeze mode is enabled, False otherwise
|
|
"""
|
|
if self.storage_manager is not None:
|
|
return self.storage_manager.is_frozen()
|
|
return False
|
|
|
|
def set_hot_cache(self, enabled: bool) -> None:
|
|
"""
|
|
Dynamically enable or disable the LocalCPUBackend hot cache.
|
|
|
|
When disabled, the existing hot cache entries will be cleared
|
|
and no new data will be written to the hot cache.
|
|
|
|
Args:
|
|
enabled (bool): Whether to enable hot cache
|
|
"""
|
|
if self.storage_manager is not None:
|
|
self.storage_manager.set_hot_cache(enabled)
|
|
|
|
def is_hot_cache_enabled(self) -> bool:
|
|
"""
|
|
Get the current hot cache status of LocalCPUBackend.
|
|
|
|
Returns:
|
|
bool: True if hot cache is enabled, False otherwise
|
|
"""
|
|
if self.storage_manager is not None:
|
|
return self.storage_manager.is_hot_cache_enabled()
|
|
return False
|
|
|
|
@_lmcache_nvtx_annotate
|
|
@torch.inference_mode()
|
|
def store(
|
|
self,
|
|
tokens: Optional[Union[torch.Tensor, list[int]]] = None,
|
|
hashes: Optional[List[int]] = None,
|
|
offsets: Optional[List[int]] = None,
|
|
mask: Optional[torch.Tensor] = None,
|
|
**kwargs,
|
|
) -> None:
|
|
"""Store the tokens/hashes and mask into the cache engine.
|
|
|
|
:param Optional[torch.Tensor] tokens: The tokens of the corresponding KV caches.
|
|
|
|
:param Optional[List[int]] hashes: The hashes of the corresponding KV caches.
|
|
|
|
:param Optional[torch.Tensor] mask: The mask for the tokens. Should
|
|
have the same length as tokens. And the mask should ALWAYS be like
|
|
FFFFFTTTTTTT, where True means the tokens needs to be matched,
|
|
and the Falses will ALWAYS be at the PREFIX of the tensor.
|
|
|
|
:param **kwargs: The additional arguments for the storage backend which
|
|
will be passed into the gpu_connector.
|
|
Should include KV cache specific information (e.g., paged KV buffer
|
|
and the page tables).
|
|
|
|
:raises: ValueError if the number of Falses in the mask is not a
|
|
multiple of the chunk size.
|
|
"""
|
|
# Health check: block operation if LMCache is unhealthy
|
|
if not self.is_healthy():
|
|
logger.warning("LMCache is unhealthy, skipping store operation")
|
|
return
|
|
|
|
assert self.gpu_connector is not None, (
|
|
"gpu_connector is required for store operation"
|
|
)
|
|
|
|
if self._is_passive():
|
|
logger.debug("rank=%d ignore store", self.metadata.worker_id)
|
|
return
|
|
|
|
assert self.storage_manager is not None
|
|
|
|
# Get req_id for logging
|
|
req_id = self._get_req_id(kwargs)
|
|
|
|
# Initialize num_to_store_tokens to avoid reference before assignment
|
|
num_to_store_tokens = 0
|
|
|
|
if mask is not None:
|
|
num_to_store_tokens = torch.sum(mask).item()
|
|
elif tokens is not None:
|
|
num_to_store_tokens = len(tokens)
|
|
elif hashes is not None:
|
|
assert offsets is not None, (
|
|
"Offsets should be set when hashes are provided during store"
|
|
)
|
|
num_to_store_tokens = sum(offsets)
|
|
kwargs["slot_mapping"] = torch.tensor(
|
|
kwargs["slot_mapping"], dtype=torch.long, device=torch_device_type
|
|
)
|
|
|
|
assert tokens is not None or hashes is not None, (
|
|
"Either 'tokens' or 'hashes' must be provided."
|
|
)
|
|
|
|
# KVCache Check logging
|
|
self._log_kvcache_for_check(
|
|
operation="Store",
|
|
kwargs=kwargs,
|
|
token_count=num_to_store_tokens,
|
|
require_req_id=False,
|
|
)
|
|
|
|
# Check if freeze mode is enabled
|
|
if self.is_frozen():
|
|
logger.debug(
|
|
"Freeze mode enabled, skipping store operation for %d tokens",
|
|
num_to_store_tokens,
|
|
)
|
|
return
|
|
|
|
store_stats = self.stats_monitor.on_store_request(num_to_store_tokens)
|
|
|
|
starts: List[int] = []
|
|
ends: List[int] = []
|
|
keys: List[CacheEngineKey] = []
|
|
memory_objs: List[MemoryObj] = []
|
|
|
|
tot_kv_size = 0
|
|
tot_token_num = 0
|
|
|
|
request_configs = kwargs.get("request_configs")
|
|
if request_configs is not None and len(request_configs) != 0:
|
|
assert isinstance(request_configs, dict)
|
|
|
|
with store_stats.profile_process_tokens():
|
|
prev_key = 0
|
|
for start, end, key in self.token_database.process_tokens(
|
|
tokens,
|
|
hashes,
|
|
offsets,
|
|
mask,
|
|
request_configs=request_configs,
|
|
):
|
|
assert isinstance(key, CacheEngineKey)
|
|
# Allocate the memory object
|
|
num_tokens = end - start
|
|
kv_shapes = self.metadata.get_shapes(num_tokens)
|
|
kv_dtypes = self.metadata.get_dtypes()
|
|
|
|
# TODO (Jiayi): should be batched in the future
|
|
memory_obj = self.storage_manager.allocate(
|
|
kv_shapes,
|
|
kv_dtypes,
|
|
busy_loop=self.config.get_extra_config_value(
|
|
"force_store_wait", False
|
|
),
|
|
fmt=self.fmt,
|
|
)
|
|
if memory_obj is None:
|
|
logger.warning(
|
|
"Local cpu memory under pressure so"
|
|
" choosing to store only "
|
|
" %d total chunks of KV cache.",
|
|
len(memory_objs),
|
|
)
|
|
break
|
|
|
|
starts.append(start)
|
|
ends.append(end)
|
|
keys.append(key)
|
|
memory_objs.append(memory_obj)
|
|
tot_kv_size += memory_obj.get_size()
|
|
tot_token_num += num_tokens
|
|
|
|
# Create KV event
|
|
if self.kv_events_enabled:
|
|
stored_event = CacheStoreEvent(
|
|
block_hashes=[key.chunk_hash],
|
|
parent_block_hash=None if start == 0 else prev_key,
|
|
token_ids=[],
|
|
block_size=num_tokens,
|
|
lora_id=None,
|
|
medium="cpu",
|
|
lora_name=None,
|
|
)
|
|
if tokens is not None:
|
|
stored_event.token_ids = convert_tokens_to_list(
|
|
tokens,
|
|
start,
|
|
end,
|
|
)
|
|
if isinstance(tokens, torch.Tensor):
|
|
stored_event.medium = tokens.device
|
|
elif hashes is not None:
|
|
stored_event.token_ids = hashes[start : end + 1]
|
|
logger.debug(
|
|
(
|
|
"Added kv cache event '%s' to kv cache events queue"
|
|
% stored_event
|
|
)
|
|
)
|
|
self.kv_events.append(stored_event)
|
|
prev_key = key.chunk_hash
|
|
|
|
# memory_objs might be empty, directly return to avoid sending tokens
|
|
if not memory_objs:
|
|
return
|
|
|
|
with store_stats.profile_from_gpu():
|
|
self.gpu_connector.batched_from_gpu(memory_objs, starts, ends, **kwargs)
|
|
|
|
with store_stats.profile_put():
|
|
transfer_spec = kwargs.get("transfer_spec", None)
|
|
# TODO: we implicitly rely on batched_put to call ref_count_down
|
|
# this management should be done in a cleaner way
|
|
self.storage_manager.batched_put(
|
|
keys,
|
|
memory_objs,
|
|
transfer_spec=transfer_spec,
|
|
location=self.store_location,
|
|
)
|
|
|
|
self.stats_monitor.on_store_finished(
|
|
store_stats,
|
|
tot_token_num,
|
|
)
|
|
tot_time = store_stats.time_to_store()
|
|
|
|
logger.info(
|
|
"[req_id=%s] Stored %d out of total %d tokens. "
|
|
"size: %.4f GB, cost %.4f ms, throughput: %.4f GB/s; "
|
|
"offload_time: %.4f ms, put_time: %.4f ms",
|
|
req_id,
|
|
tot_token_num,
|
|
num_to_store_tokens,
|
|
tot_kv_size / 1024**3,
|
|
tot_time * 1000,
|
|
tot_kv_size / tot_time / 1024**3 if tot_time > 0 else 0,
|
|
(store_stats.process_tokens_time + store_stats.from_gpu_time) * 1000,
|
|
store_stats.put_time * 1000,
|
|
)
|
|
|
|
@_lmcache_nvtx_annotate
|
|
@torch.inference_mode()
|
|
def store_layer(
|
|
self,
|
|
tokens: Union[torch.Tensor, list[int]],
|
|
mask: Optional[torch.Tensor] = None,
|
|
**kwargs,
|
|
) -> Generator[None, None, None]:
|
|
"""
|
|
Store the KV cache in a layerwise manner.
|
|
|
|
:param torch.Tensor tokens: The tokens of the corresponding KV caches.
|
|
|
|
:param Optional[torch.Tensor] mask: The mask for the tokens. Should
|
|
have the same length as tokens. And the mask should ALWAYS be like
|
|
FFFFFTTTTTTT, where True means the tokens needs to be matched.
|
|
|
|
:param **kwargs: The additional arguments for the storage backend which
|
|
will be passed into the gpu_connector.
|
|
|
|
return: A generator that yields None. In the first iteration, the
|
|
generator allocates the memory objects for all layers and moves
|
|
the KV cache of the first layer from GPU to CPU. In the next
|
|
iterations, it moves the KV cache of layer i from GPU to the memory
|
|
objects (on CPU) and puts the memory objects of layer i-1 to the
|
|
storage backends. In the last iteration, it puts the memory objects
|
|
of the last layer to the storage backends.
|
|
"""
|
|
# Health check: block operation if LMCache is unhealthy
|
|
if not self.is_healthy():
|
|
logger.warning("LMCache is unhealthy, skipping store_layer operation")
|
|
return
|
|
|
|
assert self.storage_manager is not None
|
|
assert self.gpu_connector is not None, (
|
|
"gpu_connector is required for store_layer operation"
|
|
)
|
|
|
|
# Get req_id for logging
|
|
req_id = self._get_req_id(kwargs)
|
|
|
|
if mask is not None:
|
|
num_to_store_tokens = torch.sum(mask).item()
|
|
else:
|
|
num_to_store_tokens = len(tokens)
|
|
|
|
# KVCache Check logging
|
|
self._log_kvcache_for_check(
|
|
operation="Layerwise store",
|
|
kwargs=kwargs,
|
|
token_count=num_to_store_tokens,
|
|
require_req_id=True,
|
|
)
|
|
|
|
monitor_req_id = self.stats_monitor.on_store_request(num_to_store_tokens)
|
|
|
|
# Check if freeze mode is enabled
|
|
if self.is_frozen():
|
|
logger.debug(
|
|
"Freeze mode enabled, skipping store_layer for %d tokens",
|
|
num_to_store_tokens,
|
|
)
|
|
# Still need to yield to avoid StopIteration
|
|
for layer_id in range(self.num_layers):
|
|
yield
|
|
return
|
|
|
|
starts = []
|
|
ends = []
|
|
keys = []
|
|
memory_objs = []
|
|
tot_token_num = 0
|
|
kv_dtype = self.metadata.kv_dtype
|
|
request_configs = kwargs.get("request_configs")
|
|
if request_configs is not None and len(request_configs) != 0:
|
|
assert isinstance(request_configs, dict)
|
|
|
|
prev_key = 0
|
|
for start, end, key in self.token_database.process_tokens(
|
|
tokens=tokens, mask=mask, request_configs=request_configs
|
|
):
|
|
assert isinstance(key, CacheEngineKey)
|
|
|
|
keys_multi_layer = key.split_layers(self.num_layers)
|
|
# Only check the first layer
|
|
if self.storage_manager.contains(
|
|
keys_multi_layer[0], self.retrieve_locations
|
|
):
|
|
continue
|
|
|
|
# Allocate the memory object
|
|
num_tokens = end - start
|
|
kv_shape_single_layer = self.gpu_connector.get_shape(num_tokens)
|
|
|
|
memory_objs_multi_layer = self.storage_manager.batched_allocate(
|
|
kv_shape_single_layer,
|
|
kv_dtype,
|
|
batch_size=self.num_layers,
|
|
fmt=self.fmt,
|
|
busy_loop=self.config.get_extra_config_value("force_store_wait", False),
|
|
)
|
|
|
|
if memory_objs_multi_layer is None:
|
|
logger.warning(
|
|
"Local cpu memory under pressure so"
|
|
" choosing to not store the KV cache."
|
|
)
|
|
break
|
|
|
|
starts.append(start)
|
|
ends.append(end)
|
|
keys.append(keys_multi_layer)
|
|
memory_objs.append(memory_objs_multi_layer)
|
|
tot_token_num += num_tokens
|
|
|
|
# Create KV event
|
|
if self.kv_events_enabled and tokens is not None:
|
|
stored_event = CacheStoreEvent(
|
|
block_hashes=[key.chunk_hash],
|
|
parent_block_hash=None if start == 0 else prev_key,
|
|
token_ids=[],
|
|
block_size=num_tokens,
|
|
lora_id=None,
|
|
medium="cpu",
|
|
lora_name=None,
|
|
)
|
|
if tokens is not None:
|
|
stored_event.token_ids = convert_tokens_to_list(
|
|
tokens,
|
|
start,
|
|
end,
|
|
)
|
|
if isinstance(tokens, torch.Tensor):
|
|
stored_event.medium = tokens.device
|
|
logger.debug(
|
|
"Added kv cache event '%s' to kv cache events queue",
|
|
stored_event,
|
|
)
|
|
self.kv_events.append(stored_event)
|
|
prev_key = key.chunk_hash
|
|
|
|
if keys:
|
|
# Transpose the keys and memory objects into layer major format
|
|
memory_objs = [list(row) for row in zip(*memory_objs, strict=False)]
|
|
keys = [list(row) for row in zip(*keys, strict=False)]
|
|
|
|
# Calculate total KV size for logging
|
|
tot_kv_size = sum(
|
|
mo.get_size() for layer_objs in memory_objs for mo in layer_objs
|
|
)
|
|
|
|
assert_layerwise_gpu_connector(self.gpu_connector)
|
|
|
|
t_start = time.perf_counter()
|
|
mem_obj_generator = self.gpu_connector.batched_from_gpu(
|
|
memory_objs, starts, ends, **kwargs
|
|
)
|
|
|
|
next(mem_obj_generator)
|
|
|
|
for layer_id in range(self.num_layers):
|
|
yield
|
|
next(mem_obj_generator)
|
|
self.storage_manager.batched_put(
|
|
keys[layer_id], memory_objs[layer_id], location=self.store_location
|
|
)
|
|
|
|
tot_time = time.perf_counter() - t_start
|
|
logger.info(
|
|
"[req_id=%s] Stored %d out of total %d tokens. "
|
|
"size: %.4f GB, cost %.4f ms, throughput: %.4f GB/s",
|
|
req_id,
|
|
tot_token_num,
|
|
len(tokens),
|
|
tot_kv_size / 1024**3,
|
|
tot_time * 1000,
|
|
tot_kv_size / tot_time / 1024**3 if tot_time > 0 else 0,
|
|
)
|
|
else:
|
|
# If no cache are found, we still need to yield to avoid
|
|
# `StopIteration`
|
|
for layer_id in range(self.num_layers):
|
|
yield
|
|
|
|
self.stats_monitor.on_store_finished(monitor_req_id, tot_token_num)
|
|
yield
|
|
|
|
@_lmcache_nvtx_annotate
|
|
@torch.inference_mode()
|
|
def retrieve(
|
|
self,
|
|
tokens: Union[torch.Tensor, list[int]],
|
|
mask: Optional[torch.Tensor] = None,
|
|
**kwargs,
|
|
) -> torch.Tensor:
|
|
"""Retrieve the KV caches from the cache engine. And put the retrieved
|
|
KV cache to the serving engine via the GPU connector.
|
|
|
|
:param torch.Tensor tokens: The tokens of the corresponding KV caches.
|
|
|
|
:param Optional[torch.Tensor] mask: The mask for the tokens. Should
|
|
have the same length as tokens. And the mask should ALWAYS be like
|
|
FFFFFTTTTTTT, where True means the tokens needs to be matched,
|
|
and the Falses will ALWAYS be at the PREFIX of the tensor.
|
|
|
|
:param **kwargs: The additional arguments for the storage backend which
|
|
will be passed into the gpu_connector.
|
|
Should include KV cache specific information (e.g., paged KV buffer
|
|
and the page tables).
|
|
|
|
:return: the boolean mask indicating which tokens are retrieved. The
|
|
length of the mask should be the same as the tokens. On CPU.
|
|
|
|
:raises: ValueError if the number of Falses in the mask is not a
|
|
multiple of the chunk size.
|
|
"""
|
|
# Health check: block operation if LMCache is unhealthy
|
|
if not self.is_healthy():
|
|
logger.warning("LMCache is unhealthy, skipping retrieve operation")
|
|
return torch.zeros(len(tokens), dtype=torch.bool)
|
|
|
|
assert self.gpu_connector is not None, (
|
|
"gpu_connector is required for retrieve operation"
|
|
)
|
|
|
|
# Get req_id for logging
|
|
req_id = self._get_req_id(kwargs)
|
|
|
|
tot_kv_size = 0
|
|
|
|
if mask is not None:
|
|
num_required_tokens = torch.sum(mask).item()
|
|
else:
|
|
num_required_tokens = len(tokens)
|
|
|
|
# KVCache Check logging
|
|
self._log_kvcache_for_check(
|
|
operation="retrieve",
|
|
kwargs=kwargs,
|
|
token_count=num_required_tokens,
|
|
require_req_id=True,
|
|
)
|
|
|
|
retrieve_stats = self.stats_monitor.on_retrieve_request(num_required_tokens)
|
|
|
|
ret_mask = torch.zeros(len(tokens), dtype=torch.bool, device="cpu")
|
|
|
|
reordered_chunks: List[ProcessedChunk] = []
|
|
if not self._is_passive():
|
|
with retrieve_stats.profile_process_tokens():
|
|
if self.async_loading:
|
|
reordered_chunks, tot_kv_size = self._async_process_tokens_internal( # noqa: E501
|
|
tokens,
|
|
mask,
|
|
ret_mask,
|
|
**kwargs,
|
|
)
|
|
else:
|
|
reordered_chunks, tot_kv_size = self._process_tokens_internal(
|
|
tokens,
|
|
mask,
|
|
ret_mask,
|
|
**kwargs,
|
|
)
|
|
|
|
if self.save_only_first_rank:
|
|
with retrieve_stats.profile_broadcast():
|
|
with torch_dev.stream(self.broadcast_stream):
|
|
self._broadcast_or_receive_memory_objs(
|
|
reordered_chunks,
|
|
ret_mask,
|
|
)
|
|
|
|
# if self.gpu_connector has load_stream, self.broadcast_stream is equals
|
|
# to self.gpu_connector.load_stream, the broadcast and to_gpu operation
|
|
# will execute sequentially within the stream.
|
|
# if self.gpu_connector does not have load_stream, self.broadcast_stream
|
|
# is created by torch_dev.Stream(), we need to synchronize broadcast
|
|
# operation, and then process to_cpu operation.
|
|
if not hasattr(self.gpu_connector, "load_stream"):
|
|
self.broadcast_stream.synchronize()
|
|
|
|
# NOTE(Jiayi): memory_obj doesn't have to be a pinned
|
|
# cpu tensor for the sake of performance.
|
|
# For example, disk->gpu is faster than disk->cpu->gpu.
|
|
# RDMA is another example.
|
|
if len(reordered_chunks) > 0:
|
|
with retrieve_stats.profile_to_gpu():
|
|
_, memory_objs, starts, ends = zip(*reordered_chunks, strict=False)
|
|
# When save_only_first_rank is enabled, the leader rank's
|
|
# memory_objs from L1 are CPU-resident. The broadcast above
|
|
# already created GPU-resident copies on the leader to use as
|
|
# the NCCL send buffer. Substitute those here so this kernel
|
|
# reads from HBM rather than re-reading the same L1 bytes
|
|
# over PCIe via zero-copy mapped pinned memory. Without this
|
|
# swap, batched_to_gpu on the leader takes ~9 ms (PCIe-bound)
|
|
# while passive ranks take ~0.5 ms (HBM-bound) — a structural
|
|
# asymmetry on the critical path of every retrieve.
|
|
if self.save_only_first_rank and self.metadata.is_first_rank():
|
|
if len(self._leader_gpu_substitute_objs) == len(memory_objs):
|
|
memory_objs_for_togpu = self._leader_gpu_substitute_objs
|
|
else:
|
|
# Substitute list should always match memory_objs after
|
|
# _broadcast_or_receive_memory_objs has run on the
|
|
# leader. A mismatch indicates a bug or stale state;
|
|
# fall back to the CPU L1 source so retrieval is still
|
|
# correct, but warn so the issue is visible.
|
|
logger.warning(
|
|
"Leader rank: GPU substitute count (%d) does not "
|
|
"match memory_objs count (%d); falling back to "
|
|
"CPU L1 source for batched_to_gpu (PCIe-bound, "
|
|
"~9 ms slower).",
|
|
len(self._leader_gpu_substitute_objs),
|
|
len(memory_objs),
|
|
)
|
|
memory_objs_for_togpu = list(memory_objs)
|
|
else:
|
|
memory_objs_for_togpu = list(memory_objs)
|
|
try:
|
|
self.gpu_connector.batched_to_gpu(
|
|
memory_objs_for_togpu, list(starts), list(ends), **kwargs
|
|
)
|
|
finally:
|
|
# Release GPU substitute references so the temporary
|
|
# buffers can be freed; original memory_objs in
|
|
# reordered_chunks are still tracked for the cleanup
|
|
# loop below. Done in `finally` so a raise from
|
|
# batched_to_gpu (e.g. CUDA OOM) does not leave the
|
|
# references dangling on this long-lived engine.
|
|
if self.save_only_first_rank and self.metadata.is_first_rank():
|
|
self._leader_gpu_substitute_objs = []
|
|
|
|
# TODO(Jiayi): Remove the following for loop with batched operations
|
|
# TODO(Jiayi): Need to refactor the `remove_after_retrieve` logic.
|
|
for key, memory_obj, _, _ in reordered_chunks:
|
|
if self.remove_after_retrieve and not self._is_passive():
|
|
assert self.storage_manager is not None
|
|
self.storage_manager.remove(key, self.retrieve_locations)
|
|
# Sync PDBackend.remove() does NOT call ref_count_down() internally
|
|
# (unlike async PD and other backends), so we must call it manually.
|
|
# See pd_backend.py line 605 TODO comment.
|
|
if self._is_sync_pd_backend():
|
|
memory_obj.ref_count_down()
|
|
else:
|
|
if memory_obj.is_pinned:
|
|
memory_obj.unpin()
|
|
memory_obj.ref_count_down()
|
|
|
|
retrieved_tokens = torch.sum(ret_mask)
|
|
self.stats_monitor.on_retrieve_finished(
|
|
retrieve_stats,
|
|
retrieved_tokens,
|
|
)
|
|
onload_time = retrieve_stats.time_to_retrieve()
|
|
# The retrieved may be larger than the need_to_load
|
|
# Example (page_size=16, chunk_size=256):
|
|
#
|
|
# chunks: [0..255] [256..511]
|
|
# pages: [0..15]...[240..255] [256..271][272..287] ...
|
|
#
|
|
# num_computed_tokens = 288 => vLLM already has [0..287] (18 pages)
|
|
# LMCache hit_prefix_tokens = 512 => cache covers [0..511] (2 chunks)
|
|
#
|
|
# Skip chunk 1, retrieve chunk 2, overwrite [256..287] (32-token overlap)
|
|
# need_to_load: 512 - 288 = 224 tokens
|
|
# retrieved: 256 tokens
|
|
if not self._is_passive():
|
|
logger.info(
|
|
"[req_id=%s] Retrieved %d out of %d required tokens "
|
|
"(from %d total tokens). size: %.4f gb, "
|
|
"cost %.4f ms, throughput: %.4f GB/s;",
|
|
req_id,
|
|
retrieved_tokens,
|
|
num_required_tokens,
|
|
len(tokens),
|
|
tot_kv_size / 1024**3,
|
|
onload_time * 1000,
|
|
tot_kv_size / onload_time / 1024**3 if onload_time > 0 else 0,
|
|
)
|
|
return ret_mask
|
|
|
|
@_lmcache_nvtx_annotate
|
|
@torch.inference_mode()
|
|
def retrieve_layer(
|
|
self,
|
|
tokens: Union[torch.Tensor, list[int]],
|
|
mask: Optional[torch.Tensor] = None,
|
|
**kwargs,
|
|
) -> Generator[Optional[torch.Tensor], None, None]:
|
|
"""
|
|
Retrieve the KV cache in a layerwise manner.
|
|
|
|
:param torch.Tensor tokens: The tokens of the corresponding KV caches.
|
|
|
|
:param Optional[torch.Tensor] mask: The mask for the tokens. Should
|
|
have the same length as tokens. And the mask should ALWAYS be like
|
|
FFFFFTTTTTTT, where True means the tokens needs to be matched.
|
|
|
|
:param **kwargs: The additional arguments for the storage backend which
|
|
will be passed into the gpu_connector.
|
|
|
|
return: A generator that yields Optional[torch.Tensor]. The tensor will
|
|
be the boolean mask indicating which tokens are retrieved and will
|
|
only be returned in the last iteration. In the first iteration,
|
|
the generator retrieve the memory objects of the first layer from
|
|
the storage backends. In the next iterations, it moves the KV cache
|
|
of layer i from the memory objects (on CPU) to GPU and retrieves
|
|
the memory objects of layer i+1 from the storage backends. In the
|
|
last iteration, it moves the memory objects of the last layer to
|
|
the GPU.
|
|
"""
|
|
# Health check: block operation if LMCache is unhealthy
|
|
if not self.is_healthy():
|
|
logger.warning("LMCache is unhealthy, skipping retrieve_layer operation")
|
|
yield torch.zeros(len(tokens), dtype=torch.bool)
|
|
return
|
|
|
|
assert self.storage_manager is not None
|
|
assert self.gpu_connector is not None, (
|
|
"gpu_connector is required for retrieve_layer operation"
|
|
)
|
|
|
|
# Get req_id for logging
|
|
req_id = self._get_req_id(kwargs)
|
|
|
|
if mask is not None:
|
|
num_required_tokens = torch.sum(mask).item()
|
|
else:
|
|
num_required_tokens = len(tokens)
|
|
monitor_req_id = self.stats_monitor.on_retrieve_request(num_required_tokens)
|
|
|
|
ret_mask = torch.zeros(len(tokens), dtype=torch.bool, device="cpu")
|
|
|
|
starts = []
|
|
ends = []
|
|
keys = []
|
|
|
|
request_configs = kwargs.get("request_configs")
|
|
if request_configs is not None and len(request_configs) != 0:
|
|
assert isinstance(request_configs, dict)
|
|
|
|
location = None
|
|
for start, end, key in self.token_database.process_tokens(
|
|
tokens=tokens,
|
|
mask=mask,
|
|
request_configs=request_configs,
|
|
):
|
|
assert isinstance(key, CacheEngineKey)
|
|
|
|
keys_multi_layer = key.split_layers(self.num_layers)
|
|
|
|
# NOTE: Only check the first layer
|
|
if current_location := self.storage_manager.contains(
|
|
keys_multi_layer[0], self.retrieve_locations
|
|
):
|
|
if location is None:
|
|
location = current_location
|
|
else:
|
|
# TODO(Jiayi): Support multi-location retrieval in the future
|
|
assert location == current_location, (
|
|
"All retrieved keys should be from the same location "
|
|
"when use layerwise retrieval."
|
|
"Please support multi-location retrieval in the future."
|
|
)
|
|
else:
|
|
break
|
|
|
|
starts.append(start)
|
|
ends.append(end)
|
|
keys.append(keys_multi_layer)
|
|
|
|
ret_mask[start:end] = True
|
|
|
|
if keys:
|
|
# Transpose the keys into layer major format
|
|
keys_layer_major = [list(row) for row in zip(*keys, strict=False)]
|
|
|
|
get_generator = self.storage_manager.layerwise_batched_get(
|
|
keys_layer_major,
|
|
location=location,
|
|
)
|
|
|
|
assert_layerwise_gpu_connector(self.gpu_connector)
|
|
|
|
mem_obj_consumer = self.gpu_connector.batched_to_gpu(starts, ends, **kwargs)
|
|
next(mem_obj_consumer)
|
|
|
|
to_count_down = []
|
|
for layer_id in range(self.num_layers):
|
|
task = next(get_generator)
|
|
|
|
assert task is not None
|
|
|
|
if layer_id == 0:
|
|
# NOTE(Yuwei): For sglang integration we need to provide retrieved
|
|
# tokens number in the first layer loading since there is no lookup
|
|
yield torch.sum(ret_mask)
|
|
else:
|
|
yield None
|
|
|
|
mem_objs_layer = task.result()
|
|
mem_obj_consumer.send(mem_objs_layer)
|
|
to_count_down.extend(mem_objs_layer)
|
|
|
|
for mem_obj in to_count_down:
|
|
mem_obj.ref_count_down()
|
|
else:
|
|
# If no cache are found, we still need to yield to avoid
|
|
# `StopIteration`
|
|
for layer_id in range(self.num_layers):
|
|
yield None
|
|
|
|
yield None
|
|
|
|
# synchronize the last layer
|
|
next(mem_obj_consumer)
|
|
|
|
# Unpin any disk-loaded staging objects now that the device-side sync
|
|
# has been enqueued (mem_obj_consumer advanced past its sync point).
|
|
# Without this, pin_count stays at 1 forever and the CPU staging pool
|
|
# fills up, causing the next retrieve to deadlock inside allocate().
|
|
for mem_obj in to_count_down:
|
|
if mem_obj.is_pinned:
|
|
mem_obj.unpin()
|
|
|
|
retrieved_tokens = torch.sum(ret_mask)
|
|
self.stats_monitor.on_retrieve_finished(monitor_req_id, retrieved_tokens)
|
|
if not self._is_passive():
|
|
logger.info(
|
|
"[req_id=%s] Retrieved %d out of %d out of total %d tokens",
|
|
req_id,
|
|
retrieved_tokens,
|
|
num_required_tokens,
|
|
len(tokens),
|
|
)
|
|
|
|
yield ret_mask
|
|
|
|
@_lmcache_nvtx_annotate
|
|
def lookup(
|
|
self,
|
|
tokens: Optional[Union[torch.Tensor, List[int]]] = None,
|
|
hashes: Optional[List[int]] = None,
|
|
offsets: Optional[List[int]] = None,
|
|
search_range: Optional[List[str]] = None,
|
|
lookup_id: Optional[str] = None,
|
|
pin: bool = False,
|
|
request_configs: Optional[dict] = None,
|
|
) -> int:
|
|
"""
|
|
Checks the existence of KV cache of the tokens from the cache engine.
|
|
|
|
:param Optional[Union[torch.Tensor, List[int]]] tokens: the input tokens,
|
|
with shape [seq_len]
|
|
|
|
:param Optional[List[int]] hashes: the input hashes, with length [num_chunks]
|
|
:param Optional[List[int]] offsets: the offsets of each chunk,
|
|
with length [num_chunks]
|
|
|
|
:param Optional[List[str]] search_range: The range of storage backends
|
|
to search in. Should be a subset of
|
|
["LocalCPUBackend", "LocalDiskBackend"] for now.
|
|
If None, search in all backends.
|
|
|
|
:param Optional[str] lookup_id: The lookup ID to
|
|
associate with the lookup. When pin is true, this argument is
|
|
required to be not None.
|
|
|
|
:param bool pin: If True, pin the KV cache in the storage.
|
|
|
|
:param Optional[dict] request_configs: the configs of the request.
|
|
|
|
:return: An int indicating how many prefix tokens exist inside LMCache.
|
|
"""
|
|
# Health check: block operation if LMCache is unhealthy
|
|
if not self.is_healthy():
|
|
logger.warning("LMCache is unhealthy, skipping lookup operation")
|
|
return 0
|
|
|
|
assert self.storage_manager is not None
|
|
|
|
if tokens is not None:
|
|
lookup_stats = self.stats_monitor.on_lookup_request(len(tokens))
|
|
else:
|
|
assert offsets is not None
|
|
assert hashes is not None
|
|
lookup_stats = self.stats_monitor.on_lookup_request(sum(offsets))
|
|
|
|
if search_range is None:
|
|
search_range = self.retrieve_locations
|
|
|
|
res = 0
|
|
try:
|
|
chunk_info_iterator = self.token_database.process_tokens(
|
|
tokens=tokens,
|
|
hashes=hashes,
|
|
offsets=offsets,
|
|
request_configs=request_configs,
|
|
)
|
|
|
|
# TODO: support batched_contains when layerwise is enabled
|
|
if self.use_layerwise:
|
|
for start, end, key in chunk_info_iterator:
|
|
assert isinstance(key, CacheEngineKey)
|
|
|
|
# TODO(Jiayi): Optimize by checking only the existence of the key
|
|
# of one layer
|
|
key_all_layers = key.split_layers(self.num_layers)
|
|
|
|
hit_chunks, block_mapping = self.storage_manager.batched_contains(
|
|
key_all_layers, # type: ignore
|
|
search_range,
|
|
pin,
|
|
)
|
|
# Only all layers are hit and hit in one location,
|
|
# we consider this key as a hit
|
|
if hit_chunks == self.num_layers and len(block_mapping) == 1:
|
|
if pin:
|
|
assert lookup_id is not None, (
|
|
"lookup_id is required when pin is True"
|
|
)
|
|
location = next(iter(block_mapping.keys()))
|
|
self.lookup_pins[lookup_id][location].extend(key_all_layers)
|
|
res = end
|
|
continue
|
|
return res
|
|
else:
|
|
chunk_info_list = []
|
|
keys = []
|
|
for chunk_info in chunk_info_iterator:
|
|
assert isinstance(chunk_info[2], CacheEngineKey)
|
|
start, end, _ = chunk_info
|
|
chunk_info_list.append(chunk_info)
|
|
# chunk_info contains (start, end, key)
|
|
# chunk_info[2] is the key
|
|
keys.append(chunk_info[2])
|
|
# hit chunks by prefix matching
|
|
hit_chunks, block_mapping = self.storage_manager.batched_contains(
|
|
keys, search_range, pin
|
|
)
|
|
if pin and block_mapping:
|
|
assert lookup_id is not None, (
|
|
"lookup_id is required when pin is True"
|
|
)
|
|
self.lookup_pins[lookup_id] = block_mapping
|
|
for idx, (start, end, key) in enumerate(chunk_info_list):
|
|
if idx < hit_chunks:
|
|
res = end
|
|
continue
|
|
return res
|
|
|
|
# all tokens where found, return the maximal end
|
|
return res
|
|
finally:
|
|
self.stats_monitor.on_lookup_finished(lookup_stats, res)
|
|
# vllm lookup sets pin to True
|
|
if pin:
|
|
# touch_cache is tightly coupled with batched_contains
|
|
self.storage_manager.touch_cache()
|
|
|
|
@_lmcache_nvtx_annotate
|
|
def move(
|
|
self,
|
|
tokens: Union[torch.Tensor, List[int]],
|
|
old_position: str,
|
|
new_position: tuple[str, str],
|
|
event_id: str,
|
|
do_copy: bool = True,
|
|
) -> int:
|
|
"""
|
|
Perform cross-node move of the KV cache.
|
|
"""
|
|
assert self.storage_manager is not None
|
|
|
|
num_tokens = self.lookup(
|
|
tokens,
|
|
search_range=[old_position],
|
|
lookup_id=event_id,
|
|
pin=True,
|
|
)
|
|
|
|
if not num_tokens:
|
|
logger.debug("Move is not performed as there are no tokens to move.")
|
|
return 0
|
|
|
|
block_mapping = self.lookup_pins[event_id]
|
|
assert len(block_mapping) == 1
|
|
keys = block_mapping[old_position]
|
|
|
|
memory_objs = self.storage_manager.batched_get(
|
|
keys=keys,
|
|
location=old_position,
|
|
)
|
|
assert None not in memory_objs, "Failed to get memory objects to move"
|
|
logger.debug(
|
|
f"Trying to send {len(memory_objs)} memory objects to {new_position}"
|
|
)
|
|
|
|
# TODO: reduce loops
|
|
token_dim = memory_objs[0].meta.fmt.token_dim() # type: ignore
|
|
offsets = [m.meta.shape[token_dim] for m in memory_objs] # type: ignore
|
|
|
|
transfer_spec = {
|
|
"target_peer_init_url": new_position[0],
|
|
"offsets": offsets,
|
|
}
|
|
|
|
logger.info(self.storage_manager.storage_backends)
|
|
p2p_backend = self.storage_manager.storage_backends["P2PBackend"]
|
|
|
|
future = asyncio.run_coroutine_threadsafe(
|
|
p2p_backend.async_batched_submit_put_task(
|
|
keys,
|
|
memory_objs, # type: ignore
|
|
transfer_spec=transfer_spec,
|
|
),
|
|
self.storage_manager.loop,
|
|
)
|
|
|
|
future.result()
|
|
|
|
if not do_copy:
|
|
self.storage_manager.batched_remove(keys, locations=[old_position])
|
|
|
|
logger.debug(
|
|
"Moving %d token from %s to %s", num_tokens, old_position, new_position
|
|
)
|
|
return num_tokens
|
|
|
|
# TODO(Jiayi): Add layerwise support.
|
|
@_lmcache_nvtx_annotate
|
|
def async_lookup_and_prefetch(
|
|
self,
|
|
lookup_id: str,
|
|
tokens: Optional[Union[torch.Tensor, List[int]]] = None,
|
|
hashes: Optional[List[int]] = None,
|
|
offsets: Optional[List[int]] = None,
|
|
search_range: Optional[List[str]] = None,
|
|
pin: bool = False,
|
|
request_configs: Optional[dict] = None,
|
|
) -> None:
|
|
"""
|
|
An async version of lookup + prefetch.
|
|
|
|
There are three categories of backends:
|
|
(1) sync lookup + sync retrieval (e.g., cpu)
|
|
(2) sync lookup + async retrieval (e.g., disk)
|
|
(3) async lookup + async retrieval (e.g., p2p)
|
|
"""
|
|
assert self.storage_manager is not None
|
|
|
|
keys: list[CacheEngineKey] = []
|
|
cum_chunk_lengths = [0]
|
|
|
|
if search_range is None:
|
|
search_range = self.retrieve_locations
|
|
|
|
# When layerwise is enabled, store_layer writes per-layer keys
|
|
# (LayerCacheEngineKey, chunk_hash + layer_id). Async lookup must
|
|
# split each chunk key into num_layers per-layer keys so the
|
|
# storage backend hot_cache lookups match the same key type.
|
|
keys_per_chunk = self.num_layers if self.use_layerwise else 1
|
|
|
|
# TODO(Jiayi): make token database able to return list.
|
|
for start, end, key in self.token_database.process_tokens(
|
|
tokens=tokens,
|
|
hashes=hashes,
|
|
offsets=offsets,
|
|
request_configs=request_configs,
|
|
):
|
|
assert isinstance(key, CacheEngineKey)
|
|
if self.use_layerwise:
|
|
keys.extend(key.split_layers(self.num_layers))
|
|
else:
|
|
keys.append(key)
|
|
cum_chunk_lengths.append(end)
|
|
|
|
asyncio.run_coroutine_threadsafe(
|
|
self.storage_manager.async_lookup_and_prefetch(
|
|
lookup_id,
|
|
keys,
|
|
cum_chunk_lengths,
|
|
search_range,
|
|
pin,
|
|
keys_per_chunk=keys_per_chunk,
|
|
),
|
|
self.storage_manager.loop,
|
|
)
|
|
|
|
def cleanup_memory_objs(self, lookup_id: str) -> None:
|
|
"""
|
|
Cleanup memory objects allocated during prefetch for an aborted lookup.
|
|
|
|
Called by the scheduler when it determines that an aborted lookup
|
|
has finished its prefetch tasks.
|
|
"""
|
|
try:
|
|
# Get the completed future from event_manager
|
|
if (
|
|
self.event_manager.get_event_status(EventType.LOADING, lookup_id)
|
|
!= EventStatus.DONE
|
|
):
|
|
logger.debug(
|
|
"No completed event found for lookup_id=%s to clean up.", lookup_id
|
|
)
|
|
return
|
|
future = self.event_manager.pop_event(EventType.LOADING, lookup_id)
|
|
|
|
# Get memory objects from the future result
|
|
memory_objs = future.result()
|
|
# Flatten nested lists (each backend returns a list of chunks)
|
|
memory_objs_flat = [mm for m in memory_objs for mm in m]
|
|
|
|
# Release each memory object
|
|
for key, memory_obj in memory_objs_flat:
|
|
try:
|
|
logger.debug("Releasing memory object for lookup_id=%s", lookup_id)
|
|
if memory_obj.is_pinned:
|
|
memory_obj.unpin()
|
|
memory_obj.ref_count_down()
|
|
except Exception as e:
|
|
logger.error("Error releasing memory object: %s", e)
|
|
except Exception as e:
|
|
logger.error(
|
|
"Error during cleanup_memory_objs for lookup_id=%s: %s",
|
|
lookup_id,
|
|
e,
|
|
)
|
|
|
|
# TODO(Jiayi): Need to handle the case where `tokens=None`.
|
|
# In this case, we compress all tokens.
|
|
# TODO(Jiayi): support other compression methods.
|
|
@_lmcache_nvtx_annotate
|
|
def compress(
|
|
self,
|
|
tokens: Union[torch.Tensor, List[int]],
|
|
method: str,
|
|
location: str,
|
|
event_id: str,
|
|
) -> int:
|
|
assert self.storage_manager is not None
|
|
if method not in ["cachegen"]:
|
|
logger.warning("Unsupported compression method: %s.", method)
|
|
return 0
|
|
|
|
# First Party
|
|
from lmcache.v1.storage_backend.naive_serde import CreateSerde
|
|
|
|
serializer, _ = CreateSerde(method, self.metadata, self.config)
|
|
|
|
num_tokens = self.lookup(
|
|
tokens,
|
|
search_range=[location],
|
|
lookup_id=event_id,
|
|
pin=True,
|
|
)
|
|
|
|
if not num_tokens:
|
|
logger.debug("Move is not performed as there are no tokens to move.")
|
|
return 0
|
|
|
|
block_mapping = self.lookup_pins[event_id]
|
|
assert len(block_mapping) == 1
|
|
keys = block_mapping[location]
|
|
|
|
memory_objs = self.storage_manager.batched_get(
|
|
keys=keys,
|
|
location=location,
|
|
)
|
|
assert None not in memory_objs, (
|
|
"LMCacheEngine.compress: Failed to get memory objects to compress"
|
|
)
|
|
|
|
compressed_memory_objs = []
|
|
for memory_obj in memory_objs:
|
|
assert memory_obj is not None
|
|
compressed_memory_obj = serializer.serialize(memory_obj)
|
|
if memory_obj.is_pinned:
|
|
memory_obj.unpin()
|
|
compressed_memory_objs.append(compressed_memory_obj)
|
|
|
|
self.storage_manager.batched_remove(keys, locations=[location])
|
|
|
|
self.storage_manager.batched_put(
|
|
keys=keys,
|
|
memory_objs=compressed_memory_objs,
|
|
location=location,
|
|
)
|
|
|
|
return num_tokens
|
|
|
|
@_lmcache_nvtx_annotate
|
|
def decompress(
|
|
self,
|
|
tokens: Union[torch.Tensor, List[int]],
|
|
method: str,
|
|
location: str,
|
|
event_id: str,
|
|
) -> int:
|
|
assert self.storage_manager is not None
|
|
if method not in ["cachegen"]:
|
|
logger.warning("Unsupported decompression method: %s.", method)
|
|
return 0
|
|
|
|
# First Party
|
|
from lmcache.v1.storage_backend.naive_serde import CreateSerde
|
|
|
|
_, deserializer = CreateSerde(method, self.metadata, self.config)
|
|
|
|
num_tokens = self.lookup(
|
|
tokens,
|
|
search_range=[location],
|
|
lookup_id=event_id,
|
|
pin=True,
|
|
)
|
|
|
|
if not num_tokens:
|
|
logger.debug("there are no tokens to decompress.")
|
|
return 0
|
|
|
|
block_mapping = self.lookup_pins[event_id]
|
|
assert len(block_mapping) == 1
|
|
keys = block_mapping[location]
|
|
|
|
compressed_memory_objs = self.storage_manager.batched_get(
|
|
keys=keys,
|
|
location=location,
|
|
)
|
|
|
|
assert None not in compressed_memory_objs, (
|
|
"LMCacheEngine.compress: Failed to get compressed "
|
|
"memory objects to decompress"
|
|
)
|
|
|
|
memory_objs = []
|
|
for compressed_memory_obj in compressed_memory_objs:
|
|
assert compressed_memory_obj is not None
|
|
memory_obj = deserializer.deserialize(compressed_memory_obj)
|
|
if compressed_memory_obj.is_pinned:
|
|
compressed_memory_obj.unpin()
|
|
memory_objs.append(memory_obj)
|
|
|
|
self.storage_manager.batched_remove(keys, locations=[location])
|
|
|
|
self.storage_manager.batched_put(
|
|
keys=keys,
|
|
memory_objs=memory_objs,
|
|
location=location,
|
|
)
|
|
|
|
return num_tokens
|
|
|
|
@_lmcache_nvtx_annotate
|
|
def lookup_unpin(self, lookup_id: str) -> None:
|
|
if lookup_id in self.lookup_pins:
|
|
assert self.storage_manager is not None
|
|
for location, keys in self.lookup_pins.pop(lookup_id).items():
|
|
self.storage_manager.batched_unpin(keys, [location])
|
|
|
|
elif (
|
|
self.async_loading is not None
|
|
and self.event_manager.get_event_status(EventType.LOADING, lookup_id)
|
|
!= EventStatus.NOT_FOUND
|
|
):
|
|
self.cleanup_memory_objs(lookup_id)
|
|
|
|
@_lmcache_nvtx_annotate
|
|
def clear(
|
|
self,
|
|
tokens: Optional[Union[torch.Tensor, List[int]]] = None,
|
|
locations: Optional[List[str]] = None,
|
|
request_configs: Optional[dict] = None,
|
|
) -> int:
|
|
# TODO: need to clear by request_configs
|
|
if self.save_only_first_rank:
|
|
if self.metadata.is_first_rank():
|
|
num_removed = self._clear(tokens, locations, request_configs)
|
|
return num_removed
|
|
else:
|
|
return 0
|
|
return self._clear(tokens, locations, request_configs)
|
|
|
|
@_lmcache_nvtx_annotate
|
|
def get_kv_events(self) -> Iterable[CacheStoreEvent]:
|
|
if self.kv_events_enabled and (events := self.kv_events):
|
|
self.kv_events = []
|
|
return events
|
|
return []
|
|
|
|
def _clear(
|
|
self,
|
|
tokens: Optional[Union[torch.Tensor, List[int]]] = None,
|
|
locations: Optional[List[str]] = None,
|
|
request_configs: Optional[dict] = None,
|
|
) -> int:
|
|
assert self.storage_manager is not None
|
|
assert isinstance(self.storage_manager, StorageManager)
|
|
# Clear all caches if tokens is None
|
|
if tokens is None or len(tokens) == 0:
|
|
num_cleared = self.storage_manager.clear(locations)
|
|
return num_cleared
|
|
|
|
num_removed = 0
|
|
# Only remove the caches for the given tokens
|
|
for start, end, key in self.token_database.process_tokens(
|
|
tokens=tokens, request_configs=request_configs
|
|
):
|
|
assert isinstance(key, CacheEngineKey)
|
|
removed = self.storage_manager.remove(key, locations)
|
|
num_removed += removed
|
|
return num_removed
|
|
|
|
@_lmcache_nvtx_annotate
|
|
def health(
|
|
self,
|
|
) -> int:
|
|
"""
|
|
Check the health of the cache engine.
|
|
return: 0 if healthy, otherwise the error code
|
|
"""
|
|
assert self.storage_manager is not None
|
|
return 0 if self.storage_manager.memcheck() else -1
|
|
|
|
def close(self) -> None:
|
|
"""Close the cache engine and free all the resources"""
|
|
logger.info("Closing LMCacheEngine...")
|
|
|
|
if self.hidden_state_store is not None:
|
|
try:
|
|
logger.info("Closing hidden_state_store...")
|
|
self.hidden_state_store.close()
|
|
except Exception as e:
|
|
logger.error(f"Error closing hidden_state_store: {e}")
|
|
|
|
if self.lmcache_worker is not None:
|
|
try:
|
|
logger.info("Closing lmcache_worker...")
|
|
self.lmcache_worker.close()
|
|
logger.info("lmcache_worker closed successfully")
|
|
except Exception as e:
|
|
logger.error("Error closing lmcache_worker: %s", e)
|
|
|
|
try:
|
|
logger.info("Closing storage_manager...")
|
|
if self.storage_manager is not None:
|
|
self.storage_manager.close()
|
|
logger.info("storage_manager closed successfully")
|
|
except Exception as e:
|
|
logger.error("Error closing storage_manager: %s", e)
|
|
|
|
logger.info("LMCacheEngine closed.")
|
|
|
|
def _async_process_tokens_internal(
|
|
self,
|
|
tokens,
|
|
mask,
|
|
ret_mask,
|
|
**kwargs,
|
|
) -> ProcessTokensInternalResult:
|
|
"""
|
|
This function is used to get the memory objects from the event manager.
|
|
|
|
Args:
|
|
tokens: Input tokens to process
|
|
mask: Mask indicating valid token positions
|
|
ret_mask: Output mask updated with cache hit positions
|
|
**kwargs: Additional keyword arguments
|
|
"""
|
|
assert "req_id" in kwargs, "req_id is required for async loading"
|
|
request_configs = kwargs.get("request_configs")
|
|
if request_configs is not None and len(request_configs) != 0:
|
|
assert isinstance(request_configs, dict)
|
|
|
|
tot_kv_size = 0
|
|
chunks: List[ProcessedChunk] = []
|
|
future = self.event_manager.get_event_future(
|
|
EventType.LOADING, kwargs["req_id"]
|
|
)
|
|
# As mentioned in async_lookup_and_prefetch(), the future.result()
|
|
# is key data pair for each chunk in each tier. So extract the key
|
|
# and memory object pairs to memory_obj_map
|
|
try:
|
|
keyed_memory_objs = future.result()
|
|
memory_obj_map: dict[CacheEngineKey, MemoryObj] = {}
|
|
except Exception as e:
|
|
logger.error("Error popping event for request %s: %s", kwargs["req_id"], e)
|
|
return [], 0
|
|
|
|
for backend_results in keyed_memory_objs:
|
|
for key, memory_obj in backend_results:
|
|
memory_obj_map[key] = memory_obj
|
|
|
|
# TODO(Jiayi): hashing inside `process_tokens` can be skipped.
|
|
used_keys: set[CacheEngineKey] = set()
|
|
for start, end, key in self.token_database.process_tokens(
|
|
tokens=tokens,
|
|
mask=mask,
|
|
request_configs=request_configs,
|
|
):
|
|
assert isinstance(key, CacheEngineKey)
|
|
memory_obj = memory_obj_map.get(key)
|
|
if memory_obj is None:
|
|
# returned chunks are expected to be contiguous.
|
|
# break at the first missing chunk.
|
|
break
|
|
chunks.append((key, memory_obj, start, end))
|
|
tot_kv_size += memory_obj.get_size()
|
|
ret_mask[start:end] = True
|
|
used_keys.add(key)
|
|
|
|
# NOTE: free the memory objects that are not hit.
|
|
for key, mem_obj in memory_obj_map.items():
|
|
if key not in used_keys:
|
|
mem_obj.ref_count_down()
|
|
|
|
return chunks, tot_kv_size
|
|
|
|
def _process_tokens_internal(
|
|
self,
|
|
tokens,
|
|
mask,
|
|
ret_mask,
|
|
**kwargs,
|
|
) -> ProcessTokensInternalResult:
|
|
"""Process tokens and populate the reordered lists.
|
|
|
|
This function is used to process tokens and populate the reordered lists.
|
|
|
|
Args:
|
|
tokens: Input tokens to process
|
|
mask: Mask indicating valid token positions
|
|
ret_mask: Output mask updated with cache hit positions
|
|
**kwargs: Additional keyword arguments
|
|
"""
|
|
assert self.storage_manager is not None
|
|
|
|
tot_kv_size = 0
|
|
reordered_chunks: List[ProcessedChunk] = []
|
|
request_configs = kwargs.get("request_configs")
|
|
if request_configs is not None and len(request_configs) != 0:
|
|
assert isinstance(request_configs, dict)
|
|
|
|
chunk_infos = []
|
|
for start, end, key in self.token_database.process_tokens(
|
|
tokens=tokens,
|
|
mask=mask,
|
|
request_configs=request_configs,
|
|
):
|
|
assert isinstance(key, CacheEngineKey)
|
|
chunk_infos.append((key, start, end))
|
|
|
|
# block_mapping: location -> [(CacheEngineKey, start, end)]
|
|
if (
|
|
"req_id" in kwargs
|
|
and kwargs["req_id"] in self.lookup_pins
|
|
and len(self.lookup_pins[kwargs["req_id"]]) == 1
|
|
):
|
|
location = next(iter(self.lookup_pins[kwargs["req_id"]].keys()))
|
|
block_mapping = {location: chunk_infos}
|
|
else:
|
|
block_mapping = self.storage_manager.get_block_mapping(chunk_infos)
|
|
|
|
last_failed_block_start = None
|
|
for location, blocks in block_mapping.items():
|
|
keys = [key for key, _, _ in blocks]
|
|
memory_objs = self.storage_manager.batched_get(
|
|
keys=keys,
|
|
location=location,
|
|
)
|
|
|
|
used_keys: set[CacheEngineKey] = set()
|
|
for (key, start, end), memory_obj in zip(blocks, memory_objs, strict=False):
|
|
if memory_obj is None:
|
|
logger.warning(
|
|
"The cache block is in the storage, but it can't be retrieved"
|
|
)
|
|
if (
|
|
last_failed_block_start is None
|
|
# The minimum value should be taken here to ensure that
|
|
# the prefix keys are all consecutive successful.
|
|
or last_failed_block_start > start
|
|
):
|
|
last_failed_block_start = start
|
|
break
|
|
reordered_chunks.append((key, memory_obj, start, end))
|
|
tot_kv_size += memory_obj.get_size()
|
|
ret_mask[start:end] = True
|
|
used_keys.add(key)
|
|
|
|
for (key, _, _), memory_obj in zip(blocks, memory_objs, strict=False):
|
|
if memory_obj is not None and key not in used_keys:
|
|
logger.debug(
|
|
"ref_count_down for %s of %s as the previous key failed",
|
|
key,
|
|
location,
|
|
)
|
|
memory_obj.ref_count_down()
|
|
|
|
if last_failed_block_start is not None:
|
|
ret_mask[last_failed_block_start:] = False
|
|
|
|
kept_chunks: List[ProcessedChunk] = []
|
|
for key, memory_obj, start, end in reordered_chunks:
|
|
if end <= last_failed_block_start:
|
|
kept_chunks.append((key, memory_obj, start, end))
|
|
else:
|
|
tot_kv_size -= memory_obj.get_size()
|
|
# This chunk will not be used. If the engine is configured
|
|
# to remove-after-retrieve, the caller would normally call
|
|
# remove (which frees the block), but since we are dropping
|
|
# these chunks here, we must free them ourselves to avoid
|
|
# leaking PD buffer pool memory.
|
|
if self.remove_after_retrieve:
|
|
assert self.storage_manager is not None
|
|
self.storage_manager.remove(key, self.retrieve_locations)
|
|
# Sync PDBackend.remove() does NOT call ref_count_down()
|
|
# internally (unlike async PD and other backends), so we
|
|
# must call it manually. See pd_backend.py line 605.
|
|
if self._is_sync_pd_backend():
|
|
memory_obj.ref_count_down()
|
|
else:
|
|
memory_obj.ref_count_down()
|
|
reordered_chunks = kept_chunks
|
|
return reordered_chunks, tot_kv_size
|
|
|
|
def _broadcast_or_receive_memory_objs(
|
|
self,
|
|
reordered_chunks,
|
|
ret_mask,
|
|
):
|
|
"""
|
|
Handles broadcasting or receiving memory objects in a distributed environment.
|
|
|
|
This function implements the communication logic where:
|
|
- The first rank (coordinator) broadcasts memory objects and metadata to others
|
|
- Other ranks receive and reconstruct the memory objects
|
|
|
|
Parameters:
|
|
reordered_chunks: List of tuples containing [key, memory object, start, end]
|
|
ret_mask: Boolean mask indicating which positions have been processed
|
|
|
|
Side Effects:
|
|
- On first rank:
|
|
* Broadcasts chunk count and each chunk's combined metadata
|
|
* Broadcasts tensor data
|
|
- On other ranks:
|
|
* Receives chunk data and populates reordered_chunks
|
|
* Updates ret_mask to mark received positions as True
|
|
"""
|
|
if self.metadata.is_first_rank():
|
|
# Broadcast total chunk count
|
|
chunk_count = len(reordered_chunks)
|
|
self.broadcast_object_fn(chunk_count, self.metadata.first_rank)
|
|
|
|
# Reset the GPU-resident copy list. We populate it during this
|
|
# broadcast loop so the caller's subsequent batched_to_gpu can
|
|
# read from HBM instead of re-reading the same CPU L1 buffer
|
|
# over PCIe. (Declared in __init__; reassigning to a fresh list
|
|
# rather than .clear() to drop any references the caller's
|
|
# finally block missed if a previous retrieve raised.)
|
|
self._leader_gpu_substitute_objs = []
|
|
|
|
# Broadcast each chunk's data
|
|
for key, memory_obj, start, end in reordered_chunks:
|
|
# Combine (start, end) and metadata into single broadcast
|
|
metadata_dict = memory_obj.metadata.to_dict()
|
|
combined_metadata = (start, end, metadata_dict)
|
|
self.broadcast_object_fn(combined_metadata, self.metadata.first_rank)
|
|
|
|
# Broadcast tensor data
|
|
raw_tensor = memory_obj.raw_tensor
|
|
assert raw_tensor is not None
|
|
tensor_to_broadcast = raw_tensor.to(
|
|
f"{torch_device_type}:{self.metadata.worker_id}"
|
|
)
|
|
self.broadcast_fn(tensor_to_broadcast, self.metadata.first_rank)
|
|
|
|
# Keep this GPU-resident copy alive so the subsequent
|
|
# batched_to_gpu can read from HBM rather than re-reading
|
|
# the L1 buffer over PCIe.
|
|
gpu_mo = TensorMemoryObj(
|
|
raw_data=tensor_to_broadcast,
|
|
metadata=memory_obj.metadata,
|
|
parent_allocator=None,
|
|
)
|
|
self._leader_gpu_substitute_objs.append(gpu_mo)
|
|
else:
|
|
# Receive total chunk count
|
|
chunk_count = self.broadcast_object_fn(None, self.metadata.first_rank)
|
|
if chunk_count is None:
|
|
logger.warning(
|
|
"rank=%d received None chunk_count", self.metadata.worker_id
|
|
)
|
|
return
|
|
|
|
# Fill reordered_chunks with received data
|
|
for _ in range(chunk_count):
|
|
# Receive combined metadata (start, end, metadata_dict)
|
|
combined_metadata = self.broadcast_object_fn(
|
|
None, self.metadata.first_rank
|
|
)
|
|
if combined_metadata is None:
|
|
logger.warning(
|
|
"rank=%d received None combined_metadata",
|
|
self.metadata.worker_id,
|
|
)
|
|
break
|
|
start, end, metadata_dict = combined_metadata
|
|
ret_mask[start:end] = True
|
|
|
|
# Create tensor and receive data
|
|
metadata = MemoryObjMetadata.from_dict(metadata_dict)
|
|
local_rank = self.metadata.worker_id % torch_dev.device_count()
|
|
raw_tensor = torch.empty(
|
|
torch.Size([metadata.get_size()]),
|
|
dtype=torch.uint8,
|
|
device=f"{torch_device_type}:{local_rank}",
|
|
)
|
|
self.broadcast_fn(raw_tensor, self.metadata.first_rank)
|
|
|
|
# Create temporary memory object (key not needed for other ranks)
|
|
memory_obj = TensorMemoryObj(
|
|
raw_data=raw_tensor, metadata=metadata, parent_allocator=None
|
|
)
|
|
reordered_chunks.append((None, memory_obj, start, end))
|
|
|
|
def _is_passive(self):
|
|
"""
|
|
A 'passive' CacheEngine means that the node itself will not store/retrieve
|
|
the data directly, but from the "active" worker (i.e., rank 0 in MLA)
|
|
"""
|
|
return self.save_only_first_rank and not self.metadata.is_first_rank()
|
|
|
|
def _is_sync_pd_backend(self) -> bool:
|
|
"""Check if the PD backend is the sync variant.
|
|
|
|
:return: True when PD is enabled and ``pd_backend_mode`` is ``"sync"``.
|
|
:rtype: bool
|
|
"""
|
|
return self.config.enable_pd and self.config.pd_backend_mode == "sync"
|
|
|
|
def _get_slot_mapping_list(
|
|
self,
|
|
slot_mapping: Optional[Union[torch.Tensor, List[int]]],
|
|
) -> Optional[List[int]]:
|
|
"""
|
|
Convert slot_mapping to list if it's a tensor, otherwise return as is.
|
|
|
|
:param slot_mapping: The slot_mapping to convert,
|
|
can be a torch.Tensor or List[int], or None
|
|
:type slot_mapping: Optional[Union[torch.Tensor, List[int]]]
|
|
:return: The slot_mapping as a List[int], or None if input is None
|
|
:rtype: Optional[List[int]]
|
|
"""
|
|
if slot_mapping is None:
|
|
return None
|
|
if isinstance(slot_mapping, torch.Tensor):
|
|
return slot_mapping.tolist()
|
|
# At this point, slot_mapping must be List[int]
|
|
return slot_mapping
|
|
|
|
def _log_kvcache_for_check(
|
|
self,
|
|
operation: str,
|
|
kwargs: dict,
|
|
token_count: int,
|
|
require_req_id: bool = False,
|
|
) -> None:
|
|
"""
|
|
Helper method to log KVCache Check information.
|
|
|
|
This method centralizes the KVCache Check logging logic that was
|
|
duplicated in multiple methods.
|
|
|
|
Args:
|
|
operation: The operation being performed (e.g., "Store", "retrieve")
|
|
kwargs: The keyword arguments containing slot_mapping and req_id
|
|
token_count: The number of tokens involved in the operation
|
|
require_req_id: Whether req_id must be present (default: False)
|
|
"""
|
|
if not self.kvcache_check_log_enabled:
|
|
return
|
|
|
|
slot_mapping = kwargs.get("slot_mapping")
|
|
if slot_mapping is None:
|
|
return
|
|
|
|
if require_req_id:
|
|
req_id = kwargs.get("req_id")
|
|
if req_id is None:
|
|
return
|
|
else:
|
|
req_id = kwargs.get("req_id", "unspecified")
|
|
|
|
# Convert slot_mapping to list if it's a tensor
|
|
slot_mapping_list = self._get_slot_mapping_list(slot_mapping)
|
|
# slot_mapping_list should not be None when slot_mapping is not None
|
|
assert slot_mapping_list is not None
|
|
|
|
logger.info(
|
|
"[KVCache Check] %s request %s, tokens=%d, slot_mapping: %s",
|
|
operation,
|
|
req_id,
|
|
token_count,
|
|
compress_slot_mapping(slot_mapping_list),
|
|
)
|
|
|
|
|
|
class LMCacheEngineBuilder:
|
|
_instances: Dict[str, LMCacheEngine] = {}
|
|
_cfgs: Dict[str, LMCacheEngineConfig] = {}
|
|
_metadatas: Dict[str, LMCacheMetadata] = {}
|
|
_stat_loggers: Dict[str, LMCacheStatsLogger] = {}
|
|
|
|
# TODO(Jiayi): Please remove this helper function in the future.
|
|
# Currently, it's only used for testing.
|
|
@staticmethod
|
|
def _Create_memory_allocator(
|
|
config: LMCacheEngineConfig,
|
|
metadata: LMCacheMetadata,
|
|
numa_mapping: Optional[NUMAMapping] = None,
|
|
) -> MemoryAllocatorInterface:
|
|
# NOTE: should remove this function after fixing the unit tests:
|
|
# raise RuntimeError("_Create_memory_allocator is deprecated!")
|
|
extra_config = config.extra_config
|
|
enable_nixl_storage = extra_config is not None and extra_config.get(
|
|
"enable_nixl_storage"
|
|
)
|
|
|
|
if enable_nixl_storage:
|
|
# TODO(Jiayi): weird to import from transfer utils.
|
|
# First Party
|
|
from lmcache.v1.transfer_channel.transfer_utils import (
|
|
get_correct_device,
|
|
)
|
|
|
|
corrected_device = get_correct_device(
|
|
config.nixl_buffer_device,
|
|
metadata.worker_id,
|
|
)
|
|
|
|
buffer = torch.empty(
|
|
config.nixl_buffer_size,
|
|
dtype=torch.uint8,
|
|
device=corrected_device,
|
|
)
|
|
|
|
if corrected_device == "cpu":
|
|
if not current_device_spec.pin_memory(
|
|
buffer.data_ptr(), config.nixl_buffer_size
|
|
):
|
|
raise RuntimeError("Failed to pin NIXL CPU buffer for DMA access")
|
|
else:
|
|
logger.info("Setting device to %s", corrected_device)
|
|
torch_dev.set_device(corrected_device)
|
|
|
|
return PagedTensorMemoryAllocator(
|
|
buffer,
|
|
[torch.Size(metadata.kv_shape)],
|
|
[metadata.kv_dtype],
|
|
MemoryFormat.KV_2LTD,
|
|
)
|
|
|
|
if config.gds_path is not None:
|
|
assert config.gds_buffer_size is not None
|
|
return CuFileMemoryAllocator(config.gds_buffer_size * 1024**2)
|
|
|
|
max_local_cpu_size = config.max_local_cpu_size
|
|
# save_only_first_rank only works when use mla
|
|
save_only_first_rank = (
|
|
config.get_extra_config_value("save_only_first_rank", metadata.use_mla)
|
|
and metadata.use_mla
|
|
)
|
|
if save_only_first_rank and metadata.is_first_rank():
|
|
# Only the first rank will save the cache,
|
|
# so we need to set it lager than other ranks
|
|
first_rank_max_local_cpu_size = (
|
|
config.extra_config.get(
|
|
"first_rank_max_local_cpu_size", max_local_cpu_size
|
|
)
|
|
if config.extra_config
|
|
else max_local_cpu_size
|
|
)
|
|
return MixedMemoryAllocator(
|
|
int(first_rank_max_local_cpu_size * 1024**3),
|
|
numa_mapping=numa_mapping,
|
|
)
|
|
return MixedMemoryAllocator(
|
|
int(max_local_cpu_size * 1024**3),
|
|
numa_mapping=numa_mapping,
|
|
)
|
|
|
|
@staticmethod
|
|
def _Create_token_database(
|
|
config: LMCacheEngineConfig,
|
|
metadata: LMCacheMetadata,
|
|
) -> TokenDatabase:
|
|
if config.enable_blending:
|
|
return SegmentTokenDatabase(config, metadata)
|
|
return ChunkedTokenDatabase(config, metadata)
|
|
|
|
@classmethod
|
|
def get_or_create(
|
|
cls,
|
|
instance_id: str,
|
|
config: LMCacheEngineConfig,
|
|
metadata: LMCacheMetadata,
|
|
gpu_connector: Optional[GPUConnectorInterface],
|
|
broadcast_fn: Callable[[torch.Tensor, int], None],
|
|
broadcast_object_fn: Callable[[Any, int], Any],
|
|
) -> LMCacheEngine:
|
|
"""
|
|
Builds a new LMCacheEngine instance if it doesn't already exist for the
|
|
given ID.
|
|
|
|
raises: ValueError if the instance already exists with a different
|
|
configuration.
|
|
"""
|
|
logger.info("Creating LMCacheEngine instance %s", instance_id)
|
|
if instance_id not in cls._instances:
|
|
numa_mapping = NUMADetector.get_numa_mapping(config)
|
|
logger.info("NUMA mapping for instance %s: %s", instance_id, numa_mapping)
|
|
token_database = cls._Create_token_database(config, metadata)
|
|
stat_logger = LMCacheStatsLogger(
|
|
metadata,
|
|
log_interval=10,
|
|
config=config,
|
|
)
|
|
|
|
engine = LMCacheEngine(
|
|
config,
|
|
metadata,
|
|
token_database,
|
|
gpu_connector,
|
|
broadcast_fn,
|
|
broadcast_object_fn,
|
|
)
|
|
|
|
cls._instances[instance_id] = engine
|
|
cls._cfgs[instance_id] = config
|
|
cls._metadatas[instance_id] = metadata
|
|
cls._stat_loggers[instance_id] = stat_logger
|
|
return engine
|
|
else:
|
|
if (
|
|
cls._cfgs[instance_id] != config
|
|
or cls._metadatas[instance_id] != metadata
|
|
):
|
|
raise ValueError(
|
|
f"Instance {instance_id} already exists with a different "
|
|
f"configuration or metadata."
|
|
)
|
|
return cls._instances[instance_id]
|
|
|
|
@classmethod
|
|
def get(cls, instance_id: str) -> Optional[LMCacheEngine]:
|
|
"""Returns the LMCacheEngine instance associated with the instance ID,
|
|
or None if not found."""
|
|
return cls._instances.get(instance_id)
|
|
|
|
@classmethod
|
|
def destroy(cls, instance_id: str) -> None:
|
|
"""Close and delete the LMCacheEngine instance by the instance ID"""
|
|
# TODO: unit test for this
|
|
logger.info("Destroying LMCacheEngine instance: %s", instance_id)
|
|
|
|
if instance_id in cls._instances:
|
|
stat_logger = cls._stat_loggers[instance_id]
|
|
try:
|
|
logger.info("Shutting down stats logger...")
|
|
stat_logger.shutdown()
|
|
logger.info("Stats logger shut down successfully")
|
|
except Exception as e:
|
|
logger.error("Error shutting down stats logger: %s", e)
|
|
|
|
engine = cls._instances[instance_id]
|
|
try:
|
|
logger.info("Closing cache engine...")
|
|
engine.close()
|
|
logger.info("Cache engine closed successfully")
|
|
except Exception as e:
|
|
logger.error("Error closing cache engine: %s", e)
|
|
|
|
try:
|
|
logger.info("Cleaning up instance dictionaries...")
|
|
cls._instances.pop(instance_id, None)
|
|
cls._cfgs.pop(instance_id, None)
|
|
cls._metadatas.pop(instance_id, None)
|
|
cls._stat_loggers.pop(instance_id, None)
|
|
logger.info("Instance dictionaries cleaned up")
|
|
except Exception as e:
|
|
logger.error("Error cleaning up instances: %s", e)
|
|
|
|
try:
|
|
logger.info("Destroying stats monitor...")
|
|
LMCStatsMonitor.DestroyInstance()
|
|
logger.info("Stats monitor destroyed successfully")
|
|
except Exception as e:
|
|
logger.error("Error destroying stats monitor: %s", e)
|
|
|
|
logger.info("LMCacheEngine instance %s destroyed", instance_id)
|
|
else:
|
|
logger.warning("Instance %s not found for destruction", instance_id)
|