873 lines
34 KiB
Python
873 lines
34 KiB
Python
# SPDX-License-Identifier: Apache-2.0
|
|
# Future
|
|
from __future__ import annotations
|
|
|
|
# Standard
|
|
from collections import defaultdict
|
|
from collections.abc import Sequence
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, NamedTuple
|
|
|
|
# Third Party
|
|
import torch
|
|
|
|
# First Party
|
|
from lmcache.logging import init_logger
|
|
from lmcache.python_ops_fallback import set_shape_desc_dtype
|
|
from lmcache.utils import lmcache_deprecate
|
|
from lmcache.v1.distributed.api import AttnWindowDesc
|
|
import lmcache.c_ops as lmc_ops
|
|
|
|
if TYPE_CHECKING:
|
|
# First Party
|
|
from lmcache.v1.gpu_connector.utils import DiscoverableKVCache
|
|
from lmcache.v1.multiprocess.group_view import EngineGroupInfo
|
|
|
|
logger = init_logger(__name__)
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# Constants #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
DEFAULT_LAYER_NAME_PREFIX = "model.layers."
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# dtype mapping #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
DTYPE_MAP: dict[str, torch.dtype] = {
|
|
"float16": torch.float16,
|
|
"float32": torch.float32,
|
|
"bfloat16": torch.bfloat16,
|
|
"uint8": torch.uint8,
|
|
}
|
|
|
|
|
|
# The tuple that uniquely identifies a set of kernel-equivalent layers; one
|
|
# distinct identity becomes one LMCache KV group:
|
|
# ``(kv_size, num_heads, head_size, block_size, engine_group_idx, dtype,
|
|
# engine_kv_format)``.
|
|
# ``engine_group_idx`` is the engine group id (one paged-block address space):
|
|
# block IDs are only meaningful within one group, so layers from different
|
|
# groups must not share an LMCache group even if shape and dtype match.
|
|
# ``engine_kv_format`` keeps layouts that share an engine group from merging --
|
|
# load-bearing when one ``engine_group_idx`` mixes layouts (a rank-5 K/V group
|
|
# alongside a rank-3 key-only indexer cache): this field is what splits them
|
|
# into separate kernel groups, each transferred with its own layout.
|
|
class KernelGroupIdentity(NamedTuple):
|
|
kv_size: int
|
|
num_heads: int
|
|
head_size: int
|
|
block_size: int
|
|
engine_group_idx: int
|
|
dtype: torch.dtype
|
|
engine_kv_format: "lmc_ops.EngineKVFormat"
|
|
|
|
|
|
LayerGroupIdentity = KernelGroupIdentity # Alias for compatibility
|
|
|
|
|
|
# Sentinel ``per_layer_engine_group_idx`` value: a KV tensor tagged with it is
|
|
# excluded from every LMCache group (used for cross-layer KV-sharing layers; see
|
|
# ``create_engine_group_infos_from_vllm``).
|
|
EXCLUDED_ENGINE_GROUP = -1
|
|
|
|
|
|
def group_layers_by_identity(
|
|
kv_caches: "DiscoverableKVCache",
|
|
engine_kv_formats: "Sequence[lmc_ops.EngineKVFormat]",
|
|
per_layer_engine_group_idx: Sequence[int] | None = None,
|
|
) -> list[tuple[LayerGroupIdentity, list[int]]]:
|
|
"""Partition layer indices by :data:`LayerGroupIdentity`.
|
|
|
|
Args:
|
|
kv_caches: Registered KV cache structure, one entry per layer.
|
|
engine_kv_formats: One Engine KV format per registered tensor; its length
|
|
is the layer count. Homogeneous models repeat one shared format; a
|
|
model that mixes formats across engine groups -- e.g. a K+V main cache
|
|
(``kv_size=2``) plus a key-only MLA index cache (``kv_size=1``) --
|
|
supplies the differing per-layer formats.
|
|
per_layer_engine_group_idx: Optional engine block group id per layer.
|
|
When ``None`` every layer is treated as block group 0 (non-hybrid);
|
|
when present, layers from different engine block groups never share an
|
|
identity even if their tensor shapes match. Layers whose value is
|
|
``EXCLUDED_ENGINE_GROUP`` are left out of all groups (e.g. cross-layer
|
|
KV-sharing layers whose KV lives in their target owner's blocks).
|
|
|
|
Returns:
|
|
A list of ``(identity, layer_indices)`` pairs sorted by each group's
|
|
first layer index, so the group order is deterministic.
|
|
|
|
Raises:
|
|
ValueError: If ``per_layer_engine_group_idx`` is given but its length
|
|
does not match ``engine_kv_formats``.
|
|
"""
|
|
# First Party
|
|
from lmcache.v1.gpu_connector.utils import (
|
|
get_block_size,
|
|
get_dtype,
|
|
get_head_size,
|
|
get_num_heads,
|
|
is_mla,
|
|
)
|
|
|
|
num_layers = len(engine_kv_formats)
|
|
if (
|
|
per_layer_engine_group_idx is not None
|
|
and len(per_layer_engine_group_idx) != num_layers
|
|
):
|
|
raise ValueError(
|
|
f"per_layer_engine_group_idx has {len(per_layer_engine_group_idx)} "
|
|
f"entries but engine_kv_formats has {num_layers} layers"
|
|
)
|
|
|
|
groups_dict: dict[LayerGroupIdentity, list[int]] = defaultdict(list)
|
|
for idx in range(num_layers):
|
|
engine_group_idx = (
|
|
per_layer_engine_group_idx[idx]
|
|
if per_layer_engine_group_idx is not None
|
|
else 0
|
|
)
|
|
# Skip layers explicitly excluded from grouping (e.g. cross-layer
|
|
# KV-sharing layers, whose KV lives in their target owner's blocks).
|
|
if engine_group_idx == EXCLUDED_ENGINE_GROUP:
|
|
continue
|
|
layer_format = engine_kv_formats[idx]
|
|
mla = is_mla(layer_format)
|
|
kv_size = 1 if mla else 2
|
|
nh = 1 if mla else get_num_heads(kv_caches, layer_format, idx)
|
|
hs = get_head_size(kv_caches, layer_format, idx)
|
|
dt = get_dtype(kv_caches, layer_format, idx)
|
|
bs = get_block_size(kv_caches, layer_format, idx)
|
|
|
|
identity = LayerGroupIdentity(
|
|
kv_size=kv_size,
|
|
num_heads=nh,
|
|
head_size=hs,
|
|
block_size=bs,
|
|
engine_group_idx=engine_group_idx,
|
|
dtype=dt,
|
|
engine_kv_format=layer_format,
|
|
)
|
|
groups_dict[identity].append(idx)
|
|
return sorted(groups_dict.items(), key=lambda kv: kv[1][0])
|
|
|
|
|
|
@dataclass
|
|
class KernelGroupInfo:
|
|
"""A single transfer-kernel dispatch unit: a set of KV layers that can
|
|
ride one kernel launch with one ``PageBufferShapeDesc``.
|
|
|
|
Membership is decided by :class:`KVLayerGroupsManager` according to
|
|
:data:`LayerGroupIdentity`; every layer referenced by
|
|
``layer_indices`` shares the same
|
|
``(kv_size, num_heads, head_size, block_size, engine_group_idx,
|
|
dtype, engine_kv_format)`` signature.
|
|
Consumers use ``layer_indices`` to pull the matching device pointers
|
|
out of ``kv_caches`` (via
|
|
:func:`~lmcache.v1.gpu_connector.utils.get_group_data_ptrs`) and
|
|
feed them to the kernel alongside ``shape_desc``.
|
|
|
|
``dtype`` is carried alongside ``shape_desc`` because
|
|
``PageBufferShapeDesc.element_size`` is a byte width, which cannot
|
|
distinguish dtypes that share a byte count (e.g. bfloat16 and
|
|
float16 are both 2 bytes). Kernel template instantiation keys on the
|
|
torch dtype, not the byte width, so we keep it explicit.
|
|
|
|
Treat instances as immutable after construction; callers may hold
|
|
references for the lifetime of the manager.
|
|
"""
|
|
|
|
layer_indices: list[int]
|
|
"""0-based layer indices belonging to this group, in the order the
|
|
kernel should iterate them. Fed to ``get_group_data_ptrs`` to build
|
|
the per-group pointer array."""
|
|
shape_desc: "lmc_ops.PageBufferShapeDesc"
|
|
"""Kernel-facing shape descriptor shared by every layer in the group.
|
|
All eight fields (``kv_size, nl, nb, bs, nh, hs, element_size,
|
|
block_stride_elems``) are stamped once at construction."""
|
|
dtype: torch.dtype
|
|
"""Torch dtype of the KV cache tensors for this group. Used for
|
|
kernel template instantiation; see class docstring for why we keep
|
|
this alongside ``shape_desc.element_size``."""
|
|
engine_kv_format: "lmc_ops.EngineKVFormat | None" = None
|
|
"""Per-group Engine KV format, read via
|
|
``BaseCacheContext.get_engine_kv_format`` so mixed-format models dispatch each
|
|
group with its own. ``None`` only for bench bookkeeping groups from
|
|
:func:`parse_kvcache_shape_spec`, which have no detected format and never
|
|
transfer; detection-built groups always set it."""
|
|
tokens_per_block: int = 0
|
|
"""Logical engine tokens covered by one paged chunk (one engine block
|
|
ID) of this group, as declared by the engine's KV cache spec at
|
|
initialization time (carried in ``EngineGroupInfo.tokens_per_block``).
|
|
``0`` means the engine did not report it; the group is then treated as
|
|
uncompressed (``compress_ratio == 1``)."""
|
|
engine_group_idx: int = 0
|
|
"""Engine group index (paged-block address space). 0 for non-hybrid."""
|
|
sw_size_tokens: int = -1
|
|
"""Sliding window size in logical tokens for this group's layers.
|
|
``-1`` means the layers are not sliding-window attention."""
|
|
|
|
def __repr__(self) -> str:
|
|
if not self.layer_indices:
|
|
indices_repr = "[]"
|
|
else:
|
|
indices_repr = f"{self.layer_indices[0]}-{self.layer_indices[-1]}"
|
|
sd = self.shape_desc
|
|
return (
|
|
f"KernelGroupInfo(layers={len(self.layer_indices)}, "
|
|
f"indices={indices_repr}, "
|
|
f"shape_desc=(kv={sd.kv_size}, nl={sd.nl}, nb={sd.nb}, "
|
|
f"bs={sd.bs}, nh={sd.nh}, hs={sd.hs}, "
|
|
f"element_size={sd.element_size}, "
|
|
f"block_stride_elems={sd.block_stride_elems}), "
|
|
f"dtype={self.dtype}, "
|
|
f"tokens_per_block={self.tokens_per_block}, "
|
|
f"slots_per_block={self.slots_per_block}, "
|
|
f"engine_group_idx={self.engine_group_idx}, "
|
|
f"sw_size_tokens={self.sw_size_tokens})"
|
|
)
|
|
|
|
@property
|
|
def num_layers(self) -> int:
|
|
"""Number of layers in this group."""
|
|
return len(self.layer_indices)
|
|
|
|
@property
|
|
def hidden_dim_size(self) -> int:
|
|
"""Hidden dimension size (``num_heads * head_size``)."""
|
|
return self.shape_desc.nh * self.shape_desc.hs
|
|
|
|
@property
|
|
def slots_per_block(self) -> int:
|
|
"""Physical slots in one paged chunk of this group, detected from
|
|
the registered KV tensors at registration time (the batch-size
|
|
dimension, ``shape_desc.bs``)."""
|
|
return self.shape_desc.bs
|
|
|
|
def calculate_slots(self, num_tokens: int) -> int:
|
|
"""Calculate the number of slots for the specified number of
|
|
tokens. Assuming the number of tokens are already aligned.
|
|
"""
|
|
return num_tokens * self.slots_per_block // self.tokens_per_block
|
|
|
|
|
|
KVLayerGroupInfo = KernelGroupInfo # Alias for compatibility
|
|
|
|
|
|
@dataclass
|
|
class ObjectGroupInfo:
|
|
"""Metadata for an 'object group'.
|
|
|
|
An object group contains one or more kernel groups whose
|
|
KV caches will be stored in the same memory object.
|
|
|
|
This will be useful for dealing with sliding window or mamba
|
|
KV caches that needs a different prefix matching logic from
|
|
the full attention KV caches.
|
|
"""
|
|
|
|
kernel_group_indices: list[int]
|
|
"""Indices of the kernel groups belonging to this object group, in the
|
|
order they should be laid out in memory."""
|
|
|
|
sw_size_chunks: int = -1
|
|
"""Cross-chunk sliding window size in LMCache chunks shared by every
|
|
kernel group in this object group. ``-1`` means the kernel groups are
|
|
not sliding-window attention."""
|
|
|
|
|
|
class KVLayerGroupsManager:
|
|
"""Partition a model's KV layers into transfer-kernel dispatch units.
|
|
|
|
At construction time, every layer in ``kv_caches`` is bucketed by its
|
|
:data:`LayerGroupIdentity` (``(kv_size, num_heads, head_size,
|
|
block_size, engine_group_idx, dtype, engine_kv_format)``). Each bucket
|
|
becomes one
|
|
:class:`KernelGroupInfo` holding the layer indices, a shared
|
|
:class:`PageBufferShapeDesc`, and the group's torch dtype.
|
|
|
|
Downstream consumers (``VLLMPagedMemGPUConnectorV3``,
|
|
``GPUCacheContext``, the multiprocess server) iterate
|
|
``self._kernel_groups`` and issue one transfer-kernel launch per
|
|
group. The manager itself is a pure metadata object — it does not
|
|
own any GPU buffers or perform any transfers.
|
|
|
|
Layout parsing is delegated entirely to
|
|
:mod:`lmcache.v1.gpu_connector.utils`; this class only drives the
|
|
grouping and look-up.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
kv_caches: "DiscoverableKVCache",
|
|
engine_kv_formats: "Sequence[lmc_ops.EngineKVFormat]",
|
|
engine_group_infos: "Sequence[EngineGroupInfo]" = (),
|
|
lmcache_tokens_per_chunk: int = 256,
|
|
separate_object_groups: bool = True,
|
|
) -> None:
|
|
"""Partition the layers into kernel groups for this set of KV caches.
|
|
|
|
Group order is deterministic across runs.
|
|
|
|
Args:
|
|
kv_caches: KV cache structure accepted by
|
|
:func:`normalize_and_discover_per_layer_formats`.
|
|
engine_kv_formats: One Engine KV format per layer (its length is the
|
|
layer count), from
|
|
:func:`normalize_and_discover_per_layer_formats`. A model that
|
|
mixes formats across engine groups supplies the differing
|
|
per-layer formats so each group is shaped with its own;
|
|
homogeneous models repeat one shared format.
|
|
engine_group_infos: Engine KV cache group metadata, one info per
|
|
kernel group in kernel-group order, or empty.
|
|
lmcache_logical_chunk_size: Tokens per LMCache chunk
|
|
separate_object_groups: When True (default), split kernel groups
|
|
into one object group per sliding-window size; when False, all
|
|
kernel groups share a single full-attention object group.
|
|
"""
|
|
# Import here to break a circular import via
|
|
# lmcache.v1.gpu_connector.__init__ → metadata → kv_layer_groups.
|
|
# First Party
|
|
from lmcache.v1.gpu_connector.utils import (
|
|
get_num_blocks,
|
|
make_page_buffer_shape_desc,
|
|
resolve_block_stride_and_log_layout,
|
|
)
|
|
from lmcache.v1.multiprocess.group_view import get_engine_group_indices
|
|
|
|
self._kernel_groups: list[KernelGroupInfo] = []
|
|
self._object_groups: list[ObjectGroupInfo] = []
|
|
|
|
num_layers = len(engine_kv_formats)
|
|
if num_layers == 0:
|
|
logger.debug("No KV caches available, skipping KV layer groups building")
|
|
return
|
|
|
|
per_layer_engine_group_idx = get_engine_group_indices(
|
|
engine_group_infos, num_layers
|
|
)
|
|
groups_by_identity = group_layers_by_identity(
|
|
kv_caches, engine_kv_formats, per_layer_engine_group_idx
|
|
)
|
|
|
|
# Engine group infos are produced by the same group_layers_by_identity
|
|
# bucketing on the engine side, so they correspond 1:1, in order, to
|
|
# the kernel groups built below.
|
|
if engine_group_infos and len(engine_group_infos) != len(groups_by_identity):
|
|
raise ValueError(
|
|
f"Got {len(engine_group_infos)} engine group infos for "
|
|
f"{len(groups_by_identity)} kernel groups; expecting one "
|
|
"engine group info per kernel group"
|
|
)
|
|
|
|
# Emit groups in order of their first-appearing layer so that group
|
|
# indices remain deterministic across runs.
|
|
for group_idx, (identity, indices) in enumerate(groups_by_identity):
|
|
bs = identity.block_size
|
|
engine_group_idx = identity.engine_group_idx
|
|
dt = identity.dtype
|
|
# Format is part of the identity, so every layer in the group shares
|
|
# it -- this is the whole group's format, by construction.
|
|
group_format = identity.engine_kv_format
|
|
# Block count is per engine group (each is its own block-id space), so
|
|
# read it from this group's own tensor rather than a context-wide value.
|
|
group_num_blocks = get_num_blocks([kv_caches[indices[0]]], group_format)
|
|
block_stride_elems = resolve_block_stride_and_log_layout(
|
|
kv_caches,
|
|
group_format,
|
|
layer_idx=indices[0],
|
|
group_idx=group_idx,
|
|
)
|
|
shape_desc = make_page_buffer_shape_desc(
|
|
kv_caches,
|
|
group_format,
|
|
layer_idx=indices[0],
|
|
num_layers_in_group=len(indices),
|
|
num_blocks=group_num_blocks,
|
|
block_size=bs,
|
|
block_stride_elems=block_stride_elems,
|
|
)
|
|
|
|
info = engine_group_infos[group_idx] if engine_group_infos else None
|
|
if info is not None and tuple(indices) != tuple(info.layer_indices):
|
|
raise ValueError(
|
|
f"group {group_idx}: engine group info covers layers "
|
|
f"{info.layer_indices}, but the kernel group covers "
|
|
f"layers {indices}"
|
|
)
|
|
|
|
# tokens_per_block comes from the engine's KV cache spec; when
|
|
# absent, fall back to the physical slot count so the group is
|
|
# treated as non-compressed (compress_ratio == 1).
|
|
tokens_per_block = (
|
|
info.tokens_per_block
|
|
if info is not None and info.tokens_per_block > 0
|
|
else bs
|
|
)
|
|
sw_size_tokens = info.sw_size_tokens if info is not None else -1
|
|
|
|
self._validate_block_chunk_size_config(
|
|
group_idx,
|
|
slots_per_block=bs,
|
|
tokens_per_block=tokens_per_block,
|
|
lmcache_tokens_per_chunk=lmcache_tokens_per_chunk,
|
|
sw_size_tokens=sw_size_tokens,
|
|
)
|
|
|
|
self._kernel_groups.append(
|
|
KernelGroupInfo(
|
|
layer_indices=indices,
|
|
shape_desc=shape_desc,
|
|
dtype=dt,
|
|
engine_kv_format=group_format,
|
|
tokens_per_block=tokens_per_block,
|
|
engine_group_idx=engine_group_idx,
|
|
sw_size_tokens=sw_size_tokens,
|
|
)
|
|
)
|
|
|
|
self._lmcache_tokens_per_chunk = lmcache_tokens_per_chunk
|
|
self._separate_object_groups = separate_object_groups
|
|
|
|
# When True, sliding-window groups store/transfer FULL per-chunk KV
|
|
self._full_sw_kv = False
|
|
|
|
logger.info(
|
|
"KV layer groups: ---\n%s\n---",
|
|
"\n".join(repr(g) for g in self._kernel_groups),
|
|
)
|
|
|
|
# Detect the object groups
|
|
self._object_groups = self._detect_object_groups(engine_group_infos)
|
|
|
|
@property
|
|
def kernel_groups(self) -> list[KernelGroupInfo]:
|
|
"""List of :class:`KernelGroupInfo`, one per kernel group."""
|
|
return self._kernel_groups
|
|
|
|
@property
|
|
def num_blocks(self) -> int:
|
|
"""Paged block count, shared across kernel groups (one block-id space).
|
|
|
|
Read from the first kernel group's ``shape_desc.nb``, which was computed
|
|
from that group's own tensor and format -- not a guessed representative.
|
|
Returns ``0`` when there are no kernel groups.
|
|
"""
|
|
if not self._kernel_groups:
|
|
return 0
|
|
return self._kernel_groups[0].shape_desc.nb
|
|
|
|
@property
|
|
@lmcache_deprecate("`kv_layer_groups` is an outdated alias for `kernel_groups`")
|
|
def kv_layer_groups(self) -> list[KernelGroupInfo]:
|
|
"""List of :class:`KernelGroupInfo`, one per kernel group."""
|
|
return self._kernel_groups
|
|
|
|
@property
|
|
def num_kernel_groups(self) -> int:
|
|
"""Number of :class:`KernelGroupInfo` entries.
|
|
|
|
Zero if ``kv_caches`` had no layers at construction time.
|
|
"""
|
|
return len(self._kernel_groups)
|
|
|
|
@property
|
|
def object_groups(self) -> list[ObjectGroupInfo]:
|
|
"""List of :class:`ObjectGroupInfo`, one per object group."""
|
|
return self._object_groups
|
|
|
|
@property
|
|
def num_object_groups(self) -> int:
|
|
"""Number of :class:`ObjectGroupInfo` entries."""
|
|
return len(self._object_groups)
|
|
|
|
@property
|
|
@lmcache_deprecate("`num_groups` is an outdated alias for `num_kernel_groups`")
|
|
def num_groups(self) -> int:
|
|
"""Number of :class:`KernelGroupInfo` entries.
|
|
|
|
Zero if ``kv_caches`` had no layers at construction time.
|
|
"""
|
|
return len(self._kernel_groups)
|
|
|
|
def get_shape_desc(self, kernel_group_idx: int) -> "lmc_ops.PageBufferShapeDesc":
|
|
"""Return the :class:`PageBufferShapeDesc` for *kernel_group_idx*.
|
|
|
|
Args:
|
|
kernel_group_idx: 0-based kernel group index.
|
|
|
|
Raises:
|
|
IndexError: If *kernel_group_idx* is out of range.
|
|
"""
|
|
return self._kernel_groups[kernel_group_idx].shape_desc
|
|
|
|
@lmcache_deprecate("It does not have hybrid model support")
|
|
def get_slots_per_chunk(self, kernel_group_idx: int) -> int:
|
|
"""Return the per-chunk slot count for *kernel_group_idx*.
|
|
|
|
Args:
|
|
kernel_group_idx: 0-based kernel group index.
|
|
|
|
Note:
|
|
This is a deprecated function because it does not have
|
|
hybrid model support
|
|
"""
|
|
group = self._kernel_groups[kernel_group_idx]
|
|
return group.calculate_slots(self._lmcache_tokens_per_chunk)
|
|
|
|
def get_slots_per_chunk_in_sw(self, kernel_group_idx: int) -> int:
|
|
"""Return the per-chunk *transfer* slot count for *kernel_group_idx*.
|
|
|
|
For sub-chunk sliding window groups, the transfer slots is smaller
|
|
than the physical slots in a chunk.
|
|
|
|
Args:
|
|
kernel_group_idx: 0-based kernel group index.
|
|
"""
|
|
group = self._kernel_groups[kernel_group_idx]
|
|
sw_size = self.get_subchunk_sw_size_tokens(kernel_group_idx)
|
|
return group.calculate_slots(sw_size)
|
|
|
|
def enable_full_sw_kv(self) -> None:
|
|
"""Store/transfer the FULL per-chunk KV for sliding-window groups.
|
|
|
|
Keeps every block so a chunk stays valid when reused at any position
|
|
(default windowing keeps only each chunk's last sub-chunk window).
|
|
"""
|
|
self._full_sw_kv = True
|
|
|
|
def get_subchunk_sw_size_tokens(self, kernel_group_idx: int) -> int:
|
|
"""Return the sub-chunk sliding window size of a given kernel group.
|
|
The size is measured in the number of tokens.
|
|
|
|
This is for the models like DSV4 where the sliding window size is
|
|
smaller than the tokens in a single lmcache chunk.
|
|
|
|
Args:
|
|
kernel_group_idx: 0-based kernel group index.
|
|
|
|
Returns:
|
|
The sub-chunk sliding window size. Will be the same as the
|
|
chunk size for non-slding-window models or big-sliding-
|
|
window models.
|
|
"""
|
|
sw_size_tokens = self._kernel_groups[kernel_group_idx].sw_size_tokens
|
|
# full_sw_kv: report the full chunk as the window so store/transfer keep
|
|
# every block (chunk stays valid for reuse at any position).
|
|
if self._full_sw_kv:
|
|
return self._lmcache_tokens_per_chunk
|
|
if sw_size_tokens == -1 or sw_size_tokens >= self._lmcache_tokens_per_chunk:
|
|
return self._lmcache_tokens_per_chunk
|
|
return sw_size_tokens
|
|
|
|
def get_attn_desc(self) -> AttnWindowDesc:
|
|
"""Return the cross-chunk attention windows of all object groups.
|
|
|
|
Returns:
|
|
An :class:`AttnWindowDesc` with one entry per object group, in
|
|
object-group order; the entry is ``-1`` for a non-sliding-window
|
|
group.
|
|
|
|
Note:
|
|
With object-group separation disabled (the default), the result
|
|
has a single full-attention entry.
|
|
"""
|
|
if self._full_sw_kv:
|
|
# full_sw_kv: every group reports full attention, no cross-chunk
|
|
# window skipping (mirrors get_subchunk_sw_size_tokens).
|
|
return AttnWindowDesc(num_chunks_in_sw=[-1] * len(self._object_groups))
|
|
return AttnWindowDesc(
|
|
num_chunks_in_sw=[
|
|
w if w >= 1 else -1
|
|
for w in (g.sw_size_chunks for g in self._object_groups)
|
|
]
|
|
)
|
|
|
|
def calculate_num_blocks(self, kernel_group_idx: int, num_tokens: int) -> int:
|
|
"""Calculate the number of blocks for a given number of tokens in a
|
|
specified kernel group.
|
|
|
|
Args:
|
|
kernel_group_idx: 0-based index of the kernel group.
|
|
num_tokens: The total number of tokens to be processed for the group.
|
|
|
|
Returns:
|
|
The number of blocks.
|
|
|
|
Raises:
|
|
IndexError: If *kernel_group_idx* is out of range.
|
|
"""
|
|
group = self._kernel_groups[kernel_group_idx]
|
|
# Physical slots for num_tokens, derived from the per-block geometry
|
|
# (slots_per_block / tokens_per_block) rather than a compress ratio.
|
|
num_physical_slots = (
|
|
num_tokens * group.slots_per_block // group.tokens_per_block
|
|
)
|
|
return num_physical_slots // group.shape_desc.bs
|
|
|
|
### Helper methods
|
|
def _detect_object_groups(
|
|
self, engine_group_infos: "Sequence[EngineGroupInfo]"
|
|
) -> list[ObjectGroupInfo]:
|
|
"""Bucket kernel groups into object groups.
|
|
|
|
Puts all kernel groups into a single object group when object-group
|
|
separation is disabled (the default). Otherwise groups the kernel groups
|
|
by sliding-window size measured in number of chunks.
|
|
|
|
Args:
|
|
engine_group_infos: LMCache-owned engine KV cache group metadata.
|
|
|
|
Returns:
|
|
One :class:`ObjectGroupInfo` per object group.
|
|
"""
|
|
if not self._separate_object_groups:
|
|
return [
|
|
ObjectGroupInfo(
|
|
kernel_group_indices=list(range(len(self._kernel_groups)))
|
|
)
|
|
]
|
|
|
|
chunk_size = self._lmcache_tokens_per_chunk
|
|
groups_by_sw_size: dict[int, list[int]] = defaultdict(list)
|
|
for kernel_group_idx, group in enumerate(self._kernel_groups):
|
|
if group.sw_size_tokens == -1:
|
|
sw_size_chunks = -1
|
|
else:
|
|
sw_size_chunks = (group.sw_size_tokens + chunk_size - 1) // chunk_size
|
|
groups_by_sw_size[sw_size_chunks].append(kernel_group_idx)
|
|
return [
|
|
ObjectGroupInfo(
|
|
kernel_group_indices=kernel_group_indices,
|
|
sw_size_chunks=sw_size_chunks,
|
|
)
|
|
for sw_size_chunks, kernel_group_indices in sorted(
|
|
groups_by_sw_size.items(), key=lambda kv: kv[1][0]
|
|
)
|
|
]
|
|
|
|
@staticmethod
|
|
def _validate_block_chunk_size_config(
|
|
group_idx: int,
|
|
slots_per_block: int,
|
|
tokens_per_block: int,
|
|
lmcache_tokens_per_chunk: int,
|
|
sw_size_tokens: int = -1,
|
|
) -> None:
|
|
"""Validate the chunk size configuration against the slot and
|
|
tokens block detected from the serving engine.
|
|
|
|
Raises:
|
|
ValueError: If one of the following conditions is met:
|
|
- ``tokens_per_block`` is not a whole multiple of
|
|
``slots_per_block``
|
|
- ``lmcache_tokens_per_chunk`` is not a whole multiple of
|
|
``tokens_per_block``
|
|
- a sub-chunk sliding window is not a whole multiple of
|
|
``tokens_per_block``
|
|
"""
|
|
if tokens_per_block % slots_per_block != 0:
|
|
raise ValueError(
|
|
f"group {group_idx}: tokens_per_block {tokens_per_block} "
|
|
f"must be a multiple of slots_per_block {slots_per_block}"
|
|
)
|
|
if lmcache_tokens_per_chunk % tokens_per_block != 0:
|
|
raise ValueError(
|
|
f"group {group_idx}: lmcache_tokens_per_chunk "
|
|
f"{lmcache_tokens_per_chunk} must be a multiple of "
|
|
f"tokens_per_block {tokens_per_block}"
|
|
)
|
|
if (
|
|
0 < sw_size_tokens < lmcache_tokens_per_chunk
|
|
and sw_size_tokens % tokens_per_block != 0
|
|
):
|
|
raise ValueError(
|
|
f"group {group_idx}: sub-chunk sliding window size "
|
|
f"{sw_size_tokens} must be a multiple of tokens_per_block "
|
|
f"{tokens_per_block}"
|
|
)
|
|
if slots_per_block != tokens_per_block:
|
|
logger.info(
|
|
"group %d: compressed (tokens_per_block=%d, slots_per_block=%d)",
|
|
group_idx,
|
|
tokens_per_block,
|
|
slots_per_block,
|
|
)
|
|
|
|
|
|
# ------------------------------------------------------------------ #
|
|
# CLI shape-spec parser #
|
|
# ------------------------------------------------------------------ #
|
|
|
|
|
|
def parse_kvcache_shape_spec(
|
|
spec_str: str,
|
|
) -> list[KernelGroupInfo]:
|
|
"""Parse a ``--kvcache-shape-spec`` string into layer groups.
|
|
|
|
**Grammar** (EBNF-ish)::
|
|
|
|
spec := group { ";" group }
|
|
group := "(" shape ")" ":" dtype ":" layer_count
|
|
shape := kv_size "," NB "," BS "," NH "," HS
|
|
dtype := "float16" | "float32" | "bfloat16" | "uint8"
|
|
layer_count := positive integer
|
|
|
|
**Field semantics** (names aligned with ``EngineKVFormat``; see
|
|
:func:`lmcache.v1.gpu_connector.utils.get_engine_kv_shape_description`):
|
|
|
|
* ``kv_size`` -- leading dim (``2`` for standard K/V, ``1`` for MLA).
|
|
* ``NB`` -- ``num_blocks``: paged-KV block count.
|
|
* ``BS`` -- ``block_size``: tokens per paged-KV block.
|
|
* ``NH`` -- ``num_heads``: attention heads per layer.
|
|
* ``HS`` -- ``head_size``: per-head hidden dim.
|
|
* ``dtype`` -- element dtype (case-insensitive). ``uint8`` is used
|
|
by FP8-quantized layouts.
|
|
* ``layer_count`` -- number of consecutive layers sharing this
|
|
group's geometry. Groups are concatenated in declaration order;
|
|
``layer_indices`` are assigned sequentially starting from 0.
|
|
|
|
When consumed by the ``lmcache bench server`` CLI, ``NB``/``BS``
|
|
from the spec take precedence over ``--num-blocks`` / ``--block-size``
|
|
CLI flags when set to a positive value.
|
|
|
|
**Examples**::
|
|
|
|
# Single homogeneous group: 32 layers of standard K/V
|
|
(2,1024,16,8,128):float16:32
|
|
|
|
# Heterogeneous model: 30 dense layers + 2 MLA-ish layers
|
|
(2,1024,16,8,128):float16:30;(1,1024,16,4,64):bfloat16:2
|
|
|
|
# FP8-quantized KV cache
|
|
(2,1024,16,8,128):uint8:32
|
|
|
|
See also :func:`format_kvcache_shape_spec` for the inverse -- it
|
|
turns a parsed group list back into a human-readable spec string
|
|
(handy for CLI echo-back / debug logging).
|
|
|
|
Returns:
|
|
A list of :class:`KernelGroupInfo`, one per group.
|
|
|
|
Raises:
|
|
ValueError: Malformed spec, unknown dtype, or a shape with a
|
|
wrong number of dimensions.
|
|
"""
|
|
if not spec_str:
|
|
raise ValueError("KV shape specification cannot be empty")
|
|
|
|
groups: list[KernelGroupInfo] = []
|
|
layer_offset = 0
|
|
|
|
for group_spec in spec_str.split(";"):
|
|
group_spec = group_spec.strip()
|
|
if not group_spec:
|
|
continue
|
|
|
|
if not (group_spec.startswith("(") and "):" in group_spec):
|
|
raise ValueError("Invalid group spec format: %s" % group_spec)
|
|
|
|
shape_end = group_spec.find(")")
|
|
shape_str = group_spec[1:shape_end]
|
|
|
|
remaining = group_spec[shape_end + 2 :] # Skip "):"
|
|
parts = remaining.split(":")
|
|
if len(parts) != 2:
|
|
raise ValueError("Invalid group spec format: %s" % group_spec)
|
|
|
|
dtype_str = parts[0].strip()
|
|
layer_count_str = parts[1].strip()
|
|
|
|
dtype_key = dtype_str.lower()
|
|
if dtype_key not in DTYPE_MAP:
|
|
raise ValueError(
|
|
"Unrecognized dtype '%s' in group spec: %s. "
|
|
"Supported: %s" % (dtype_str, group_spec, list(DTYPE_MAP.keys()))
|
|
)
|
|
try:
|
|
shape = tuple(int(p.strip()) for p in shape_str.split(","))
|
|
layer_count = int(layer_count_str)
|
|
except ValueError as exc:
|
|
raise ValueError("Invalid number in group spec: %s" % group_spec) from exc
|
|
dtype = DTYPE_MAP[dtype_key]
|
|
|
|
if len(shape) != 5:
|
|
raise ValueError(
|
|
"Shape must be a 5-tuple (kv_size,nb,bs,nh,hs): %s" % group_spec
|
|
)
|
|
kv_size, nb, bs, nh, hs = shape
|
|
shape_desc = lmc_ops.PageBufferShapeDesc()
|
|
shape_desc.kv_size = kv_size
|
|
shape_desc.nl = layer_count
|
|
shape_desc.nb = nb
|
|
shape_desc.bs = bs
|
|
shape_desc.nh = nh
|
|
shape_desc.hs = hs
|
|
shape_desc.element_size = dtype.itemsize
|
|
set_shape_desc_dtype(shape_desc, dtype)
|
|
|
|
indices = list(range(layer_offset, layer_offset + layer_count))
|
|
groups.append(
|
|
KernelGroupInfo(
|
|
layer_indices=indices,
|
|
shape_desc=shape_desc,
|
|
dtype=dtype,
|
|
)
|
|
)
|
|
layer_offset += layer_count
|
|
|
|
if not groups:
|
|
raise ValueError("No valid layer groups found in spec")
|
|
|
|
return groups
|
|
|
|
|
|
def format_kvcache_shape_spec(groups: list[KernelGroupInfo]) -> str:
|
|
"""Format layer groups back into a ``--kvcache-shape-spec`` string.
|
|
|
|
This is the inverse of :func:`parse_kvcache_shape_spec`; the
|
|
result is round-trip safe (i.e. ``parse(format(x)) == x`` for any
|
|
``x`` that ``parse`` would produce).
|
|
|
|
The returned string is also human-readable and is used by the
|
|
``lmcache bench server`` CLI to echo the resolved KV cache
|
|
geometry at startup, so operators can verify that their spec was
|
|
interpreted as intended.
|
|
|
|
Example::
|
|
|
|
>>> groups = parse_kvcache_shape_spec(
|
|
... "(2,1024,16,8,128):float16:30;"
|
|
... "(1,1024,16,4,64):bfloat16:2"
|
|
... )
|
|
>>> format_kvcache_shape_spec(groups)
|
|
'(2,1024,16,8,128):float16:30;(1,1024,16,4,64):bfloat16:2'
|
|
|
|
Args:
|
|
groups: Layer groups as returned by
|
|
:func:`parse_kvcache_shape_spec`.
|
|
|
|
Raises:
|
|
ValueError: If *groups* is empty or contains an unsupported
|
|
dtype (one that is not present in :data:`DTYPE_MAP`).
|
|
"""
|
|
if not groups:
|
|
raise ValueError("Cannot format an empty layer group list")
|
|
|
|
# Invert DTYPE_MAP once: torch.dtype -> canonical string name.
|
|
dtype_names = {v: k for k, v in DTYPE_MAP.items()}
|
|
|
|
parts: list[str] = []
|
|
for g in groups:
|
|
sd = g.shape_desc
|
|
try:
|
|
dtype_str = dtype_names[g.dtype]
|
|
except KeyError as exc:
|
|
raise ValueError("dtype %s is not present in DTYPE_MAP" % g.dtype) from exc
|
|
parts.append(
|
|
"(%d,%d,%d,%d,%d):%s:%d"
|
|
% (sd.kv_size, sd.nb, sd.bs, sd.nh, sd.hs, dtype_str, sd.nl)
|
|
)
|
|
return ";".join(parts)
|