[MRV2][Spec] Fuse AR speculator multi-step decodes back into one CUDA graph (#46849)
Signed-off-by: Yizhou Liu <liu_yizhou@outlook.com>
Yizhou committed
Aug 11, 2026 at 15:06 UTC
a311916a291c1fed3dbfb72e60f74cd778c8419d
10 files changed
+492
-49
docs/design/model_runner_v2.md
+8
@@ -191,6 +191,14 @@ V1's CUDA graph handling is implicit and hard to reason about. MRV2 uses a `CUDA
191
192
This makes graph lifecycle and execution mode decisions more understandable and easier to extend. Example: MRV2 can capture multiple draft-model forward passes into one CUDA graph.
193
194
+### Fused Multi-Step Draft Decoding
195
+
196
+Autoregressive speculative decoding executes several dependent draft steps per scheduler step. In the fused path, MRV2 captures all post-prefill draft steps in one full CUDA graph instead of replaying a separate graph for each draft token. Attention metadata is built once before the loop, while common step-dependent tensors keep stable addresses and are updated in place between draft steps.
197
+
198
+Some attention backends also materialize derived state, such as scheduler metadata or sparse indices. Before opting into the fused path, these backends must implement `AttentionMetadataBuilder.update_draft_decode_metadata()` to update or invalidate that state after the draft inputs advance. The hook runs during CUDA graph capture, so only the GPU operations it issues are recorded and executed during replay; its Python body is not run again. Implementations must therefore use capture-safe operations and keep all replayed tensor state in persistent storage.
199
+
200
+For draft models that advance positions, the fused path is enabled only when every draft attention group declares `supports_draft_decode_metadata_update`. Otherwise, MRV2 falls back to rebuilding attention metadata between draft steps. Draft models that keep positions fixed do not require this update. Before enabling a backend, developers must audit all derived metadata, including state inherited from parent builders or owned by auxiliary attention backends.
201
+
202
## Development Philosophy
203
204
MRV2 changes should meet a higher code quality bar. As feature gaps with V1 are filled, features should be reconsidered from first principles in the MRV2 design context instead of quickly porting V1 behavior.
tests/v1/worker/test_gpu_autoregressive_speculator.py
+166
@@ -3,6 +3,7 @@
3
4
from contextlib import nullcontext
5
from types import SimpleNamespace
6
+from unittest.mock import Mock
7
8
import pytest
9
import torch
@@ -16,6 +17,9 @@ from vllm.model_executor.models.mistral_eagle import EagleMistralForCausalLM
17
from vllm.model_executor.models.mistral_large_3_eagle import (
18
EagleMistralLarge3ForCausalLM,
19
)
20
+from vllm.v1.attention.backends import flash_attn as flash_attn_module
21
+from vllm.v1.attention.backends.flash_attn import FlashAttentionMetadata
22
+from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
23
from vllm.v1.worker.gpu.spec_decode import speculator as base_spec_module
24
from vllm.v1.worker.gpu.spec_decode.autoregressive import speculator as spec_module
25
from vllm.v1.worker.gpu.spec_decode.autoregressive.speculator import (
@@ -278,3 +282,165 @@ def test_run_model_reuses_tensor_return_for_mtp(monkeypatch):
282
283
assert actual_logits_hidden is hidden
284
assert actual_feedback_hidden is hidden
285
+
286
+
287
+@pytest.mark.parametrize(
288
+ (
289
+ "method_name",
290
+ "cg_mode",
291
+ "expected_eager_calls",
292
+ "expected_graph_replays",
293
+ ),
294
+ [
295
+ ("_multi_step_decode", CUDAGraphMode.NONE, 3, 0),
296
+ ("_multi_step_decode", CUDAGraphMode.FULL, 0, 3),
297
+ ("_fused_multi_step_decode", CUDAGraphMode.NONE, 3, 0),
298
+ ("_fused_multi_step_decode", CUDAGraphMode.FULL, 0, 1),
299
+ ],
300
+)
301
+def test_multi_step_decode_replays_captured_graph_as_expected(
302
+ method_name,
303
+ cg_mode,
304
+ expected_eager_calls,
305
+ expected_graph_replays,
306
+):
307
+ speculator = object.__new__(_TestSpeculator)
308
+ speculator.num_speculative_steps = 4
309
+ speculator.current_draft_step = torch.tensor(0)
310
+ speculator.input_buffers = SimpleNamespace(
311
+ positions=torch.arange(2),
312
+ query_start_loc=torch.arange(3),
313
+ )
314
+ speculator.idx_mapping = torch.arange(2)
315
+ generate_draft = Mock()
316
+ speculator._generate_draft = generate_draft
317
+ run_fullgraph = Mock()
318
+ speculator.decode_cudagraph_manager = SimpleNamespace(run_fullgraph=run_fullgraph)
319
+ batch_desc = BatchExecutionDescriptor(
320
+ cg_mode=cg_mode,
321
+ num_tokens=2,
322
+ num_reqs=2,
323
+ )
324
+
325
+ getattr(speculator, method_name)(
326
+ num_reqs=2,
327
+ skip_attn=True,
328
+ batch_desc=batch_desc,
329
+ seq_lens_cpu_upper_bound=None,
330
+ num_tokens_across_dp=None,
331
+ )
332
+
333
+ assert generate_draft.call_count == expected_eager_calls
334
+ assert run_fullgraph.call_count == expected_graph_replays
335
+
336
+
337
+def test_update_draft_decode_metadata_updates_fa3_scheduler_metadata(
338
+ monkeypatch,
339
+):
340
+ builder = object.__new__(flash_attn_module.FlashAttentionMetadataBuilder)
341
+ builder.aot_schedule = True
342
+ builder.use_full_cuda_graph = True
343
+ builder.scheduler_metadata = torch.zeros(8, dtype=torch.int32)
344
+ builder.cache_config = SimpleNamespace(cache_dtype="bfloat16")
345
+ builder.kv_cache_dtype = torch.bfloat16
346
+ builder.num_heads_q = 2
347
+ builder.num_heads_kv = 1
348
+ builder.headdim = 128
349
+ builder.block_size = 16
350
+ builder.dcp_world_size = 1
351
+ builder.dcp_rank = 0
352
+ builder.cp_kv_cache_interleave_size = 1
353
+ builder.aot_sliding_window = None
354
+
355
+ expected = torch.tensor([7, 8, 9], dtype=torch.int32)
356
+
357
+ def fake_get_scheduler_metadata(**kwargs):
358
+ return expected
359
+
360
+ monkeypatch.setattr(builder, "_get_scheduler_metadata", fake_get_scheduler_metadata)
361
+
362
+ metadata = FlashAttentionMetadata(
363
+ num_actual_tokens=3,
364
+ max_query_len=2,
365
+ query_start_loc=torch.tensor([0, 1, 3], dtype=torch.int32),
366
+ max_seq_len=8,
367
+ seq_lens=torch.tensor([5, 6], dtype=torch.int32),
368
+ block_table=torch.zeros((2, 1), dtype=torch.int32),
369
+ slot_mapping=torch.zeros(3, dtype=torch.int32),
370
+ use_cascade=False,
371
+ common_prefix_len=0,
372
+ cu_prefix_query_lens=None,
373
+ prefix_kv_lens=None,
374
+ suffix_kv_lens=None,
375
+ max_dcp_context_kv_len=None,
376
+ dcp_context_kv_lens=None,
377
+ num_decode_reqs=2,
378
+ num_prefill_reqs=0,
379
+ num_decode_tokens=3,
380
+ num_prefill_tokens=0,
381
+ scheduler_metadata=torch.tensor([-1, -1, -1], dtype=torch.int32),
382
+ prefix_scheduler_metadata=None,
383
+ max_num_splits=4,
384
+ causal=True,
385
+ sliding_window=None,
386
+ mm_prefix_query_range_tensor=None,
387
+ rswa_prefix_lens=None,
388
+ rswa_window=None,
389
+ rswa_window_tensor=None,
390
+ )
391
+
392
+ builder.update_draft_decode_metadata(metadata)
393
+
394
+ assert torch.equal(metadata.scheduler_metadata, expected)
395
+ assert torch.equal(builder.scheduler_metadata[:3], expected)
396
+
397
+
398
+def test_update_draft_decode_metadata_skips_without_scheduler_metadata(monkeypatch):
399
+ builder = object.__new__(flash_attn_module.FlashAttentionMetadataBuilder)
400
+ builder.aot_schedule = True
401
+ builder.use_full_cuda_graph = True
402
+ builder.scheduler_metadata = torch.zeros(4, dtype=torch.int32)
403
+
404
+ called = False
405
+
406
+ def fake_get_scheduler_metadata(**kwargs):
407
+ nonlocal called
408
+ called = True
409
+ return torch.tensor([1], dtype=torch.int32)
410
+
411
+ monkeypatch.setattr(builder, "_get_scheduler_metadata", fake_get_scheduler_metadata)
412
+
413
+ metadata = FlashAttentionMetadata(
414
+ num_actual_tokens=1,
415
+ max_query_len=1,
416
+ query_start_loc=torch.tensor([0, 1], dtype=torch.int32),
417
+ max_seq_len=1,
418
+ seq_lens=torch.tensor([1], dtype=torch.int32),
419
+ block_table=torch.zeros((1, 1), dtype=torch.int32),
420
+ slot_mapping=torch.zeros(1, dtype=torch.int32),
421
+ use_cascade=False,
422
+ common_prefix_len=0,
423
+ cu_prefix_query_lens=None,
424
+ prefix_kv_lens=None,
425
+ suffix_kv_lens=None,
426
+ max_dcp_context_kv_len=None,
427
+ dcp_context_kv_lens=None,
428
+ num_decode_reqs=1,
429
+ num_prefill_reqs=0,
430
+ num_decode_tokens=1,
431
+ num_prefill_tokens=0,
432
+ scheduler_metadata=None,
433
+ prefix_scheduler_metadata=None,
434
+ max_num_splits=1,
435
+ causal=True,
436
+ sliding_window=None,
437
+ mm_prefix_query_range_tensor=None,
438
+ rswa_prefix_lens=None,
439
+ rswa_window=None,
440
+ rswa_window_tensor=None,
441
+ )
442
+
443
+ builder.update_draft_decode_metadata(metadata)
444
+
445
+ assert not called
446
+ assert metadata.scheduler_metadata is None
vllm/models/deepseek_v4/amd/rocm.py
+4
@@ -372,6 +372,10 @@ class DeepseekV4ROCMAiterMLASparseMetadataBuilder(DeepseekV4FlashMLAMetadataBuil
372
373
374
class DeepseekV4ROCMAiterSparseSWAMetadataBuilder(DeepseekSparseSWAMetadataBuilder):
375
+ # Keep fused multi-step decode disabled until update_draft_decode_metadata()
376
+ # also refreshes the ROCm-specific ragged SWA indices and indptrs.
377
+ supports_draft_decode_metadata_update = False
378
+
379
def __init__(self, *args, **kwargs):
380
super().__init__(*args, **kwargs)
381
max_tokens = self.vllm_config.scheduler_config.max_num_batched_tokens
vllm/v1/attention/backend.py
+13
@@ -633,6 +633,9 @@ class AttentionMetadataBuilder(ABC, Generic[M]):
633
supports_update_block_table: bool = False
634
# Whether the builder constructor requires the block-table width.
635
requires_block_table_width: ClassVar[bool] = False
636
+ # Whether all step-dependent draft decode metadata can be updated in place,
637
+ # allowing one metadata build to be reused across autoregressive draft steps.
638
+ supports_draft_decode_metadata_update: bool = False
639
640
@abstractmethod
641
def __init__(
@@ -757,6 +760,16 @@ class AttentionMetadataBuilder(ABC, Generic[M]):
760
fast_build=True,
761
)
762
763
+ def update_draft_decode_metadata(self, metadata: M) -> None:
764
+ """Update step-dependent draft decode metadata in place.
765
+
766
+ The fused draft loop may call this method during full CUDA graph
767
+ capture. CUDA graph replay does not run this Python method, so
768
+ implementations must emit capture-safe operations and keep replayed
769
+ tensor state in persistent storage.
770
+ """
771
+ raise NotImplementedError
772
+
773
def use_cascade_attention(
774
self,
775
common_prefix_len: int,
vllm/v1/attention/backends/flash_attn.py
+94
-42
@@ -370,6 +370,57 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
370
) -> AttentionCGSupport:
371
return cls._cudagraph_support
372
373
+ def _get_scheduler_metadata(
374
+ self,
375
+ *,
376
+ aot_schedule: bool,
377
+ batch_size: int,
378
+ cu_query_lens: torch.Tensor,
379
+ max_query_len: int,
380
+ seqlens: torch.Tensor,
381
+ max_seq_len: int,
382
+ causal: bool | torch.Tensor,
383
+ max_num_splits: int,
384
+ ) -> torch.Tensor | None:
385
+ if not aot_schedule:
386
+ return None
387
+
388
+ cache_dtype = self.cache_config.cache_dtype
389
+ if is_quantized_kv_cache(cache_dtype):
390
+ qkv_dtype = current_platform.fp8_dtype()
391
+ else:
392
+ qkv_dtype = self.kv_cache_dtype
393
+ return get_scheduler_metadata(
394
+ batch_size=batch_size,
395
+ max_seqlen_q=max_query_len,
396
+ max_seqlen_k=max_seq_len,
397
+ num_heads_q=self.num_heads_q * self.dcp_world_size,
398
+ num_heads_kv=self.num_heads_kv,
399
+ headdim=self.headdim,
400
+ cache_seqlens=seqlens,
401
+ qkv_dtype=qkv_dtype,
402
+ cu_seqlens_q=cu_query_lens,
403
+ page_size=self.block_size,
404
+ causal=causal,
405
+ window_size=_maybe_symmetrize_window(self.aot_sliding_window, causal),
406
+ num_splits=max_num_splits,
407
+ )
408
+
409
+ def _store_scheduler_metadata(
410
+ self, scheduler_metadata: torch.Tensor | None
411
+ ) -> torch.Tensor | None:
412
+ if self.use_full_cuda_graph and scheduler_metadata is not None:
413
+ n = scheduler_metadata.shape[0]
414
+ assert self.scheduler_metadata is not None
415
+ self.scheduler_metadata[:n] = scheduler_metadata
416
+ # NOTE(woosuk): We should zero out the rest of the scheduler
417
+ # metadata to guarantee the correctness. Otherwise, some thread
418
+ # blocks may use the invalid scheduler metadata and overwrite the
419
+ # output buffer.
420
+ self.scheduler_metadata[n:] = 0
421
+ return self.scheduler_metadata[:n]
422
+ return scheduler_metadata
423
+
424
def __init__(
425
self,
426
kv_cache_spec: AttentionSpec,
@@ -405,6 +456,14 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
456
self.dcp_world_size = 1
457
self.dcp_rank = 0
458
459
+ # Fused draft decode reuses the captured metadata object across draft
460
+ # steps. For DCP, build-time host-side decisions such as
461
+ # skip_dcp_context_attention() can change the metadata shape/control
462
+ # path (for example max_dcp_context_kv_len), and those Python-side
463
+ # fields are not refreshed in-place between graph replays. Keep the
464
+ # fused path disabled until DCP gets a full replay-safe refresh model.
465
+ self.supports_draft_decode_metadata_update = self.dcp_world_size == 1
466
+
467
self.cp_kv_cache_interleave_size = (
468
self.parallel_config.cp_kv_cache_interleave_size
469
)
@@ -533,34 +592,6 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
592
if envs.VLLM_BATCH_INVARIANT:
593
max_num_splits = 1
594
536
- def schedule(
537
- batch_size, cu_query_lens, max_query_len, seqlens, max_seq_len, causal
538
- ):
539
- cache_dtype = self.cache_config.cache_dtype
540
- if is_quantized_kv_cache(cache_dtype):
541
- qkv_dtype = current_platform.fp8_dtype()
542
- else:
543
- qkv_dtype = self.kv_cache_dtype
544
- if aot_schedule:
545
- return get_scheduler_metadata(
546
- batch_size=batch_size,
547
- max_seqlen_q=max_query_len,
548
- max_seqlen_k=max_seq_len,
549
- num_heads_q=self.num_heads_q * self.dcp_world_size,
550
- num_heads_kv=self.num_heads_kv,
551
- headdim=self.headdim,
552
- cache_seqlens=seqlens,
553
- qkv_dtype=qkv_dtype,
554
- cu_seqlens_q=cu_query_lens,
555
- page_size=self.block_size,
556
- causal=causal,
557
- window_size=_maybe_symmetrize_window(
558
- self.aot_sliding_window, causal
559
- ),
560
- num_splits=max_num_splits,
561
- )
562
- return None
563
-
595
use_cascade = common_prefix_len > 0
596
max_dcp_context_kv_len = 0
597
dcp_context_kv_lens = None
@@ -627,13 +658,15 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
658
(max_seq_len + num_partitions - 1) // num_partitions
659
) * self.cp_kv_cache_interleave_size
660
630
- scheduler_metadata = schedule(
661
+ scheduler_metadata = self._get_scheduler_metadata(
662
+ aot_schedule=aot_schedule,
663
batch_size=num_reqs,
664
cu_query_lens=query_start_loc,
665
max_query_len=max_query_len,
666
seqlens=dcp_context_kv_lens,
667
max_seq_len=max_dcp_context_kv_len,
668
causal=False,
669
+ max_num_splits=max_num_splits,
670
)
671
elif use_cascade:
672
cu_prefix_query_lens = torch.tensor(
@@ -644,41 +677,38 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
677
)
678
# Use GPU tensor directly - no CPU sync needed
679
suffix_kv_lens = seq_lens[:num_reqs] - common_prefix_len
647
- prefix_scheduler_metadata = schedule(
680
+ prefix_scheduler_metadata = self._get_scheduler_metadata(
681
+ aot_schedule=aot_schedule,
682
batch_size=1,
683
cu_query_lens=cu_prefix_query_lens,
684
max_query_len=num_actual_tokens,
685
seqlens=prefix_kv_lens,
686
max_seq_len=common_prefix_len,
687
causal=False,
688
+ max_num_splits=max_num_splits,
689
)
655
- scheduler_metadata = schedule(
690
+ scheduler_metadata = self._get_scheduler_metadata(
691
+ aot_schedule=aot_schedule,
692
batch_size=num_reqs,
693
cu_query_lens=query_start_loc,
694
max_query_len=max_query_len,
695
seqlens=suffix_kv_lens,
696
max_seq_len=max_seq_len - common_prefix_len,
697
causal=True,
698
+ max_num_splits=max_num_splits,
699
)
700
else:
664
- scheduler_metadata = schedule(
701
+ scheduler_metadata = self._get_scheduler_metadata(
702
+ aot_schedule=aot_schedule,
703
batch_size=num_reqs,
704
cu_query_lens=query_start_loc,
705
max_query_len=max_query_len,
706
seqlens=seq_lens,
707
max_seq_len=max_seq_len,
708
causal=causal,
709
+ max_num_splits=max_num_splits,
710
)
672
- # For FA3 + full cudagraph
673
- if self.use_full_cuda_graph and scheduler_metadata is not None:
674
- n = scheduler_metadata.shape[0]
675
- self.scheduler_metadata[:n] = scheduler_metadata
676
- # NOTE(woosuk): We should zero out the rest of the scheduler
677
- # metadata to guarantee the correctness. Otherwise, some thread
678
- # blocks may use the invalid scheduler metadata and overwrite the
679
- # output buffer.
680
- self.scheduler_metadata[n:] = 0
681
- scheduler_metadata = self.scheduler_metadata[:n]
711
+ scheduler_metadata = self._store_scheduler_metadata(scheduler_metadata)
712
713
if isinstance(causal, torch.Tensor) and causal.dtype != torch.int32:
714
causal = causal.to(torch.int32)
@@ -772,6 +802,28 @@ class FlashAttentionMetadataBuilder(AttentionMetadataBuilder[FlashAttentionMetad
802
new_metadata.slot_mapping = slot_mapping
803
return new_metadata
804
805
+ def update_draft_decode_metadata(self, metadata: FlashAttentionMetadata) -> None:
806
+ if metadata.scheduler_metadata is None:
807
+ return
808
+
809
+ num_reqs = metadata.num_decode_reqs or metadata.seq_lens.shape[0]
810
+
811
+ assert self.dcp_world_size == 1
812
+ assert not metadata.use_cascade
813
+
814
+ scheduler_metadata = self._get_scheduler_metadata(
815
+ aot_schedule=True,
816
+ batch_size=num_reqs,
817
+ cu_query_lens=metadata.query_start_loc,
818
+ max_query_len=metadata.max_query_len,
819
+ seqlens=metadata.seq_lens,
820
+ max_seq_len=metadata.max_seq_len,
821
+ causal=metadata.causal,
822
+ max_num_splits=metadata.max_num_splits,
823
+ )
824
+
825
+ metadata.scheduler_metadata = self._store_scheduler_metadata(scheduler_metadata)
826
+
827
def use_cascade_attention(self, *args, **kwargs) -> bool:
828
return use_cascade_attention(*args, **kwargs)
829
vllm/v1/attention/backends/mla/sparse_swa.py
+35
@@ -400,6 +400,7 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
400
401
reorder_batch_threshold: int | None = None
402
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.UNIFORM_BATCH
403
+ supports_draft_decode_metadata_update = True
404
405
def __init__(self, *args, **kwargs):
406
super().__init__(*args, **kwargs)
@@ -649,6 +650,40 @@ class DeepseekSparseSWAMetadataBuilder(AttentionMetadataBuilder):
650
**deepseek_v4_fields, # type: ignore[arg-type]
651
)
652
653
+ def update_draft_decode_metadata(
654
+ self,
655
+ metadata: DeepseekSparseSWAMetadata,
656
+ ) -> None:
657
+ if metadata.num_decode_tokens == 0:
658
+ return
659
+ assert metadata.query_start_loc is not None
660
+ assert metadata.seq_lens is not None
661
+ assert metadata.token_to_req_indices is not None
662
+ assert metadata.is_valid_token is not None
663
+ assert metadata.decode_swa_indices is not None
664
+ assert metadata.decode_swa_lens is not None
665
+
666
+ _compute_swa_indices_and_lens_kernel[(metadata.num_decode_tokens,)](
667
+ metadata.decode_swa_indices,
668
+ metadata.decode_swa_indices.stride(0),
669
+ metadata.decode_swa_lens,
670
+ metadata.decode_swa_indices.shape[-1],
671
+ metadata.query_start_loc,
672
+ metadata.seq_lens,
673
+ metadata.token_to_req_indices,
674
+ metadata.is_valid_token,
675
+ metadata.block_table,
676
+ metadata.block_table.stride(0),
677
+ self.block_size,
678
+ token_offset=0,
679
+ TRITON_BLOCK_SIZE=1024,
680
+ )
681
+ tile_sched = self.build_tile_scheduler(metadata.num_decode_tokens)
682
+ metadata.tile_sched_swaonly = tile_sched[_LAYER_TYPE_SWAONLY]
683
+ metadata.tile_sched_c4a = tile_sched[_LAYER_TYPE_C4A]
684
+ metadata.tile_sched_c128a = tile_sched[_LAYER_TYPE_C128A]
685
+ metadata.flashinfer_sparse_index_cache.clear()
686
+
687
def build_tile_scheduler(
688
self, num_decode_tokens: int
689
) -> dict[str, FlashMLASchedMeta | None]:
vllm/v1/attention/backends/mla/triton_mla.py
+5
@@ -57,6 +57,8 @@ class TritonMLAMetadataBuilder(MLACommonMetadataBuilder[MLACommonMetadata]):
57
58
def __init__(self, kv_cache_spec, layer_names, vllm_config, device):
59
super().__init__(kv_cache_spec, layer_names, vllm_config, device)
60
+ # DCP local sequence lengths are not advanced between draft steps.
61
+ self.supports_draft_decode_metadata_update = self.dcp_world_size == 1
62
# Only the non-causal DSpark draft group serves multi-token blocks via
63
# the decode path; raise its reorder threshold to the spec block length
64
# so full-cudagraph capture admits it. Causal usage stays single-token.
@@ -64,6 +66,9 @@ class TritonMLAMetadataBuilder(MLACommonMetadataBuilder[MLACommonMetadata]):
66
self._init_reorder_batch_threshold(1, supports_spec_as_decode=True)
67
self._reserve_attn_logits_workspace()
68
69
+ def update_draft_decode_metadata(self, _metadata: MLACommonMetadata) -> None:
70
+ pass
71
+
72
def _reserve_attn_logits_workspace(self) -> None:
73
"""Pre-size the shared workspace for the decode split-KV attn logits.
74
vllm/v1/attention/backends/triton_attn.py
+5
@@ -100,6 +100,8 @@ class TritonAttentionMetadata:
100
101
class TritonAttentionMetadataBuilder(AttentionMetadataBuilder[TritonAttentionMetadata]):
102
_cudagraph_support: ClassVar[AttentionCGSupport] = AttentionCGSupport.ALWAYS
103
+ # Step-dependent fields reference persistent input buffers directly.
104
+ supports_draft_decode_metadata_update = True
105
106
def __init__(
107
self,
@@ -267,6 +269,9 @@ class TritonAttentionMetadataBuilder(AttentionMetadataBuilder[TritonAttentionMet
269
270
return attn_metadata
271
272
+ def update_draft_decode_metadata(self, _metadata: TritonAttentionMetadata) -> None:
273
+ pass
274
+
275
276
class TritonAttentionBackend(AttentionBackend):
277
supported_dtypes: ClassVar[list[torch.dtype]] = [
vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py
+151
-7
@@ -10,17 +10,21 @@ from vllm.config.compilation import CUDAGraphMode
10
from vllm.forward_context import BatchDescriptor, set_forward_context
11
from vllm.logger import init_logger
12
from vllm.triton_utils import tl, triton
13
+from vllm.v1.kv_cache_interface import KVCacheConfig
14
from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer
15
+from vllm.v1.worker.gpu.block_table import BlockTables
16
from vllm.v1.worker.gpu.cudagraph_utils import (
17
BatchExecutionDescriptor,
18
get_uniform_token_count,
19
)
20
from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp
21
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
22
+from vllm.v1.worker.gpu.model_states.interface import ModelState
23
from vllm.v1.worker.gpu.spec_decode.autoregressive.cudagraph_utils import (
24
SpeculatorCudaGraphManager,
25
)
26
from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
27
+from vllm.v1.worker.utils import AttentionGroup
28
29
logger = init_logger(__name__)
30
@@ -41,6 +45,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
45
46
self.prefill_cudagraph_manager: SpeculatorCudaGraphManager | None = None
47
self.decode_cudagraph_manager: SpeculatorCudaGraphManager | None = None
48
+ self.use_fused_multi_step_decode = False
49
50
def load_model(self, target_model: nn.Module) -> None:
51
super().load_model(target_model)
@@ -76,6 +81,49 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
81
"""
82
return True
83
84
+ def set_attn(
85
+ self,
86
+ model_state: ModelState,
87
+ kv_cache_config: KVCacheConfig,
88
+ block_tables: BlockTables,
89
+ target_input_buffers: InputBuffers,
90
+ target_attn_groups: list[list[AttentionGroup]],
91
+ ) -> None:
92
+ super().set_attn(
93
+ model_state,
94
+ kv_cache_config,
95
+ block_tables,
96
+ target_input_buffers,
97
+ target_attn_groups,
98
+ )
99
+ self._configure_fused_multi_step_decode()
100
+
101
+ def _configure_fused_multi_step_decode(self) -> None:
102
+ if self.num_speculative_steps == 1:
103
+ self.use_fused_multi_step_decode = False
104
+ return
105
+
106
+ if not self.advance_draft_positions:
107
+ self.use_fused_multi_step_decode = True
108
+ return
109
+
110
+ unsupported_backends = sorted(
111
+ {
112
+ attn_group.backend.get_name()
113
+ for attn_groups in self.attn_groups
114
+ for attn_group in attn_groups
115
+ if not attn_group.supports_draft_decode_metadata_update
116
+ }
117
+ )
118
+ self.use_fused_multi_step_decode = not unsupported_backends
119
+ if unsupported_backends:
120
+ logger.info_once(
121
+ "Fused multi-step draft decode is not supported by attention "
122
+ "backend(s) %s; falling back to rebuilding attention metadata "
123
+ "between draft steps.",
124
+ ", ".join(unsupported_backends),
125
+ )
126
+
127
def init_cudagraph_manager(self, cudagraph_mode: CUDAGraphMode) -> None:
128
# Initialize cudagraph manager for draft prefill (draft position 0).
129
self.prefill_cudagraph_manager = SpeculatorCudaGraphManager(
@@ -104,6 +152,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
152
# Reset indices to zeros to prevent stale values from prior
153
# dummy runs to cause out-of-bounds indexing during capture.
154
self.last_token_indices.zero_()
155
+ self.idx_mapping.zero_()
156
157
# Capture the prefill routine (model forward + compute_logits +
158
# sample).
@@ -133,12 +182,15 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
182
return
183
184
self.on_multi_step_decode_begin(self.max_num_reqs)
136
- # Capture the decode draft generation routine (model forward +
137
- # sample + update_draft_inputs) for a single
138
- # step.
185
+ # Capture either the fused decode loop or one decode step per graph.
186
assert self.decode_cudagraph_manager is not None
187
+ decode_fn = (
188
+ self._generate_fused_drafts
189
+ if self.use_fused_multi_step_decode
190
+ else self._generate_draft
191
+ )
192
self.decode_cudagraph_manager.capture(
141
- self._generate_draft,
193
+ decode_fn,
194
self.model_state,
195
self.input_buffers,
196
self.block_tables,
@@ -290,7 +342,12 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
342
343
self.on_multi_step_decode_begin(num_reqs)
344
# Generate the remaining num_speculative_steps - 1 draft tokens.
293
- self._multi_step_decode(
345
+ decode_fn = (
346
+ self._fused_multi_step_decode
347
+ if self.use_fused_multi_step_decode
348
+ else self._multi_step_decode
349
+ )
350
+ decode_fn(
351
num_reqs,
352
dummy_run and skip_attn_for_dummy_run,
353
decode_batch_desc,
@@ -435,10 +492,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
492
step=step,
493
)
494
438
- # Update the current draft step.
495
self.current_draft_step.fill_(step)
496
441
- # Generate draft tokens for the current step.
497
if batch_desc.cg_mode == CUDAGraphMode.FULL:
498
assert self.decode_cudagraph_manager is not None
499
self.decode_cudagraph_manager.run_fullgraph(batch_desc)
@@ -452,6 +507,95 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
507
cudagraph_runtime_mode=batch_desc.cg_mode,
508
)
509
510
+ def _fused_multi_step_decode(
511
+ self,
512
+ num_reqs: int,
513
+ skip_attn: bool,
514
+ batch_desc: BatchExecutionDescriptor,
515
+ num_tokens_across_dp: torch.Tensor | None,
516
+ seq_lens_cpu_upper_bound: torch.Tensor,
517
+ ) -> None:
518
+ positions = self.input_buffers.positions[:num_reqs]
519
+ query_start_loc = self.input_buffers.query_start_loc[: num_reqs + 1]
520
+ idx_mapping = self.idx_mapping[:num_reqs]
521
+
522
+ attn_metadata = None
523
+ slot_mappings_by_layer = None
524
+ if not skip_attn:
525
+ slot_mappings = self.block_tables.compute_slot_mappings(
526
+ idx_mapping,
527
+ query_start_loc,
528
+ positions,
529
+ batch_desc.num_tokens,
530
+ )
531
+ if batch_desc.cg_mode != CUDAGraphMode.FULL:
532
+ slot_mappings_by_layer = build_slot_mappings_by_layer(
533
+ slot_mappings, self.kv_cache_config
534
+ )
535
+ attn_metadata = self._build_draft_attn_metadata(
536
+ num_reqs=num_reqs,
537
+ num_reqs_padded=batch_desc.num_reqs or num_reqs,
538
+ num_tokens_padded=batch_desc.num_tokens,
539
+ seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
540
+ step=1,
541
+ )
542
+
543
+ if batch_desc.cg_mode == CUDAGraphMode.FULL:
544
+ assert self.decode_cudagraph_manager is not None
545
+ self.decode_cudagraph_manager.run_fullgraph(batch_desc)
546
+ return
547
+
548
+ self._generate_fused_drafts(
549
+ num_reqs,
550
+ batch_desc.num_tokens,
551
+ attn_metadata,
552
+ slot_mappings_by_layer,
553
+ num_tokens_across_dp,
554
+ batch_desc.cg_mode,
555
+ )
556
+
557
+ def _generate_fused_drafts(
558
+ self,
559
+ num_reqs: int,
560
+ num_tokens_padded: int,
561
+ attn_metadata: dict[str, Any] | None,
562
+ slot_mappings: dict[str, torch.Tensor] | None,
563
+ num_tokens_across_dp: torch.Tensor | None,
564
+ cudagraph_runtime_mode: CUDAGraphMode = CUDAGraphMode.NONE,
565
+ ) -> None:
566
+ idx_mapping = self.idx_mapping[:num_reqs]
567
+ positions = self.input_buffers.positions[:num_reqs]
568
+ query_start_loc = self.input_buffers.query_start_loc[: num_reqs + 1]
569
+ attn_groups = (
570
+ [group for groups in self.attn_groups for group in groups]
571
+ if attn_metadata is not None
572
+ else []
573
+ )
574
+
575
+ for step in range(1, self.num_speculative_steps):
576
+ self.current_draft_step.fill_(step)
577
+ self._generate_draft(
578
+ num_reqs,
579
+ num_tokens_padded,
580
+ attn_metadata,
581
+ slot_mappings,
582
+ num_tokens_across_dp,
583
+ cudagraph_runtime_mode,
584
+ )
585
+ if (
586
+ step < self.num_speculative_steps - 1
587
+ and attn_metadata is not None
588
+ and self.advance_draft_positions
589
+ ):
590
+ self.block_tables.compute_slot_mappings(
591
+ idx_mapping,
592
+ query_start_loc,
593
+ positions,
594
+ num_tokens_padded,
595
+ )
596
+ for attn_group in attn_groups:
597
+ attn_group.update_draft_decode_metadata(attn_metadata)
598
+
599
def _generate_draft(
600
self,
601
num_reqs: int,
vllm/v1/worker/utils.py
+11
@@ -299,6 +299,17 @@ class AttentionGroup:
299
assert len(self.metadata_builders) > ubatch_id
300
return self.metadata_builders[ubatch_id]
301
302
+ @property
303
+ def supports_draft_decode_metadata_update(self) -> bool:
304
+ return self.get_metadata_builder().supports_draft_decode_metadata_update
305
+
306
+ def update_draft_decode_metadata(
307
+ self,
308
+ attn_metadata: Mapping[str, Any],
309
+ ) -> None:
310
+ metadata = attn_metadata[self.layer_names[0]]
311
+ self.get_metadata_builder().update_draft_decode_metadata(metadata)
312
+
313
314
def select_common_block_size(
315
kv_manager_block_size: int,