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

782 lines
23 KiB
Python

# SPDX-License-Identifier: Apache-2.0
# Future
from __future__ import annotations
# Standard
from dataclasses import dataclass, field
from enum import Enum
from typing import TYPE_CHECKING, Any, Callable, List, Optional, Tuple, TypeVar, Union
import asyncio
import functools
import hashlib
import inspect
import re
import threading
import traceback
import warnings
try:
# Third Party
from nvtx import annotate # type: ignore
except ImportError:
def annotate(*args, **kwargs):
"""Dummy decorator when nvtx is not available."""
def decorator(func):
return func
return decorator
# Third Party
import torch
# First Party
from lmcache.logging import init_logger
if TYPE_CHECKING:
# First Party
from lmcache.v1.memory_management import MemoryFormat
logger = init_logger(__name__)
# Type definition
KVCache = Tuple[Tuple[torch.Tensor, torch.Tensor], ...]
# Device utility functions
def check_interprocess_event_support() -> None:
"""Check if the current backend supports interprocess Events.
This function checks if torch_dev.Event exists and exposes the
interprocess parameter, which is required for multiprocess IPC.
Raises:
RuntimeError: If the backend does not support interprocess Events
or if the Event class doesn't expose the interprocess parameter.
"""
# First Party
from lmcache import torch_dev, torch_device_type
if not hasattr(torch_dev, "Event"):
raise RuntimeError(
f"Backend '{torch_device_type}' does not support "
"interprocess Events (torch_dev.Event not available). "
"Multiprocess IPC requires CUDA."
)
event_cls = torch_dev.Event
def has_interprocess_parameter(obj) -> bool:
try:
sig = inspect.signature(obj)
except (TypeError, ValueError):
return False
return "interprocess" in sig.parameters
if not (
has_interprocess_parameter(event_cls)
or has_interprocess_parameter(event_cls.__new__)
):
raise RuntimeError(
f"Backend '{torch_device_type}' does not support "
"interprocess=True parameter for Events. "
"Multiprocess IPC requires CUDA."
)
if not hasattr(torch_dev.Event, "from_ipc_handle"):
raise RuntimeError(
f"Backend '{torch_device_type}' does not support IPC event "
"handles (Event.from_ipc_handle not available). "
"Multiprocess IPC requires CUDA."
)
# Math utility functions
def cdiv(a: int, b: int) -> int:
"""Ceiling division."""
return -(a // -b)
def round_down(x: int, y: int) -> int:
"""Round down x to the nearest multiple of y."""
return (x // y) * y
def compress_slot_mapping(slots: list[int]) -> list[Union[int, list[int]]]:
"""Compress a list of slot indices into ranges while preserving order.
Consecutive slots (3 or more) are represented as [start, end] ranges.
Single elements or pairs are kept as individual integers.
For example: [1, 2, 3, 4, 5, 9, 10, 11, 12] -> [[1, 5], [9, 12]]
Order-preserving: [5, 3, 1, 2, 4] -> [5, 3, 1, 2, 4] (no compression)
Mixed: [1, 2, 3, 4, 5, 7, 8] -> [[1, 5], 7, 8]
Args:
slots: List of slot indices (order is preserved).
Returns:
List of integers or [start, end] ranges. Ranges are only used
when there are 3 or more consecutive elements.
"""
if not slots:
return []
result: list[Union[int, list[int]]] = []
range_start = slots[0]
range_end = slots[0]
for slot in slots[1:]:
if slot == range_end + 1:
# Extend current range
range_end = slot
else:
# Close current range and start a new one
_append_range_or_elements(result, range_start, range_end)
range_start = slot
range_end = slot
# Append the last range
_append_range_or_elements(result, range_start, range_end)
return result
def _append_range_or_elements(
result: list[Union[int, list[int]]], start: int, end: int
) -> None:
"""Helper to append range or individual elements based on length.
Only compresses to [start, end] if there are 3 or more consecutive elements.
"""
length = end - start + 1
if length >= 3:
# Compress: 3 or more consecutive elements
result.append([start, end])
else:
# Don't compress: 1 or 2 elements
for i in range(start, end + 1):
result.append(i)
def decompress_slot_mapping(compressed: list[Union[int, list[int]]]) -> list[int]:
"""Decompress slot ranges back to a list of slot indices.
Inverse operation of compress_slot_mapping.
For example: [[1, 5], [9, 12]] -> [1, 2, 3, 4, 5, 9, 10, 11, 12]
Mixed: [[1, 5], 7, 8] -> [1, 2, 3, 4, 5, 7, 8]
Args:
compressed: List of integers or [start, end] ranges from
compress_slot_mapping.
Returns:
List of slot indices.
"""
slots: list[int] = []
for item in compressed:
if isinstance(item, list):
start, end = item
slots.extend(range(start, end + 1))
else:
slots.append(item)
return slots
def parse_mixed_slot_mapping(
slot_mapping_str: str,
) -> Tuple[Optional[list[int]], Optional[dict]]:
"""Parse mixed format slot_mapping string.
Supports two formats:
1. Single numbers: "1,2,3,17,19"
2. Range format: "[9,12]" (represents 9,10,11,12)
3. Mixed format: "1,2,3,[9,12],17,19" (represents 1,2,3,9,10,11,12,17,19)
Args:
slot_mapping_str: String containing slot mapping information.
Returns:
Tuple of (slot_indices list, error dict).
If error dict is not None, slot_indices will be None.
"""
try:
# Remove all whitespace
clean_str = "".join(slot_mapping_str.split())
# Split by comma but preserve range expressions
parts = []
buffer = ""
in_brackets = False
for char in clean_str:
if char == "[":
if in_brackets:
raise ValueError("Nested brackets not allowed")
in_brackets = True
buffer += char
elif char == "]":
if not in_brackets:
raise ValueError("Unmatched closing bracket")
in_brackets = False
buffer += char
parts.append(buffer)
buffer = ""
elif char == "," and not in_brackets:
if buffer:
parts.append(buffer)
buffer = ""
else:
buffer += char
# Add the last part if any
if buffer:
parts.append(buffer)
if in_brackets:
raise ValueError("Unclosed bracket")
# Parse each part
compressed: list[Union[int, list[int]]] = []
for part in parts:
part = part.strip()
if not part:
continue
# Check if it's a range format [start,end]
range_match = re.match(r"^\[(\d+),(\d+)\]$", part)
if range_match:
start = int(range_match.group(1))
end = int(range_match.group(2))
if start > end:
raise ValueError(f"Range start {start} must be <= end {end}")
compressed.append([start, end])
else:
# Single number
try:
num = int(part)
compressed.append(num)
except ValueError as ve:
raise ValueError(f"Invalid slot format: '{part}'") from ve
# Decompress to individual slot indices
slot_indices = decompress_slot_mapping(compressed)
return slot_indices, None
except Exception as e:
return None, {
"error": "Invalid slot_mapping format",
"message": (
f"slot_mapping must be comma-separated integers "
f"or ranges like [start,end]: {str(e)}"
),
}
try:
# First Party
from lmcache import _version # type: ignore[attr-defined]
VERSION = getattr(_version, "__version__", "")
COMMIT_ID = getattr(_version, "__commit_id__", "")
except ImportError:
VERSION = ""
COMMIT_ID = ""
def get_version():
"""Return a human-readable version string.
Returns:
``"<version>-<commit-id>"``, with ``"NA"`` substituted when the
package version or commit id is not available (e.g. when running
from a source checkout without a tag).
"""
version_display = VERSION if VERSION else "NA"
commit_id_display = COMMIT_ID if COMMIT_ID else "NA"
return f"{version_display}-{commit_id_display}"
def convert_tokens_to_list(
tokens: Optional[Union[torch.Tensor, list[int]]], token_start: int, token_end: int
) -> List[int]:
"""Convert tokens to a list.
token_start and token_end delineate tokens to convert"""
if tokens is None:
return []
return (
tokens.tolist()[token_start : token_end + 1]
if isinstance(tokens, torch.Tensor)
else tokens[token_start : token_end + 1]
)
@dataclass
class DiskCacheMetadata:
path: str
size: int # in bytes
shape: Optional[torch.Size] = None
dtype: Optional[torch.dtype] = None
cached_positions: Optional[torch.Tensor] = None
fmt: Optional[MemoryFormat] = None
pin_count: int = 0
def pin(self) -> bool:
self.pin_count += 1
return True
def unpin(self) -> bool:
self.pin_count -= 1
return True
@property
def is_pinned(self) -> bool:
return self.pin_count > 0
@property
def can_evict(self) -> bool:
"""
Check if the disk cache can be evicted.
"""
return not self.is_pinned
TORCH_DTYPE_TO_STR_DTYPE = {
torch.half: "half",
torch.float16: "half",
torch.bfloat16: "bfloat16",
torch.float: "float",
torch.float32: "float",
torch.double: "double",
torch.float64: "double",
torch.int8: "int8",
torch.uint8: "uint8",
torch.int16: "int16",
torch.int32: "int32",
torch.int64: "int64",
torch.bool: "bool",
}
# FP8 variants (PyTorch ≥2.1)
if hasattr(torch, "float8_e4m3fn"):
TORCH_DTYPE_TO_STR_DTYPE[torch.float8_e4m3fn] = "fp8_e4m3fn"
if hasattr(torch, "float8_e4m3fnuz"):
TORCH_DTYPE_TO_STR_DTYPE[torch.float8_e4m3fnuz] = "fp8_e4m3fnuz"
if hasattr(torch, "float8_e5m2"):
TORCH_DTYPE_TO_STR_DTYPE[torch.float8_e5m2] = "fp8_e5m2"
if hasattr(torch, "float8_e5m2fnuz"):
TORCH_DTYPE_TO_STR_DTYPE[torch.float8_e5m2fnuz] = "fp8_e5m2fnuz"
STR_DTYPE_TO_TORCH_DTYPE = {v: k for k, v in TORCH_DTYPE_TO_STR_DTYPE.items()}
def parse_cache_key(key_str: str) -> Union[CacheEngineKey, LayerCacheEngineKey]:
"""Parse a key string into either a CacheEngineKey or LayerCacheEngineKey.
Args:
key_str: String in format:
CacheEngineKey:
model_name@world_size@worker_id@chunk_hash@dtype[@tag%value...]
LayerCacheEngineKey:
model_name@world_size@worker_id@chunk_hash@dtype@layer_id[@tag%value...]
Returns:
CacheEngineKey if no layer_id, LayerCacheEngineKey if valid layer_id
"""
parts = key_str.strip().split("@")
# parts[0]=model, [1]=world_size, [2]=worker_id, [3]=chunk_hash, [4]=dtype
# parts[5]=layer_id OR tag%value
# If parts[5] exists and is a digit (not containing '%'), it's a LayerCacheEngineKey
if len(parts) >= 6 and parts[5].isdigit():
return LayerCacheEngineKey.from_string(key_str)
return CacheEngineKey.from_string(key_str)
@dataclass(slots=True)
class CacheEngineKey:
model_name: str
world_size: int
worker_id: int
chunk_hash: int
dtype: torch.dtype
request_configs: Optional[dict] = field(default_factory=dict)
tags: Optional[tuple] = field(init=False, default=None)
_dtype_str: str = field(init=False, default="")
def __post_init__(self):
tag_list = None
if self.request_configs is not None:
for k, v in self.request_configs.items():
if k.startswith("lmcache.tag."):
if tag_list is None:
tag_list = []
tag_list.append((k[len("lmcache.tag.") :], v))
if self.dtype not in TORCH_DTYPE_TO_STR_DTYPE:
raise ValueError(f"Unsupported dtype in CacheEngineKey: {self.dtype}")
self._dtype_str = TORCH_DTYPE_TO_STR_DTYPE[self.dtype]
# use tuple to save tags
self.tags = None if tag_list is None else tuple(tag_list)
def __hash__(self):
return hash(
(
self.model_name,
self.world_size,
self.worker_id,
self.chunk_hash,
self._dtype_str,
self.tags,
)
)
def __eq__(self, other):
if type(self) is type(other):
return (
self.model_name == other.model_name
and self.world_size == other.world_size
and self.worker_id == other.worker_id
and self.chunk_hash == other.chunk_hash
and self.dtype == other.dtype
and self.tags == other.tags
)
return False
def to_string(self):
s = (
f"{self.model_name}@{self.world_size}"
f"@{self.worker_id}@{self.chunk_hash_hex}@{self._dtype_str}"
)
if self.tags is not None and len(self.tags) != 0:
tags = [f"{k}%{v}" for k, v in self.tags]
s += "@" + "@".join(tags)
return s
def split_layers(self, num_layers: int) -> List["LayerCacheEngineKey"]:
"""Split the key into multiple keys for each layer"""
keys = []
for layer_id in range(num_layers):
keys.append(
LayerCacheEngineKey(
model_name=self.model_name,
world_size=self.world_size,
worker_id=self.worker_id,
chunk_hash=self.chunk_hash,
dtype=self.dtype,
request_configs=self.request_configs,
layer_id=layer_id,
)
)
return keys
def get_first_layer(self) -> "LayerCacheEngineKey":
"""Return the key for the first layer"""
key = LayerCacheEngineKey(
model_name=self.model_name,
world_size=self.world_size,
worker_id=self.worker_id,
chunk_hash=self.chunk_hash,
dtype=self.dtype,
request_configs=self.request_configs,
layer_id=0,
)
return key
@staticmethod
def from_string(s):
parts = s.split("@")
if len(parts) < 5:
raise ValueError(f"Invalid key string: {s}")
request_configs = None
if len(parts) >= 6:
request_configs = {}
for kv in parts[5:]:
kvs = kv.split("%", 1)
if len(kvs) != 2:
raise ValueError(f"Invalid key string: {s}")
request_configs["lmcache.tag." + kvs[0]] = kvs[1]
return CacheEngineKey(
model_name=parts[0],
world_size=int(parts[1]),
worker_id=int(parts[2]),
chunk_hash=int(parts[3], 16),
dtype=STR_DTYPE_TO_TORCH_DTYPE[parts[4]],
request_configs=request_configs,
)
def to_dict(self):
# Note(Kuntai): this is used for serializing CacheEngineKey via msgpack.
msg = {
"__type__": "CacheEngineKey",
"model_name": self.model_name,
"world_size": self.world_size,
"worker_id": self.worker_id,
"chunk_hash": self.chunk_hash,
"dtype": self._dtype_str,
}
if self.request_configs is not None and len(self.request_configs) != 0:
msg["request_configs"] = [
f"{k}%{v}" for k, v in self.request_configs.items()
]
return msg
@staticmethod
def from_dict(d):
request_configs = None
if request_configs_list := d.get("request_configs"):
request_configs = {}
for kv in request_configs_list:
kvs = kv.split("%", 1)
if len(kvs) != 2:
raise ValueError(f"Invalid key dict: {d}")
request_configs[kvs[0]] = kvs[1]
return CacheEngineKey(
model_name=d["model_name"],
world_size=d["world_size"],
worker_id=d["worker_id"],
chunk_hash=d["chunk_hash"],
dtype=STR_DTYPE_TO_TORCH_DTYPE[d["dtype"]],
request_configs=request_configs,
)
def with_new_worker_id(self, new_worker_id: int) -> "CacheEngineKey":
# Reconstruct the cache engine key with new worker id
return CacheEngineKey(
self.model_name,
world_size=self.world_size,
worker_id=new_worker_id,
chunk_hash=self.chunk_hash,
dtype=self.dtype,
request_configs=self.request_configs,
)
@property
def chunk_hash_hex(self) -> str:
if isinstance(self.chunk_hash, bytes):
return self.chunk_hash.hex()
return f"{self.chunk_hash:x}"
@dataclass(slots=True)
class LayerCacheEngineKey(CacheEngineKey):
"""A key for the layer cache engine"""
layer_id: int = 0
def __hash__(self):
return hash(
(
self.model_name,
self.world_size,
self.worker_id,
self.chunk_hash,
self._dtype_str,
self.tags,
self.layer_id,
)
)
def __eq__(self, other):
if super(LayerCacheEngineKey, self).__eq__(other):
return self.layer_id == other.layer_id
return False
def to_string(self):
s = (
f"{self.model_name}@{self.world_size}"
f"@{self.worker_id}@{self.chunk_hash_hex}@{self._dtype_str}@{self.layer_id}"
)
if self.tags is not None and len(self.tags) != 0:
tags = [f"{k}%{v}" for k, v in self.tags]
s += "@" + "@".join(tags)
return s
def split_layers(self, num_layers: int) -> List["LayerCacheEngineKey"]:
"""Split the key into multiple keys for each layer"""
keys = []
for layer_id in range(num_layers):
keys.append(
LayerCacheEngineKey(
model_name=self.model_name,
world_size=self.world_size,
worker_id=self.worker_id,
chunk_hash=self.chunk_hash,
dtype=self.dtype,
request_configs=self.request_configs,
layer_id=layer_id,
)
)
return keys
@staticmethod
def from_string(s):
parts = s.split("@")
if len(parts) < 6:
raise ValueError(f"Invalid key string: {s}")
request_configs = None
if len(parts) >= 7:
request_configs = {}
for kv in parts[6:]:
kvs = kv.split("%", 1)
if len(kvs) != 2:
raise ValueError(f"Invalid key string: {s}")
request_configs["lmcache.tag." + kvs[0]] = kvs[1]
return LayerCacheEngineKey(
model_name=parts[0],
world_size=int(parts[1]),
worker_id=int(parts[2]),
chunk_hash=int(parts[3], 16),
dtype=STR_DTYPE_TO_TORCH_DTYPE[parts[4]],
request_configs=request_configs,
layer_id=int(parts[5]),
)
@dataclass
class CacheStoreEvent:
block_hashes: list[int]
parent_block_hash: int | None
token_ids: list[int]
block_size: int
# Deprecated, use lora_name instead
# Retained for backwards compatibility
# Remove when vLLM removes it from BlockStored
lora_id: int | None
medium: str | None
lora_name: str | None
class EngineType(Enum):
VLLM = "vllm"
SGLANG = "sglang"
TRTLLM = "trtllm"
MOCK = "mock"
##### NVTX annotation #####
_NVTX_COLORS = ["green", "blue", "purple", "rapids"]
def _get_color_for_nvtx(name):
m = hashlib.sha256()
m.update(name.encode())
hash_value = int(m.hexdigest(), 16)
idx = hash_value % len(_NVTX_COLORS)
return _NVTX_COLORS[idx]
def _lmcache_nvtx_annotate(func, domain="lmcache"):
"""Decorator for applying nvtx annotations to methods in lmcache."""
return annotate(
message=func.__qualname__,
color=_get_color_for_nvtx(func.__qualname__),
domain=domain,
)(func)
##### Observability Threading related #####
_shared_observability_lock = threading.Lock()
def thread_safe(func):
"""Wrap a callable with the shared observability lock.
Args:
func: Callable to execute while holding the lock.
Returns:
A wrapper that serializes calls to ``func`` using the shared lock.
"""
def wrapper(*args, **kwargs):
with _shared_observability_lock:
result = func(*args, **kwargs)
return result
return wrapper
##### Deprecation #####
F = TypeVar("F", bound=Callable[..., Any])
def lmcache_deprecate(reason: str) -> Callable[[F], F]:
"""Mark a function or method as deprecated.
Calling the wrapped callable emits a ``DeprecationWarning`` and logs a
warning the first time it is invoked, including the supplied reason.
Args:
reason: Human-readable explanation of why the callable is deprecated
and, ideally, what to use instead.
Returns:
A decorator that wraps the target callable while preserving its
signature and metadata.
"""
def decorator(func: F) -> F:
warned = False
@functools.wraps(func)
def wrapper(*args, **kwargs):
nonlocal warned
if not warned:
message = f"{func.__qualname__} is deprecated: {reason}"
warnings.warn(message, DeprecationWarning, stacklevel=2)
logger.warning(message)
warned = True
return func(*args, **kwargs)
return wrapper # type: ignore[return-value]
return decorator
#### Thread/asyncio-related utilities ####
def handle_thread_exception(args):
"""Handle an uncaught exception reported by ``threading``.
Args:
args: Thread exception information provided by ``threading``.
"""
logger.error(
f"Thread {args.thread.name} crashed: {args.exc_type.__name__}: {args.exc_value}"
)
def start_loop_in_thread_with_exceptions(loop: asyncio.AbstractEventLoop):
"""Run an event loop forever with an exception handler.
Args:
loop: Event loop to bind to the current thread and run.
"""
# The loop must be set in the *same* thread where it runs.
asyncio.set_event_loop(loop)
# Catch unhandled exceptions from callbacks/tasks in this loop:
def loop_excepthook(loop, context):
msg = context.get("message", "Unhandled exception in event loop")
exc = context.get("exception")
logger.error("[asyncio] %s", msg)
if exc:
traceback.print_exception(type(exc), exc, exc.__traceback__)
loop.set_exception_handler(loop_excepthook)
loop.run_forever()
#### Placeholder for dpsk broadcast functionality ####
def mock_up_broadcast_fn(t: torch.Tensor, i: int) -> None:
raise NotImplementedError("Calling invalid broadcast function")
def mock_up_broadcast_object_fn(a: Any, i: int) -> None:
raise NotImplementedError("Calling invalid broadcast object function")