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

405 lines
13 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""
Defines the data structures that will be used by the
distributed storage manager public functions
Could be implemented by native code in the future
"""
# Standard
from dataclasses import dataclass
from typing import TYPE_CHECKING
import enum
# Third Party
import torch
# First Party
from lmcache.logging import init_logger
if TYPE_CHECKING:
# First Party
from lmcache.v1.multiprocess.custom_types import IPCCacheServerKey
logger = init_logger(__name__)
class TrimPolicy(enum.Enum):
"""How to pick the retained subset of found keys for a prefetch.
PREFIX retains the longest contiguous run from index 0; SEGMENTED_PREFIX
keeps the keys that loaded when an L2 hit failed to load into L1 mid-prefix
(gaps and all); SPARSE retains every found key for an intentional scatter.
"""
PREFIX = enum.auto()
SEGMENTED_PREFIX = enum.auto()
SPARSE = enum.auto()
class PrefetchMode(enum.Enum):
"""The intent of a prefetch request.
``LOOKUP`` -- prefetch for an imminent reader: loaded keys are pinned for
the requesting workers, and whether they persist or are dropped after use
follows the configured prefetch policy.
``WARM`` -- speculative pre-warm with no imminent reader: loaded keys are
retained and left unpinned (immediately resident and evictable), so a later
lookup can hit them.
"""
LOOKUP = enum.auto()
WARM = enum.auto()
@dataclass(frozen=True)
class ObjectKey:
"""
The unique identifier for an object in the distributed storage manager
"""
chunk_hash: bytes
""" Content hash of this particular chunk """
model_name: str
""" Name of the model this chunk belongs to.
Invariant: must not contain ``@``. The L2 adapters use ``@`` as the
field separator in serialized keys/filenames and rely on this
invariant for unambiguous parsing. HuggingFace model IDs use
alphanumerics + ``/-_.`` so this rejects nothing that appears in
practice.
"""
kv_rank: int
""" The rank that uniquely identifies the slice of the KV cache """
object_group_id: int = 0
""" Index of the object group this chunk belongs to. """
cache_salt: str = ""
""" Per-user isolation salt. Same content from different users with
different cache_salt values produces different ObjectKeys, giving
strict per-user cache isolation. Defaults to empty string, in which
case serialized keys and filenames match the pre-cache_salt shape
(no trailing salt field) — no migration is needed for un-salted
deployments.
Invariant: must not contain ``@``, ``/``, ``\\``, or NUL. The L2
adapters use ``@`` as the field separator; ``/`` and ``\\`` are
filesystem path separators (FS adapter embeds the salt into
filenames); NUL terminates C strings (C++ connector). Max length
128 to stay well within ``NAME_MAX`` (255) after the model, rank,
hash, and extension are added.
"""
_SALT_FORBIDDEN_CHARS = frozenset("@/\\\x00")
_SALT_MAX_LEN = 128
def __post_init__(self) -> None:
if "@" in self.model_name:
raise ValueError(
f"model_name must not contain '@' (got {self.model_name!r})"
)
if self.object_group_id < 0:
raise ValueError(
f"object_group_id must be >= 0 (got {self.object_group_id})"
)
bad = self._SALT_FORBIDDEN_CHARS & set(self.cache_salt)
if bad:
raise ValueError(
f"cache_salt must not contain {bad!r} (got {self.cache_salt!r})"
)
if len(self.cache_salt) > self._SALT_MAX_LEN:
raise ValueError(
f"cache_salt exceeds max length {self._SALT_MAX_LEN} "
f"(got {len(self.cache_salt)})"
)
def to_encoded_object_key(self) -> "EncodedObjectKey":
"""Return the JSON-safe :class:`EncodedObjectKey` projection."""
return EncodedObjectKey(
chunk_hash_hex=self.chunk_hash.hex(),
model_name=self.model_name,
kv_rank=self.kv_rank,
object_group_id=self.object_group_id,
cache_salt=self.cache_salt,
)
@staticmethod
def IntHash2Bytes(chunk_hash: int) -> bytes:
# NOTE: this is only used by tests
return chunk_hash.to_bytes(4, byteorder="big")
@staticmethod
def Bytes2IntHash(chunk_hash: bytes) -> int:
# NOTE: this is only used by tests
return int.from_bytes(chunk_hash, byteorder="big") & ((1 << 64) - 1)
@staticmethod
def ComputeKVRank(
world_size: int,
global_rank: int,
local_world_size: int,
local_rank: int,
) -> int:
"""
Compute the kv_rank from world_size and worker_id
Args:
world_size (int): The total number of workers (include TP + PP)
global_rank (int): The global worker id (from 0 to world_size - 1)
local_world_size (int): The local world size (for local node),
should NOT be greater than 8
local_rank (int): The local world rank (for local node)
Returns:
The special KV rank (bitmap) used by the objectkey
Example:
In the case of TP=4, PP=2, the TP worker 1 on node 1 has:
- world_size = 8
- global_rank = 5
- local_world_size = 4
- local_rank = 1
The output KV rank is the bitmap:
+--head--+
|00000000|
|00000000|
|00000000|
|00000000| layers
|00001100|
|00001100|
|00001100|
|00001100|
+--------+
"""
# TODO(ApostaC): in the long run, we want to have the above bitmap based
# representation for asymmetric parallelism (e.g., sharing across different
# TP/PP settings).
# For now, let's have a simple implementation that just
# differentiate between different parallel setups
# For each number, we use 8-bit, and pack them together
return (
(world_size << 24)
| (global_rank << 16)
| (local_world_size << 8)
| local_rank
)
@dataclass(frozen=True)
class EncodedObjectKey:
"""JSON-safe wire form of :class:`ObjectKey` — ``chunk_hash`` is
hex-encoded; other fields are preserved verbatim."""
chunk_hash_hex: str
"""Hex-encoded ``ObjectKey.chunk_hash``."""
model_name: str
kv_rank: int
object_group_id: int = 0
"""Defaults to ``0`` so pre-``object_group_id`` wire payloads still
deserialize."""
cache_salt: str = ""
def to_object_key(self) -> ObjectKey:
"""Recover the corresponding :class:`ObjectKey`.
Raises:
ValueError: ``chunk_hash_hex`` is not valid hex, or one of
:class:`ObjectKey`'s field invariants is violated.
"""
return ObjectKey(
chunk_hash=bytes.fromhex(self.chunk_hash_hex),
model_name=self.model_name,
kv_rank=self.kv_rank,
object_group_id=self.object_group_id,
cache_salt=self.cache_salt,
)
@dataclass(frozen=True)
class KeyEntry:
"""One entry in a :class:`KeyListPage` including the encoded object
key and its object size."""
key: EncodedObjectKey
size_bytes: int
@dataclass(frozen=True)
class KeyListPage:
"""A page of keys returned by ``L2AdapterInterface.list_l2_keys``."""
entries: tuple[KeyEntry, ...]
"""The keys in the current page."""
next_page_token: str | None
"""``None`` means this is the last page. Otherwise pass the token
verbatim to the next call to fetch the next page."""
@dataclass(frozen=True)
class MemoryLayoutDesc:
"""
Describes the layout of a memory object
"""
shapes: list[torch.Size]
dtypes: list[torch.dtype]
def __post_init__(self):
if len(self.shapes) != len(self.dtypes):
raise ValueError(
"MemoryLayoutDesc: shapes and dtype must have the same length"
)
@dataclass(frozen=True)
class AttnWindowDesc:
"""Per-object-group cross-chunk attention windows, in LMCache chunks.
``num_chunks_in_sw[g]`` is the number of trailing prefix chunks that must
be present for object group ``g`` to serve a cache hit. ``-1`` means full
attention (the whole prefix); ``w >= 1`` is a sliding window of ``w``
chunks (mamba is ``1``).
"""
num_chunks_in_sw: list[int]
def __post_init__(self) -> None:
for w in self.num_chunks_in_sw:
if w == 0 or w < -1:
raise ValueError(
"AttnWindowDesc: each window must be -1 (full attention) "
f"or >= 1 chunk, got {w}"
)
@property
def num_object_groups(self) -> int:
"""Number of object groups this descriptor covers."""
return len(self.num_chunks_in_sw)
def is_full_attention(self, object_group_idx: int) -> bool:
"""Whether the object group depends on the entire prefix.
Args:
object_group_idx: 0-based object group index.
Returns:
True if the group attends to the whole prefix, False if it uses a
bounded sliding window.
"""
return self.num_chunks_in_sw[object_group_idx] < 0
DEFAULT_ATTN_WINDOW_DESC = AttnWindowDesc(num_chunks_in_sw=[-1])
"""A single full-attention object group; the default when no per-object-group
windows are supplied."""
@dataclass(frozen=True)
class PrefetchHandle:
"""Opaque handle returned by ``StorageManager.submit_prefetch_task``.
Carries the bookkeeping needed to later query lookup / prefetch status
without exposing controller internals.
"""
prefetch_request_id: int
"""Opaque ID for tracking L2 prefetch in the controller.
-1 if no L2 request was submitted."""
external_request_id: str
"""Request ID from the caller for end-to-end tracing."""
l1_found_indices: tuple[int, ...]
"""Original-key indices found (read-locked) in L1 at submission time."""
total_requested_keys: int
"""Total number of keys originally requested (the result-bitmap size)."""
submit_time: float
"""Monotonic timestamp when the prefetch task was submitted."""
l2_orig_indices: tuple[int, ...] = ()
"""Original-key index of each key submitted to L2; maps the controller's
local result bitmap back to original positions."""
def ipc_key_to_object_keys(
ipc_key: "IPCCacheServerKey",
chunk_hashes: list[bytes],
object_group_ids: list[int],
) -> list[list[ObjectKey]]:
"""
Convert a single IPCCacheServerKey and its chunk hashes to per-object-group
lists of ObjectKey.
When the ipc_key's worker_id is None, each chunk hash is exploded into
multiple ObjectKeys (one per worker in world_size).
``cache_salt`` is read directly from ``ipc_key`` so the produced
ObjectKeys are per-user isolated whenever the sender set a non-empty
salt. There is intentionally no separate ``cache_salt`` parameter —
duplicating the source of truth would risk silent isolation bugs
where a caller passes ``ipc_key`` but forgets the salt.
Args:
ipc_key: The IPC key providing model_name, world_size, worker_id,
and cache_salt.
chunk_hashes: List of chunk hash bytes, one per chunk.
object_group_ids: Object group ids to produce keys for.
Returns:
list[list[ObjectKey]]: The i-th element is the list of ObjectKeys
for ``object_group_ids[i]``.
"""
cache_salt = ipc_key.cache_salt
# The (chunk_hash, kv_rank) expansion is independent of the object group,
# so compute it once and reuse it for every group.
if ipc_key.worker_id is None:
# For look up request, we want to expand to all workers
# TODO (ApostaC): include local world size/rank info
# in the future once it's in IPCCacheServerKey
kv_ranks = [
ObjectKey.ComputeKVRank(
world_size=ipc_key.world_size,
global_rank=worker_id,
local_world_size=ipc_key.world_size,
local_rank=worker_id,
)
for worker_id in range(ipc_key.world_size)
]
else:
kv_ranks = [
ObjectKey.ComputeKVRank(
world_size=ipc_key.world_size,
global_rank=ipc_key.worker_id,
local_world_size=ipc_key.world_size,
local_rank=ipc_key.worker_id,
)
]
return [
[
ObjectKey(
chunk_hash=chunk_hash,
model_name=ipc_key.model_name,
kv_rank=kv_rank,
object_group_id=object_group_id,
cache_salt=cache_salt,
)
for chunk_hash in chunk_hashes
for kv_rank in kv_ranks
]
for object_group_id in object_group_ids
]