[Perf][MRV2] Reuse Mamba/GDN metadata across KV cache groups (#58762)

Signed-off-by: Luca Motz <luca.motz@icloud.com> Co-authored-by: Xin Yang <xyangx@amazon.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Codex <noreply@openai.com> Co-authored-by: Lucas Wilkinson <LucasWilkinson@users.noreply.github.com>

Luca Motz committed Sep 28, 2026 at 06:36 UTC dd3bc9c72faf410043b453e18e58553a1ad4d388
5 files changed +189 -21
tests/models/kimi_k3/test_kda_metadata.py
+1
@@ -49,6 +49,7 @@ PRUNED_METADATA_FIELDS = {
49 "prefill_state_indices",
50 "prefill_has_initial_state",
51 "spec_sequence_masks",
52 + "spec_sequence_masks_cpu",
53 "uniform_spec_sequence_length",
54 "flashinfer_prefill_query_start_loc",
55 "flashinfer_prefill_seq_order",
tests/v1/attention/test_gdn_metadata_builder.py
+63
@@ -21,6 +21,7 @@ from vllm.v1.attention.backends.gdn_attn import (
21 GDNAttentionMetadata,
22 GDNAttentionMetadataBuilder,
23 )
24 +from vllm.v1.attention.backends.utils import mamba_get_block_table_tensor
25 from vllm.v1.kv_cache_interface import MambaSpec
26
27 BLOCK_SIZE = 16
@@ -75,6 +76,17 @@ GDN_BUILD_TEST_CASES = {
76 expected_num_prefill_tokens=0,
77 expected_num_spec_decodes=0,
78 ),
79 + # No speculative config, decode alongside prefill
80 + "regular_decode_with_prefill": GDNBuildTestCase(
81 + seq_lens=[40, 100],
82 + query_lens=[1, 50],
83 + num_decode_draft_tokens=None,
84 + num_speculative_tokens=0,
85 + expected_num_decodes=1,
86 + expected_num_prefills=1,
87 + expected_num_prefill_tokens=50,
88 + expected_num_spec_decodes=0,
89 + ),
90 # Multi-token prefill alongside spec decode — no decode to reclassify
91 "spec_decode_with_real_prefill": GDNBuildTestCase(
92 seq_lens=[100, 20],
@@ -155,9 +167,12 @@ def _build(
167 builder: GDNAttentionMetadataBuilder,
168 batch_spec: BatchSpec,
169 num_decode_draft_tokens: list[int] | None = None,
170 + block_table: torch.Tensor | None = None,
171 ) -> GDNAttentionMetadata:
172 """Build GDN attention metadata, optionally with spec-decode kwargs."""
173 common = create_common_attn_metadata(batch_spec, BLOCK_SIZE, DEVICE)
174 + if block_table is not None:
175 + common = common.replace(block_table_tensor=block_table)
176 kwargs: dict = {}
177 if num_decode_draft_tokens is not None:
178 kwargs["num_decode_draft_tokens_cpu"] = torch.tensor(
@@ -184,6 +199,54 @@ def test_gdn_build_classification(test_case: GDNBuildTestCase):
199 assert meta.num_spec_decodes == test_case.expected_num_spec_decodes
200
201
202 +@pytest.mark.parametrize("mamba_cache_mode", ["none", "align"])
203 +@pytest.mark.parametrize("full_cuda_graph", [False, True])
204 +@pytest.mark.parametrize(
205 + "test_case", GDN_BUILD_TEST_CASES.values(), ids=GDN_BUILD_TEST_CASES.keys()
206 +)
207 +def test_update_block_table_matches_build(
208 + test_case: GDNBuildTestCase, full_cuda_graph: bool, mamba_cache_mode: str
209 +):
210 + """update_block_table() on another group's metadata matches build()."""
211 + batch = BatchSpec(seq_lens=test_case.seq_lens, query_lens=test_case.query_lens)
212 + src, dst, ref = (
213 + _create_gdn_builder(test_case.num_speculative_tokens, full_cuda_graph)
214 + for _ in range(3)
215 + )
216 + for builder in (src, dst, ref):
217 + builder.vllm_config.cache_config.mamba_cache_mode = mamba_cache_mode
218 + common = create_common_attn_metadata(batch, BLOCK_SIZE, DEVICE)
219 + if mamba_cache_mode == "align":
220 + # MRV2 precomputes these for every Mamba group each step.
221 + dst.mamba_aligned_state_indices = ref.mamba_aligned_state_indices = (
222 + mamba_get_block_table_tensor(
223 + common.block_table_tensor, common.seq_lens, ref.kv_cache_spec, "align"
224 + )
225 + )
226 + draft_tokens = test_case.num_decode_draft_tokens
227 + expected = _build(ref, batch, draft_tokens, common.block_table_tensor)
228 + source = _build(src, batch, draft_tokens)
229 + fields = (
230 + "spec_state_indices_tensor",
231 + "non_spec_state_indices_tensor",
232 + "prefill_state_indices",
233 + )
234 + source_indices = [getattr(source, f) for f in fields]
235 + source_indices = [t if t is None else t.clone() for t in source_indices]
236 + meta = dst.update_block_table(
237 + source, common.block_table_tensor, common.slot_mapping
238 + )
239 +
240 + for field, source_index in zip(fields, source_indices):
241 + actual = getattr(meta, field)
242 + torch.testing.assert_close(actual, getattr(expected, field))
243 + # The source group's indices are untouched.
244 + torch.testing.assert_close(getattr(source, field), source_index)
245 + # FULL graph state indices land in this group's own buffers.
246 + if full_cuda_graph and meta.num_prefills == 0 and actual is not None:
247 + assert actual.data_ptr() == getattr(dst, field).data_ptr()
248 +
249 +
250 def test_has_initial_state_after_reclassification():
251 """After reclassification, num_prefills > 0 so the prefill kernel path
252 should compute has_initial_state. For the reclassified request with
tests/v1/worker/test_gpu_autoregressive_speculator.py
+3 -1
@@ -98,7 +98,9 @@ def test_pcp_draft_metadata_keeps_graph_padding_in_decode(cg_mode):
98 speculator.attn_groups = [
99 [
100 SimpleNamespace(
101 - get_metadata_builder=lambda _: SimpleNamespace(build=build),
101 + get_metadata_builder=lambda _: SimpleNamespace(
102 + build=build, supports_update_block_table=False
103 + ),
104 layer_names=["draft"],
105 )
106 ]
vllm/v1/attention/backends/gdn_attn.py
+105 -19
@@ -2,7 +2,7 @@
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3 """Backend for GatedDeltaNet attention."""
4
5 -from dataclasses import dataclass
5 +from dataclasses import dataclass, replace
6 from typing import Literal
7
8 import torch
@@ -60,6 +60,7 @@ class GDNAttentionMetadata:
60 None # shape: [batch - num_spec_decodes,]
61 )
62 spec_sequence_masks: torch.Tensor | None = None # shape: [batch,]
63 + spec_sequence_masks_cpu: torch.Tensor | None = None # shape: [batch,]
64 spec_token_indx: torch.Tensor | None = None
65 non_spec_token_indx: torch.Tensor | None = None
66
@@ -114,6 +115,18 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata]
115 self.use_full_cuda_graph: bool = (
116 self.compilation_config.cudagraph_mode.has_full_cudagraphs()
117 )
118 + # update_block_table() keeps the source group's batch-level FULL graph
119 + # buffers, so only MRV2, which also reuses metadata at capture, may use
120 + # it.
121 + self.supports_update_block_table = (
122 + vllm_config.use_v2_model_runner
123 + and device.type == "cuda"
124 + # Not isinstance: KDA's RecoverSSM/checkpoint metadata is per group.
125 + and type(self) is GDNAttentionMetadataBuilder
126 + )
127 + if self.supports_update_block_table:
128 + # Opts into MRV2's CUDA-only aligned-index precompute.
129 + self.mamba_aligned_state_indices: torch.Tensor | None = None
130
131 self.decode_cudagraph_max_bs: int = (
132 self.vllm_config.scheduler_config.max_num_seqs * (self.num_spec + 1)
@@ -220,12 +233,16 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata]
233 query_start_loc = m.query_start_loc
234 query_start_loc_cpu = m.query_start_loc_cpu
235 nums_dict, batch_ptr, token_chunk_offset_ptr = None, None, None
223 - block_table_tensor = mamba_get_block_table_tensor(
224 - m.block_table_tensor,
225 - m.seq_lens,
226 - self.kv_cache_spec,
227 - self.vllm_config.cache_config.mamba_cache_mode,
228 - )
236 + aligned_state_indices = getattr(self, "mamba_aligned_state_indices", None)
237 + if aligned_state_indices is not None:
238 + block_table_tensor = aligned_state_indices[: m.num_reqs]
239 + else:
240 + block_table_tensor = mamba_get_block_table_tensor(
241 + m.block_table_tensor,
242 + m.seq_lens,
243 + self.kv_cache_spec,
244 + self.vllm_config.cache_config.mamba_cache_mode,
245 + )
246
247 uniform_spec_sequence_length = None
248 spec_sequence_masks_cpu: torch.Tensor | None = None
@@ -460,12 +477,8 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata]
477 # metadata below is indexed by request.
478 batch_size = m.num_reqs
479
463 - if (
464 - self.use_full_cuda_graph
465 - and num_prefills == 0
466 - and num_decodes == 0
467 - and num_spec_decodes <= self.decode_cudagraph_max_bs
468 - and num_spec_decode_tokens <= self.decode_cudagraph_max_bs
480 + if self._stage_spec_decode(
481 + num_prefills, num_decodes, num_spec_decodes, num_spec_decode_tokens
482 ):
483 assert spec_sequence_masks is not None
484 self.spec_state_indices_tensor[:num_spec_decodes].copy_(
@@ -506,12 +519,7 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata]
519 num_accepted_tokens = self.num_accepted_tokens[:batch_size]
520 num_accepted_tokens[num_spec_decodes:].fill_(1)
521
509 - if (
510 - self.use_full_cuda_graph
511 - and num_prefills == 0
512 - and num_spec_decodes == 0
513 - and num_decodes <= self.decode_cudagraph_max_bs
514 - ):
522 + if self._stage_decode(num_prefills, num_decodes, num_spec_decodes):
523 self.non_spec_state_indices_tensor[:num_decodes].copy_(
524 non_spec_state_indices_tensor, non_blocking=True
525 )
@@ -546,6 +554,7 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata]
554 spec_state_indices_tensor=spec_state_indices_tensor,
555 non_spec_state_indices_tensor=non_spec_state_indices_tensor,
556 spec_sequence_masks=spec_sequence_masks,
557 + spec_sequence_masks_cpu=spec_sequence_masks_cpu,
558 spec_token_indx=spec_token_indx,
559 non_spec_token_indx=non_spec_token_indx,
560 num_accepted_tokens=num_accepted_tokens,
@@ -556,6 +565,83 @@ class GDNAttentionMetadataBuilder(AttentionMetadataBuilder[GDNAttentionMetadata]
565 )
566 return attn_metadata
567
568 + def _stage_spec_decode(
569 + self,
570 + num_prefills: int,
571 + num_decodes: int,
572 + num_spec_decodes: int,
573 + num_spec_decode_tokens: int,
574 + ) -> bool:
575 + """Whether spec-decode metadata goes into the FULL cudagraph buffers."""
576 + return (
577 + self.use_full_cuda_graph
578 + and num_prefills == 0
579 + and num_decodes == 0
580 + and num_spec_decodes <= self.decode_cudagraph_max_bs
581 + and num_spec_decode_tokens <= self.decode_cudagraph_max_bs
582 + )
583 +
584 + def _stage_decode(
585 + self, num_prefills: int, num_decodes: int, num_spec_decodes: int
586 + ) -> bool:
587 + """Whether decode metadata goes into the FULL cudagraph buffers."""
588 + return (
589 + self.use_full_cuda_graph
590 + and num_prefills == 0
591 + and num_spec_decodes == 0
592 + and num_decodes <= self.decode_cudagraph_max_bs
593 + )
594 +
595 + def update_block_table(
596 + self,
597 + metadata: GDNAttentionMetadata,
598 + blk_table: torch.Tensor,
599 + slot_mapping: torch.Tensor,
600 + ) -> GDNAttentionMetadata:
601 + """Re-gather this group's state indices. The other fields are
602 + batch-level and stay shared with ``metadata``."""
603 + m = metadata
604 + if self.vllm_config.cache_config.mamba_cache_mode == "align":
605 + assert self.mamba_aligned_state_indices is not None
606 + blk_table = self.mamba_aligned_state_indices
607 + masks = m.spec_sequence_masks_cpu
608 + spec_indices = non_spec_indices = prefill_indices = None
609 + if masks is None:
610 + non_spec_indices = blk_table[:, 0]
611 + if m.num_prefills > 0:
612 + prefill_indices = non_spec_indices[m.num_decodes :]
613 + else:
614 + spec_indices = blk_table[masks, : self.num_spec + 1]
615 + if m.num_prefills > 0:
616 + non_spec_indices = prefill_indices = blk_table[~masks, 0]
617 +
618 + if self._stage_spec_decode(
619 + m.num_prefills, m.num_decodes, m.num_spec_decodes, m.num_spec_decode_tokens
620 + ):
621 + assert m.spec_state_indices_tensor is not None
622 + batch_size = m.spec_state_indices_tensor.shape[0]
623 + self.spec_state_indices_tensor[: m.num_spec_decodes].copy_(
624 + spec_indices, non_blocking=True
625 + )
626 + spec_indices = self.spec_state_indices_tensor[:batch_size]
627 + spec_indices[m.num_spec_decodes :].fill_(NULL_BLOCK_ID)
628 +
629 + if self._stage_decode(m.num_prefills, m.num_decodes, m.num_spec_decodes):
630 + assert m.non_spec_state_indices_tensor is not None
631 + batch_size = m.non_spec_state_indices_tensor.shape[0]
632 + self.non_spec_state_indices_tensor[: m.num_decodes].copy_(
633 + non_spec_indices, non_blocking=True
634 + )
635 + non_spec_indices = self.non_spec_state_indices_tensor[:batch_size]
636 + non_spec_indices[m.num_decodes :].fill_(NULL_BLOCK_ID)
637 +
638 + return replace(
639 + m,
640 + spec_state_indices_tensor=spec_indices,
641 + non_spec_state_indices_tensor=non_spec_indices,
642 + prefill_state_indices=prefill_indices,
643 + )
644 +
645 def build_for_cudagraph_capture(
646 self, common_attn_metadata: CommonAttentionMetadata
647 ):
vllm/v1/worker/gpu/attn_utils.py
+17 -1
@@ -29,6 +29,7 @@ from vllm.v1.kv_cache_interface import (
29 AttentionSpec,
30 KVCacheConfig,
31 KVCacheSpec,
32 + MambaSpec,
33 UniformTypeKVCacheSpecs,
34 )
35 from vllm.v1.worker.gpu.model_states.interface import ModelSpecificAttnMetadata
@@ -462,6 +463,10 @@ def build_attn_metadata(
463
464 attn_metadata: dict[str, Any] = {}
465 token_to_req_indices: torch.Tensor | None = None
466 + # Mamba groups with the same spec and builder differ only in their state
467 + # indices, so later groups re-gather those from the first group's metadata.
468 + # Also at capture, so FULL graphs share the batch-level buffers.
469 + cached_metadata: dict[tuple[KVCacheSpec, type], Any] = {}
470 num_kv_cache_groups = len(kv_cache_config.kv_cache_groups)
471 for i in range(num_kv_cache_groups):
472 if not attn_groups[i]:
@@ -514,7 +519,16 @@ def build_attn_metadata(
519
520 for attn_group in attn_groups[i]:
521 attn_metadata_builder = attn_group.get_metadata_builder(ubatch_idx)
517 - if for_cudagraph_capture:
522 + reuse_key = None
523 + if attn_metadata_builder.supports_update_block_table and isinstance(
524 + attn_group.kv_cache_spec, MambaSpec
525 + ):
526 + reuse_key = (attn_group.kv_cache_spec, type(attn_metadata_builder))
527 + if reuse_key in cached_metadata:
528 + metadata = attn_metadata_builder.update_block_table(
529 + cached_metadata[reuse_key], block_table, slot_mapping
530 + )
531 + elif for_cudagraph_capture:
532 metadata = attn_metadata_builder.build_for_cudagraph_capture(
533 common_attn_metadata
534 )
@@ -532,6 +546,8 @@ def build_attn_metadata(
546 common_attn_metadata=common_attn_metadata,
547 **attn_metadata_extra_kwargs,
548 )
549 + if reuse_key is not None and reuse_key not in cached_metadata:
550 + cached_metadata[reuse_key] = metadata
551 for layer_name in attn_group.layer_names:
552 attn_metadata[layer_name] = metadata
553 token_to_req_indices = common_attn_metadata._token_to_req_indices_cache