[Spec decode] Support variable-length decode for Kimi-K3 adaptive ver (#52988)

Signed-off-by: Albert Cheng <albecheng@nvidia.com> Signed-off-by: Albert Cheng (Engrg-Hardware 1) <albecheng@login-bia01.bia.clusters.nvidia.com> Co-authored-by: mergify[bot] <37929162+mergify[bot]@users.noreply.github.com> Co-authored-by: Benjamin Chislett <bchislett@nvidia.com>

Albert Cheng committed Sep 22, 2026 at 20:43 UTC 88aa0d287dd3abac89741bbea349350a4d49194e
13 files changed +203 -22
tests/v1/attention/test_flashinfer_mla_dcp.py
+10
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +from types import MethodType
5 from unittest.mock import MagicMock
6
7 import pytest
@@ -61,6 +62,7 @@ def test_flashinfer_mla_forward_uses_gathered_head_count(monkeypatch):
62 attn_metadata.max_seq_len = 1
63 attn_metadata.decode.block_table = torch.zeros(2, 1, dtype=torch.int32)
64 attn_metadata.decode.seq_lens = torch.ones(2, dtype=torch.int32)
65 + attn_metadata.decode.max_query_len = 1
66
67 query = torch.ones(2, 24, 576, dtype=torch.bfloat16)
68 kv_cache = torch.ones(1, 128, 576, dtype=torch.bfloat16)
@@ -100,6 +102,9 @@ def test_flashinfer_mla_forward_uses_native_dcp_api(monkeypatch, causal):
102 impl.dcp_world_size = 8
103 impl.dcp_rank = 3
104 impl.cp_kv_cache_interleave_size = 1
105 + impl._flattened_decode_metadata = MethodType(
106 + flashinfer_mla.FlashInferMLAImpl._flattened_decode_metadata, impl
107 + )
108
109 num_reqs, query_len = 2, 3
110 num_tokens = num_reqs * query_len
@@ -114,6 +119,11 @@ def test_flashinfer_mla_forward_uses_native_dcp_api(monkeypatch, causal):
119 attn_metadata.decode.block_table = block_table
120 attn_metadata.decode.seq_lens = seq_lens
121 attn_metadata.decode.dcp_tot_seq_lens = global_causal_seq_lens
122 + attn_metadata.decode.max_query_len = query_len
123 + attn_metadata.decode.query_start_loc = torch.arange(
124 + 0, num_tokens + 1, query_len, dtype=torch.int32
125 + )
126 + attn_metadata.decode.query_len = 0
127
128 query = torch.ones(num_tokens, 24, 576, dtype=torch.bfloat16)
129 kv_cache = torch.ones(2, 128, 576, dtype=torch.bfloat16)
tests/v1/attention/test_mla_backends.py
+1
@@ -1267,6 +1267,7 @@ def test_flashmla_dcp_decode_metadata_uses_gathered_query_heads(
1267 query_start_loc_cpu=query_start_loc,
1268 query_start_loc_device=query_start_loc,
1269 num_decode_tokens=2,
1270 + max_query_len=1,
1271 dcp_tot_seq_lens_device=None,
1272 )
1273
tests/v1/attention/test_rocm_aiter_mla_mtp_split.py
+8
@@ -278,6 +278,7 @@ def test_dcp_fp8_verify_build_uses_segmented(monkeypatch):
278 query_start_loc_cpu=torch.tensor([0, qlen, 2 * qlen], dtype=torch.int32),
279 query_start_loc_device=torch.tensor([0, qlen, 2 * qlen], dtype=torch.int32),
280 num_decode_tokens=2 * qlen,
281 + max_query_len=qlen,
282 dcp_tot_seq_lens_device=torch.tensor([10, 12], dtype=torch.int32),
283 )
284
@@ -633,6 +634,7 @@ def test_mtp_decode_qlen4_keeps_uniform_rows_with_metadata(monkeypatch):
634 query_start_loc_cpu=torch.tensor([0, 4, 8], dtype=torch.int32),
635 query_start_loc_device=torch.tensor([0, 4, 8], dtype=torch.int32),
636 num_decode_tokens=8,
637 + max_query_len=4,
638 dcp_tot_seq_lens_device=None,
639 )
640
@@ -683,6 +685,7 @@ def test_min_kv_seq_len_ignores_cudagraph_padding_rows(monkeypatch):
685 query_start_loc_cpu=query_start_loc,
686 query_start_loc_device=query_start_loc,
687 num_decode_tokens=num_reqs * mtp_qlen,
688 + max_query_len=mtp_qlen,
689 dcp_tot_seq_lens_device=None,
690 )
691
@@ -733,6 +736,7 @@ def test_full_cudagraph_padded_uniform_mtp_synthesizes_decode_indptr(
736 query_start_loc_cpu=torch.tensor([0, mtp_qlen, mtp_qlen], dtype=torch.int32),
737 query_start_loc_device=torch.tensor([0, mtp_qlen, mtp_qlen], dtype=torch.int32),
738 num_decode_tokens=seq_lens.numel() * mtp_qlen,
739 + max_query_len=mtp_qlen,
740 dcp_tot_seq_lens_device=None,
741 )
742
@@ -801,6 +805,7 @@ def test_decode_expands_kernel_block_page_indices(monkeypatch):
805 query_start_loc_cpu=torch.tensor([0, 1, 2], dtype=torch.int32),
806 query_start_loc_device=torch.tensor([0, 1, 2], dtype=torch.int32),
807 num_decode_tokens=seq_lens.numel(),
808 + max_query_len=1,
809 dcp_tot_seq_lens_device=None,
810 )
811
@@ -892,6 +897,7 @@ def test_persistent_metadata_gate(
897 query_start_loc_cpu=query_start_loc,
898 query_start_loc_device=query_start_loc,
899 num_decode_tokens=num_reqs * qo_len,
900 + max_query_len=qo_len,
901 dcp_tot_seq_lens_device=None,
902 )
903
@@ -961,6 +967,7 @@ def test_persistent_metadata_gate_without_gluon_build(
967 query_start_loc_cpu=query_start_loc,
968 query_start_loc_device=query_start_loc,
969 num_decode_tokens=num_reqs * qo_len,
970 + max_query_len=qo_len,
971 dcp_tot_seq_lens_device=None,
972 )
973
@@ -993,6 +1000,7 @@ def _build_non_causal(monkeypatch, *, num_heads, kv_cache_dtype, qlen, mtp_qlen)
1000 query_start_loc_cpu=torch.tensor([0, qlen, 2 * qlen], dtype=torch.int32),
1001 query_start_loc_device=torch.tensor([0, qlen, 2 * qlen], dtype=torch.int32),
1002 num_decode_tokens=2 * qlen,
1003 + max_query_len=qlen,
1004 dcp_tot_seq_lens_device=None,
1005 )
1006 return metadata, get_mla_metadata_v1
tests/v1/worker/test_mamba_hybrid_model_state.py
+1
@@ -34,6 +34,7 @@ def test_prepare_attn_forwards_positions(monkeypatch: pytest.MonkeyPatch) -> Non
34 query_start_loc_np=torch.tensor([0, 1], dtype=torch.int32).numpy(),
35 query_start_loc=torch.tensor([0, 1], dtype=torch.int32),
36 num_scheduled_tokens=torch.tensor([1], dtype=torch.int32),
37 + max_query_len=None,
38 seq_lens_cpu_upper_bound=torch.tensor([1537], dtype=torch.int32),
39 seq_lens=torch.tensor([1537], dtype=torch.int32),
40 is_prefilling_np=torch.tensor([False]).numpy(),
vllm/model_executor/layers/attention/mla_attention.py
+4
@@ -2471,6 +2471,7 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
2471 query_start_loc_cpu: torch.Tensor,
2472 query_start_loc_device: torch.Tensor,
2473 num_decode_tokens: int,
2474 + max_query_len: int,
2475 dcp_tot_seq_lens_device: torch.Tensor | None,
2476 ) -> MLACommonDecodeMetadata:
2477 return MLACommonDecodeMetadata(
@@ -2635,6 +2636,9 @@ class MLACommonMetadataBuilder(AttentionMetadataBuilder[M]):
2636 query_start_loc_cpu=query_start_loc_cpu[: num_decodes + 1],
2637 query_start_loc_device=query_start_loc[: num_decodes + 1],
2638 num_decode_tokens=num_decode_tokens,
2639 + max_query_len=min(
2640 + common_attn_metadata.max_query_len, self.reorder_batch_threshold
2641 + ),
2642 dcp_tot_seq_lens_device=dcp_tot_seq_lens_device,
2643 )
2644
vllm/models/dots3_note/nvidia/attention.py
+2 -1
@@ -353,9 +353,10 @@ class Dots3NoteMLAMetadataBuilder(TritonMLAMetadataBuilder):
353 query_start_loc_cpu: torch.Tensor,
354 query_start_loc_device: torch.Tensor,
355 num_decode_tokens: int,
356 + max_query_len: int,
357 dcp_tot_seq_lens_device: torch.Tensor | None,
358 ) -> Dots3NoteDecodeMetadata:
358 - del max_seq_len, query_start_loc_device, num_decode_tokens
359 + del max_seq_len, query_start_loc_device, num_decode_tokens, max_query_len
360 query_len = int(query_start_loc_cpu[1] - query_start_loc_cpu[0])
361 return Dots3NoteDecodeMetadata(
362 block_table=block_table_tensor,
vllm/models/kimi_k3/nvidia/dspark_mla.py
+29 -1
@@ -15,7 +15,10 @@ from vllm.model_executor.layers.linear import (
15 ReplicatedLinear,
16 )
17 from vllm.model_executor.layers.logits_processor import LogitsProcessor
18 -from vllm.model_executor.models.qwen3_dspark import DSparkMarkovHead
18 +from vllm.model_executor.models.qwen3_dspark import (
19 + DSparkConfidenceHead,
20 + DSparkMarkovHead,
21 +)
22 from vllm.model_executor.models.utils import (
23 AutoWeightsLoader,
24 WeightsMapper,
@@ -188,6 +191,18 @@ class K3DSparkModel(nn.Module):
191 self.config.markov_rank,
192 prefix=maybe_prefix(prefix, "markov_head"),
193 )
194 + self.confidence_head: DSparkConfidenceHead | None = None
195 + if getattr(self.config, "enable_confidence_head", False):
196 + with_markov = getattr(self.config, "confidence_head_with_markov", False)
197 + input_dim = self.config.hidden_size + (
198 + self.config.markov_rank if with_markov else 0
199 + )
200 + self.confidence_head = DSparkConfidenceHead(
201 + input_dim,
202 + prefix=maybe_prefix(prefix, "confidence_head"),
203 + bias=True,
204 + with_markov=with_markov,
205 + )
206 self._max_num_context_tokens = (
207 vllm_config.scheduler_config.max_num_batched_tokens
208 )
@@ -436,6 +451,12 @@ class K3DSparkForCausalLM(nn.Module):
451 assert vllm_config.speculative_config is not None
452 self.draft_model_config = vllm_config.speculative_config.draft_model_config
453 self.config = self.draft_model_config.hf_config
454 + if getattr(self.config, "enable_confidence_head", False):
455 + # The class mapper drops the head as training-only; keep it for drafts
456 + # that actually built one, since adaptive verification reads it.
457 + self.hf_to_vllm_mapper = self.hf_to_vllm_mapper | WeightsMapper(
458 + orig_to_new_substr={"confidence_head": "confidence_head"}
459 + )
460 target_layer_num = vllm_config.model_config.get_total_num_hidden_layers()
461 self.model = K3DSparkModel(
462 vllm_config=vllm_config,
@@ -494,6 +515,13 @@ class K3DSparkForCausalLM(nn.Module):
515 def markov_bias(self, markov_embed: torch.Tensor) -> torch.Tensor:
516 return self.model.markov_head.bias(markov_embed, self.logits_processor)
517
518 + def compute_confidence(
519 + self, head_hidden: torch.Tensor, markov_embed: torch.Tensor
520 + ) -> torch.Tensor:
521 + """Per-position acceptance probability for each drafted token."""
522 + assert self.model.confidence_head is not None
523 + return torch.sigmoid(self.model.confidence_head(head_hidden, markov_embed))
524 +
525 def load_weights(self, weights: Iterable[tuple[str, torch.Tensor]]) -> set[str]:
526 loader = AutoWeightsLoader(self)
527 # read: 1. all weights. 2. context kv weights
vllm/models/kimi_k3/nvidia/kda_metadata.py
+17 -1
@@ -25,7 +25,7 @@ from vllm.model_executor.layers.mamba.checkpoint import (
25 from vllm.platforms import current_platform
26 from vllm.triton_utils import tl, triton
27 from vllm.utils.torch_utils import async_tensor_h2d
28 -from vllm.v1.attention.backend import CommonAttentionMetadata
28 +from vllm.v1.attention.backend import AttentionCGSupport, CommonAttentionMetadata
29 from vllm.v1.attention.backends.gdn_attn import (
30 GDNAttentionBackend,
31 GDNAttentionMetadata,
@@ -305,6 +305,10 @@ class KimiK3KDAMetadata(GDNAttentionMetadata, RecoverSSMMetadata):
305
306
307 class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
308 + # Overrides GDN's UNIFORM_BATCH: adaptive verification requires ALWAYS from every
309 + # builder, and KDA reads per-request offsets off device within a fixed k+1 window,
310 + # so one k+1 graph replays any 1..k+1 mix.
311 + _cudagraph_support = AttentionCGSupport.ALWAYS
312 mamba_aligned_state_indices: torch.Tensor | None = None
313
314 def __init__(
@@ -407,6 +411,12 @@ class KimiK3KDAMetadataBuilder(GDNAttentionMetadataBuilder):
411 query_start_loc_cpu.diff() > 0
412 )
413 spec_sequence_masks_cpu |= active_decode_mask_cpu
414 + # Adaptive's cost-table profiler probes eager dummy rows longer than
415 + # RecoverSSM's num_spec+1 activation capacity; route them out instead
416 + # of tripping the capacity check below.
417 + spec_sequence_masks_cpu &= (
418 + query_start_loc_cpu.diff() <= self.num_spec + 1
419 + )
420 # Native KDA can use its regular decode path when no draft token
421 # was scheduled. RecoverSSM must preserve its extended conv window.
422 if (
@@ -750,3 +760,9 @@ class KimiK3KDAAttentionBackend(GDNAttentionBackend):
760 @staticmethod
761 def get_builder_cls() -> type[KimiK3KDAMetadataBuilder]:
762 return KimiK3KDAMetadataBuilder
763 +
764 + @classmethod
765 + def supports_device_cpu_query_lens_mismatch(cls) -> bool:
766 + # Pure spec-decode reads its plan from DEVICE offsets, so CPU/device
767 + # mismatch is fine; the mixed branch still assumes CPU totals match.
768 + return True
vllm/v1/attention/backends/mla/flashattn_mla.py
+3
@@ -194,8 +194,11 @@ class FlashAttnMLAMetadataBuilder(MLACommonMetadataBuilder[FlashAttnMLAMetadata]
194 query_start_loc_cpu: torch.Tensor,
195 query_start_loc_device: torch.Tensor,
196 num_decode_tokens: int,
197 + max_query_len: int,
198 dcp_tot_seq_lens_device: torch.Tensor | None,
199 ) -> FlashAttnMLADecodeMetadata:
200 + # Measured, not the promised bound: _schedule_decode asserts metadata size
201 + # against a persistent buffer.
202 query_lens_cpu = query_start_loc_cpu[1:] - query_start_loc_cpu[:-1]
203 max_query_len = query_lens_cpu.max().item()
204
vllm/v1/attention/backends/mla/flashinfer_mla.py
+116 -14
@@ -1,6 +1,7 @@
1 # SPDX-License-Identifier: Apache-2.0
2 # SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
4 +from dataclasses import dataclass
5 from typing import TYPE_CHECKING, Any, ClassVar
6
7 import torch
@@ -15,6 +16,7 @@ from vllm.config.cache import CacheDType
16 from vllm.logger import init_logger
17 from vllm.model_executor.layers.attention.mla_attention import (
18 MLACommonBackend,
19 + MLACommonDecodeMetadata,
20 MLACommonImpl,
21 MLACommonMetadata,
22 MLACommonMetadataBuilder,
@@ -115,9 +117,28 @@ def _get_multi_ctas_kv_counter_buffer(
117 return _fi_multi_ctas_kv_counter
118
119
118 -class FlashInferMLAMetadataBuilder(MLACommonMetadataBuilder[MLACommonMetadata]):
119 - _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
120 - query_len_support: ClassVar[QueryLenSupport] = QueryLenSupport.UNIFORM
120 +@dataclass
121 +class FlashInferMLADecodeMetadata(MLACommonDecodeMetadata):
122 + flattened_block_table: torch.Tensor | None = None
123 + flattened_seq_lens: torch.Tensor | None = None
124 + flattened_row_req: torch.Tensor | None = None
125 + # Row count the flattened tensors were built for (0 = not built), not a query len.
126 + query_len: int = 0
127 + query_start_loc: torch.Tensor | None = None
128 + max_query_len: int = 1
129 +
130 +
131 +@dataclass
132 +class FlashInferMLAMetadata(MLACommonMetadata[FlashInferMLADecodeMetadata]):
133 + pass
134 +
135 +
136 +class FlashInferMLAMetadataBuilder(MLACommonMetadataBuilder[FlashInferMLAMetadata]):
137 + # Adaptive verification requires ALWAYS from every builder, matching upstream's
138 + # DeepseekV4FlashMLAMetadataBuilder. The kernels tile ragged queries from the device
139 + # query offsets (flashinfer #3238), so one k+1 graph replays any 1..k+1 mix.
140 + _cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS
141 + query_len_support: ClassVar[QueryLenSupport] = QueryLenSupport.VARLEN
142 # Non-causal DSpark blocks are flattened to single-token rows in forward_mqa.
143 supports_non_causal_multi_token_decode: ClassVar[bool] = True
144 supports_non_causal_multi_token_dcp: ClassVar[bool] = True
@@ -143,10 +164,31 @@ class FlashInferMLAMetadataBuilder(MLACommonMetadataBuilder[MLACommonMetadata]):
164 layer_names,
165 vllm_config,
166 device,
146 - MLACommonMetadata,
167 + FlashInferMLAMetadata,
168 supports_dcp_with_varlen=True,
169 )
170
171 + def _build_decode(
172 + self,
173 + block_table_tensor: torch.Tensor,
174 + seq_lens_device: torch.Tensor,
175 + max_seq_len: int,
176 + query_start_loc_cpu: torch.Tensor,
177 + query_start_loc_device: torch.Tensor,
178 + num_decode_tokens: int,
179 + max_query_len: int,
180 + dcp_tot_seq_lens_device: torch.Tensor | None,
181 + ) -> FlashInferMLADecodeMetadata:
182 + # Promised bound, not measured: a capture dummy puts one token/req, so
183 + # measuring would bake an undersized graph. Ragged only when max_query_len>1.
184 + return FlashInferMLADecodeMetadata(
185 + block_table=block_table_tensor,
186 + seq_lens=seq_lens_device,
187 + dcp_tot_seq_lens=dcp_tot_seq_lens_device,
188 + query_start_loc=query_start_loc_device,
189 + max_query_len=max_query_len,
190 + )
191 +
192
193 class FlashInferMLABackend(MLACommonBackend):
194 supported_dtypes: ClassVar[list[torch.dtype]] = [torch.float16, torch.bfloat16]
@@ -208,7 +250,7 @@ class FlashInferMLABackend(MLACommonBackend):
250 return None
251
252
211 -class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
253 +class FlashInferMLAImpl(MLACommonImpl[FlashInferMLAMetadata]):
254 can_return_lse_for_decode: bool = True
255 supports_dcp: bool = True
256 # DCP is the only path that consumes LSE. It uses monolithic CuTeDSL,
@@ -276,7 +318,7 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
318 self,
319 q: torch.Tensor | tuple[torch.Tensor, torch.Tensor],
320 kv_c_and_k_pe_cache: torch.Tensor,
279 - attn_metadata: MLACommonMetadata,
321 + attn_metadata: FlashInferMLAMetadata,
322 layer: AttentionLayer,
323 ) -> tuple[torch.Tensor, torch.Tensor | None]:
324 assert kv_c_and_k_pe_cache.numel() > 0
@@ -289,14 +331,32 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
331 block_table = attn_metadata.decode.block_table
332 seq_lens = attn_metadata.decode.seq_lens
333
334 + # Led by the promised bound (baked into the graph), not
335 + # num_decode_tokens // num_decodes -- that average is a per-request length
336 + # only for uniform batches.
337 + multi_token_decode = (
338 + attn_metadata.decode.max_query_len > 1
339 + or attn_metadata.num_decode_tokens > attn_metadata.num_decodes
340 + )
341 + cum_seq_lens_q: torch.Tensor | None = None
342 + max_q_len: int | None = None
343 + row_req: torch.Tensor | None = None
344 +
345 if not attn_metadata.causal:
346 # FlashInfer decode has no causal flag. Flatten each non-causal
347 # query block into independent single-token rows.
295 - query_len = attn_metadata.num_decode_tokens // attn_metadata.num_decodes
348 q = q.unsqueeze(1)
297 - if query_len > 1:
298 - block_table = block_table.repeat_interleave(query_len, dim=0)
299 - seq_lens = seq_lens.repeat_interleave(query_len)
349 + if multi_token_decode:
350 + block_table, seq_lens, row_req = self._flattened_decode_metadata(
351 + attn_metadata, q.shape[0]
352 + )
353 + elif attn_metadata.decode.max_query_len > 1 and self.dcp_world_size == 1:
354 + # Causal spec decode: keep q compact and let the kernel tile each
355 + # request's length (uniform 1+k and adaptive ragged). Mirrors vllm #52157.
356 + # DCP keeps the uniform reshape below: it needs LSE, which the kernels
357 + # do not return on the ragged path (flashinfer #3238).
358 + cum_seq_lens_q = attn_metadata.decode.query_start_loc
359 + max_q_len = attn_metadata.decode.max_query_len
360 # trtllm API requires extra dimension q_len_per_request for MTP
361 elif attn_metadata.num_decode_tokens % attn_metadata.num_decodes != 0:
362 logger.warning_once(
@@ -327,10 +387,8 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
387 if self.dcp_world_size > 1:
388 causal_seqlens_kv_global = attn_metadata.decode.dcp_tot_seq_lens
389 assert causal_seqlens_kv_global is not None
330 - if not attn_metadata.causal and query_len > 1:
331 - causal_seqlens_kv_global = causal_seqlens_kv_global.repeat_interleave(
332 - query_len
333 - )
390 + if row_req is not None:
391 + causal_seqlens_kv_global = causal_seqlens_kv_global[row_req]
392 extra_kwargs.update(
393 enable_dcp=True,
394 cp_world=self.dcp_world_size,
@@ -342,6 +400,16 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
400 # trtllm-gen rejects MLA head counts it can't tile (e.g. 96);
401 # fall back to cute-dsl for those.
402 decode_backend = _select_mla_decode_backend(runtime_num_heads)
403 + if cum_seq_lens_q is not None:
404 + # Neither decode backend returns LSE on the ragged path
405 + # (flashinfer #3238); DCP, the only LSE consumer, took the uniform
406 + # branch above, so this only guards a future caller wiring the two.
407 + assert not return_lse, (
408 + "FlashInferMLA ragged decode cannot return LSE; DCP and adaptive "
409 + "variable-length decode are mutually exclusive."
410 + )
411 + extra_kwargs["cum_seq_lens_q"] = cum_seq_lens_q
412 + extra_kwargs["max_q_len"] = max_q_len
413 if decode_backend:
414 extra_kwargs["backend"] = decode_backend
415 elif kv_c_and_k_pe_cache.shape[-2] in (32, 64):
@@ -386,3 +454,37 @@ class FlashInferMLAImpl(MLACommonImpl[MLACommonMetadata]):
454 o = o.view(-1, o.shape[-2], o.shape[-1])
455
456 return o, lse
457 +
458 + def _flattened_decode_metadata(
459 + self,
460 + attn_metadata: FlashInferMLAMetadata,
461 + num_rows: int,
462 + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]:
463 + """Expand per-request decode tensors to one row per query token.
464 +
465 + Cached across the layers of a group, which all see the same batch.
466 + """
467 + decode = attn_metadata.decode
468 + assert decode is not None
469 + if decode.query_len != num_rows:
470 + cu = decode.query_start_loc
471 + assert cu is not None
472 + # searchsorted on the device offsets, not repeat_interleave(uniform_len):
473 + # the latter only lines up for uniform batches and hands ragged rows
474 + # another request's KV (silent garbage).
475 + rows = torch.arange(num_rows, device=cu.device, dtype=cu.dtype)
476 + row_req = torch.searchsorted(cu[1:], rows, right=True).clamp_(
477 + max=decode.block_table.shape[0] - 1
478 + )
479 + decode.flattened_row_req = row_req
480 + decode.flattened_block_table = decode.block_table[row_req]
481 + decode.flattened_seq_lens = decode.seq_lens[row_req]
482 + decode.query_len = num_rows
483 + assert decode.flattened_block_table is not None
484 + assert decode.flattened_seq_lens is not None
485 + assert decode.flattened_row_req is not None
486 + return (
487 + decode.flattened_block_table,
488 + decode.flattened_seq_lens,
489 + decode.flattened_row_req,
490 + )
vllm/v1/attention/backends/mla/flashmla.py
+1
@@ -158,6 +158,7 @@ class FlashMLAMetadataBuilder(MLACommonMetadataBuilder[FlashMLAMetadata]):
158 query_start_loc_cpu: torch.Tensor,
159 query_start_loc_device: torch.Tensor,
160 num_decode_tokens: int,
161 + max_query_len: int,
162 dcp_tot_seq_lens_device: torch.Tensor | None,
163 ) -> FlashMLADecodeMetadata:
164 query_lens_cpu = query_start_loc_cpu[1:] - query_start_loc_cpu[:-1]
vllm/v1/attention/backends/mla/rocm_aiter_mla.py
+1
@@ -1007,6 +1007,7 @@ class AiterMLAMetadataBuilder(MLACommonMetadataBuilder[AiterMLAMetadata]):
1007 query_start_loc_cpu: torch.Tensor,
1008 query_start_loc_device: torch.Tensor,
1009 num_decode_tokens: int,
1010 + max_query_len: int,
1011 dcp_tot_seq_lens_device: torch.Tensor | None,
1012 ) -> AiterMLADecodeMetadata:
1013 causal = self._decode_causal
vllm/v1/worker/gpu/model_states/mamba_hybrid.py
+10 -5
@@ -250,7 +250,11 @@ class MambaHybridModelState(DefaultModelState):
250 num_reqs = input_batch.num_reqs
251 num_tokens = input_batch.num_tokens
252 query_start_loc_cpu = torch.from_numpy(input_batch.query_start_loc_np)
253 - max_query_len = input_batch.num_scheduled_tokens.max().item()
253 + # Prefer the promised bound: a capture dummy's measured max is its even
254 + # split, not the length the graph must replay.
255 + max_query_len = input_batch.max_query_len
256 + if max_query_len is None:
257 + max_query_len = input_batch.num_scheduled_tokens.max().item()
258 seq_lens_cpu_upper_bound = input_batch.seq_lens_cpu_upper_bound
259 if for_capture:
260 # Capture with worst-case max_seq_len so the graph is valid at any replay.
@@ -278,10 +282,11 @@ class MambaHybridModelState(DefaultModelState):
282 num_decode_draft_tokens_np = np.full(num_reqs, -1, dtype=np.int32)
283 num_draft_tokens_per_req = input_batch.num_draft_tokens_per_req
284 if num_draft_tokens_per_req is not None:
281 - # A row is a spec-decode row only when its whole prompt is already
282 - # computed, i.e. exactly one non-draft (decode) token is scheduled.
283 - is_decode = (
284 - input_batch.num_scheduled_tokens == num_draft_tokens_per_req + 1
285 + # Test request state, not num_scheduled_tokens == draft_count+1:
286 + # adaptive rewrites num_scheduled_tokens to an even split, so that
287 + # equality rarely holds and would demote every verify row to decode.
288 + is_decode = (~input_batch.is_prefilling_np) & (
289 + input_batch.num_scheduled_tokens > 0
290 )
291 spec_decode_mask = (num_draft_tokens_per_req > 0) & is_decode
292 num_decode_draft_tokens_np[: input_batch.num_reqs] = np.where(