[BugFix] Fix DP token padding in dflash attention metadata (#56181)
Signed-off-by: Giancarlo Delfin <gdelfin@inferact.ai>
Giancarlo Delfin committed
Sep 11, 2026 at 14:33 UTC
9dcf6bf344caa7793bae0b45a7896d3f8e03a01a
5 files changed
+97
-97
tests/v1/spec_decode/test_eagle_draft_attn_metadata.py
+41
-23
@@ -2,7 +2,7 @@
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
"""Unit tests for the EAGLE speculator's draft attention metadata builder.
4
5
-These tests guard the regression where ``_build_draft_attn_metadata`` did
5
+These tests guard the regression where ``_build_uniform_attn_metadata`` did
6
not populate ``seq_lens_cpu_upper_bound`` on the per-step
7
``CommonAttentionMetadata``. Several downstream attention backends and
8
helpers (``split_decodes_prefills_and_extends``, the MLA indexer,
@@ -13,11 +13,14 @@ backends (e.g. ``ROCM_AITER_FA`` with eagle/eagle3 spec decode):
13
AssertionError: assert common_attn_metadata.seq_lens_cpu_upper_bound is not None
14
"""
15
16
-from types import SimpleNamespace
16
+from types import MethodType, SimpleNamespace
17
from unittest.mock import patch
18
19
+import numpy as np
20
import torch
21
22
+from vllm.config.compilation import CUDAGraphMode
23
+from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
24
from vllm.v1.worker.gpu.spec_decode import speculator as base_speculator
25
from vllm.v1.worker.gpu.spec_decode.eagle.speculator import EagleSpeculator
26
@@ -30,7 +33,7 @@ def _make_fake_speculator(
33
draft_max_seq_len: int = 1024,
34
) -> SimpleNamespace:
35
"""Build a fake EagleSpeculator with just the attributes used by
33
- ``_build_draft_attn_metadata``. We deliberately avoid constructing a
36
+ ``_build_uniform_attn_metadata``. We deliberately avoid constructing a
37
real ``EagleSpeculator`` because that requires a full ``VllmConfig``
38
and a draft model.
39
"""
@@ -46,8 +49,8 @@ def _make_fake_speculator(
49
cp_rank=0,
50
cp_interleave=1,
51
)
49
- return SimpleNamespace(
50
- arange=torch.arange(max_num_reqs + 1, dtype=torch.int32, device="cpu"),
52
+ fake = SimpleNamespace(
53
+ arange_np=np.arange(max_num_reqs + 1, dtype=np.int32),
54
draft_is_prefilling=torch.zeros(max_num_reqs, dtype=torch.bool),
55
block_tables=fake_block_tables,
56
input_buffers=fake_input_buffers,
@@ -56,21 +59,42 @@ def _make_fake_speculator(
59
max_model_len=max_model_len,
60
draft_max_seq_len=draft_max_seq_len,
61
)
62
+ # The uniform wrapper delegates through self; bind the real implementation.
63
+ fake._build_attn_metadata = MethodType(EagleSpeculator._build_attn_metadata, fake)
64
+ return fake
65
66
61
-def _run_build(fake, *, num_reqs, num_reqs_padded, num_tokens_padded, base, step):
67
+def _run_build(
68
+ fake,
69
+ *,
70
+ num_reqs,
71
+ num_reqs_padded,
72
+ base,
73
+ step,
74
+ num_query_per_req=1,
75
+ cg_mode=CUDAGraphMode.FULL,
76
+):
77
captured: dict[str, object] = {}
78
79
def fake_build_attn_metadata(**kwargs):
80
captured.update(kwargs)
81
return {}
82
83
+ # Request padding only occurs under FULL graphs, where the captured batch
84
+ # is a uniform decode of num_reqs_padded * num_query_per_req tokens.
85
+ batch_desc = BatchExecutionDescriptor(
86
+ cg_mode=cg_mode,
87
+ num_tokens=num_reqs_padded * num_query_per_req,
88
+ num_reqs=num_reqs_padded,
89
+ uniform_token_count=num_query_per_req,
90
+ )
91
+
92
with patch.object(base_speculator, "build_attn_metadata", fake_build_attn_metadata):
69
- EagleSpeculator._build_draft_attn_metadata(
93
+ EagleSpeculator._build_uniform_attn_metadata(
94
fake, # type: ignore[arg-type]
95
+ batch_desc=batch_desc,
96
num_reqs=num_reqs,
72
- num_reqs_padded=num_reqs_padded,
73
- num_tokens_padded=num_tokens_padded,
97
+ num_query_per_req=num_query_per_req,
98
seq_lens_cpu_upper_bound=base,
99
step=step,
100
)
@@ -85,9 +109,7 @@ def test_build_draft_attn_metadata_sets_seq_lens_cpu_upper_bound():
109
fake = _make_fake_speculator()
110
base = torch.tensor([100, 200, 300, 0], dtype=torch.int32)
111
88
- captured = _run_build(
89
- fake, num_reqs=3, num_reqs_padded=4, num_tokens_padded=4, base=base, step=2
90
- )
112
+ captured = _run_build(fake, num_reqs=3, num_reqs_padded=4, base=base, step=2)
113
114
bound = captured["seq_lens_cpu_upper_bound"]
115
assert isinstance(bound, torch.Tensor), (
@@ -102,20 +124,19 @@ def test_build_draft_attn_metadata_sets_seq_lens_cpu_upper_bound():
124
assert torch.equal(bound, torch.tensor([102, 202, 302, 0], dtype=torch.int32))
125
126
105
-def test_build_draft_attn_metadata_handles_zero_unpadded_reqs():
106
- """Edge case: when ``num_reqs == 0`` the upper-bound tensor must
107
- still be a valid all-zero tensor of length ``num_reqs_padded``."""
127
+def test_build_draft_attn_metadata_zeroes_padded_upper_bound_tail():
128
+ """The padded tail of the upper-bound tensor is zeroed, so it stays a
129
+ valid tensor of length ``num_reqs_padded`` regardless of padding."""
130
fake = _make_fake_speculator()
131
base = torch.zeros(2, dtype=torch.int32)
132
111
- captured = _run_build(
112
- fake, num_reqs=0, num_reqs_padded=2, num_tokens_padded=2, base=base, step=1
113
- )
133
+ captured = _run_build(fake, num_reqs=1, num_reqs_padded=2, base=base, step=1)
134
135
bound = captured["seq_lens_cpu_upper_bound"]
136
assert isinstance(bound, torch.Tensor)
137
assert bound.shape == (2,)
118
- assert torch.equal(bound, torch.zeros(2, dtype=torch.int32))
138
+ # base is all zeros, so the real entry is 0 + step and the pad entry is 0.
139
+ assert torch.equal(bound, torch.tensor([1, 0], dtype=torch.int32))
140
141
142
def test_build_draft_attn_metadata_clamps_to_max_model_len():
@@ -124,9 +145,7 @@ def test_build_draft_attn_metadata_clamps_to_max_model_len():
145
fake = _make_fake_speculator(max_model_len=1024)
146
base = torch.tensor([1023, 500], dtype=torch.int32)
147
127
- captured = _run_build(
128
- fake, num_reqs=2, num_reqs_padded=2, num_tokens_padded=2, base=base, step=3
129
- )
148
+ captured = _run_build(fake, num_reqs=2, num_reqs_padded=2, base=base, step=3)
149
150
bound = captured["seq_lens_cpu_upper_bound"]
151
# 1023 + 3 = 1026 -> clamped to 1024; 500 + 3 = 503 unaffected.
@@ -154,7 +173,6 @@ def test_build_draft_attn_metadata_recomputes_dcp_local_seq_lens():
173
fake,
174
num_reqs=3,
175
num_reqs_padded=4,
157
- num_tokens_padded=4,
176
base=torch.tensor([5, 9, 16]),
177
step=0,
178
)
vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py
+6
-8
@@ -532,11 +532,10 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
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(
535
+ attn_metadata = self._build_uniform_attn_metadata(
536
num_reqs=num_reqs,
537
- num_reqs_padded=batch_desc.num_reqs or num_reqs,
538
- # One query per request; exclude DP-only model padding.
539
- num_tokens_padded=batch_desc.num_reqs or num_reqs,
537
+ batch_desc=batch_desc,
538
+ num_query_per_req=1,
539
seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
540
step=step,
541
)
@@ -581,11 +580,10 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
580
slot_mappings_by_layer = build_slot_mappings_by_layer(
581
slot_mappings, self.kv_cache_config
582
)
584
- attn_metadata = self._build_draft_attn_metadata(
583
+ attn_metadata = self._build_uniform_attn_metadata(
584
num_reqs=num_reqs,
586
- num_reqs_padded=batch_desc.num_reqs or num_reqs,
587
- # One query per request; exclude DP-only model padding.
588
- num_tokens_padded=batch_desc.num_reqs or num_reqs,
585
+ batch_desc=batch_desc,
586
+ num_query_per_req=1,
587
seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
588
step=1,
589
)
vllm/v1/worker/gpu/spec_decode/dflash/speculator.py
+3
-33
@@ -1,10 +1,8 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
import copy
4
-from collections.abc import Mapping
4
from typing import Any
5
7
-import numpy as np
6
import torch
7
import torch.nn as nn
8
@@ -299,33 +297,6 @@ class DFlashSpeculator(DraftModelSpeculator):
297
num_reqs, self.num_speculative_steps
298
)
299
302
- def _build_draft_attn_metadata(
303
- self,
304
- num_reqs: int,
305
- num_reqs_padded: int,
306
- num_tokens_padded: int,
307
- seq_lens_cpu_upper_bound: torch.Tensor,
308
- step: int,
309
- num_query_per_req: int | None = None,
310
- causal: bool | Mapping[int, bool] = False,
311
- query_start_loc_np: np.ndarray | None = None,
312
- dcp_local_seq_lens: torch.Tensor | None = None,
313
- ) -> dict[str, Any] | None:
314
- if not self.draft_attn_layer_names:
315
- return None
316
- assert num_query_per_req is None # Omitted for DFlash, read from self instead
317
- return super()._build_draft_attn_metadata(
318
- num_reqs,
319
- num_reqs_padded,
320
- num_tokens_padded,
321
- seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
322
- step=step,
323
- num_query_per_req=self.num_query_per_req,
324
- causal=causal,
325
- query_start_loc_np=query_start_loc_np,
326
- dcp_local_seq_lens=dcp_local_seq_lens,
327
- )
328
-
300
@torch.inference_mode()
301
def propose(
302
self,
@@ -471,7 +442,6 @@ class DFlashSpeculator(DraftModelSpeculator):
442
need_eager=is_profile,
443
dp_sync=batch_sync,
444
)
474
- num_reqs_padded = batch_desc.num_reqs or num_reqs
445
num_tokens_padded = batch_desc.num_tokens
446
num_tokens_across_dp = (
447
batch_sync.num_tokens_across_dp if batch_sync is not None else None
@@ -479,10 +449,10 @@ class DFlashSpeculator(DraftModelSpeculator):
449
450
# Rebuild the draft attention metadata even when replaying the FULL
451
# graph so that any attention metadata builder state is updated.
482
- draft_attn_metadata = self._build_draft_attn_metadata(
452
+ draft_attn_metadata = self._build_uniform_attn_metadata(
453
num_reqs=num_reqs,
484
- num_reqs_padded=num_reqs_padded,
485
- num_tokens_padded=num_tokens_padded,
454
+ batch_desc=batch_desc,
455
+ num_query_per_req=self.num_query_per_req,
456
seq_lens_cpu_upper_bound=input_batch.seq_lens_cpu_upper_bound,
457
step=self.num_query_per_req,
458
causal=self._group_causal,
vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py
+2
-3
@@ -226,10 +226,9 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator):
226
slot_mappings = build_slot_mappings_by_layer(
227
slot_mappings_tensor, self.kv_cache_config
228
)
229
- draft_attn_metadata = self._build_draft_attn_metadata(
229
+ draft_attn_metadata = self._build_attn_metadata(
230
num_reqs=num_reqs,
231
- num_reqs_padded=batch_desc.num_reqs or num_reqs,
232
- num_tokens_padded=batch_desc.num_tokens,
231
+ batch_desc=batch_desc,
232
query_start_loc_np=input_batch.query_start_loc_np,
233
seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
234
step=0,
vllm/v1/worker/gpu/spec_decode/speculator.py
+45
-30
@@ -23,6 +23,7 @@ from vllm.v1.worker.gpu.attn_utils import (
23
)
24
from vllm.v1.worker.gpu.block_table import BlockTables
25
from vllm.v1.worker.gpu.cp_utils import maybe_prepare_dcp_local_seq_lens
26
+from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
27
from vllm.v1.worker.gpu.dp_utils import DPSyncState
28
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
29
from vllm.v1.worker.gpu.model_states.interface import ModelState
@@ -149,9 +150,7 @@ class DraftModelSpeculator(BaseSpeculator):
150
dtype=torch.int64,
151
device=device,
152
)
152
- self.arange = torch.arange(
153
- self.max_num_reqs + 1, dtype=torch.int32, device="cpu"
154
- )
153
+ self.arange_np = np.arange(self.max_num_reqs + 1, dtype=np.int32)
154
self.draft_is_prefilling = torch.zeros(self.max_num_reqs, dtype=torch.bool)
155
156
self.draft_logits: torch.Tensor | None = None
@@ -254,42 +253,37 @@ class DraftModelSpeculator(BaseSpeculator):
253
self.target_input_buffers = target_input_buffers
254
self.target_attn_groups = target_attn_groups
255
257
- def _build_draft_attn_metadata(
256
+ def _build_attn_metadata(
257
self,
258
num_reqs: int,
260
- num_reqs_padded: int,
261
- num_tokens_padded: int,
259
+ batch_desc: BatchExecutionDescriptor,
260
+ query_start_loc_np: np.ndarray,
261
seq_lens_cpu_upper_bound: torch.Tensor,
262
step: int,
264
- num_query_per_req: int = 1,
263
causal: bool | Mapping[int, bool] = True,
266
- query_start_loc_np: np.ndarray | None = None,
264
dcp_local_seq_lens: torch.Tensor | None = None,
265
) -> dict[str, Any] | None:
269
- if query_start_loc_np is not None:
270
- # Non-uniform query layout (e.g. multi-module MTP's mixed
271
- # prefill/decode queries); num_query_per_req is ignored.
272
- query_start_loc_cpu = torch.empty(num_reqs_padded + 1, dtype=torch.int32)
273
- query_start_loc_cpu[: num_reqs + 1] = torch.from_numpy(
274
- query_start_loc_np[: num_reqs + 1]
275
- )
276
- query_start_loc_cpu[num_reqs:] = query_start_loc_cpu[num_reqs]
277
- max_query_len = int(
278
- (query_start_loc_cpu[1:] - query_start_loc_cpu[:-1]).max()
279
- )
280
- else:
281
- # Uniform query: query_start_loc[i] = min(i, num_reqs) * num_query_per_req.
282
- # Clamp keeps the series non-decreasing past num_reqs, which some
283
- # attention backends require.
284
- query_start_loc_cpu = (
285
- torch.clamp(self.arange[: num_reqs_padded + 1], max=num_reqs)
286
- * num_query_per_req
287
- )
288
- max_query_len = num_query_per_req
266
+ num_reqs_padded = batch_desc.num_reqs or num_reqs
267
+ # A FULL graph replays a captured shape whose padded requests each hold
268
+ # a full query width, so attention must see the padded token count.
269
+ # PIECEWISE/eager needs the actual token count, because batch_desc may
270
+ # carry graph or DP padding that no request owns, which would desync it
271
+ # from query_start_loc.
272
+ num_tokens = (
273
+ batch_desc.num_tokens
274
+ if batch_desc.cg_mode == CUDAGraphMode.FULL
275
+ else int(query_start_loc_np[-1])
276
+ )
277
+ query_start_loc_cpu = torch.empty(num_reqs_padded + 1, dtype=torch.int32)
278
+ query_start_loc_cpu[: num_reqs + 1] = torch.from_numpy(
279
+ query_start_loc_np[: num_reqs + 1]
280
+ )
281
+ query_start_loc_cpu[num_reqs:] = query_start_loc_cpu[num_reqs]
282
+ max_query_len = int((query_start_loc_cpu[1:] - query_start_loc_cpu[:-1]).max())
283
block_tables = [
284
x[:num_reqs_padded] for x in self.block_tables.input_block_tables
285
]
292
- slot_mappings = self.block_tables.slot_mappings[:, :num_tokens_padded]
286
+ slot_mappings = self.block_tables.slot_mappings[:, :num_tokens]
287
draft_seq_lens_cpu_upper_bound = torch.zeros(
288
num_reqs_padded, dtype=torch.int32, device="cpu"
289
)
@@ -313,7 +307,7 @@ class DraftModelSpeculator(BaseSpeculator):
307
attn_metadata = build_attn_metadata(
308
attn_groups=self.attn_groups,
309
num_reqs=num_reqs_padded,
316
- num_tokens=num_tokens_padded,
310
+ num_tokens=num_tokens,
311
query_start_loc_gpu=self.input_buffers.query_start_loc[
312
: num_reqs_padded + 1
313
],
@@ -434,3 +428,24 @@ class DraftModelSpeculator(BaseSpeculator):
428
uniform_token_count=num_query_per_req,
429
eager=False,
430
), num_batch_tokens
431
+
432
+ def _build_uniform_attn_metadata(
433
+ self,
434
+ batch_desc: BatchExecutionDescriptor,
435
+ num_reqs: int,
436
+ num_query_per_req: int,
437
+ seq_lens_cpu_upper_bound: torch.Tensor,
438
+ step: int,
439
+ causal: bool | Mapping[int, bool] = True,
440
+ dcp_local_seq_lens: torch.Tensor | None = None,
441
+ ) -> dict[str, Any] | None:
442
+ query_start_loc_np = self.arange_np[: num_reqs + 1] * num_query_per_req
443
+ return self._build_attn_metadata(
444
+ num_reqs=num_reqs,
445
+ batch_desc=batch_desc,
446
+ query_start_loc_np=query_start_loc_np,
447
+ seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
448
+ step=step,
449
+ causal=causal,
450
+ dcp_local_seq_lens=dcp_local_seq_lens,
451
+ )