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

403 lines
17 KiB
Python

# SPDX-License-Identifier: Apache-2.0
"""Centralized edits to vLLM kv cache specs.
This is needed to mask out attention-specific details while making sure that
LMCache can still store / load KV cache correctly.
Currently there are two edits, one per :class:`KVCacheGroupEdit` subclass
below. Both are for Mamba-hybrid models; the registry is only consulted when
``kv_cache_config.has_mamba_layers``. :func:`validate_kv_cache_groups`
additionally rejects, at startup, group specs the transfer path cannot serve
correctly yet (see its docstring).
Reference design: vLLM PR #42828 ("[KVConnector][DSV4] HMA support for
Mooncake store connector") solves the same problem class for an external KV
store. Check it again when working on any of the following:
- Extending :func:`validate_kv_cache_groups` (its connector validates and
rejects unsupported specs up front, with one aggregated error).
- Per-group store/load masks (SWA / Mamba tail-only transfer, the
"sliding-window load-plan trimming" deferred in the hybrid design doc):
its ``MooncakeStoreCoordinator.store_mask`` / ``load_mask`` derive masks
from vLLM's own per-spec managers. Requires per-group objects in LMCache
(``ObjectKey.object_group_id``, LMCache #3608).
- Hit-length computation for hybrid models: its ``ExternalCachedBlockPool``
duck-types vLLM's ``BlockPool`` over a ``(group_id, hash)`` existence set
and reuses ``KVCacheSpecRegistry`` manager classes, so promised hits are
consumable by construction. NOTE: LMCache's lookup doubles as prefetch, so
vLLM's lookup-first-then-trim flow does not map directly -- this needs its
own design before borrowing.
- Eagle + HMA: see its ``apply_eagle`` notes (the eagle last-block prune must
be applied exactly once between hit-length and mask computation).
"""
# Future
from __future__ import annotations
# Standard
from abc import ABC, abstractmethod
from collections import Counter
from collections.abc import Mapping
from typing import TypeAlias
# Third Party
from vllm.v1.kv_cache_interface import (
KVCacheConfig,
KVCacheSpec,
KVCacheSpecKind,
get_kv_cache_spec_kind,
)
import torch
# First Party
from lmcache.logging import init_logger
logger = init_logger(__name__)
# One registered cache value: a paged KV tensor, or [conv_state, ssm_state]
# for Mamba layers.
RegisteredKVCache: TypeAlias = torch.Tensor | list[torch.Tensor]
# Synthetic head count for a reinterpreted page. The page is opaque bytes, so
# one "head" holding the whole per-(K/V) slab is enough; head_size is derived
# to fill the page.
_SYNTHETIC_NUM_HEADS = 1
# Standard-paged (non-MLA) attention kinds eligible for the sub-paged edit.
_SUBPAGEABLE_ATTENTION_KINDS = frozenset(
{
KVCacheSpecKind.FULL_ATTENTION,
KVCacheSpecKind.SLIDING_WINDOW,
KVCacheSpecKind.CHUNKED_LOCAL_ATTENTION,
KVCacheSpecKind.SINK_FULL_ATTENTION,
}
)
def _declares_slot_compression(spec: KVCacheSpec) -> bool:
"""Return whether a spec declares slot compression (must not be edited).
Covers ``MLAAttentionSpec.compress_ratio > 1`` (DeepSeek-V4 slot packing)
and ``TQFullAttentionSpec.tq_slot_size > 0`` (TurboQuant slots); such
groups belong to the compression path in ``lmcache.v1.kv_layer_groups``.
"""
return (
getattr(spec, "compress_ratio", 1) > 1 or getattr(spec, "tq_slot_size", 0) > 0
)
def _leaf_specs(spec: KVCacheSpec) -> list[KVCacheSpec]:
"""Return a group spec's leaf specs, unwrapping ``UniformTypeKVCacheSpecs``."""
inner = getattr(spec, "kv_cache_specs", None)
if isinstance(inner, dict):
return list(inner.values())
return [spec]
def validate_kv_cache_groups(kv_cache_config: KVCacheConfig | None) -> None:
"""Reject KV cache group specs the transfer path cannot serve correctly.
Rejected, with one aggregated error listing every offending group:
- ``CrossAttentionSpec`` (encoder-decoder caches).
- Mamba groups with ``mamba_cache_mode != "align"``: other modes keep no
reusable per-block state snapshots.
Specs declaring slot compression (``compress_ratio > 1`` /
``tq_slot_size > 0``, e.g. DeepSeek-V4) are NOT rejected: they are served
by the compression path in ``lmcache.v1.kv_layer_groups`` and merely
skipped by the edits here (see ``_declares_slot_compression``).
Args:
kv_cache_config: vLLM ``KVCacheConfig``; ``None`` skips validation
(callers without the config validate again at registration).
Raises:
ValueError: Listing every unsupported group and why.
"""
if kv_cache_config is None:
return
unsupported: list[str] = []
for group_idx, group in enumerate(kv_cache_config.kv_cache_groups):
for spec in _leaf_specs(group.kv_cache_spec):
kind = get_kv_cache_spec_kind(spec)
if kind == KVCacheSpecKind.CROSS_ATTENTION:
unsupported.append(f"group {group_idx}: CrossAttentionSpec")
elif (
kind == KVCacheSpecKind.MAMBA
and getattr(spec, "mamba_cache_mode", "none") != "align"
):
unsupported.append(
f"group {group_idx}: MambaSpec with mamba_cache_mode="
f"'{getattr(spec, 'mamba_cache_mode', 'none')}' "
f"(only 'align' keeps reusable state snapshots)"
)
if unsupported:
raise ValueError(
"LMCache cannot serve this model's KV cache groups: "
+ "; ".join(unsupported)
+ ". See lmcache/integration/vllm/kv_cache_group_edits.py."
)
def _synthetic_attention_shape(elems_per_page: int, block_size: int) -> tuple[int, int]:
"""Factor a page's element count into the synthetic attention layout.
Args:
elems_per_page: Total elements in one page (one logical block).
block_size: Logical block size (tokens per page).
Returns:
``(num_heads, head_size)`` such that
``2 * block_size * num_heads * head_size == elems_per_page``.
Raises:
ValueError: If the page size does not factor into the target shape.
"""
denom = 2 * block_size * _SYNTHETIC_NUM_HEADS
if elems_per_page % denom != 0:
raise ValueError(
f"page ({elems_per_page} elems) does not factor into "
f"(2, block_size={block_size}, num_heads={_SYNTHETIC_NUM_HEADS}, head_size)"
)
return _SYNTHETIC_NUM_HEADS, elems_per_page // denom
class KVCacheGroupEdit(ABC):
"""One structural edit rule for a KV cache group's registered cache.
``matches`` must be side-effect free and decide purely from the vLLM spec
and the registered cache value; ``apply`` must return a view over the same
storage (never a copy). ``name`` labels the rule in logs.
"""
name: str
@abstractmethod
def matches(self, spec: KVCacheSpec, kv_cache: RegisteredKVCache) -> bool:
"""Return whether this rule applies to the layer's registered cache.
Args:
spec: The layer's vLLM KV cache spec (from its group).
kv_cache: The layer's registered cache value -- a tensor, or a
list of tensors for Mamba layers.
"""
@abstractmethod
def apply(self, spec: KVCacheSpec, kv_cache: RegisteredKVCache) -> torch.Tensor:
"""Return the edited view for a layer this rule matched.
Args:
spec: The layer's vLLM KV cache spec (from its group).
kv_cache: The layer's registered cache value.
Raises:
ValueError: If the cache's layout violates the rule's invariants.
"""
class _MambaPageViewEdit(KVCacheGroupEdit):
"""Convert a Mamba page to its equivalent: a sliding-window-style layer.
A Mamba layer registers ``[conv_state, ssm_state]`` -- two tensors with
different shapes and dtypes sharing one padded page per block
(``conv | ssm | pad``) -- which LMCache's attention-shaped transfer path
cannot represent. Each page is one recurrent state snapshot, equivalent
for caching purposes to one block of a sliding-window attention layer with
window == block_size: only the last matched block is ever consumed. The
edit reinterprets the page buffer as one
``[#blocks, 2, block_size, 1, head_size]`` tensor in the conv state's
dtype, with ``head_size`` derived to fill the page exactly.
The view's dims are addressing metadata only; the bytes are opaque
(conv | ssm | pad, not K/V), so content-aware processing does not apply.
"""
name = "mamba-page-view"
def matches(self, spec: KVCacheSpec, kv_cache: RegisteredKVCache) -> bool:
return get_kv_cache_spec_kind(spec) == KVCacheSpecKind.MAMBA
def apply(self, spec: KVCacheSpec, kv_cache: RegisteredKVCache) -> torch.Tensor:
# vLLM lays out one padded page per block as (conv | ssm | pad), and
# the conv state is the view that starts at the page base: its leading
# dim is the block count and its per-block stride is one full page.
# Re-striding it therefore covers the whole page, ssm and pad included.
if not isinstance(kv_cache, list) or not kv_cache:
raise ValueError(
f"expected a Mamba [conv_state, ssm_state] tensor list, "
f"got {type(kv_cache).__name__}"
)
conv_state = kv_cache[0]
if conv_state.storage_offset() != 0:
raise ValueError(
f"Mamba conv state must view the page base, got "
f"storage_offset={conv_state.storage_offset()}"
)
if conv_state.stride(0) * conv_state.element_size() != spec.page_size_bytes:
raise ValueError(
f"Mamba conv state per-block stride "
f"({conv_state.stride(0) * conv_state.element_size()} bytes) "
f"does not equal the page size ({spec.page_size_bytes} bytes)"
)
num_blocks = conv_state.shape[0]
elems_per_page = spec.page_size_bytes // conv_state.element_size()
num_heads, head_size = _synthetic_attention_shape(
elems_per_page, spec.block_size
)
flat = conv_state.as_strided((num_blocks, elems_per_page), (elems_per_page, 1))
return flat.reshape(num_blocks, 2, spec.block_size, num_heads, head_size)
class _SubpagedAttentionViewEdit(KVCacheGroupEdit):
"""Re-view a kernel-paged attention tensor as logical-block pages.
For a Mamba-hybrid model vLLM inflates the attention block size to align
with the Mamba page (e.g. 544 for Qwen3.5-0.8B), and that size is used for
all prefix-caching logic at the scheduler. But at the worker the attention
kernel has to run at block size 32 for numerical stability (vLLM #27753,
working around the NaN-propagation issue
Dao-AILab/flash-attention#1974), so the registered tensor is paged as
``[#blocks, 2, 32, #heads, head_size]``
which makes LMCache detect block size 32, mistake the group for a
DeepSeek-compression layer (``block size < scheduler block size``), and
corrupt the store/retrieve. Fix: re-view the tensor at the scheduler
block size (one logical block = its 17 contiguous kernel pages),
``[#blocks / 17, 2, 544, 1, head_size']``
Cost: before this fix ``kv_caches[:, 0]`` is just the K tensor; after, it
interleaves K and V at kernel-page granularity. The bytes round-trip
correctly (store and retrieve share the mapping), but the dims are no
longer semantic, so content-aware processing does not apply.
"""
name = "subpaged-attention-view"
def matches(self, spec: KVCacheSpec, kv_cache: RegisteredKVCache) -> bool:
return (
# Standard-paged attention only; MLA layouts and declared slot
# compression (DeepSeek) belong to other transfer paths.
get_kv_cache_spec_kind(spec) in _SUBPAGEABLE_ATTENTION_KINDS
and not _declares_slot_compression(spec)
# (num_blocks, 2, block_size, num_heads, head_size) layout whose
# block dim disagrees with the scheduler block-id unit -- the
# backend re-paged the tensor at its kernel block size.
and isinstance(kv_cache, torch.Tensor)
and kv_cache.ndim == 5
and kv_cache.shape[2] != spec.block_size
)
def apply(self, spec: KVCacheSpec, kv_cache: RegisteredKVCache) -> torch.Tensor:
"""Re-view ``kv_cache`` at logical-block granularity.
The tensor is kernel-paged as ``(num_kernel_pages, 2,
kernel_block_size, num_kv_heads, head_size)``; the result is
``(num_logical_blocks, 2, spec.block_size, num_heads, head_size)``
over the same storage.
Raises:
ValueError: If the layout is not the expected kernel-paged shape,
the sizes do not divide evenly, or the kernel pages of one
logical block do not tile its page bytes exactly (which would
indicate an undeclared packed layout that must not be edited).
"""
if not isinstance(kv_cache, torch.Tensor) or kv_cache.shape[1] != 2:
got = (
tuple(kv_cache.shape)
if isinstance(kv_cache, torch.Tensor)
else type(kv_cache).__name__
)
raise ValueError(
f"expected a (num_blocks, 2, block_size, num_heads, head_size) "
f"attention KV tensor, got {got}"
)
logical_block_size = spec.block_size
kernel_block_size = kv_cache.shape[2]
if logical_block_size % kernel_block_size != 0:
raise ValueError(
f"logical block size {logical_block_size} is not a multiple of "
f"kernel block size {kernel_block_size}"
)
ratio = logical_block_size // kernel_block_size
num_kernel_pages = kv_cache.shape[0]
if num_kernel_pages % ratio != 0:
raise ValueError(
f"kernel page count {num_kernel_pages} is not a multiple of "
f"the logical/kernel block ratio {ratio}"
)
kernel_page_bytes = kv_cache.shape[1:].numel() * kv_cache.element_size()
if kernel_page_bytes * ratio != spec.page_size_bytes:
raise ValueError(
f"{ratio} kernel pages ({kernel_page_bytes * ratio} bytes) do "
f"not tile the logical page ({spec.page_size_bytes} bytes)"
)
if not kv_cache.is_contiguous():
raise ValueError(
"kernel-paged attention KV tensor must be contiguous to "
"re-view as logical pages"
)
num_blocks = num_kernel_pages // ratio
elems_per_page = spec.page_size_bytes // kv_cache.element_size()
num_heads, head_size = _synthetic_attention_shape(
elems_per_page, logical_block_size
)
return kv_cache.view(num_blocks, 2, logical_block_size, num_heads, head_size)
# Rule registry, in match priority order.
_EDITS: tuple[KVCacheGroupEdit, ...] = (
_MambaPageViewEdit(),
_SubpagedAttentionViewEdit(),
)
def apply_kv_cache_group_edits(
kv_cache_config: KVCacheConfig | None,
kv_caches: Mapping[str, RegisteredKVCache],
) -> dict[str, RegisteredKVCache]:
"""Apply all KV cache group metadata edits for LMCache registration.
Each layer is checked against the ``_EDITS`` rules (first match wins) and
re-viewed by the matching rule; layers matching no rule pass through
unchanged. ``None`` configs and configs without Mamba groups are returned
as-is (as a dict): all current rules only apply to Mamba-hybrid models.
Args:
kv_cache_config: vLLM ``KVCacheConfig`` (read for per-group specs).
kv_caches: Registered tensors keyed by layer name. Mamba entries are
``[conv_state, ssm_state]`` lists; others are single tensors.
Returns:
A new ``dict`` with edited layers re-viewed, others untouched.
Raises:
ValueError: If the groups fail :func:`validate_kv_cache_groups`, or a
matched layer's cache layout violates its rule's invariants (see
each rule's ``apply``).
"""
# Backstop for connectors initialized without a kv_cache_config.
validate_kv_cache_groups(kv_cache_config)
if kv_cache_config is None or not kv_cache_config.has_mamba_layers:
return dict(kv_caches)
edited = dict(kv_caches)
counts: Counter[str] = Counter()
for group in kv_cache_config.kv_cache_groups:
spec = group.kv_cache_spec
for name in group.layer_names:
for edit in _EDITS:
if edit.matches(spec, kv_caches[name]):
edited[name] = edit.apply(spec, kv_caches[name])
counts[edit.name] += 1
break
logger.info(
"KV cache group edits applied: %s",
dict(counts) if counts else "none",
)
return edited