[v1][kvcache] Honor prefix-cache retention interval for Mamba/linear attention (#45845)

Signed-off-by: Dao Le <daole@inferact.ai>
Signed-off-by: Dao Le <Dao007forever@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Dao007forever
2026-06-22 19:51:11 -07:00
committed by GitHub
co-authored by Claude Opus 4.8
parent 56e5797511
commit 430a95ae3a
3 changed files with 105 additions and 9 deletions
+38
View File
@@ -3905,3 +3905,41 @@ def test_pure_swa_retention_dense_default_caches_all(monkeypatch):
is not None
}
assert cached == set(range(16))
def test_mamba_reachable_block_mask_sparsifies_retention():
"""Mamba state-snapshot retention: with VLLM_PREFIX_CACHE_RETENTION_INTERVAL
the manager keeps one cached state per interval-sized segment (plus the
latest replay boundary) instead of a snapshot per block, which is what
lets a small attention block_size avoid Mamba dominating the KV pool."""
from vllm.v1.core.single_type_kv_cache_manager import MambaManager
block_size = 16
spec = MambaSpec(
block_size=block_size,
shapes=(1, 1),
dtypes=(torch.float32,),
mamba_cache_mode="align",
)
def retained(retention_interval, num_prompt_tokens=256, end_block=16):
m = MambaManager.reachable_block_mask(
start_block=0,
end_block=end_block,
alignment_tokens=block_size,
kv_cache_spec=spec,
use_eagle=False,
retention_interval=retention_interval,
num_prompt_tokens=num_prompt_tokens,
)
return None if m is None else {i for i, v in enumerate(m) if v}
# Dense default (None) -> no mask, every block cached (unchanged behavior).
assert retained(None) is None
# interval == block_size -> every block is a boundary -> stays dense.
assert retained(block_size) is None
# interval 64 = 4 blocks: segment tails at i%4==3 -> {3,7,11,15}; latest
# replay boundary 240//16 - 1 = 14. Sparse subset of the 16 blocks.
assert retained(64) == {3, 7, 11, 14, 15}
# interval 0 -> only the latest replay boundary (block 14).
assert retained(0) == {14}
+8 -7
View File
@@ -22,6 +22,7 @@ from vllm.v1.kv_cache_interface import (
FullAttentionSpec,
KVCacheConfig,
KVCacheSpec,
MambaSpec,
SlidingWindowSpec,
)
from vllm.v1.request import Request
@@ -35,18 +36,18 @@ def _validate_prefix_cache_retention_interval(
if retention_interval is None:
return
# Retention only sparsifies sliding-window checkpoints for now; every other
# manager (full attention, Mamba, chunked-local) caches densely and
# ignores it to be conservative.
# TODO: Support Mamba/linear attention.
# Retention sparsifies sliding-window and Mamba (linear-attention)
# checkpoints; full-attention and chunked-local groups cache densely and
# ignore it (their hit granularity must stay fine).
if not any(
isinstance(g.kv_cache_spec, SlidingWindowSpec)
isinstance(g.kv_cache_spec, (SlidingWindowSpec, MambaSpec))
for g in kv_cache_config.kv_cache_groups
):
raise ValueError(
"VLLM_PREFIX_CACHE_RETENTION_INTERVAL is set but this model has "
"no sliding-window KV cache group, so retention has no effect. "
"Unset it (the feature only applies to sliding-window attention)."
"no sliding-window or Mamba KV cache group, so retention has no "
"effect. Unset it (it only applies to sliding-window and Mamba "
"attention)."
)
if retention_interval < 0 or retention_interval % scheduler_block_size != 0:
+59 -2
View File
@@ -1018,6 +1018,60 @@ class MambaManager(SingleTypeKVCacheManager):
return computed_blocks
@classmethod
def reachable_block_mask(
cls,
start_block: int,
end_block: int,
alignment_tokens: int | None,
kv_cache_spec: KVCacheSpec,
use_eagle: bool,
retention_interval: int | None = None,
num_prompt_tokens: int | None = None,
) -> list[bool] | None:
"""Sparse Mamba state-snapshot retention.
``retention_interval``:
``None`` -> dense (cache every block; default, unchanged behavior)
``0`` -> keep only the latest replay boundary
``> 0`` -> keep one state per ``retention_interval``-sized segment
"""
if retention_interval is None or alignment_tokens is None:
# Dense caching (default) or no alignment constraint imposed.
return None
assert isinstance(kv_cache_spec, MambaSpec)
block_size = kv_cache_spec.block_size
mask = [False] * (end_block - start_block)
# (1) Segment-boundary states. A Mamba hit needs exactly the single
# state block ending on the boundary (no window, and draft models have
# no mamba layers, so no eagle shift). Block ``i`` ends at token
# ``(i + 1) * block_size``.
segment_tokens = None if retention_interval == 0 else retention_interval
if segment_tokens is not None:
per_segment = segment_tokens // block_size
if per_segment <= 1:
# Interval at/below the block size: every block is a boundary.
return None
first_boundary = (
start_block + per_segment
) // per_segment * per_segment - 1
for i in range(first_boundary - start_block, len(mask), per_segment):
mask[i] = True
# (2) Replay boundary. ``get_computed_blocks`` caps hits at
# ``num_prompt - 1``, so an exact prompt replay lands on the latest
# fine-aligned boundary. Sparse retention would otherwise skip its
# state, so keep it explicitly.
if num_prompt_tokens is not None:
latest = (num_prompt_tokens - 1) // alignment_tokens * alignment_tokens
boundary_block = latest // block_size - 1
if start_block <= boundary_block < end_block:
mask[boundary_block - start_block] = True
return mask
def remove_skipped_blocks(self, request_id: str, num_computed_tokens: int) -> None:
assert isinstance(self.kv_cache_spec, MambaSpec)
@@ -1224,9 +1278,12 @@ class MambaManager(SingleTypeKVCacheManager):
for block in self.req_to_blocks[request.request_id][
num_cached_blocks_before:num_cached_blocks_after
]:
if block.is_null:
# Skip null blocks (align-mode skipped states) and blocks that
# were not cached this step — with sparse retention
# (reachable_block_mask) the intermediate state snapshots carry
# no hash and must not be recorded as cached-this-step.
if block.is_null or block.block_hash is None:
continue
assert block.block_hash is not None
self.cached_blocks_this_step.add(block.block_hash)
def new_step_starts(self) -> None: