[Bugfix][MRV2] Require all requests to be decoding for uniform-decode dispatch (#51865)
Signed-off-by: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Rahul Chalamala <22563365+rchalamala@users.noreply.github.com> Co-authored-by: Janelle Cai <janelle.cai@modal.com>
Nick Hill committed
Aug 11, 2026 at 17:15 UTC
0a94d85a66499cad8297ead86a470967de5c0212
9 files changed
+341
-54
tests/v1/spec_decode/test_dynamic_sd_cug.py
+72
@@ -16,6 +16,7 @@ from vllm.config import (
16
VllmConfig,
17
)
18
from vllm.v1.worker.gpu import cudagraph_utils as gpu_cudagraph_utils
19
+from vllm.v1.worker.utils import get_uniform_decode_token_count
20
21
pytestmark = pytest.mark.cpu_test
22
@@ -196,6 +197,77 @@ def test_dynamic_sd_non_uniform_batch_falls_back_to_piecewise(monkeypatch):
197
assert desc.num_active_loras == 0
198
199
200
+def test_prompt_chunks_shaped_like_spec_decode_miss_the_full_graph(monkeypatch):
201
+ """A batch holding K+1-token prompt chunks must not replay a decode graph.
202
+
203
+ Prompt chunks of exactly K + 1 tokens give the batch a spec-decode shape
204
+ by coincidence; replaying the FULL graph over them corrupts every request
205
+ in the batch. See https://github.com/vllm-project/vllm/issues/49918.
206
+ """
207
+
208
+ max_spec_tokens = 7
209
+ decode_query_len = max_spec_tokens + 1
210
+
211
+ monkeypatch.setattr(
212
+ gpu_cudagraph_utils,
213
+ "get_pp_group",
214
+ lambda: SimpleNamespace(is_first_rank=True, is_last_rank=True),
215
+ )
216
+
217
+ vllm_config = _create_vllm_config_for_dsd(
218
+ max_num_seqs=64,
219
+ max_spec_tokens=max_spec_tokens,
220
+ use_dynamic_sd=False,
221
+ )
222
+ manager = gpu_cudagraph_utils.CudaGraphManager(
223
+ vllm_config=vllm_config,
224
+ device=torch.device("cpu"),
225
+ cudagraph_mode=CUDAGraphMode.FULL_AND_PIECEWISE,
226
+ decode_query_len=decode_query_len,
227
+ )
228
+ manager._graphs_captured = True
229
+
230
+ num_decodes, num_chunks = 6, 2
231
+ num_reqs = num_decodes + num_chunks
232
+ num_tokens = num_reqs * decode_query_len
233
+
234
+ # The batch shape is indistinguishable from a full batch of spec decodes.
235
+ assert (
236
+ gpu_cudagraph_utils.get_uniform_token_count(
237
+ num_reqs, num_tokens, decode_query_len
238
+ )
239
+ == decode_query_len
240
+ )
241
+
242
+ # Two of the requests are decode_query_len tokens into a longer prompt.
243
+ uniform_tok_count = get_uniform_decode_token_count(
244
+ num_reqs, num_tokens, decode_query_len, has_prefill=True
245
+ )
246
+ assert uniform_tok_count is None
247
+ desc = manager.dispatch(
248
+ num_reqs=num_reqs,
249
+ num_tokens=num_tokens,
250
+ uniform_token_count=uniform_tok_count,
251
+ num_active_loras=0,
252
+ )
253
+ assert desc.cg_mode == CUDAGraphMode.PIECEWISE
254
+ assert desc.uniform_token_count is None
255
+
256
+ # The same shape with every request decoding still gets its FULL graph.
257
+ uniform_tok_count = get_uniform_decode_token_count(
258
+ num_reqs, num_tokens, decode_query_len, has_prefill=False
259
+ )
260
+ assert uniform_tok_count == decode_query_len
261
+ desc = manager.dispatch(
262
+ num_reqs=num_reqs,
263
+ num_tokens=num_tokens,
264
+ uniform_token_count=uniform_tok_count,
265
+ num_active_loras=0,
266
+ )
267
+ assert desc.cg_mode == CUDAGraphMode.FULL
268
+ assert desc.uniform_token_count == decode_query_len
269
+
270
+
271
def test_basic_sd_does_not_capture_shorter_full_decode_shapes(monkeypatch):
272
"""Without DSD, only the max decode query length should get FULL graphs.
273
tests/v1/worker/test_gpu_batch_ordering.py
+149
-2
@@ -1,18 +1,28 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-"""Batch ordering in Model Runner V2 (vllm.v1.worker.gpu).
3
+"""Batch ordering and classification in Model Runner V2 (vllm.v1.worker.gpu).
4
5
split_decodes_and_prefills assumes decode -> short_extend -> prefill request
6
ordering. With spec decode (decode_query_len > 1), a shorter chunked-prefill
7
tail sorted in front of the uniform decodes misclassifies every decode as a
8
prefill.
9
+
10
+Conversely, a prompt chunk of exactly decode_query_len tokens has a decode
11
+batch's shape, and must not be classified as a uniform decode batch.
12
"""
13
14
+import ast
15
+from pathlib import Path
16
+from types import SimpleNamespace
17
+from typing import Any
18
+
19
+import numpy as np
20
import torch
21
22
from vllm.v1.attention.backend import CommonAttentionMetadata
23
from vllm.v1.attention.backends.utils import split_decodes_and_prefills
15
-from vllm.v1.worker.gpu.model_runner import sort_batch_req_ids
24
+from vllm.v1.worker.gpu.model_runner import GPUModelRunner, sort_batch_req_ids
25
+from vllm.v1.worker.utils import get_uniform_decode_token_count
26
27
28
def _make_common_attn_metadata(query_lens: list[int]) -> CommonAttentionMetadata:
@@ -37,6 +47,72 @@ def _make_common_attn_metadata(query_lens: list[int]) -> CommonAttentionMetadata
47
)
48
49
50
+def _make_runner(
51
+ req_states: dict[str, tuple[int, int]], decode_query_len: int = 8
52
+) -> Any:
53
+ """Build a runner stub from {req_id: (num_computed_tokens, prefill_len)}."""
54
+ prefill_lens = np.array([s[1] for s in req_states.values()], dtype=np.int32)
55
+ num_computed = np.array([s[0] for s in req_states.values()], dtype=np.int32)
56
+ runner: Any = GPUModelRunner.__new__(GPUModelRunner)
57
+ runner.decode_query_len = decode_query_len
58
+ runner.req_states = SimpleNamespace(
59
+ req_id_to_index={req_id: i for i, req_id in enumerate(req_states)},
60
+ # The runner keeps this as min(num_computed_tokens, prefill_len).
61
+ num_computed_prefill_tokens=np.minimum(num_computed, prefill_lens),
62
+ prefill_len=SimpleNamespace(np=prefill_lens),
63
+ )
64
+ return runner
65
+
66
+
67
+def _uniform_token_count(
68
+ req_states: dict[str, tuple[int, int]],
69
+ query_len: int,
70
+ dummy_run: bool = False,
71
+) -> int | None:
72
+ """Classify via the runner's pre-dispatch gather, as execute_model does."""
73
+ scheduler_output = SimpleNamespace(
74
+ num_scheduled_tokens={req_id: query_len for req_id in req_states},
75
+ total_num_scheduled_tokens=query_len * len(req_states),
76
+ )
77
+ runner = _make_runner(req_states, decode_query_len=query_len)
78
+ _, uniform_tok_count = runner.gather_batch_req_state(scheduler_output, dummy_run)
79
+ return uniform_tok_count
80
+
81
+
82
+def test_spec_decode_batch_is_uniform_decode():
83
+ # decode_query_len == 8 (K=7): every request past its prefill.
84
+ decodes = {f"d{i}": (16, 16) for i in range(6)}
85
+ assert _uniform_token_count(decodes, 8) == 8
86
+
87
+
88
+def test_prompt_chunk_of_decode_query_len_is_not_uniform_decode():
89
+ # Two prompt chunks whose query length coincides with the K+1 spec-decode
90
+ # query length must reject the batch (issue #49918).
91
+ batch = {f"d{i}": (16, 16) for i in range(6)}
92
+ batch.update({f"p{i}": (8, 40) for i in range(2)})
93
+ assert _uniform_token_count(batch, 8) is None
94
+
95
+ # Same collision without spec decoding: 1-token prompt vs 1-token decode.
96
+ assert _uniform_token_count({"p0": (0, 1)}, 1) is None
97
+
98
+ # A fresh prompt of exactly decode_query_len tokens.
99
+ assert _uniform_token_count({"p0": (0, 8)}, 8) is None
100
+
101
+
102
+def test_dummy_batches_stay_uniform_decode():
103
+ # Dummy batches have no request state to consult and must stay classified
104
+ # by shape alone; rejecting them would make capture and replay disagree.
105
+ scheduler_output = SimpleNamespace(
106
+ num_scheduled_tokens={f"_dummy_req_{i}": 8 for i in range(4)},
107
+ total_num_scheduled_tokens=32,
108
+ )
109
+ state, uniform_tok_count = _make_runner({}).gather_batch_req_state(
110
+ scheduler_output, True
111
+ )
112
+ assert state is None
113
+ assert uniform_tok_count == 8
114
+
115
+
116
def test_sort_batch_req_ids_no_spec():
117
# decode_query_len == 1: plain ascending order (decodes first).
118
num_tokens_per_req = {"p1": 100, "d1": 1, "p2": 7, "d2": 1}
@@ -68,3 +144,74 @@ def test_spec_decodes_lead_short_prefill_tail():
144
)
145
assert (num_decodes, num_prefills) == (8, 1)
146
assert (num_decode_tokens, num_prefill_tokens) == (16, 1)
147
+
148
+
149
+def test_uniform_decode_uses_state_index_not_batch_position():
150
+ """The gather must read each request's own state, not its batch slot.
151
+
152
+ Batch position and state index diverge as requests come and go; here the
153
+ only prefilling request sits at state index 0, so a gather keyed on batch
154
+ position would read its row and wrongly reject the two decodes.
155
+ """
156
+ runner: Any = GPUModelRunner.__new__(GPUModelRunner)
157
+ runner.decode_query_len = 8
158
+ # State arrays in state-index order: a prefilling request, then two decodes.
159
+ runner.req_states = SimpleNamespace(
160
+ req_id_to_index={"prefilling": 0, "decode_a": 1, "decode_b": 2},
161
+ num_computed_prefill_tokens=np.array([8, 16, 16], dtype=np.int32),
162
+ prefill_len=SimpleNamespace(np=np.array([40, 16, 16], dtype=np.int32)),
163
+ )
164
+ scheduler_output = SimpleNamespace(
165
+ num_scheduled_tokens={"decode_a": 8, "decode_b": 8},
166
+ total_num_scheduled_tokens=16,
167
+ )
168
+
169
+ state, uniform_tok_count = runner.gather_batch_req_state(scheduler_output, False)
170
+ assert not state.has_prefill
171
+ assert uniform_tok_count == 8
172
+
173
+ # Scheduling the prefilling request alongside them must reject the batch.
174
+ scheduler_output.num_scheduled_tokens = {
175
+ "decode_a": 8,
176
+ "prefilling": 8,
177
+ "decode_b": 8,
178
+ }
179
+ scheduler_output.total_num_scheduled_tokens = 24
180
+ state, uniform_tok_count = runner.gather_batch_req_state(scheduler_output, False)
181
+ assert state.has_prefill
182
+ assert uniform_tok_count is None
183
+
184
+
185
+def test_uniform_decode_predicate():
186
+ # Shape and prefill state must both pass.
187
+ assert get_uniform_decode_token_count(2, 16, 8, False) == 8
188
+ assert get_uniform_decode_token_count(2, 16, 8, True) is None
189
+ # 12 tokens over 2 requests is no shared query length.
190
+ assert get_uniform_decode_token_count(2, 12, 8, False) is None
191
+
192
+
193
+def test_no_speculator_dispatches_on_query_length_alone():
194
+ """No speculator may pick its cudagraph from a shape-only test.
195
+
196
+ Written over the whole package because speculators are added by copying an
197
+ existing one: the shape-only call already reappeared verbatim in a
198
+ speculator added after the original call sites were fixed.
199
+ """
200
+ import vllm.v1.worker.gpu.spec_decode as spec_decode
201
+
202
+ shape_only = {"get_uniform_token_count", "is_uniform_query_len"}
203
+ root = Path(spec_decode.__file__).parent
204
+ offenders = []
205
+ for path in sorted(root.rglob("speculator.py")):
206
+ for node in ast.walk(ast.parse(path.read_text())):
207
+ if not isinstance(node, ast.Call):
208
+ continue
209
+ func = node.func
210
+ name = func.id if isinstance(func, ast.Name) else getattr(func, "attr", "")
211
+ if name in shape_only:
212
+ offenders.append(f"{path.relative_to(root)}:{node.lineno} {name}")
213
+
214
+ assert not offenders, (
215
+ "these speculators classify a decode batch by query length alone; use "
216
+ f"get_uniform_decode_token_count instead: {offenders}"
217
+ )
vllm/v1/worker/gpu/cudagraph_utils.py
+7
-7
@@ -35,7 +35,7 @@ from vllm.v1.worker.gpu.block_table import BlockTables
35
from vllm.v1.worker.gpu.cp_utils import prepare_dcp_local_seq_lens
36
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
37
from vllm.v1.worker.gpu.model_states.interface import ModelState
38
-from vllm.v1.worker.utils import AttentionGroup
38
+from vllm.v1.worker.utils import AttentionGroup, is_uniform_query_len
39
40
logger = init_logger(__name__)
41
@@ -90,17 +90,17 @@ def _is_compatible(
90
91
92
def get_uniform_token_count(
93
- num_reqs: int,
94
- num_tokens: int,
95
- max_query_len: int,
93
+ num_reqs: int, num_tokens: int, max_query_len: int
94
) -> int | None:
95
"""
96
Return the uniform token count if batch is uniform, else None.
97
A batch is uniform if all requests have the same number of tokens.
98
+
99
+ Shape test only, valid for batches known to be decoding by construction
100
+ (e.g. dummy runs). Scheduled batches must use
101
+ `get_uniform_decode_token_count`.
102
"""
101
- if (max_query_len == num_tokens // num_reqs) and (
102
- num_tokens == max_query_len * num_reqs
103
- ):
103
+ if is_uniform_query_len(num_reqs, num_tokens, max_query_len):
104
return max_query_len
105
return None
106
vllm/v1/worker/gpu/input_batch.py
+4
-4
@@ -76,6 +76,8 @@ class InputBatch:
76
num_computed_prefill_tokens_np: np.ndarray
77
# [num_reqs] CPU bool array == (num_computed_prefill_tokens_np < prefill_len_np).
78
is_prefilling_np: np.ndarray
79
+ # == np.any(is_prefilling_np)
80
+ has_prefill: bool
81
82
# [num_reqs] only populated when pipeline parallelism is enabled.
83
max_seq_len_np: np.ndarray | None
@@ -101,10 +103,7 @@ class InputBatch:
103
104
@classmethod
105
def make_dummy(
104
- cls,
105
- num_reqs: int,
106
- num_tokens: int,
107
- input_buffers: InputBuffers,
106
+ cls, num_reqs: int, num_tokens: int, input_buffers: InputBuffers
107
) -> "InputBatch":
108
assert 0 < num_reqs <= num_tokens
109
device = input_buffers.device
@@ -175,6 +174,7 @@ class InputBatch:
174
prefill_len_np=np.zeros(num_reqs, dtype=np.int32),
175
num_computed_prefill_tokens_np=np.zeros(num_reqs, dtype=np.int32),
176
is_prefilling_np=np.zeros(num_reqs, dtype=np.bool_),
177
+ has_prefill=False,
178
max_seq_len_np=None,
179
input_ids=input_ids,
180
positions=positions,
vllm/v1/worker/gpu/model_runner.py
+83
-28
@@ -133,7 +133,11 @@ from vllm.v1.worker.gpu.spec_decode.utils import DraftTokensHandler
133
from vllm.v1.worker.gpu.states import RequestState
134
from vllm.v1.worker.gpu.structured_outputs import StructuredOutputsWorker
135
from vllm.v1.worker.lora_model_runner_mixin import LoRAModelRunnerMixin
136
-from vllm.v1.worker.utils import KVBlockZeroer, copy_kv_cache_blocks_inplace
136
+from vllm.v1.worker.utils import (
137
+ KVBlockZeroer,
138
+ copy_kv_cache_blocks_inplace,
139
+ get_uniform_decode_token_count,
140
+)
141
142
logger = init_logger(__name__)
143
@@ -974,21 +978,20 @@ class GPUModelRunner(LoRAModelRunnerMixin):
978
scheduler_output.kv_cache_block_copies,
979
)
980
977
- def prepare_inputs(
978
- self, scheduler_output: SchedulerOutput, batch_desc: BatchExecutionDescriptor
979
- ) -> InputBatch:
980
- num_tokens = scheduler_output.total_num_scheduled_tokens
981
- num_tokens_after_padding = batch_desc.num_tokens
982
- assert num_tokens > 0
983
- if envs.VLLM_MOE_SKIP_PADDING:
984
- # Mark trailing cudagraph-padding rows so kernels can skip work for
985
- # them when supported.
986
- self.input_buffers.is_padding[:num_tokens].fill_(False)
987
- self.input_buffers.is_padding[num_tokens:num_tokens_after_padding].fill_(
988
- True
989
- )
981
+ def gather_batch_req_state(
982
+ self, scheduler_output: SchedulerOutput, dummy_run: bool
983
+ ) -> tuple["BatchReqState | None", int | None]:
984
+ """Gather CPU request state for the scheduled batch, in batch order.
985
+ Returns (batch_state, uniform_decode_token_count)
986
+ """
987
num_tokens_per_req = scheduler_output.num_scheduled_tokens
988
num_reqs = len(num_tokens_per_req)
989
+ num_toks = scheduler_output.total_num_scheduled_tokens
990
+ max_query_len = max(scheduler_output.num_scheduled_tokens.values())
991
+
992
+ if dummy_run:
993
+ # Dummy batches are uniform by construction.
994
+ return None, get_uniform_token_count(num_reqs, num_toks, max_query_len)
995
996
# batch_idx -> req_id
997
req_ids = sort_batch_req_ids(num_tokens_per_req, self.decode_query_len)
@@ -997,7 +1000,46 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1000
1001
idx_mapping_iter = map(self.req_states.req_id_to_index.__getitem__, req_ids)
1002
idx_mapping_np = np.fromiter(idx_mapping_iter, dtype=np.intp, count=num_reqs)
1003
+ prefill_len_np = self.req_states.prefill_len.np[idx_mapping_np]
1004
+ num_computed_prefill_tokens_np = self.req_states.num_computed_prefill_tokens[
1005
+ idx_mapping_np
1006
+ ]
1007
+ is_prefilling_np = num_computed_prefill_tokens_np < prefill_len_np
1008
+
1009
+ batch_state = BatchReqState(
1010
+ req_ids=req_ids,
1011
+ num_scheduled_tokens=num_scheduled_tokens,
1012
+ idx_mapping_np=idx_mapping_np,
1013
+ prefill_len_np=prefill_len_np,
1014
+ num_computed_prefill_tokens_np=num_computed_prefill_tokens_np,
1015
+ is_prefilling_np=is_prefilling_np,
1016
+ has_prefill=bool(is_prefilling_np.any()),
1017
+ )
1018
+ return batch_state, get_uniform_decode_token_count(
1019
+ num_reqs, num_toks, max_query_len, batch_state.has_prefill
1020
+ )
1021
+
1022
+ def prepare_inputs(
1023
+ self,
1024
+ scheduler_output: SchedulerOutput,
1025
+ batch_req_state: "BatchReqState",
1026
+ batch_desc: BatchExecutionDescriptor,
1027
+ ) -> InputBatch:
1028
+ num_tokens = scheduler_output.total_num_scheduled_tokens
1029
+ num_tokens_after_padding = batch_desc.num_tokens
1030
+ assert num_tokens > 0
1031
+ if envs.VLLM_MOE_SKIP_PADDING:
1032
+ # Mark trailing cudagraph-padding rows so kernels can skip work for
1033
+ # them when supported.
1034
+ is_padding = self.input_buffers.is_padding
1035
+ is_padding[:num_tokens].fill_(False)
1036
+ is_padding[num_tokens:num_tokens_after_padding].fill_(True)
1037
+
1038
+ req_ids = batch_req_state.req_ids
1039
+ num_scheduled_tokens_np = batch_req_state.num_scheduled_tokens
1040
+ idx_mapping_np = batch_req_state.idx_mapping_np
1041
idx_mapping = async_copy_to_gpu(idx_mapping_np, device=self.device)
1042
+ num_reqs = len(req_ids)
1043
1044
# Get the number of draft tokens for each request.
1045
draft_tokens = scheduler_output.scheduled_spec_decode_tokens
@@ -1039,20 +1081,16 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1081
num_reqs_padded = batch_desc.num_reqs or num_reqs
1082
query_start_loc_np = np.empty(self.max_num_reqs + 1, dtype=np.int32)
1083
query_start_loc_np[0] = 0
1042
- np.cumsum(num_scheduled_tokens, out=query_start_loc_np[1 : num_reqs + 1])
1084
+ np.cumsum(num_scheduled_tokens_np, out=query_start_loc_np[1 : num_reqs + 1])
1085
# Pad for full CUDA graph mode.
1086
# Some attention backends like FA3 require query_start_loc to be non-decreasing.
1087
query_start_loc_np[num_reqs + 1 :] = num_tokens
1088
async_copy_to_gpu(query_start_loc_np, out=self.input_buffers.query_start_loc)
1089
query_start_loc_np = query_start_loc_np[: num_reqs_padded + 1]
1090
query_start_loc = self.input_buffers.query_start_loc[: num_reqs_padded + 1]
1049
- prefill_len_np = self.req_states.prefill_len.np[idx_mapping_np]
1050
- computed_prefill_tokens_np = self.req_states.num_computed_prefill_tokens
1051
- num_computed_prefill_tokens_np = computed_prefill_tokens_np[idx_mapping_np]
1052
- is_prefilling_np = num_computed_prefill_tokens_np < prefill_len_np
1091
1092
# Get prefill tokens if any.
1055
- if np.any(is_prefilling_np):
1093
+ if batch_req_state.has_prefill:
1094
prepare_prefill_inputs(
1095
self.input_buffers.input_ids,
1096
self.req_states.next_prefill_tokens,
@@ -1106,7 +1144,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1144
seq_lens_cpu_upper_bound_np = np.zeros(num_reqs_padded, dtype=np.int32)
1145
np.add(
1146
num_computed_tokens_np,
1109
- num_scheduled_tokens,
1147
+ num_scheduled_tokens_np,
1148
out=seq_lens_cpu_upper_bound_np[:num_reqs],
1149
)
1150
seq_lens_cpu_upper_bound = torch.from_numpy(seq_lens_cpu_upper_bound_np)
@@ -1129,7 +1167,7 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1167
idx_mapping_np=idx_mapping_np,
1168
expanded_idx_mapping=expanded_idx_mapping,
1169
expanded_local_pos=expanded_local_pos,
1132
- num_scheduled_tokens=num_scheduled_tokens,
1170
+ num_scheduled_tokens=num_scheduled_tokens_np,
1171
num_tokens=num_tokens,
1172
num_tokens_after_padding=num_tokens_after_padding,
1173
num_draft_tokens=total_num_draft_tokens,
@@ -1140,9 +1178,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1178
seq_lens_cpu_upper_bound=seq_lens_cpu_upper_bound,
1179
dcp_local_seq_lens=dcp_local_seq_lens,
1180
num_computed_tokens_np=num_computed_tokens_np,
1143
- prefill_len_np=prefill_len_np,
1144
- num_computed_prefill_tokens_np=num_computed_prefill_tokens_np,
1145
- is_prefilling_np=is_prefilling_np,
1181
+ prefill_len_np=batch_req_state.prefill_len_np,
1182
+ num_computed_prefill_tokens_np=batch_req_state.num_computed_prefill_tokens_np,
1183
+ is_prefilling_np=batch_req_state.is_prefilling_np,
1184
+ has_prefill=batch_req_state.has_prefill,
1185
max_seq_len_np=max_seq_len_np,
1186
input_ids=self.input_buffers.input_ids[:num_tokens_after_padding],
1187
positions=self.input_buffers.positions[:num_tokens_after_padding],
@@ -1275,8 +1314,9 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1314
# Get batch descriptor and sync across DP ranks.
1315
num_reqs = len(scheduler_output.num_scheduled_tokens)
1316
num_toks = scheduler_output.total_num_scheduled_tokens
1278
- max_query_len = max(scheduler_output.num_scheduled_tokens.values())
1279
- uniform_tok_count = get_uniform_token_count(num_reqs, num_toks, max_query_len)
1317
+ batch_req_state, uniform_tok_count = self.gather_batch_req_state(
1318
+ scheduler_output, dummy_run
1319
+ )
1320
1321
num_active_loras = 0
1322
if self.lora_config:
@@ -1311,7 +1351,10 @@ class GPUModelRunner(LoRAModelRunnerMixin):
1351
if not dummy_run:
1352
# Common case.
1353
# Prepare all the inputs and copy to the input buffers.
1314
- input_batch = self.prepare_inputs(scheduler_output, batch_desc)
1354
+ assert batch_req_state is not None
1355
+ input_batch = self.prepare_inputs(
1356
+ scheduler_output, batch_req_state, batch_desc
1357
+ )
1358
block_tables, slot_mappings = self.prepare_attn(input_batch)
1359
# Mamba "align" pre-copy: migrate recurrent state across block
1360
# boundaries before the forward. Runs only on real batches, and
@@ -1790,6 +1833,18 @@ class ExecuteModelState(NamedTuple):
1833
routed_experts: RoutedExpertsTensors | None
1834
1835
1836
+class BatchReqState(NamedTuple):
1837
+ """CPU request state for a scheduled batch, in batch (sorted) order."""
1838
+
1839
+ req_ids: list[str]
1840
+ num_scheduled_tokens: np.ndarray # [num_reqs]
1841
+ idx_mapping_np: np.ndarray # [num_reqs]
1842
+ prefill_len_np: np.ndarray # [num_reqs]
1843
+ num_computed_prefill_tokens_np: np.ndarray # [num_reqs]
1844
+ is_prefilling_np: np.ndarray # [num_reqs]
1845
+ has_prefill: bool
1846
+
1847
+
1848
def sort_batch_req_ids(
1849
num_tokens_per_req: dict[str, int], decode_query_len: int
1850
) -> list[str]:
vllm/v1/worker/gpu/pcp_manager.py
+1
@@ -529,6 +529,7 @@ class PCPManager:
529
prefill_len_np=local_prefill_len_np,
530
num_computed_prefill_tokens_np=local_num_computed_prefill_tokens_np,
531
is_prefilling_np=local_is_prefilling_np,
532
+ has_prefill=bool(local_is_prefilling_np.any()),
533
max_seq_len_np=global_batch.max_seq_len_np[local_to_global_batch_req_idx_np]
534
if global_batch.max_seq_len_np is not None
535
else None,
vllm/v1/worker/gpu/spec_decode/autoregressive/speculator.py
+4
-6
@@ -13,10 +13,7 @@ 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
-)
16
+from vllm.v1.worker.gpu.cudagraph_utils import BatchExecutionDescriptor
17
from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp
18
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
19
from vllm.v1.worker.gpu.model_states.interface import ModelState
@@ -24,7 +21,7 @@ from vllm.v1.worker.gpu.spec_decode.autoregressive.cudagraph_utils import (
21
SpeculatorCudaGraphManager,
22
)
23
from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
27
-from vllm.v1.worker.utils import AttentionGroup
24
+from vllm.v1.worker.utils import AttentionGroup, get_uniform_decode_token_count
25
26
logger = init_logger(__name__)
27
@@ -274,12 +271,13 @@ class AutoRegressiveSpeculator(DraftModelSpeculator):
271
272
# When all requests are decoding (no true prefills), each has
273
# num_speculative_steps + 1 tokens, enabling FULL graph replay.
277
- uniform_token_count = get_uniform_token_count(
274
+ uniform_token_count = get_uniform_decode_token_count(
275
num_reqs,
276
# Use the actual number of tokens without padding added by
277
# the target model during FULL cudagraph.
278
num_tokens,
279
max_query_len,
280
+ input_batch.has_prefill,
281
)
282
prefill_batch_desc, num_tokens_across_dp = dispatch_cg_and_sync_dp(
283
self.prefill_cudagraph_manager,
vllm/v1/worker/gpu/spec_decode/multi_module_mtp/speculator.py
+3
-7
@@ -12,9 +12,6 @@ from vllm.logger import init_logger
12
from vllm.triton_utils import tl, triton
13
from vllm.v1.attention.backends.utils import PAD_SLOT_ID
14
from vllm.v1.worker.gpu.attn_utils import build_slot_mappings_by_layer
15
-from vllm.v1.worker.gpu.cudagraph_utils import (
16
- get_uniform_token_count,
17
-)
15
from vllm.v1.worker.gpu.dp_utils import dispatch_cg_and_sync_dp
16
from vllm.v1.worker.gpu.input_batch import InputBatch, InputBuffers
17
from vllm.v1.worker.gpu.spec_decode.autoregressive.cudagraph_utils import (
@@ -22,6 +19,7 @@ from vllm.v1.worker.gpu.spec_decode.autoregressive.cudagraph_utils import (
19
)
20
from vllm.v1.worker.gpu.spec_decode.eagle.utils import load_eagle_model
21
from vllm.v1.worker.gpu.spec_decode.speculator import DraftModelSpeculator
22
+from vllm.v1.worker.utils import get_uniform_decode_token_count
23
24
logger = init_logger(__name__)
25
@@ -190,10 +188,8 @@ class MultiModuleMTPSpeculator(DraftModelSpeculator):
188
189
# When all requests are decoding (no true prefills), each has
190
# num_speculative_steps + 1 tokens, enabling FULL graph replay.
193
- uniform_token_count = get_uniform_token_count(
194
- num_reqs,
195
- num_tokens,
196
- max_query_len,
191
+ uniform_token_count = get_uniform_decode_token_count(
192
+ num_reqs, num_tokens, max_query_len, input_batch.has_prefill
193
)
194
batch_desc, num_tokens_across_dp = dispatch_cg_and_sync_dp(
195
self.cudagraph_manager,
vllm/v1/worker/utils.py
+18
@@ -611,6 +611,24 @@ def copy_kv_cache_blocks_inplace(
611
blocks[dst_indices] = blocks[src_indices]
612
613
614
+def is_uniform_query_len(num_reqs: int, num_tokens: int, max_query_len: int) -> bool:
615
+ """Whether every request in the batch has the same query length.
616
+
617
+ Shape test only; use ``get_uniform_decode_token_count`` to classify a
618
+ scheduled batch, since a prompt chunk can have a decode batch's shape.
619
+ """
620
+ return num_reqs > 0 and num_tokens == max_query_len * num_reqs
621
+
622
+
623
+def get_uniform_decode_token_count(
624
+ num_reqs: int, num_tokens: int, max_query_len: int, has_prefill: bool
625
+) -> int | None:
626
+ """Per-request token count of a uniform decode batch, or None."""
627
+ if not has_prefill and is_uniform_query_len(num_reqs, num_tokens, max_query_len):
628
+ return max_query_len
629
+ return None
630
+
631
+
632
def is_residual_scattered_for_sp(
633
vllm_config: VllmConfig, num_input_tokens: int
634
) -> bool: