[BugFix][ModelRunner V2] Fix stale attn metadata in speculator prefill cudagraph capture (#48261)
Signed-off-by: Nick Hill <nickhill123@gmail.com>
Nick Hill committed
Jul 13, 2026 at 17:39 UTC
26587f9519e22a5c4549ead7595ad9ca3229c4fd
7 files changed
+72
-108
vllm/v1/worker/gpu/cudagraph_utils.py
+17
-35
@@ -44,11 +44,6 @@ class AttentionState(NamedTuple):
44
slot_mappings: dict[str, torch.Tensor]
45
46
47
-class AttentionStatePair(NamedTuple):
48
- warmup: AttentionState
49
- captured: AttentionState
50
-
51
-
47
@dataclass(frozen=True)
48
class BatchExecutionDescriptor:
49
"""Describes the shape of the batch and CG mode to run; this is used to make shape
@@ -62,15 +57,15 @@ class BatchExecutionDescriptor:
57
58
59
class CreateForwardFn(Protocol):
65
- """Factory that prepares inputs (OUTSIDE the graph) and returns a tuple of
66
- (forward_fn, attn_state). Called with warmup=True for the warmup pass and
67
- warmup=False for the captured pass."""
60
+ """Factory that prepares inputs (OUTSIDE the graph) and returns a
61
+ forward_fn. Called with warmup=True for the warmup pass and warmup=False
62
+ for the captured pass."""
63
64
def __call__(
65
self,
66
desc: BatchExecutionDescriptor,
67
warmup: bool,
73
- ) -> tuple[Callable[[CUDAGraphMode], None], AttentionState]: ...
68
+ ) -> Callable[[CUDAGraphMode], None]: ...
69
70
71
def _is_compatible(
@@ -301,19 +296,18 @@ class CudaGraphManager:
296
self,
297
create_forward_fn: CreateForwardFn,
298
progress_bar_desc: str = "Capturing CUDA graphs",
304
- ) -> dict[BatchExecutionDescriptor, AttentionStatePair]:
299
+ ) -> None:
300
"""Capture CUDA graphs.
301
302
Args:
303
create_forward_fn: Factory that prepares inputs (OUTSIDE graph) and
309
- returns a tuple of (forward_fn, attn_state). For FULL cudagraph
310
- mode, it is invoked once with warmup=True for the warmup pass,
311
- and again with warmup=False for the captured pass. For attention
312
- backends that perform lazy metadata initialization (e.g. FlashMLA),
313
- FULL cudagraph capture requires distinct metadatas for warmup and
314
- capture.
304
+ returns a forward_fn. For FULL cudagraph mode, it is invoked
305
+ once with warmup=True for the warmup pass, and again with
306
+ warmup=False for the captured pass. For attention backends
307
+ that perform lazy metadata initialization (e.g. FlashMLA),
308
+ FULL cudagraph capture requires distinct metadatas for warmup
309
+ and capture.
310
"""
316
- attn_states: dict[BatchExecutionDescriptor, AttentionStatePair] = {}
311
with graph_capture(device=self.device):
312
# Capture in order: PIECEWISE first, then FULL. PIECEWISE has larger
313
# activations so FULL activations should fit in already allocated
@@ -327,7 +321,7 @@ class CudaGraphManager:
321
descs = tqdm(descs, desc=f"{progress_bar_desc} ({mode.name})")
322
for desc in descs:
323
# Prepare inputs and get forward function
330
- forward_fn, warmup_attn_state = create_forward_fn(desc, warmup=True)
324
+ forward_fn = create_forward_fn(desc, warmup=True)
325
326
# Warmup
327
forward_fn(CUDAGraphMode.NONE)
@@ -337,18 +331,10 @@ class CudaGraphManager:
331
"CG Capture: mode=%s, batch_desc=%s", desc.cg_mode.name, desc
332
)
333
if desc.cg_mode == CUDAGraphMode.PIECEWISE:
340
- attn_states[desc] = AttentionStatePair(
341
- warmup_attn_state, warmup_attn_state
342
- )
334
forward_fn(CUDAGraphMode.PIECEWISE)
335
else:
336
# Capture with fresh attention state.
346
- forward_fn, capture_attn_state = create_forward_fn(
347
- desc, warmup=False
348
- )
349
- attn_states[desc] = AttentionStatePair(
350
- warmup_attn_state, capture_attn_state
351
- )
337
+ forward_fn = create_forward_fn(desc, warmup=False)
338
assert desc not in self.graphs, (
339
f"Graph already captured for {desc}"
340
)
@@ -366,7 +352,6 @@ class CudaGraphManager:
352
self.graphs[desc] = graph
353
compilation_counter.num_cudagraph_captured += 1
354
self._graphs_captured = True
369
- return attn_states
355
356
def dispatch(
357
self,
@@ -461,7 +446,7 @@ class ModelCudaGraphManager(CudaGraphManager):
446
use_aux_hidden_state_outputs: bool = False,
447
lora_capture_hook: Callable[[int, int, int], None] | None = None,
448
progress_bar_desc: str = "Capturing CUDA graphs",
464
- ) -> dict[BatchExecutionDescriptor, AttentionStatePair]:
449
+ ) -> None:
450
"""Capture CUDA graphs for model forward pass."""
451
self.use_aux_hidden_state_outputs = use_aux_hidden_state_outputs
452
if self.use_breakable_cg:
@@ -470,10 +455,7 @@ class ModelCudaGraphManager(CudaGraphManager):
455
def create_forward_fn(
456
desc: BatchExecutionDescriptor,
457
warmup: bool,
473
- ) -> tuple[
474
- Callable[[CUDAGraphMode], None],
475
- AttentionState,
476
- ]:
458
+ ) -> Callable[[CUDAGraphMode], None]:
459
num_tokens = desc.num_tokens
460
num_reqs = desc.num_reqs or min(num_tokens, self.max_num_reqs)
461
@@ -571,9 +553,9 @@ class ModelCudaGraphManager(CudaGraphManager):
553
for k, v in intermediate_tensors.tensors.items():
554
self.intermediate_tensors[k][:num_tokens] = v
555
574
- return forward_fn, AttentionState(attn_metadata, slot_mappings)
556
+ return forward_fn
557
576
- return super().capture(create_forward_fn, progress_bar_desc)
558
+ super().capture(create_forward_fn, progress_bar_desc)
559
560
def run_fullgraph(
561
self, desc: BatchExecutionDescriptor
vllm/v1/worker/gpu/model_runner.py
+7
-3
@@ -479,7 +479,11 @@ class GPUModelRunner(LoRAModelRunnerMixin):
479
if isinstance(self.speculator, DraftModelSpeculator):
480
# HACK(woosuk)
481
self.speculator.set_attn(
482
- self.model_state, self.kv_cache_config, self.block_tables
482
+ self.model_state,
483
+ self.kv_cache_config,
484
+ self.block_tables,
485
+ self.input_buffers,
486
+ self.attn_groups,
487
)
488
489
self.kv_caches: list[torch.Tensor] = []
@@ -706,7 +710,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
710
start_free_gpu_memory = torch.accelerator.get_memory_info()[0]
711
712
with self.maybe_setup_dummy_loras(self.lora_config):
709
- attn_states = self.cudagraph_manager.capture(
713
+ self.cudagraph_manager.capture(
714
self.model,
715
self.model_state,
716
self.input_buffers,
@@ -719,7 +723,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
723
lora_capture_hook=create_lora_capture_hook(self.lora_config, self),
724
)
725
if self.speculator is not None:
722
- self.speculator.capture(attn_states)
726
+ self.speculator.capture()
727
728
end_time = time.perf_counter()
729
end_free_gpu_memory = torch.accelerator.get_memory_info()[0]
vllm/v1/worker/gpu/spec_decode/autoregressive/cudagraph_utils.py
+11
-45
@@ -8,8 +8,6 @@ from vllm.config.compilation import CUDAGraphMode
8
from vllm.v1.kv_cache_interface import KVCacheConfig
9
from vllm.v1.worker.gpu.block_table import BlockTables
10
from vllm.v1.worker.gpu.cudagraph_utils import (
11
- AttentionState,
12
- AttentionStatePair,
11
BatchExecutionDescriptor,
12
CudaGraphManager,
13
prepare_inputs_to_capture,
@@ -19,45 +17,15 @@ from vllm.v1.worker.gpu.model_states.interface import ModelState
17
from vllm.v1.worker.utils import AttentionGroup
18
19
22
-class PrefillSpeculatorCudaGraphManager(CudaGraphManager):
23
- """CudaGraphManager for draft prefill, using pre-built attention states
24
- from the target model's capture."""
20
+class SpeculatorCudaGraphManager(CudaGraphManager):
21
+ """CudaGraphManager for draft prefill and decode.
22
26
- def capture(
27
- self,
28
- forward_fn: Callable,
29
- attn_states: dict[BatchExecutionDescriptor, AttentionStatePair],
30
- progress_bar_desc: str = "Capturing CUDA graphs",
31
- ) -> None:
32
- def create_forward_fn(
33
- desc: BatchExecutionDescriptor,
34
- warmup: bool,
35
- ) -> tuple[Callable[[CUDAGraphMode], None], AttentionState]:
36
- num_tokens = desc.num_tokens
37
- num_reqs = desc.num_reqs or min(num_tokens, self.max_num_reqs)
38
- num_tokens_across_dp = (
39
- torch.full((self.dp_size,), num_tokens, dtype=torch.int32, device="cpu")
40
- if self.dp_size > 1
41
- else None
42
- )
43
- attn_state_pair = attn_states[desc]
44
- attn_state = attn_state_pair.warmup if warmup else attn_state_pair.captured
45
- attn_metadata, slot_mappings = attn_state
46
- fwd = lambda cg_mode: forward_fn(
47
- num_reqs,
48
- num_tokens,
49
- attn_metadata,
50
- slot_mappings,
51
- num_tokens_across_dp,
52
- cg_mode,
53
- )
54
- return fwd, attn_state
55
-
56
- super().capture(create_forward_fn, progress_bar_desc)
57
-
58
-
59
-class DecodeSpeculatorCudaGraphManager(CudaGraphManager):
60
- """CudaGraphManager for draft decode, building its own attention metadata."""
23
+ Builds fresh dummy inputs and attention metadata for every warmup and
24
+ capture pass so that the contents of the shared persistent buffers
25
+ (e.g. query_start_loc, seq_lens, FA3 scheduler metadata) always match
26
+ the batch descriptor being captured. Reusing metadata built during an
27
+ earlier capture would execute kernels with stale buffer contents.
28
+ """
29
30
def capture(
31
self,
@@ -72,7 +40,7 @@ class DecodeSpeculatorCudaGraphManager(CudaGraphManager):
40
def create_forward_fn(
41
desc: BatchExecutionDescriptor,
42
warmup: bool,
75
- ) -> tuple[Callable[[CUDAGraphMode], None], AttentionState]:
43
+ ) -> Callable[[CUDAGraphMode], None]:
44
num_tokens = desc.num_tokens
45
num_reqs = desc.num_reqs or min(num_tokens, self.max_num_reqs)
46
num_tokens_across_dp = (
@@ -80,7 +48,7 @@ class DecodeSpeculatorCudaGraphManager(CudaGraphManager):
48
if self.dp_size > 1
49
else None
50
)
83
- attn_state = prepare_inputs_to_capture(
51
+ attn_metadata, slot_mappings = prepare_inputs_to_capture(
52
num_reqs,
53
num_tokens,
54
model_state,
@@ -90,9 +58,8 @@ class DecodeSpeculatorCudaGraphManager(CudaGraphManager):
58
kv_cache_config,
59
skip_attn=(desc.cg_mode == CUDAGraphMode.PIECEWISE),
60
)
93
- attn_metadata, slot_mappings = attn_state
61
95
- fwd = lambda cg_mode: forward_fn(
62
+ return lambda cg_mode: forward_fn(
63
num_reqs,
64
num_tokens,
65
attn_metadata,
@@ -100,6 +67,5 @@ class DecodeSpeculatorCudaGraphManager(CudaGraphManager):
67
num_tokens_across_dp,
68
cg_mode,
69
)
103
- return fwd, attn_state
70
71
super().capture(create_forward_fn, progress_bar_desc)
vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py
+14
-12
@@ -12,15 +12,13 @@ from vllm.multimodal import MULTIMODAL_REGISTRY
12
from vllm.triton_utils import tl, triton
13
from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer
14
from vllm.v1.worker.gpu.cudagraph_utils import (
15
- AttentionStatePair,
15
BatchExecutionDescriptor,
16
get_uniform_token_count,
17
)
18
from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp
19
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
20
from vllm.v1.worker.gpu.spec_decode.autoregressive.cudagraph_utils import (
22
- DecodeSpeculatorCudaGraphManager,
23
- PrefillSpeculatorCudaGraphManager,
21
+ SpeculatorCudaGraphManager,
22
)
23
from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
24
@@ -47,8 +45,8 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
45
self.max_num_tokens, self.hidden_size, dtype=self.dtype, device=device
46
)
47
50
- self.prefill_cudagraph_manager: PrefillSpeculatorCudaGraphManager | None = None
51
- self.decode_cudagraph_manager: DecodeSpeculatorCudaGraphManager | None = None
48
+ self.prefill_cudagraph_manager: SpeculatorCudaGraphManager | None = None
49
+ self.decode_cudagraph_manager: SpeculatorCudaGraphManager | None = None
50
51
@property
52
def advance_draft_positions(self) -> bool:
@@ -62,7 +60,7 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
60
61
def init_cudagraph_manager(self, cudagraph_mode: CUDAGraphMode) -> None:
62
# Initialize cudagraph manager for draft prefill (draft position 0).
65
- self.prefill_cudagraph_manager = PrefillSpeculatorCudaGraphManager(
63
+ self.prefill_cudagraph_manager = SpeculatorCudaGraphManager(
64
self.vllm_config,
65
self.device,
66
cudagraph_mode,
@@ -76,17 +74,14 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
74
cudagraph_mode = CUDAGraphMode.NONE
75
76
# Initialize cudagraph manager for draft decodes (draft positions > 0).
79
- self.decode_cudagraph_manager = DecodeSpeculatorCudaGraphManager(
77
+ self.decode_cudagraph_manager = SpeculatorCudaGraphManager(
78
self.vllm_config,
79
self.device,
80
cudagraph_mode,
81
decode_query_len=1,
82
)
83
86
- def capture(
87
- self,
88
- attn_states: dict[BatchExecutionDescriptor, AttentionStatePair],
89
- ) -> None:
84
+ def capture(self) -> None:
85
logger.info("Capturing model for speculator...")
86
# Reset indices to zeros to prevent stale values from prior
87
# dummy runs to cause out-of-bounds indexing during capture.
@@ -97,12 +92,19 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
92
# For FULL graphs, the entire routine is recorded as one graph.
93
# For PIECEWISE, only the model's compiled regions are captured
94
# and the rest (compute_logits, gumbel_sample) runs eagerly.
95
+ # Draft prefill reuses the target model's attention metadata at
96
+ # runtime, so capture builds its dummy metadata through the target
97
+ # model runner's builders and buffers.
98
assert self.prefill_cudagraph_manager is not None
99
if self.prefill_cudagraph_manager.use_breakable_cg:
100
self.prefill_cudagraph_manager.init_breakable_cg_runner(self.model)
101
self.prefill_cudagraph_manager.capture(
102
self._prefill,
105
- attn_states,
103
+ self.model_state,
104
+ self.target_input_buffers,
105
+ self.block_tables,
106
+ self.target_attn_groups,
107
+ self.kv_cache_config,
108
progress_bar_desc="Capturing prefill CUDA graphs",
109
)
110
vllm/v1/worker/gpu/spec_decode/dflash/cudagraph.py
+2
-3
@@ -77,7 +77,7 @@ class DFlashCudaGraphManager(CudaGraphManager):
77
def create_forward_fn(
78
desc: BatchExecutionDescriptor,
79
warmup: bool,
80
- ) -> tuple[Callable[[CUDAGraphMode], None], AttentionState]:
80
+ ) -> Callable[[CUDAGraphMode], None]:
81
num_tokens = desc.num_tokens
82
num_reqs = desc.num_reqs or min(num_tokens, self.max_num_reqs)
83
num_tokens_across_dp = (
@@ -98,7 +98,7 @@ class DFlashCudaGraphManager(CudaGraphManager):
98
)
99
attn_metadata, slot_mappings = attn_state
100
101
- fwd = lambda cg_mode: forward_fn(
101
+ return lambda cg_mode: forward_fn(
102
num_reqs,
103
num_tokens,
104
attn_metadata,
@@ -106,6 +106,5 @@ class DFlashCudaGraphManager(CudaGraphManager):
106
num_tokens_across_dp,
107
cg_mode,
108
)
109
- return fwd, attn_state
109
110
super().capture(create_forward_fn, progress_bar_desc)
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+11
-2
@@ -25,6 +25,7 @@ from vllm.v1.worker.gpu.spec_decode.dflash.utils import (
25
)
26
from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
27
from vllm.v1.worker.gpu.spec_decode.utils import get_parallel_drafting_token_id
28
+from vllm.v1.worker.utils import AttentionGroup
29
30
logger = init_logger(__name__)
31
@@ -97,7 +98,7 @@ class DFlashSpeculator(DraftModelSpeculator):
98
decode_query_len=self.num_query_per_req,
99
)
100
100
- def capture(self, attn_states: dict | None = None) -> None:
101
+ def capture(self) -> None:
102
logger.info("Capturing model for %s speculator...", self._speculator_name)
103
# Reset sampling indices to zero to prevent stale values from prior
104
# dummy runs from being baked into the captured graph.
@@ -128,8 +129,16 @@ class DFlashSpeculator(DraftModelSpeculator):
129
model_state: ModelState,
130
kv_cache_config: KVCacheConfig,
131
block_tables: BlockTables,
132
+ target_input_buffers: InputBuffers,
133
+ target_attn_groups: list[list[AttentionGroup]],
134
) -> None:
132
- super().set_attn(model_state, kv_cache_config, block_tables)
135
+ super().set_attn(
136
+ model_state,
137
+ kv_cache_config,
138
+ block_tables,
139
+ target_input_buffers,
140
+ target_attn_groups,
141
+ )
142
143
self.draft_kv_cache_group_ids = [
144
gid for gid, g in enumerate(self.attn_groups) if g
vllm/v1/worker/gpu/spec_decode/speculator.py
+10
-8
@@ -18,13 +18,10 @@ from vllm.v1.worker.gpu.attn_utils import (
18
init_attn_backend,
19
)
20
from vllm.v1.worker.gpu.block_table import BlockTables
21
-from vllm.v1.worker.gpu.cudagraph_utils import (
22
- AttentionStatePair,
23
- BatchExecutionDescriptor,
24
-)
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.sample.gumbel import gumbel_sample
24
+from vllm.v1.worker.utils import AttentionGroup
25
26
logger = init_logger(__name__)
27
@@ -35,10 +32,7 @@ class BaseSpeculator(ABC):
32
pass
33
34
@abstractmethod
38
- def capture(
39
- self,
40
- attn_states: dict[BatchExecutionDescriptor, AttentionStatePair],
41
- ) -> None:
35
+ def capture(self) -> None:
36
pass
37
38
@abstractmethod
@@ -186,6 +180,8 @@ class DraftModelSpeculator(BaseSpeculator):
180
model_state: ModelState,
181
kv_cache_config: KVCacheConfig,
182
block_tables: BlockTables,
183
+ target_input_buffers: InputBuffers,
184
+ target_attn_groups: list[list[AttentionGroup]],
185
) -> None:
186
self.model_state = model_state
187
self.kv_cache_config = kv_cache_config
@@ -196,6 +192,12 @@ class DraftModelSpeculator(BaseSpeculator):
192
active_layer_names=self.draft_attn_layer_names,
193
)
194
self.block_tables = block_tables
195
+ # The target model runner's buffers and attention groups. Draft
196
+ # prefill reuses the target model's attention metadata, so its
197
+ # cudagraph capture must build dummy metadata through the same
198
+ # builders and buffers.
199
+ self.target_input_buffers = target_input_buffers
200
+ self.target_attn_groups = target_attn_groups
201
202
def _build_draft_attn_metadata(
203
self,