Files
vLLM-4x-RTX-3090/patches/0088-kv-side-cache-layout-fold-the-PLE-state-into-a-GDN-g.patch
T
MOVZX 777e910dd0 Initial commit
Signed-off-by: MOVZX <movzx@yahoo.com>
2026-10-15 01:01:52 +07:00

1729 lines
72 KiB
Diff

From e2d6962242963fb82f4b55ae460bd549bd2b4152 Mon Sep 17 00:00:00 2001
From: halt95 <halt95@users.noreply.github.com>
Date: Fri, 2 Oct 2026 09:50:32 -0400
Subject: [PATCH 088/103] kv: side-cache layout -- fold the PLE state into a
GDN group, pack QSA rings
The packed pool strides every block by the widest group, and every group draws whole blocks. The PLE short-conv
state now shares a GDN cache group, and the QSA raw-key rings are packed as equal slots inside shared blocks, so
neither holds a whole block per request. VLLM_E2_SIDE_CACHE_LAYOUT (default 1) selects the old layout with 0.
On its own the layout raises the Flash-Next pool from 806,792 to 853,994 tokens at the same budget.
---
tests/test_envs.py | 23 +
tests/v1/core/test_side_cache_layout.py | 882 +++++++++++++++++++
tests/v1/test_pp_kv_projection.py | 20 +-
vllm/envs.py | 15 +
vllm/v1/core/kv_cache_coordinator.py | 12 +
vllm/v1/core/kv_cache_utils.py | 335 ++++++-
vllm/v1/core/sched/scheduler.py | 4 +
vllm/v1/core/single_type_kv_cache_manager.py | 112 ++-
vllm/v1/kv_cache_interface.py | 23 +-
vllm/v1/worker/gpu/model_runner.py | 1 +
vllm/v1/worker/gpu_model_runner.py | 1 +
vllm/v1/worker/utils.py | 11 +
12 files changed, 1426 insertions(+), 13 deletions(-)
create mode 100644 tests/v1/core/test_side_cache_layout.py
diff --git a/tests/test_envs.py b/tests/test_envs.py
index bcd7b33..af5e39d 100644
--- a/tests/test_envs.py
+++ b/tests/test_envs.py
@@ -621,6 +621,10 @@ _FORK_HASHED = [
# PLE pull transport: selects a different PLE module class and captured
# forward, so it is hashed. Strict "0"/"1" parser (envs._e2_strict_switch).
("VLLM_E2_PLE_PULL_TRANSPORT", "1", True),
+ # v2.5 side-cache layout: changes the ring views' block stride and which
+ # block table the PLE state is read through, so ON/OFF must not share an
+ # AOT cache. Strict "0"/"1", default ON; "0" selects the old layout.
+ ("VLLM_E2_SIDE_CACHE_LAYOUT", "0", False),
]
_FORK_IGNORED = [
@@ -1065,6 +1069,7 @@ _MUST_BE_READ = {
"VLLM_E2_PLE_FLAG_CHECK": "vllm/v1/ple_offload/connector.py",
"VLLM_E2_MAMBA_SLOT_OWNERSHIP": "vllm/v1/worker/mamba_utils.py",
"VLLM_E2_MAMBA_RETAIN_CHECKPOINT": "vllm/v1/core/single_type_kv_cache_manager.py",
+ "VLLM_E2_SIDE_CACHE_LAYOUT": "vllm/v1/core/kv_cache_utils.py",
}
_INERT_NO_READ_SITE = ["VLLM_PLE_FP8_GLOBAL_SCALE"]
@@ -1128,3 +1133,21 @@ def test_mamba_retain_checkpoint_switch_defaults_on_and_is_strict(
monkeypatch.setenv(name, bad)
with pytest.raises(ValueError, match=name):
_fresh_env_value(name)
+
+
+def test_side_cache_layout_switch_defaults_on_and_is_strict(
+ monkeypatch: pytest.MonkeyPatch,
+):
+ """Unset is ON (the layout fix ships enabled); "0" is the only way back to
+ the old one-group-each layout, and a misspelt value is a boot error."""
+ name = "VLLM_E2_SIDE_CACHE_LAYOUT"
+ monkeypatch.delenv(name, raising=False)
+ assert _fresh_env_value(name) is True
+ monkeypatch.setenv(name, "1")
+ assert _fresh_env_value(name) is True
+ monkeypatch.setenv(name, "0")
+ assert _fresh_env_value(name) is False
+ for bad in ("off", "false", "", " 1"):
+ monkeypatch.setenv(name, bad)
+ with pytest.raises(ValueError, match=name):
+ _fresh_env_value(name)
diff --git a/tests/v1/core/test_side_cache_layout.py b/tests/v1/core/test_side_cache_layout.py
new file mode 100644
index 0000000..9ebba77
--- /dev/null
+++ b/tests/v1/core/test_side_cache_layout.py
@@ -0,0 +1,882 @@
+# SPDX-License-Identifier: Apache-2.0
+# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
+"""v2.5 side-cache layout (``VLLM_E2_SIDE_CACHE_LAYOUT``, default ON).
+
+Flash-Next's packed block-outermost KV pool strides every block by the widest
+group (12,902,400 B on PP1 at FP32 SSM state) and every group draws whole
+blocks from it. The TP-replicated PLE short-conv state (one layer, 2 + K blocks
+per request in align mode) and the QSA raw-key ring (one block per request)
+hold ~1.2 MB of real state but took 6 of the 103 blocks a 262K request needs.
+The layout fix folds the PLE group into a GDN group that has a spare page on
+every PP stage, and packs several requests' rings into one pool block.
+
+What these tests pin (CPU only):
+
+1. Pool size. On the reference host's per-layer specs (TP2 x PP2, partition 25/23, MTP
+ K=3, fp8 KV, BLNHC, pin 4.1e9) the block count and both stages' strides are
+ unchanged and a 262K request needs 97 blocks instead of 103 at FP32 state
+ (173 instead of 179 at FP16). FAILING CONTROL: switch "0" reproduces the
+ logged pools (806,792 and 894,804 tokens) block for block.
+2. Allocation / eviction through ``KVCacheManager`` as the scheduler drives it
+ (aligned prefill chunks, MTP K=3 decode with draft acceptance, finish,
+ preemption, the deferred ``pop_blocks_for_free`` path, prefix hits with the
+ retained checkpoints). The PLE state's block table evolves exactly as
+ it did in its own group, hits are identical, and N rings share one pool
+ block. FAILING CONTROLS: with the switch off the same pool cannot admit the
+ extra request, and every ring takes its own block.
+3. Worker views. The ring slots and the folded PLE page lie inside their pool
+ block and overlap nothing they must not, and the KV zeroer zeroes exactly
+ the same bytes of every block in both layouts. FAILING CONTROL: a zeroer
+ built without the slot-group skip zeroes different bytes.
+"""
+
+from dataclasses import replace
+from types import SimpleNamespace
+
+import pytest
+import torch
+
+from vllm import envs
+from vllm.model_executor.layers.mamba.mamba_utils import MambaStateShapeCalculator
+from vllm.sampling_params import SamplingParams
+from vllm.utils.hashing import sha256
+from vllm.utils.math_utils import cdiv
+from vllm.v1.attention.backends.registry import MambaAttentionBackendEnum
+from vllm.v1.core import kv_cache_utils as ku
+from vllm.v1.core.block_pool import BlockPool
+from vllm.v1.core.kv_cache_manager import KVCacheManager
+from vllm.v1.core.kv_cache_utils import get_request_block_hasher, init_none_hash
+from vllm.v1.core.sched.scheduler import Scheduler
+from vllm.v1.core.single_type_kv_cache_manager import (
+ CircularBufferManager,
+ RingSlotPool,
+)
+from vllm.v1.kv_cache_interface import (
+ CircularBufferSpec,
+ FullAttentionSpec,
+ KVCacheLayout,
+ MambaSpec,
+ MLAAttentionSpec,
+ UniformTypeKVCacheSpecs,
+ get_kv_quant_mode,
+)
+from vllm.v1.request import Request
+
+pytestmark = pytest.mark.cpu_test
+
+SWITCH = "VLLM_E2_SIDE_CACHE_LAYOUT"
+TP = 2
+K = 3 # MTP num_speculative_tokens, as deployed
+PIN = 4_100_000_000 # --kv-cache-memory on every rank
+MAX_LEN = 262_144
+
+
+def _set_switch(monkeypatch, value: str) -> None:
+ monkeypatch.setenv(SWITCH, value)
+ if hasattr(envs.__getattr__, "cache_clear"):
+ envs.__getattr__.cache_clear()
+
+
+@pytest.fixture(params=["1", "0"], ids=["layout-on", "layout-off-control"])
+def layout(request, monkeypatch) -> bool:
+ _set_switch(monkeypatch, request.param)
+ return request.param == "1"
+
+
+@pytest.fixture(autouse=True)
+def _init_hash():
+ init_none_hash(sha256)
+
+
+# ---------------------------------------------------------------------------
+# Flash-Next per-layer specs (the reference fixture, in align mode, any block size
+# and SSM dtype). Boot log: "Setting attention block size to 3200 tokens" at FP32
+# state, 1664 at FP16; every Mamba page padded to the attention page.
+# ---------------------------------------------------------------------------
+def _flash_next_specs(
+ block: int = 3200,
+ ssm_dtype: torch.dtype = torch.float32,
+ *,
+ tiny_state: bool = False,
+) -> tuple[dict, dict]:
+ attn_page = block * 1 * 256 * 2 # fp8 K+V, 1 KV head per TP rank
+ main = FullAttentionSpec(
+ block_size=block,
+ num_kv_heads=2 // TP,
+ head_size=256,
+ head_size_v=256,
+ dtype=torch.uint8,
+ kv_quant_mode=get_kv_quant_mode("fp8_e4m3"),
+ )
+ ring = CircularBufferSpec(
+ block_size=8, num_kv_heads=1, head_size=144, head_size_v=0, dtype=torch.bfloat16
+ )
+ comp = MLAAttentionSpec(
+ block_size=block,
+ num_kv_heads=1,
+ head_size=128,
+ dtype=torch.bfloat16,
+ tokens_per_state=4,
+ )
+ if tiny_state:
+ # Small blocks for scheduler tests: the real states exceed the page.
+ gdn_shapes: tuple = ((4,),)
+ gdn_dtypes: tuple = (torch.float32,)
+ ple_shapes: tuple = ((2,),)
+ else:
+ gdn_shapes = tuple(
+ MambaStateShapeCalculator.gated_delta_net_state_shape(
+ TP, 16, 48, 128, 128, 4, K
+ )
+ )
+ gdn_dtypes = (torch.bfloat16, ssm_dtype)
+ ple_shapes = tuple(
+ MambaStateShapeCalculator.short_conv_state_shape(
+ tp_world_size=1, intermediate_size=10240, conv_kernel=10, num_spec=K
+ )
+ )
+ gdn = MambaSpec(
+ shapes=gdn_shapes,
+ dtypes=gdn_dtypes,
+ block_size=block,
+ page_size_padded=attn_page,
+ mamba_type=MambaAttentionBackendEnum.GDN_ATTN,
+ mamba_cache_mode="align",
+ num_speculative_blocks=K,
+ )
+ ple = MambaSpec(
+ shapes=ple_shapes,
+ dtypes=(torch.bfloat16,),
+ block_size=block,
+ page_size_padded=attn_page,
+ mamba_type=MambaAttentionBackendEnum.SHORT_CONV,
+ mamba_cache_mode="align",
+ tp_replicated=True,
+ num_speculative_blocks=K,
+ )
+
+ def qsa(prefix: str, specs: dict) -> None:
+ specs[f"{prefix}.self_attn.indexer.raw_key_cache"] = ring
+ specs[f"{prefix}.self_attn.indexer.compressed_key_cache"] = comp
+ specs[f"{prefix}.self_attn.attn"] = main
+
+ def stage(lo: int, hi: int, mtp: bool) -> dict:
+ specs: dict = {}
+ for i in range(lo, hi):
+ prefix = f"language_model.model.layers.{i}"
+ if i == 2:
+ specs[f"{prefix}.ple"] = ple
+ if i % 4 == 3:
+ qsa(prefix, specs)
+ else:
+ specs[f"{prefix}.linear_attn"] = gdn
+ if mtp:
+ qsa("mtp.layers.48", specs)
+ return specs
+
+ return stage(0, 25, False), stage(25, 48, True)
+
+
+def _vllm_config(
+ *,
+ override: int | None = None,
+ max_num_seqs: int = 8,
+ kv_transfer_config=None,
+ max_model_len: int = MAX_LEN,
+):
+ cache = SimpleNamespace(
+ kv_cache_layout="BLNHC",
+ num_gpu_blocks_override=override,
+ prefix_cache_retention_interval=0,
+ mamba_cache_mode="align",
+ get_resolved_kv_cache_layout=lambda: KVCacheLayout.BLNHC,
+ )
+ return SimpleNamespace(
+ cache_config=cache,
+ speculative_config=SimpleNamespace(
+ num_speculative_tokens=K,
+ method="mtp",
+ use_multi_module_mtp=lambda: False,
+ use_eagle=lambda: True,
+ use_eagle_block_drop=lambda: True,
+ ),
+ scheduler_config=SimpleNamespace(
+ disable_hybrid_kv_cache_manager=False,
+ max_num_encoder_input_tokens=0,
+ max_num_seqs=max_num_seqs,
+ ),
+ attention_config=SimpleNamespace(hisparse_config=None),
+ model_config=SimpleNamespace(
+ original_max_model_len=max_model_len,
+ max_model_len=max_model_len,
+ hf_config=SimpleNamespace(model_type="qwen4_exp"),
+ get_total_num_hidden_layers=lambda: 48,
+ ),
+ device_config=SimpleNamespace(device_type="cuda"),
+ parallel_config=SimpleNamespace(
+ pipeline_parallel_size=2, decode_context_parallel_size=1
+ ),
+ kv_transfer_config=kv_transfer_config,
+ )
+
+
+def _configs(block=3200, ssm_dtype=torch.float32, *, pin=PIN, **kw):
+ pp0, pp1 = _flash_next_specs(block, ssm_dtype, tiny_state=kw.pop("tiny", False))
+ cfg = _vllm_config(**kw)
+ specs = [pp0, pp0, pp1, pp1] # TP2 x PP2
+ return cfg, specs, ku.get_kv_cache_configs(cfg, specs, [pin] * 4)
+
+
+def _first_spec(group):
+ spec = group.kv_cache_spec
+ return spec.first_spec if isinstance(spec, UniformTypeKVCacheSpecs) else spec
+
+
+def _request_blocks(cfg, config) -> int:
+ """Blocks one max_model_len request holds, as the boot's capacity line
+ counts them (shared ring pages excluded)."""
+ total = 0
+ for group in config.kv_cache_groups:
+ if group.slots_per_block > 1:
+ continue
+ total += cdiv(
+ group.kv_cache_spec.max_memory_usage_bytes(cfg),
+ group.kv_cache_spec.page_size_bytes,
+ )
+ return total
+
+
+# ---------------------------------------------------------------------------
+# 1. Pool size (blocks freed per request), FP32 and FP16 state.
+# ---------------------------------------------------------------------------
+# (block, ssm dtype, stage strides, blocks, per-request OFF, ON, pool OFF, ON)
+_POOL_CASES = {
+ "fp32": (
+ 3200,
+ torch.float32,
+ (11_468_800, 12_902_400),
+ 317,
+ 103,
+ 97,
+ 806_792,
+ 853_994,
+ ),
+ "fp16": (
+ 1664,
+ torch.float16,
+ (5_963_776, 6_709_248),
+ 611,
+ 179,
+ 173,
+ 894_804,
+ 924_322,
+ ),
+}
+
+
+@pytest.mark.parametrize("case", sorted(_POOL_CASES))
+def test_pool_blocks_per_request(case, layout):
+ block, dtype, strides, blocks, req_off, req_on, pool_off, pool_on = _POOL_CASES[
+ case
+ ]
+ cfg, specs, configs = _configs(block, dtype)
+ for rank, config in enumerate(configs):
+ # The pool itself does not move: same block count, same stride on
+ # both stages (PP1 binds), so not one byte of pin is spent.
+ assert config.num_blocks == blocks, f"rank {rank}"
+ assert (
+ ku._get_kv_cache_bytes_per_block(config.kv_cache_groups)
+ == strides[rank // 2]
+ )
+ per_request = _request_blocks(cfg, configs[0])
+ capacity, _ = ku.get_kv_cache_capacity(cfg, configs[0])
+ if layout:
+ assert per_request == req_on
+ assert req_off - per_request == 6 # 5 PLE + 1 ring blocks freed
+ assert capacity == pool_on
+ else:
+ # FAILING CONTROL: the old layout, exactly as the reference host logged it
+ # ("GPU KV cache size: 806,792 tokens" / "894,804 tokens").
+ assert per_request == req_off
+ assert capacity == pool_off
+ # Every rank agrees, and so does the scheduler's config, which is what
+ # update_kv_cache_capacity logs at boot ("GPU KV cache size: N tokens").
+ assert {ku.get_kv_cache_capacity(cfg, c)[0] for c in configs} == {capacity}
+ sched = ku.generate_scheduler_kv_cache_config(configs)
+ assert ku.get_kv_cache_capacity(cfg, sched)[0] == capacity
+
+
+def test_ple_rides_in_a_gdn_group_with_a_spare_page(layout):
+ _, specs, configs = _configs()
+ ple = "language_model.model.layers.2.ple"
+ for rank, config in enumerate(configs):
+ tensor_layers = sorted(n for t in config.kv_cache_tensors for n in t.layers)
+ assert tensor_layers == sorted(specs[rank]), f"rank {rank}"
+ owner = [g for g in config.kv_cache_groups if ple in g.layer_names]
+ mamba_groups = [
+ g for g in config.kv_cache_groups if isinstance(_first_spec(g), MambaSpec)
+ ]
+ if rank >= 2:
+ assert not owner # PLE lives on PP0 only
+ if layout:
+ assert len(mamba_groups) == 3
+ assert all(g.layer_names for g in config.kv_cache_groups)
+ if rank < 2:
+ [group] = owner
+ gdn = [n for n in group.layer_names if n.endswith("linear_attn")]
+ assert len(gdn) == 6 and group.layer_names[-1] == ple
+ else:
+ # FAILING CONTROL: its own group, empty on PP1 (the reference shape).
+ assert len(mamba_groups) == 4
+ if rank < 2:
+ assert owner[0].layer_names == [ple]
+
+
+def test_ring_slots_divide_both_stage_strides():
+ _, _, configs = _configs()
+ rings = [
+ (rank, g)
+ for rank, c in enumerate(configs)
+ for g in c.kv_cache_groups
+ if isinstance(_first_spec(g), CircularBufferSpec)
+ ]
+ assert {g.slots_per_block for _, g in rings} == {800}
+ for rank, config in enumerate(configs):
+ stride = ku._get_kv_cache_bytes_per_block(config.kv_cache_groups)
+ for tensor in config.kv_cache_tensors:
+ if tensor.slots_per_block > 1:
+ assert tensor.block_stride * tensor.slots_per_block == stride
+ assert tensor.block_stride % 256 == 0
+ assert tensor.block_stride >= len(tensor.layers) * 2304
+ assert config.num_blocks_of(tensor) == config.num_blocks * 800
+
+
+def test_kv_connector_keeps_the_old_layout(monkeypatch):
+ """NIXL / offload / P/D address groups themselves: layout stays off."""
+ _set_switch(monkeypatch, "1")
+ cfg, _, configs = _configs(kv_transfer_config=SimpleNamespace())
+ assert _request_blocks(cfg, configs[0]) == 103
+ assert all(g.slots_per_block == 1 for g in configs[0].kv_cache_groups)
+
+
+def test_fold_never_widens_a_stage(monkeypatch):
+ """The PP-aware target choice. Folding into GDN group 0 (7 pages on PP0)
+ would widen PP0's block to 8 pages; the pass must pick a 6-page group.
+ CONTROL: the forced group-0 fold really does widen PP0."""
+ _set_switch(monkeypatch, "0")
+ pp0, pp1 = _flash_next_specs()
+ specs = [pp0, pp0, pp1, pp1]
+ merged: dict = {}
+ for s in specs:
+ merged.update(s)
+ groups = ku.get_kv_cache_groups(_vllm_config(), merged)
+ base = ku._worker_strides(groups, specs)
+ folded, notes = ku._fold_replicated_mamba_groups(groups, specs)
+ assert ku._worker_strides(folded, specs) == base
+ assert len(folded) == len(groups) - 1 and "into Mamba group 1" in notes[0]
+
+ ple_index = next(
+ i
+ for i, g in enumerate(groups)
+ if any(n.endswith(".ple") for n in g.layer_names)
+ )
+ g0 = groups[0]
+ forced = UniformTypeKVCacheSpecs(
+ block_size=g0.kv_cache_spec.block_size,
+ kv_cache_specs={
+ **g0.kv_cache_spec.kv_cache_specs,
+ **groups[ple_index].kv_cache_spec.kv_cache_specs,
+ },
+ )
+ wrong = [
+ replace(
+ g0,
+ layer_names=g0.layer_names + groups[ple_index].layer_names,
+ kv_cache_spec=forced,
+ )
+ ]
+ wrong += [g for i, g in enumerate(groups) if i not in (0, ple_index)]
+ widened = ku._worker_strides(wrong, specs)
+ assert widened[0] == base[0] + 1_638_400 # PP0: 8 pages instead of 7
+
+
+# ---------------------------------------------------------------------------
+# 2. Allocation / eviction through KVCacheManager (tiny states, block 32).
+# ---------------------------------------------------------------------------
+BLOCK = 32
+PLE = "language_model.model.layers.2.ple"
+
+
+SCHED_MAX_LEN = 512 # small, so small test pools pass the boot memory check
+
+
+def _manager(num_blocks: int) -> KVCacheManager:
+ _, _, configs = _configs(
+ BLOCK, tiny=True, override=num_blocks, max_model_len=SCHED_MAX_LEN
+ )
+ config = ku.generate_scheduler_kv_cache_config(configs)
+ return KVCacheManager(
+ replace(config, prefix_cache_retention_interval=0),
+ max_model_len=SCHED_MAX_LEN,
+ scheduler_block_size=BLOCK,
+ hash_block_size=BLOCK,
+ enable_caching=True,
+ use_eagle=True,
+ )
+
+
+def _group_of(manager: KVCacheManager, layer: str) -> int:
+ return next(
+ i
+ for i, g in enumerate(manager.kv_cache_config.kv_cache_groups)
+ if layer in g.layer_names
+ )
+
+
+def _ring_gid(manager: KVCacheManager) -> int:
+ return next(
+ i
+ for i, m in enumerate(manager.coordinator.single_type_managers)
+ if type(m) is CircularBufferManager
+ )
+
+
+def _ring(manager: KVCacheManager) -> CircularBufferManager:
+ mgr = manager.coordinator.single_type_managers[_ring_gid(manager)]
+ assert isinstance(mgr, CircularBufferManager)
+ return mgr
+
+
+def _request(rid: str, tokens: list[int]) -> Request:
+ params = SamplingParams(max_tokens=4096)
+ params.update_from_generation_config({}, eos_token_id=100000)
+ return Request(
+ request_id=rid,
+ prompt_token_ids=tokens,
+ sampling_params=params,
+ pooling_params=None,
+ block_hasher=get_request_block_hasher(BLOCK, sha256),
+ )
+
+
+def _tokens(seed: int, n: int) -> list[int]:
+ return [seed * 10000 + i for i in range(n)]
+
+
+def _split(request: Request, num_new: int, num_local_hit: int = 0) -> int:
+ stub = SimpleNamespace(
+ block_size=BLOCK,
+ cache_config=SimpleNamespace(block_size=BLOCK),
+ use_eagle_block_drop=True,
+ max_num_scheduled_tokens=8192,
+ scheduler_config=SimpleNamespace(long_prefill_token_threshold=0),
+ mamba_partial_cache_hit=False,
+ mamba_fine_grained_prefix_cache=False,
+ hash_block_size=BLOCK,
+ mamba_has_prefill_checkpoint_blocks=False,
+ mamba_prefill_checkpoint_alignment=None,
+ )
+ return Scheduler._mamba_block_aligned_split(stub, request, num_new, num_local_hit)
+
+
+def _prefill(manager, request, budget=40, trace=None, may_stall=False) -> int | None:
+ """Prefill in scheduler-sized chunks; None if admission is refused (or,
+ with ``may_stall``, if a later chunk does not fit -- the request is then
+ freed, as a scheduler would preempt it)."""
+ computed, num_hit, _ = manager.get_computed_blocks(request)
+ first = True
+ while request.num_computed_tokens < request.num_tokens:
+ start = request.num_computed_tokens + (num_hit if first else 0)
+ num_new = _split(
+ request, min(request.num_tokens - start, budget), num_hit if first else 0
+ )
+ blocks = manager.allocate_slots(
+ request,
+ num_new,
+ num_new_computed_tokens=num_hit if first else 0,
+ new_computed_blocks=computed if first else None,
+ num_lookahead_tokens=K,
+ full_sequence_must_fit=first,
+ )
+ if blocks is None:
+ if not first:
+ assert may_stall, "a later prefill chunk did not fit"
+ manager.free(request)
+ return None
+ request.num_computed_tokens = start + num_new
+ manager.new_step_starts()
+ first = False
+ if trace is not None:
+ trace()
+ return num_hit
+
+
+def _decode(manager, request, steps, accepted=lambda step: 0, trace=None) -> None:
+ next_tok = 50000 + len(request.output_token_ids)
+ if request.num_computed_tokens == request.num_tokens:
+ request.append_output_token_ids(next_tok)
+ next_tok += 1
+ for step in range(steps):
+ request.spec_token_ids = [next_tok + i for i in range(K)]
+ assert manager.allocate_slots(request, 1 + K, num_lookahead_tokens=K)
+ n_acc = accepted(step)
+ request.spec_token_ids = []
+ request.num_computed_tokens += 1 + n_acc
+ request.append_output_token_ids(list(range(next_tok, next_tok + n_acc + 1)))
+ next_tok += n_acc + 1
+ manager.new_step_starts()
+ if trace is not None:
+ trace()
+
+
+def _pattern(blocks) -> tuple:
+ """A block table with ids renamed by first appearance (null = -1), so two
+ layouts can be compared position by position."""
+ names: dict[int, int] = {}
+ return tuple(
+ -1 if b.is_null else names.setdefault(b.block_id, len(names)) for b in blocks
+ )
+
+
+def _trajectory(manager: KVCacheManager, accept_seed: int = 7) -> list:
+ """Run A (prefill + MTP decode across block boundaries, varying accepts),
+ finish it, resume it as A2 (prefix hit), run cold B beside it, free B via
+ the deferred path, resume it as B2, and record, after every step, the
+ block-table pattern of the attention group, of the PLE layer's group and
+ of every GDN group, the ring table width, and every hit length."""
+ groups = manager.kv_cache_config.kv_cache_groups
+ ple_gid = _group_of(manager, PLE)
+ gdn_gids = [
+ i
+ for i, g in enumerate(groups)
+ if any(n.endswith("linear_attn") for n in g.layer_names)
+ ]
+ attn_gid = _group_of(manager, "language_model.model.layers.3.self_attn.attn")
+ ring_gid = _ring_gid(manager)
+ log: list = []
+ live: list[Request] = []
+
+ def trace():
+ for req in live:
+ blocks = manager.coordinator.get_blocks(req.request_id)
+ if not blocks[attn_gid]:
+ continue
+ log.append(
+ (
+ req.request_id,
+ _pattern(blocks[attn_gid]),
+ _pattern(blocks[ple_gid]),
+ tuple(sorted(_pattern(blocks[g]) for g in gdn_gids)),
+ len(blocks[ring_gid]),
+ )
+ )
+
+ a = _request("A", _tokens(1, 150))
+ live.append(a)
+ log.append(("hitA", _prefill(manager, a, trace=trace)))
+ _decode(manager, a, 30, accepted=lambda s: (s * accept_seed) % 4, trace=trace)
+ manager.free(a)
+ live.remove(a)
+ a2 = _request("A2", list(a.all_token_ids) + _tokens(3, 40))
+ live.append(a2)
+ log.append(("hitA2", _prefill(manager, a2, trace=trace)))
+ b = _request("B", _tokens(4, 120))
+ live.append(b)
+ log.append(("hitB", _prefill(manager, b, trace=trace)))
+ _decode(manager, a2, 6, accepted=lambda s: s % 4, trace=trace)
+ _decode(manager, b, 3, trace=trace)
+ popped = manager.pop_blocks_for_free(b) # deferred free (the block-free fence)
+ live.remove(b)
+ manager.block_pool.free_blocks(popped) # the scheduler's fenced drain
+ b2 = _request("B2", list(b.all_token_ids) + _tokens(5, 10))
+ live.append(b2)
+ log.append(("hitB2", _prefill(manager, b2, trace=trace)))
+ for req in (a2, b2):
+ manager.free(req)
+ return log
+
+
+def test_ple_block_table_and_hits_match_the_old_layout(monkeypatch):
+ """The PLE layer's block table (null/live positions, which entries share a
+ block across steps) and every hybrid hit length are the same as when it
+ had its own group: its kernels index the same state positions, so its
+ outputs cannot change. A large pool keeps eviction out of the comparison.
+ The retained checkpoints are on (the default) in both runs."""
+ runs = {}
+ for value in ("0", "1"):
+ _set_switch(monkeypatch, value)
+ manager = _manager(num_blocks=400)
+ assert envs.VLLM_E2_MAMBA_RETAIN_CHECKPOINT
+ runs[value] = _trajectory(manager)
+ # Nothing leaked: only the null block is not free.
+ assert manager.block_pool.get_num_free_blocks() == 399
+ assert len(runs["1"]) > 50
+ assert runs["1"] == runs["0"]
+ hits = {
+ e[0]: e[1] for e in runs["1"] if isinstance(e[0], str) and e[0][:3] == "hit"
+ }
+ assert hits["hitA"] == 0 and hits["hitB"] == 0
+ assert hits["hitA2"] > 0 and hits["hitB2"] > 0 # real hits were compared
+ # CONTROL: the comparison is not vacuous -- a different accept pattern
+ # gives different tables, and the comparator sees it.
+ manager = _manager(num_blocks=400)
+ assert _trajectory(manager, accept_seed=1) != runs["1"]
+
+
+def _held(manager: KVCacheManager) -> set[int]:
+ ids: set[int] = set()
+ for mgr in manager.coordinator.single_type_managers:
+ pool = getattr(mgr, "slot_pool", None)
+ if pool is not None:
+ ids |= set(pool._pages)
+ continue
+ for blocks in mgr.req_to_blocks.values():
+ ids |= {b.block_id for b in blocks if not b.is_null}
+ held = getattr(mgr, "_retained_checkpoints", {})
+ ids |= {blk.block_id for _, blk in held.values()}
+ return ids
+
+
+def _owner_count(manager: KVCacheManager) -> int:
+ """Owner references to pool blocks, counting shared prefix blocks once
+ per holder. Equal to len(_held) iff no block has two exclusive owners."""
+ total = 0
+ for mgr in manager.coordinator.single_type_managers:
+ pool = getattr(mgr, "slot_pool", None)
+ if pool is not None:
+ total += pool.num_pages
+ continue
+ total += len(
+ {
+ b.block_id
+ for blocks in mgr.req_to_blocks.values()
+ for b in blocks
+ if not b.is_null
+ }
+ )
+ total += len(getattr(mgr, "_retained_checkpoints", {}))
+ return total
+
+
+def test_rings_share_one_pool_block_and_pool_partitions(layout):
+ manager = _manager(num_blocks=400)
+ pool = manager.block_pool
+ ring = _ring(manager)
+ reqs = [_request(f"R{i}", _tokens(10 + i, 70)) for i in range(5)]
+ for req in reqs:
+ assert _prefill(manager, req) == 0
+ _decode(manager, req, 2)
+ # Partition: every non-null block is free XOR held, by one owner.
+ assert pool.get_num_free_blocks() + len(_held(manager)) == 399
+ assert _owner_count(manager) == len(_held(manager))
+ ring_ids = [ring.get_ring_block_ids(r.request_id)[0] for r in reqs]
+ assert len(set(ring_ids)) == 5
+ claimed = manager.take_new_ring_block_ids()
+ assert claimed == ring_ids # tag clears name exactly the claimed ids
+ if layout:
+ slots = ring.slot_pool.slots_per_block
+ assert slots > 5
+ assert ring.slot_pool.num_pages == 1 # five rings, one pool block
+ assert len({rid // slots for rid in ring_ids}) == 1
+ assert sorted(rid % slots for rid in ring_ids) == [0, 1, 2, 3, 4]
+ else:
+ # FAILING CONTROL: one pool block per ring.
+ assert ring.slot_pool is None
+ assert all(pool.blocks[rid].ref_cnt == 1 for rid in ring_ids)
+ # Free in a mixed order, one through the deferred path.
+ manager.free(reqs[1])
+ popped = manager.pop_blocks_for_free(reqs[3])
+ if layout:
+ # The popped slot is not reusable before the fence: still live.
+ assert ring.slot_pool.num_live_slots == 4
+ manager.block_pool.free_blocks(popped)
+ if layout:
+ assert ring.slot_pool.num_live_slots == 3
+ # A new request reuses the lowest free slot of the same page.
+ r5 = _request("R5", _tokens(30, 70))
+ _prefill(manager, r5)
+ assert ring.get_ring_block_ids("R5") == [ring_ids[1]]
+ manager.free(r5)
+ for req in (reqs[0], reqs[2], reqs[4]):
+ manager.free(req)
+ assert pool.get_num_free_blocks() == 399
+ if layout:
+ assert ring.slot_pool.num_pages == 0
+
+
+def _running_footprint(value: str, prompt: int) -> int:
+ with pytest.MonkeyPatch.context() as mp:
+ _set_switch(mp, value)
+ manager = _manager(num_blocks=400)
+ req = _request("X", _tokens(99, prompt))
+ _prefill(manager, req)
+ _decode(manager, req, 3)
+ return 399 - manager.block_pool.get_num_free_blocks()
+
+
+def test_freed_blocks_admit_requests_the_old_layout_refuses(layout):
+ """Admission under pool pressure, through the full-sequence gate. The
+ per-request footprint is measured, not assumed; the pool is sized to six
+ requests of the NEW footprint plus less than one request's saving. ON
+ admits all six; the old layout cannot."""
+ on, off = _running_footprint("1", 160), _running_footprint("0", 160)
+ # 2 + K PLE state blocks (retained checkpoint included) and the ring block,
+ # minus the one shared ring page the new layout holds for everyone.
+ assert off - on == (2 + K) + 1 - 1
+ n = 6
+ margin = off - on - 1
+ manager = _manager(num_blocks=1 + n * on + margin) # + the null block
+ admitted = []
+ for i in range(n):
+ req = _request(f"C{i}", _tokens(200 + i, 160))
+ if _prefill(manager, req, may_stall=True) is None:
+ break
+ _decode(manager, req, 3)
+ admitted.append(req)
+ if layout:
+ assert len(admitted) == n
+ else:
+ # FAILING CONTROL: the same pool fits fewer conversations.
+ assert len(admitted) < n
+ for req in admitted:
+ manager.free(req)
+ assert manager.block_pool.get_num_free_blocks() == n * on + margin
+
+
+def test_ring_slot_pool_unit():
+ pool = BlockPool(num_gpu_blocks=6, enable_caching=True, hash_block_size=16)
+ slots = RingSlotPool(pool, slots_per_block=3)
+ got = slots.get_new_blocks(4)
+ assert [b.block_id // 3 for b in got] == [got[0].block_id // 3] * 3 + [
+ got[3].block_id // 3
+ ]
+ assert slots.num_pages == 2 and pool.get_num_free_blocks() == 3
+ # Routed through the main pool's free path (as the scheduler drain does).
+ pool.free_blocks([got[3]])
+ assert slots.num_pages == 1 and pool.get_num_free_blocks() == 4
+ pool.free_blocks(got[:2])
+ assert slots.has_free_slot() and slots.num_pages == 1
+ with pytest.raises(AssertionError):
+ slots.free_blocks([got[0]]) # double free is loud
+ pool.free_blocks([got[2]])
+ assert slots.num_pages == 0 and pool.get_num_free_blocks() == 5
+
+
+# ---------------------------------------------------------------------------
+# 3. Worker views and KV zeroing (CPU allocation of a 3-block pool).
+# ---------------------------------------------------------------------------
+def _views(monkeypatch, value: str, rank: int):
+ from vllm.v1.worker.utils import allocate_kv_cache
+
+ _set_switch(monkeypatch, value)
+ # 24 blocks (~300 MB on CPU): one 3200-token request (22 blocks in the old
+ # layout) must still pass the boot's memory check.
+ _, _, configs = _configs(override=24, max_model_len=3200)
+ config = configs[rank]
+ caches = allocate_kv_cache(config, torch.device("cpu"), KVCacheLayout.BLNHC)
+ return config, caches
+
+
+def _byte_range(view: torch.Tensor, index: int, base: int) -> tuple[int, int]:
+ page = view[index]
+ start = page.data_ptr() - base
+ span = page.element_size() * (
+ 1 + sum((s - 1) * st for s, st in zip(page.shape, page.stride()))
+ )
+ return start, start + span
+
+
+@pytest.mark.parametrize("rank", [0, 2], ids=["pp0", "pp1"])
+def test_slot_and_ple_views_stay_inside_their_block(monkeypatch, rank):
+ config, caches = _views(monkeypatch, "1", rank)
+ stride = ku._get_kv_cache_bytes_per_block(config.kv_cache_groups)
+ base = min(v.data_ptr() for v in caches.values())
+ rings = sorted(n for n in caches if n.endswith("raw_key_cache"))
+ slots = 800
+ assert all(caches[n].shape[0] == 24 * slots for n in rings)
+ for pool_block in (1, 2):
+ spans = []
+ for slot in (0, 1, slots - 1):
+ for name in rings:
+ lo, hi = _byte_range(caches[name], pool_block * slots + slot, base)
+ assert pool_block * stride <= lo < hi <= (pool_block + 1) * stride
+ spans.append((lo, hi))
+ spans.sort()
+ assert all(a[1] <= b[0] for a, b in zip(spans, spans[1:])), "slots overlap"
+ if rank == 0:
+ group = next(g for g in config.kv_cache_groups if PLE in g.layer_names)
+ lo, hi = _byte_range(caches[PLE], 1, base)
+ assert stride <= lo < hi <= 2 * stride
+ for name in group.layer_names[:-1]: # the GDN layers of that group
+ for state in (
+ caches[name] if isinstance(caches[name], list) else [caches[name]]
+ ):
+ glo, ghi = _byte_range(state, 1, base)
+ assert ghi <= lo or glo >= hi, f"PLE page overlaps {name}"
+
+
+def _zeroed_offsets(config, caches, skip: bool) -> tuple[set, int]:
+ """Block-relative byte ranges the KV zeroer would clear, from its own
+ segment table (built on CPU tensors; the kernel is not launched)."""
+ from vllm.v1.worker.utils import KVBlockZeroer
+
+ groups = []
+ for gid, group in enumerate(config.kv_cache_groups):
+ for name in group.layer_names:
+ spec = group.kv_cache_spec
+ if isinstance(spec, UniformTypeKVCacheSpecs):
+ spec = spec.kv_cache_specs[name]
+ groups.append(
+ SimpleNamespace(
+ kv_cache_spec=spec, kv_cache_group_id=gid, layer_names=[name]
+ )
+ )
+ kernel_block_sizes = [_first_spec(g).block_size for g in config.kv_cache_groups]
+ ctx = {name: SimpleNamespace(kv_cache=view) for name, view in caches.items()}
+ zeroer = KVBlockZeroer(
+ torch.device("cpu"),
+ attn_groups_iter=iter(groups),
+ kernel_block_sizes=kernel_block_sizes,
+ static_forward_context=ctx,
+ num_blocks=config.num_blocks,
+ skip_kv_cache_group_ids=config.slot_packed_group_ids if skip else (),
+ )
+ addrs, strides, pages, *_ = zeroer._meta
+ num_segments = len(addrs)
+ base = min(v.data_ptr() for v in caches.values())
+ stride = ku._get_kv_cache_bytes_per_block(config.kv_cache_groups)
+ covered: list[tuple[int, int]] = []
+ for addr, page in zip(addrs.tolist(), pages.tolist()):
+ lo = (addr - base) % stride
+ covered.append((lo, lo + 4 * page))
+ # Merge into a canonical union of intervals.
+ covered.sort()
+ merged: list[list[int]] = []
+ for lo, hi in covered:
+ if merged and lo <= merged[-1][1]:
+ merged[-1][1] = max(merged[-1][1], hi)
+ else:
+ merged.append([lo, hi])
+ return {tuple(m) for m in merged}, num_segments
+
+
+@pytest.mark.parametrize("rank", [0, 2], ids=["pp0", "pp1"])
+def test_zeroed_bytes_per_block_are_unchanged(monkeypatch, rank):
+ old_config, old_caches = _views(monkeypatch, "0", rank)
+ new_config, new_caches = _views(monkeypatch, "1", rank)
+ old, old_segments = _zeroed_offsets(old_config, old_caches, skip=True)
+ new, new_segments = _zeroed_offsets(new_config, new_caches, skip=True)
+ assert new == old
+ # The old ring segments were redundant (inside the first attention page);
+ # the new table drops exactly those.
+ assert new_segments <= old_segments
+ # FAILING CONTROL: without the slot-group skip every ring slot becomes a
+ # segment. On PP0 that zeroes bytes the old layout never zeroed (past the
+ # attention pages, inside the 7th GDN page); on PP1 the attention pages
+ # already cover the block, so it is "only" 5,600 redundant segments.
+ unskipped, unskipped_segments = _zeroed_offsets(new_config, new_caches, skip=False)
+ assert unskipped_segments > new_segments + 4000 # ~800 per ring layer
+ if rank == 0:
+ assert unskipped != old
+ else:
+ assert unskipped == old
diff --git a/tests/v1/test_pp_kv_projection.py b/tests/v1/test_pp_kv_projection.py
index 2f8cc59..cbf5956 100644
--- a/tests/v1/test_pp_kv_projection.py
+++ b/tests/v1/test_pp_kv_projection.py
@@ -170,10 +170,22 @@ def _allocate_lookup(config) -> None:
)
-def test_flash_next_pp1_has_an_empty_uniform_group():
+def _side_cache_layout(monkeypatch, value: str) -> None:
+ monkeypatch.setenv("VLLM_E2_SIDE_CACHE_LAYOUT", value)
+ from vllm import envs
+
+ if hasattr(envs.__getattr__, "cache_clear"):
+ envs.__getattr__.cache_clear()
+
+
+def test_flash_next_pp1_has_an_empty_uniform_group(monkeypatch):
"""Guard that the shape really exercises the path (else the test below
could not fail): PP1 projects the PLE group to zero layers while keeping
- the global UniformTypeKVCacheSpecs."""
+ the global UniformTypeKVCacheSpecs. v2.5: that shape exists only with the
+ side-cache layout off (VLLM_E2_SIDE_CACHE_LAYOUT=0, or an engine it does
+ not apply to); with it on the PLE state rides in a GDN group, so no group
+ projects empty (test_side_cache_layout.py)."""
+ _side_cache_layout(monkeypatch, "0")
_, configs = _worker_configs()
for rank in (2, 3):
empty = [g for g in configs[rank].kv_cache_groups if not g.layer_names]
@@ -186,7 +198,9 @@ def test_flash_next_pp1_has_an_empty_uniform_group():
assert all(g.layer_names for g in configs[rank].kv_cache_groups)
-def test_flash_next_pp1_every_tensor_is_grouped():
+@pytest.mark.parametrize("layout", ["0", "1"])
+def test_flash_next_pp1_every_tensor_is_grouped(monkeypatch, layout):
+ _side_cache_layout(monkeypatch, layout)
specs, configs = _worker_configs()
for rank, config in enumerate(configs):
tensor_layers = [n for t in config.kv_cache_tensors for n in t.layers]
diff --git a/vllm/envs.py b/vllm/envs.py
index 4159f34..7401ed7 100755
--- a/vllm/envs.py
+++ b/vllm/envs.py
@@ -452,6 +452,16 @@ if TYPE_CHECKING:
# for A/B. AOT: no. CAPTURE: no -- scheduler-side block bookkeeping only;
# the worker sees the same block tables either way.
VLLM_E2_MAMBA_RETAIN_CHECKPOINT: bool = True
+ # v2.5 side-cache layout (Flash-Next PLE + QSA ring): stop charging each
+ # tiny side cache a whole packed pool block per request. The TP-replicated
+ # PLE short-conv state group is folded into a GDN group that has a spare
+ # page on every PP stage, and the one-block-per-request QSA ring group packs
+ # several requests' rings into one pool block. Strict "0"/"1", default ON;
+ # "0" restores the old one-group-each layout for A/B. Read in
+ # vllm/v1/core/kv_cache_utils.py (get_kv_cache_configs). AOT: yes, hashed --
+ # it changes the ring views' block stride and which block table the PLE
+ # state is read through, so ON and OFF must not share a compile cache.
+ VLLM_E2_SIDE_CACHE_LAYOUT: bool = True
# Opt-out for the fork startup guards in vllm/v1/e2_guards.py, read at
# Qwen4ExpForCausalLM construction. AOT: no. CAPTURE: no -- it changes
# nothing itself; it only permits a boot whose identity IS different
@@ -2615,6 +2625,11 @@ environment_variables: dict[str, Callable[[], Any]] = {
"VLLM_E2_MAMBA_RETAIN_CHECKPOINT": lambda: _e2_strict_switch(
"VLLM_E2_MAMBA_RETAIN_CHECKPOINT", default=True
),
+ # Side-cache layout (v2.5; PLE state folded into a GDN group + QSA ring slot
+ # packing), default ON. Strict "0"/"1"; read once per get_kv_cache_configs call.
+ "VLLM_E2_SIDE_CACHE_LAYOUT": lambda: _e2_strict_switch(
+ "VLLM_E2_SIDE_CACHE_LAYOUT", default=True
+ ),
# Seconds between `topk-exact-counters` lines from each GPU worker
# (0, the default, disables the reader entirely). The line reports the
# medium-path exact-restart counters; see
diff --git a/vllm/v1/core/kv_cache_coordinator.py b/vllm/v1/core/kv_cache_coordinator.py
index 4b2839d..7537ccd 100644
--- a/vllm/v1/core/kv_cache_coordinator.py
+++ b/vllm/v1/core/kv_cache_coordinator.py
@@ -14,6 +14,7 @@ from vllm.v1.core.kv_cache_utils import (
dcp_world_size_for_kv_cache_spec,
)
from vllm.v1.core.single_type_kv_cache_manager import (
+ CircularBufferManager,
CrossAttentionManager,
MambaManager,
SingleTypeKVCacheManager,
@@ -151,6 +152,17 @@ class KVCacheCoordinator(ABC):
)
for i, kv_cache_group in enumerate(self.kv_cache_config.kv_cache_groups)
)
+ # v2.5 side-cache layout: a slot-packed ring group shares each pool
+ # block between several requests (kv_cache_utils._choose_ring_slots).
+ for kv_cache_group, manager in zip(
+ self.kv_cache_config.kv_cache_groups, self.single_type_managers
+ ):
+ if kv_cache_group.slots_per_block > 1:
+ assert type(manager) is CircularBufferManager, (
+ f"slot packing is only defined for the QSA ring, got "
+ f"{type(manager).__name__}"
+ )
+ manager.enable_slot_packing(kv_cache_group.slots_per_block)
# Match Mamba checkpoints to Eagle's attention replay boundary.
if use_eagle:
for manager in self.single_type_managers:
diff --git a/vllm/v1/core/kv_cache_utils.py b/vllm/v1/core/kv_cache_utils.py
index ad179e6..2d81658 100644
--- a/vllm/v1/core/kv_cache_utils.py
+++ b/vllm/v1/core/kv_cache_utils.py
@@ -1082,7 +1082,19 @@ def get_max_concurrency_for_kv_cache_config(
"""
num_blocks_per_request = 0
host_blocks_per_request = 0
+ # v2.5 side-cache layout: a slot-packed ring group shares each pool block
+ # between `slots_per_block` requests, so it costs cdiv(max_num_seqs, slots)
+ # blocks for the whole pool instead of one block per request.
+ shared_blocks = 0
+ max_num_seqs = getattr(
+ getattr(vllm_config, "scheduler_config", None), "max_num_seqs", 1
+ )
+ if not isinstance(max_num_seqs, int) or max_num_seqs < 1:
+ max_num_seqs = 1
for group in kv_cache_config.kv_cache_groups:
+ if group.slots_per_block > 1 and not group.host_resident:
+ shared_blocks += cdiv(max_num_seqs, group.slots_per_block)
+ continue
required = cdiv(
group.kv_cache_spec.max_memory_usage_bytes(vllm_config),
group.kv_cache_spec.page_size_bytes,
@@ -1091,7 +1103,9 @@ def get_max_concurrency_for_kv_cache_config(
host_blocks_per_request += required
else:
num_blocks_per_request += required
- limits = [kv_cache_config.num_blocks / num_blocks_per_request]
+ limits = [
+ max(kv_cache_config.num_blocks - shared_blocks, 0) / num_blocks_per_request
+ ]
if host_blocks_per_request:
assert kv_cache_config.hisparse_host_num_blocks is not None
limits.append(
@@ -1779,14 +1793,36 @@ def get_kv_cache_config_from_groups(
elif group.layer_names:
layers_by_spec[group_spec].extend(group.layer_names)
+ # v2.5 side-cache layout: a slot-packed group views every pool block as
+ # `slots` equal slots, so its block stride is one slot and its view has
+ # num_blocks * slots blocks. Pages of its layers must fit in one slot.
+ slots = group.slots_per_block
+ group_block_stride = interleaved_block_stride
+ group_num_blocks = num_blocks
+ if slots > 1:
+ assert layout.is_block_outermost, (
+ "Slot-packed KV cache groups need a block-outermost layout"
+ )
+ assert bytes_per_block % slots == 0, (bytes_per_block, slots)
+ group_block_stride = bytes_per_block // slots
+ group_num_blocks = num_blocks * slots
+ group_bytes = sum(
+ spec.page_size_bytes * len(names)
+ for spec, names in layers_by_spec.items()
+ )
+ assert group_bytes <= group_block_stride, (
+ f"slot-packed group needs {group_bytes} B per slot, slot is "
+ f"{group_block_stride} B"
+ )
+
byte_offset = 0
for spec, layer_names in layers_by_spec.items():
layer_stride, block_stride, _, _, _ = compute_layout_strides(
spec,
- num_blocks,
+ group_num_blocks,
len(layer_names),
layout,
- fixed_strides=(None, interleaved_block_stride, None, None, None),
+ fixed_strides=(None, group_block_stride, None, None, None),
)
offset = (
byte_offset
@@ -1800,6 +1836,7 @@ def get_kv_cache_config_from_groups(
layer_stride=layer_stride,
block_stride=block_stride,
offset=offset,
+ slots_per_block=slots,
)
)
byte_offset += len(layer_names) * spec.page_size_bytes
@@ -2650,6 +2687,289 @@ def _project_kv_cache_groups_to_worker(
return projected_groups
+# ---------------------------------------------------------------------------
+# v2.5 side-cache layout (VLLM_E2_SIDE_CACHE_LAYOUT, default ON).
+#
+# The packed block-outermost layout strides every pool block by the widest
+# group, and every group draws whole blocks from the one pool. Flash-Next has
+# two side caches whose real state is tiny next to that stride (12.9 MB on the
+# binding PP stage): the TP-replicated PLE short-conv state (one layer, 2 + K
+# blocks per request in align mode, a 1.6 MB padded page) and the QSA raw-key
+# ring (one block per request, 16 KB for all of a stage's layers). Together
+# they hold 6 of the 103 blocks a 262K request needs. Two layout-only changes
+# remove that cost without changing any value a kernel reads:
+#
+# 1. PLE fold. The replicated Mamba group joins a sharded Mamba group with the
+# same scheduling parameters whose block has a spare page on every worker
+# (Flash-Next: a GDN group holds 6 of PP0's 7 pages), so the PLE state rides
+# in that group's blocks under the block table it would have had on its
+# own. No worker's stride changes, so the pool block count does not change.
+# 2. Ring slot packing. The ring group splits every pool block into S equal
+# slots and the ring manager hands out slots; a pool block goes back to the
+# pool when its last slot is released. The ring then costs cdiv(requests, S)
+# blocks in all instead of one per request.
+#
+# Off (=0) restores one group each, block for block.
+# ---------------------------------------------------------------------------
+
+# Slot stride alignment for packed ring views (bytes) and the largest S tried.
+_RING_SLOT_ALIGN = 256
+_RING_MAX_SLOTS = 1024
+
+
+def _side_cache_layout_enabled(vllm_config: VllmConfig) -> bool:
+ """Whether the v2.5 side-cache layout may apply to this engine.
+
+ KV connectors (NIXL, offload, P/D) address groups and blocks themselves
+ (the NIXL worker requires exactly one single-layer replicated group), and
+ HiSparse has its own pool layout, so those engines keep the old layout.
+ """
+ if not envs.VLLM_E2_SIDE_CACHE_LAYOUT:
+ return False
+ if getattr(vllm_config, "kv_transfer_config", None) is not None:
+ return False
+ attention_config = getattr(vllm_config, "attention_config", None)
+ if getattr(attention_config, "hisparse_config", None) is not None:
+ return False
+ parallel_config = getattr(vllm_config, "parallel_config", None)
+ if getattr(parallel_config, "decode_context_parallel_size", 1) not in (1, None):
+ return False
+ try:
+ layout = vllm_config.cache_config.get_resolved_kv_cache_layout()
+ except ValueError:
+ # Unresolved layout: only reachable from callers that bypass the
+ # engine core; nothing to pack without a block-outermost layout.
+ return False
+ return bool(layout.is_block_outermost)
+
+
+def _group_layer_specs(group: KVCacheGroupSpec) -> list[KVCacheSpec]:
+ spec = group.kv_cache_spec
+ if isinstance(spec, UniformTypeKVCacheSpecs):
+ return [spec.kv_cache_specs[name] for name in group.layer_names]
+ return [spec] * len(group.layer_names)
+
+
+def _mamba_schedule_key(spec: MambaSpec) -> tuple:
+ """Everything the scheduler's MambaManager and the worker's align-mode
+ bookkeeping read from a group's spec. Two Mamba groups with equal keys get
+ identical block tables for every request."""
+ return (
+ spec.block_size,
+ spec.page_size_bytes,
+ spec.num_speculative_blocks,
+ spec.num_prefill_checkpoint_blocks,
+ spec.prefill_checkpoint_alignment,
+ spec.mamba_cache_mode,
+ )
+
+
+def _worker_strides(
+ groups: list[KVCacheGroupSpec], worker_specs: list[dict[str, KVCacheSpec]]
+) -> list[int]:
+ """Pool block bytes of every worker for this global grouping."""
+ strides = []
+ for spec in worker_specs:
+ projected = _project_kv_cache_groups_to_worker(groups, spec)
+ if not any(g.layer_names for g in projected):
+ strides.append(0)
+ continue
+ strides.append(_get_kv_cache_bytes_per_block(projected))
+ return strides
+
+
+def _fold_replicated_mamba_groups(
+ groups: list[KVCacheGroupSpec],
+ worker_specs: list[dict[str, KVCacheSpec]],
+) -> tuple[list[KVCacheGroupSpec], list[str]]:
+ """Fold each TP-replicated Mamba group into a compatible sharded one.
+
+ A target holds only sharded Mamba layers with the same scheduling key and
+ EAGLE flag, and the merged group must not widen the block of any worker
+ (checked on every worker's projection, so PP stages are covered). Among
+ valid targets the lowest index wins. Returns the new group list and one
+ log note per replicated group.
+ """
+ groups = list(groups)
+ notes: list[str] = []
+ base_strides = _worker_strides(groups, worker_specs)
+ index = 0
+ while index < len(groups):
+ source = groups[index]
+ layer_specs = _group_layer_specs(source)
+ if not (
+ layer_specs
+ and all(
+ isinstance(spec, MambaSpec) and spec.tp_replicated
+ for spec in layer_specs
+ )
+ and len({_mamba_schedule_key(spec) for spec in layer_specs}) == 1
+ ):
+ index += 1
+ continue
+ key = _mamba_schedule_key(cast(MambaSpec, layer_specs[0]))
+ chosen = None
+ for target_index, target in enumerate(groups):
+ if target_index == index or target.is_eagle_group != source.is_eagle_group:
+ continue
+ target_specs = _group_layer_specs(target)
+ if not target_specs or not all(
+ isinstance(spec, MambaSpec)
+ and not spec.tp_replicated
+ and _mamba_schedule_key(spec) == key
+ for spec in target_specs
+ ):
+ continue
+ merged_specs: dict[str, KVCacheSpec] = dict(
+ zip(target.layer_names, target_specs)
+ )
+ merged_specs.update(zip(source.layer_names, layer_specs))
+ # Built directly: is_uniform_type rejects mixed tp_replicated on
+ # purpose (NIXL), and connectors never reach this path.
+ merged = KVCacheGroupSpec(
+ target.layer_names + source.layer_names,
+ UniformTypeKVCacheSpecs(
+ block_size=target_specs[0].block_size,
+ kv_cache_specs=merged_specs,
+ ),
+ is_eagle_group=target.is_eagle_group,
+ )
+ trial = [
+ merged if i == target_index else g
+ for i, g in enumerate(groups)
+ if i != index
+ ]
+ strides = _worker_strides(trial, worker_specs)
+ if all(new <= old for new, old in zip(strides, base_strides)):
+ chosen = (target_index, merged, trial)
+ break
+ if chosen is None:
+ notes.append(
+ f"replicated Mamba group {index} ({len(source.layer_names)} "
+ "layer(s)) kept separate: no sharded group has a spare page on "
+ "every worker"
+ )
+ index += 1
+ continue
+ target_index, merged, trial = chosen
+ notes.append(
+ f"folded replicated Mamba group {index} "
+ f"({', '.join(source.layer_names)}) into Mamba group "
+ f"{target_index} (now {len(merged.layer_names)} layers); per-worker "
+ f"block bytes unchanged {sorted(set(base_strides))}"
+ )
+ groups = trial
+ # The group after the folded one now sits at `index`.
+ return groups, notes
+
+
+def _is_ring_group(group: KVCacheGroupSpec) -> bool:
+ spec = group.kv_cache_spec
+ layer_specs = (
+ list(spec.kv_cache_specs.values())
+ if isinstance(spec, UniformTypeKVCacheSpecs)
+ else [spec]
+ )
+ return bool(layer_specs) and all(
+ type(layer_spec) is CircularBufferSpec for layer_spec in layer_specs
+ )
+
+
+def _choose_ring_slots(
+ projected_groups_per_worker: list[list[KVCacheGroupSpec]],
+) -> dict[int, int]:
+ """Slots per pool block for every ring group (group index -> S >= 2).
+
+ S must cut every worker's block stride into equal slots that are
+ `_RING_SLOT_ALIGN`-aligned and hold that worker's ring pages; the largest
+ such S up to `_RING_MAX_SLOTS` is taken. Block ids are global, so S is the
+ same on every worker. A ring group with no valid S keeps one pool block
+ per request.
+ """
+ chosen: dict[int, int] = {}
+ if not projected_groups_per_worker or not projected_groups_per_worker[0]:
+ return chosen
+ num_groups = len(projected_groups_per_worker[0])
+ strides = [
+ _get_kv_cache_bytes_per_block(groups)
+ if any(g.layer_names for g in groups)
+ else 0
+ for groups in projected_groups_per_worker
+ ]
+ for group_index in range(num_groups):
+ if not _is_ring_group(projected_groups_per_worker[0][group_index]):
+ continue
+ needs = []
+ for groups, stride in zip(projected_groups_per_worker, strides):
+ group = groups[group_index]
+ if not group.layer_names:
+ continue
+ ring_bytes = sum(spec.page_size_bytes for spec in _group_layer_specs(group))
+ needs.append((stride, ring_bytes))
+ if not needs:
+ continue
+ for slots in range(_RING_MAX_SLOTS, 1, -1):
+ if all(
+ stride % slots == 0
+ and (stride // slots) % _RING_SLOT_ALIGN == 0
+ and stride // slots >= ring_bytes
+ for stride, ring_bytes in needs
+ ):
+ chosen[group_index] = slots
+ break
+ return chosen
+
+
+def _apply_side_cache_layout(
+ vllm_config: VllmConfig,
+ global_kv_cache_groups: list[KVCacheGroupSpec],
+ kv_cache_specs: list[dict[str, KVCacheSpec]],
+) -> tuple[list[KVCacheGroupSpec], list[list[KVCacheGroupSpec]]]:
+ """Fold side Mamba groups, project per worker, then pick ring slots.
+
+ Returns the (possibly folded) global groups and every worker's projection,
+ with `slots_per_block` set on packed ring groups.
+ """
+ relevant = any(
+ (isinstance(spec, MambaSpec) and spec.tp_replicated)
+ or type(spec) is CircularBufferSpec
+ for worker_spec in kv_cache_specs
+ for spec in worker_spec.values()
+ )
+ enabled = relevant and _side_cache_layout_enabled(vllm_config)
+ notes: list[str] = []
+ if enabled:
+ global_kv_cache_groups, notes = _fold_replicated_mamba_groups(
+ global_kv_cache_groups, kv_cache_specs
+ )
+ projected = [
+ _project_kv_cache_groups_to_worker(global_kv_cache_groups, worker_spec)
+ for worker_spec in kv_cache_specs
+ ]
+ if enabled and all(
+ _glm5_next_tensor_layout(groups) is None for groups in projected if groups
+ ):
+ for group_index, slots in _choose_ring_slots(projected).items():
+ for groups in projected:
+ groups[group_index].slots_per_block = slots
+ notes.append(
+ f"QSA ring group {group_index} packs {slots} requests per pool block"
+ )
+ if relevant and envs.VLLM_E2_SIDE_CACHE_LAYOUT and not enabled:
+ notes.append(
+ "not applicable to this engine (KV connector, HiSparse, DCP or a "
+ "layer-outermost layout)"
+ )
+ if relevant:
+ # The A/B evidence line: boot logs must show which arm ran.
+ logger.info(
+ "Side-cache layout: %s (VLLM_E2_SIDE_CACHE_LAYOUT)%s",
+ "on" if enabled else "off",
+ "".join(f"; {note}" for note in notes),
+ )
+ return global_kv_cache_groups, projected
+
+
def get_kv_cache_configs(
vllm_config: VllmConfig,
kv_cache_specs: list[dict[str, KVCacheSpec]],
@@ -2725,10 +3045,11 @@ def get_kv_cache_configs(
# If original_max_model_len was -1, automatically
# determine the maximum model length that fits in available GPU memory.
# We use per-worker projected groups to account for PP sharding.
- projected_groups_per_worker = [
- _project_kv_cache_groups_to_worker(global_kv_cache_groups, worker_spec)
- for worker_spec in kv_cache_specs
- ]
+ # v2.5: the side-cache layout (PLE fold, ring slots) is applied here, where
+ # every worker's layers are known, so no PP stage's block can widen.
+ global_kv_cache_groups, projected_groups_per_worker = _apply_side_cache_layout(
+ vllm_config, global_kv_cache_groups, kv_cache_specs
+ )
# If `num_gpu_blocks_override` is set, the cache size that will actually
# be allocated is decoupled from the profiled `available_memory`:
diff --git a/vllm/v1/core/sched/scheduler.py b/vllm/v1/core/sched/scheduler.py
index 1402e8e..dc8f033 100644
--- a/vllm/v1/core/sched/scheduler.py
+++ b/vllm/v1/core/sched/scheduler.py
@@ -1554,6 +1554,10 @@ class Scheduler(SchedulerInterface):
pending = set(block_ids)
owners: dict[int, set[str]] = {}
for mgr in self.kv_cache_manager.coordinator.single_type_managers:
+ if getattr(mgr, "slot_pool", None) is not None:
+ # v2.5 slot-packed ring: its block ids are slot ids, not pool
+ # block ids, and its pages are never zeroed.
+ continue
for req_id, blocks in mgr.req_to_blocks.items():
for blk in blocks:
if blk.block_id in pending:
diff --git a/vllm/v1/core/single_type_kv_cache_manager.py b/vllm/v1/core/single_type_kv_cache_manager.py
index 0ec6bca..ca390ef 100644
--- a/vllm/v1/core/single_type_kv_cache_manager.py
+++ b/vllm/v1/core/single_type_kv_cache_manager.py
@@ -1227,16 +1227,119 @@ class SlidingWindowManager(SingleTypeKVCacheManager):
return 0
+class _RingSlotPage:
+ """One pool block shared by up to ``slots`` requests' rings."""
+
+ __slots__ = ("block", "free_slots", "live")
+
+ def __init__(self, block: KVCacheBlock, slots: int) -> None:
+ self.block = block
+ # Highest first, so pop() hands out the lowest free slot.
+ self.free_slots = list(range(slots - 1, -1, -1))
+ self.live = 0
+
+
+class RingSlotPool:
+ """v2.5 side-cache layout: ring blocks as slots of shared pool blocks.
+
+ The QSA ring needs one block per request for the request's lifetime, but
+ its pages for every layer of a stage are ~16 KB against a 12.9 MB pool
+ block. This pool takes whole blocks from the main ``BlockPool`` and cuts
+ each into ``slots_per_block`` slots; a slot's block id is
+ ``pool_block_id * slots_per_block + slot``, which is exactly the index of
+ the slot in the worker's ring view (``KVCacheTensor.slots_per_block``).
+
+ The slot blocks it hands out carry ``pool=self``, so every existing free
+ path -- ``SingleTypeKVCacheManager.free``, the coordinator's deferred
+ ``pop_blocks_for_free`` and the scheduler's fenced drain, which all call
+ ``BlockPool.free_blocks`` -- routes them back here, at the time the old
+ per-request block would have gone back (the deferred free is kept). A pool
+ block returns to the main pool when its last slot is freed. Rings are never
+ prefix-cached, so a slot block carries no hash.
+ """
+
+ def __init__(self, block_pool: BlockPool, slots_per_block: int) -> None:
+ assert slots_per_block > 1
+ self.block_pool = block_pool
+ self.slots_per_block = slots_per_block
+ # Pool block id -> page, in claim order (the oldest page fills first).
+ self._pages: dict[int, _RingSlotPage] = {}
+
+ @property
+ def num_pages(self) -> int:
+ """Pool blocks currently held for rings."""
+ return len(self._pages)
+
+ @property
+ def num_live_slots(self) -> int:
+ return sum(page.live for page in self._pages.values())
+
+ def has_free_slot(self) -> bool:
+ return any(page.free_slots for page in self._pages.values())
+
+ def get_new_blocks(self, num_blocks: int) -> list[KVCacheBlock]:
+ out: list[KVCacheBlock] = []
+ for _ in range(num_blocks):
+ page = next((p for p in self._pages.values() if p.free_slots), None)
+ if page is None:
+ (pool_block,) = self.block_pool.get_new_blocks(1)
+ page = _RingSlotPage(pool_block, self.slots_per_block)
+ self._pages[pool_block.block_id] = page
+ slot = page.free_slots.pop()
+ page.live += 1
+ out.append(
+ KVCacheBlock(
+ page.block.block_id * self.slots_per_block + slot,
+ ref_cnt=1,
+ pool=self, # type: ignore[arg-type]
+ )
+ )
+ return out
+
+ def free_blocks(self, ordered_blocks) -> None:
+ released: list[KVCacheBlock] = []
+ for block in ordered_blocks:
+ assert block.pool is self, "not a ring slot of this pool"
+ block.ref_cnt -= 1
+ assert block.ref_cnt == 0, "ring slots have a single owner"
+ page_id, slot = divmod(block.block_id, self.slots_per_block)
+ page = self._pages[page_id]
+ assert slot not in page.free_slots, f"slot {block.block_id} freed twice"
+ page.free_slots.append(slot)
+ page.free_slots.sort(reverse=True)
+ page.live -= 1
+ if page.live == 0:
+ del self._pages[page_id]
+ released.append(page.block)
+ if released:
+ self.block_pool.free_blocks(released)
+
+
class CircularBufferManager(FullAttentionManager):
"""Claims the ring's single block per request; prefix caching disabled."""
supports_fine_grained_hash_lookup: ClassVar[bool] = False
+ # v2.5 side-cache layout: set by the coordinator for a slot-packed group.
+ _slot_pool: RingSlotPool | None = None
+
+ def enable_slot_packing(self, slots_per_block: int) -> None:
+ """Claim ring slots of shared pool blocks instead of whole blocks."""
+ assert not self.req_to_blocks, "enable slot packing before any claim"
+ self._slot_pool = RingSlotPool(self.block_pool, slots_per_block)
+
+ @property
+ def slot_pool(self) -> RingSlotPool | None:
+ return self._slot_pool
+
def _claim_ring_block(self, request_id: str) -> list[KVCacheBlock]:
req_blocks = self.req_to_blocks[request_id]
if req_blocks:
return []
- new_blocks = self.block_pool.get_new_blocks(1)
+ if self._slot_pool is not None:
+ new_blocks = self._slot_pool.get_new_blocks(1)
+ else:
+ new_blocks = self.block_pool.get_new_blocks(1)
req_blocks.extend(new_blocks)
if self._record_new_block_ids:
self.new_block_ids.extend(block.block_id for block in new_blocks)
@@ -1281,7 +1384,12 @@ class CircularBufferManager(FullAttentionManager):
num_tokens_main_model: int,
apply_admission_cap: bool = False,
) -> int:
- return 0 if self.req_to_blocks.get(request_id) else 1
+ if self.req_to_blocks.get(request_id):
+ return 0
+ # A free slot in a page the ring already holds costs no pool block.
+ if self._slot_pool is not None and self._slot_pool.has_free_slot():
+ return 0
+ return 1
def allocate_new_blocks(
self, request_id: str, num_tokens: int, num_tokens_main_model: int
diff --git a/vllm/v1/kv_cache_interface.py b/vllm/v1/kv_cache_interface.py
index 8cd867e..8931b95 100644
--- a/vllm/v1/kv_cache_interface.py
+++ b/vllm/v1/kv_cache_interface.py
@@ -1380,6 +1380,11 @@ class KVCacheTensor:
block_stride: int
offset: int = 0 # byte offset of layers[0]'s block 0
host_resident: bool = False
+ # v2.5 side-cache layout: a slot-packed ring tensor views every pool block
+ # as ``slots_per_block`` equal slots (``block_stride`` is then one slot), so
+ # its block ids are ``pool_block * slots_per_block + slot``. 1 = one view
+ # block per pool block, the ordinary case.
+ slots_per_block: int = 1
class KVCacheGroupRole(str, Enum):
@@ -1408,6 +1413,10 @@ class KVCacheGroupSpec:
# source group after a connector load.
enable_kv_transfer: bool = True
role: KVCacheGroupRole = KVCacheGroupRole.DEFAULT
+ # v2.5 side-cache layout: > 1 when this one-block-per-request ring group
+ # shares each pool block between that many requests (see
+ # ``CircularBufferManager.enable_slot_packing``); 1 = one pool block each.
+ slots_per_block: int = 1
@dataclass
@@ -1494,6 +1503,18 @@ class KVCacheConfig:
)
return tuple(block_ids[group_id] for group_id in self.transfer_group_ids)
+ @property
+ def slot_packed_group_ids(self) -> tuple[int, ...]:
+ """Groups whose blocks are slots of shared pool blocks (v2.5 side-cache
+ layout). Their views are not one block per pool block, so whole-block
+ zeroing must skip them; their bytes lie inside pool blocks the ring
+ manager owns and that no other group writes."""
+ return tuple(
+ group_id
+ for group_id, group in enumerate(self.kv_cache_groups)
+ if group.slots_per_block > 1
+ )
+
@property
def host_group_ids(self) -> tuple[int, ...]:
return tuple(
@@ -1505,7 +1526,7 @@ class KVCacheConfig:
def num_blocks_of(self, tensor: KVCacheTensor) -> int:
"""Number of blocks addressable by the pool backing ``tensor``."""
if not tensor.host_resident:
- return self.num_blocks
+ return self.num_blocks * tensor.slots_per_block
assert self.hisparse_host_num_blocks is not None
return self.hisparse_host_num_blocks
diff --git a/vllm/v1/worker/gpu/model_runner.py b/vllm/v1/worker/gpu/model_runner.py
index 1876f4b..d2bb002 100644
--- a/vllm/v1/worker/gpu/model_runner.py
+++ b/vllm/v1/worker/gpu/model_runner.py
@@ -843,6 +843,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
kernel_block_sizes=self.kernel_block_sizes,
static_forward_context=self.compilation_config.static_forward_context,
num_blocks=self.kv_cache_config.num_blocks,
+ skip_kv_cache_group_ids=self.kv_cache_config.slot_packed_group_ids,
)
@torch.inference_mode()
diff --git a/vllm/v1/worker/gpu_model_runner.py b/vllm/v1/worker/gpu_model_runner.py
index 910c72c..010f110 100644
--- a/vllm/v1/worker/gpu_model_runner.py
+++ b/vllm/v1/worker/gpu_model_runner.py
@@ -1173,6 +1173,7 @@ class GPUModelRunner(
runner_only_attn_layers=self.runner_only_attn_layers,
static_forward_context=self.compilation_config.static_forward_context,
num_blocks=self.kv_cache_config.num_blocks,
+ skip_kv_cache_group_ids=self.kv_cache_config.slot_packed_group_ids,
)
def _zero_block_ids(self, block_ids: list[int]) -> None:
diff --git a/vllm/v1/worker/utils.py b/vllm/v1/worker/utils.py
index 61698bf..c09a0f7 100644
--- a/vllm/v1/worker/utils.py
+++ b/vllm/v1/worker/utils.py
@@ -138,6 +138,7 @@ class KVBlockZeroer:
static_forward_context: dict[str, Any],
num_blocks: int,
runner_only_attn_layers: set[str] | None = None,
+ skip_kv_cache_group_ids: Iterable[int] = (),
) -> None:
"""Precompute the absolute-address table for the Triton zeroing kernel.
@@ -156,6 +157,13 @@ class KVBlockZeroer:
physical block stride and zeroed page span remain independent.
Only AttentionSpec layers are processed; Mamba layers are skipped.
+
+ ``skip_kv_cache_group_ids`` (v2.5 side-cache layout): slot-packed ring
+ groups, whose views hold several slots per pool block. Their claims
+ never enter whole-block zeroing (the ring-tag path clears their tag words instead),
+ and with one view block per pool block their pages sat inside the
+ first attention page, so skipping them leaves the zeroed bytes of
+ every block unchanged.
"""
self.device = device
# Bounds-guard instrumentation: pool bound for the guard in _zero_kv_blocks_kernel.
@@ -174,12 +182,15 @@ class KVBlockZeroer:
seg_block_strides: list[int] = []
seg_page_sizes: list[int] = []
+ skip_group_ids = set(skip_kv_cache_group_ids)
for group in attn_groups_iter:
spec = group.kv_cache_spec
if not isinstance(spec, AttentionSpec):
continue
if group.kv_cache_group_id >= len(kernel_block_sizes):
continue
+ if group.kv_cache_group_id in skip_group_ids:
+ continue
kernel_bs = kernel_block_sizes[group.kv_cache_group_id]
assert spec.block_size % kernel_bs == 0
for layer_name in group.layer_names:
--
2.43.0