[Core] Avoid mixed length specdec batches via padding (#45237)
Signed-off-by: Yiliu Dong <91178480+qianlihuang@users.noreply.github.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Jade Zheng <zheng.shoujian@outlook.com> Co-authored-by: Giancarlo Delfin <gdelfin@inferact.ai> Co-authored-by: Zijing Liu <liuzijing2014@gmail.com>
Yiliu Dong committed
Jun 25, 2026 at 23:34 UTC
d490b98162422922230c8b91757c58077e24682f
2 files changed
+120
tests/v1/core/test_scheduler.py
+95
@@ -1302,6 +1302,101 @@ def test_no_spec_tokens_scheduled_for_prefill_chunks():
1302
assert len(output.scheduled_spec_decode_tokens[req.request_id]) == num_spec_tokens
1303
1304
1305
+def _model_output(scheduler, output, sampled):
1306
+ """Feed `sampled` (per-request list) back to the scheduler."""
1307
+ req_ids = list(output.num_scheduled_tokens.keys())
1308
+ scheduler.update_from_output(
1309
+ output,
1310
+ ModelRunnerOutput(
1311
+ req_ids=req_ids,
1312
+ req_id_to_index={r: i for i, r in enumerate(req_ids)},
1313
+ sampled_token_ids=sampled,
1314
+ logprobs=None,
1315
+ prompt_logprobs_dict={},
1316
+ pooler_output=[],
1317
+ ),
1318
+ )
1319
+
1320
+
1321
+def test_spec_decode_padding_first_decode_step():
1322
+ """A request taking its first decode step (whole prompt already computed via
1323
+ a prefix-cache hit) is padded with placeholder (-1) spec tokens so it enters
1324
+ the worker with the same 1 + num_spec_tokens shape as the other speculative
1325
+ decodes, keeping the batch uniform.
1326
+ """
1327
+ num_spec = 3
1328
+ scheduler = create_scheduler(
1329
+ num_speculative_tokens=num_spec,
1330
+ enable_prefix_caching=True,
1331
+ block_size=16,
1332
+ )
1333
+ # Two identical 33-token prompts: 2 full blocks (32 tokens) get cached, so a
1334
+ # second identical request hits num_computed == num_prompt_tokens - 1.
1335
+ r1, r2 = create_requests(
1336
+ num_requests=2, num_tokens=33, same_prompt=True, max_tokens=16
1337
+ )
1338
+
1339
+ # Drive r1 through prefill so its prompt blocks are cached, then give it real
1340
+ # drafts so it is a running speculative decode (1 + num_spec shape).
1341
+ scheduler.add_request(r1)
1342
+ out = scheduler.schedule()
1343
+ assert out.num_scheduled_tokens[r1.request_id] == 33
1344
+ _model_output(scheduler, out, [[100]])
1345
+ scheduler.update_draft_token_ids(DraftTokenIds([r1.request_id], [[1, 2, 3]]))
1346
+
1347
+ # r2 arrives; its whole prompt is a prefix-cache hit -> first decode step.
1348
+ scheduler.add_request(r2)
1349
+ out = scheduler.schedule()
1350
+
1351
+ # r1 verifies its real drafts.
1352
+ assert out.scheduled_spec_decode_tokens[r1.request_id] == [1, 2, 3]
1353
+ # r2 is padded to the 1 + num_spec shape with placeholder (-1) drafts.
1354
+ assert out.num_scheduled_tokens[r2.request_id] == 1 + num_spec
1355
+ assert out.scheduled_spec_decode_tokens[r2.request_id] == [-1] * num_spec
1356
+
1357
+
1358
+def test_spec_decode_padding_skipped_with_prefill_in_batch():
1359
+ """Padding is skipped when the batch contains a prefill chunk: the batch is
1360
+ already mixed/non-uniform, so padding a new decode request buys nothing.
1361
+ """
1362
+ num_spec = 3
1363
+ scheduler = create_scheduler(
1364
+ num_speculative_tokens=num_spec,
1365
+ enable_prefix_caching=True,
1366
+ block_size=16,
1367
+ max_num_batched_tokens=64,
1368
+ )
1369
+ # r_warm + r_candidate share a prompt so r_candidate gets a full prefix hit.
1370
+ r_warm, r_candidate = create_requests(
1371
+ num_requests=2, num_tokens=33, same_prompt=True, max_tokens=1
1372
+ )
1373
+ # r_long has a different, long prompt that prefills over multiple chunks.
1374
+ (r_long,) = create_requests(num_requests=1, num_tokens=100, max_tokens=16)
1375
+
1376
+ # Warm the prefix cache with r_warm's prompt (it finishes; blocks stay cached).
1377
+ scheduler.add_request(r_warm)
1378
+ out = scheduler.schedule()
1379
+ assert out.num_scheduled_tokens[r_warm.request_id] == 33
1380
+ _model_output(scheduler, out, [[100]])
1381
+ assert r_warm.request_id in scheduler.finished_req_ids
1382
+
1383
+ # Start r_long; after one chunk it remains a prefill chunk in the running queue.
1384
+ scheduler.add_request(r_long)
1385
+ out = scheduler.schedule()
1386
+ _model_output(scheduler, out, [[]]) # still prefilling, no sampled token
1387
+ assert r_long.is_prefill_chunk
1388
+
1389
+ # r_candidate arrives (prefix-cache hit -> first decode step) alongside the
1390
+ # in-flight prefill chunk.
1391
+ scheduler.add_request(r_candidate)
1392
+ out = scheduler.schedule()
1393
+
1394
+ # The batch has a prefill chunk, so r_candidate is NOT padded.
1395
+ assert r_long.request_id in out.num_scheduled_tokens
1396
+ assert out.num_scheduled_tokens[r_candidate.request_id] == 1
1397
+ assert r_candidate.request_id not in out.scheduled_spec_decode_tokens
1398
+
1399
+
1400
def test_scheduler_stats_waiting_queues():
1401
"""Test that scheduler stats correctly report waiting and skipped_waiting queues."""
1402
# Create scheduler with limited capacity so we can have waiting requests
vllm/v1/core/sched/scheduler.py
+25
@@ -415,6 +415,8 @@ class Scheduler(SchedulerInterface):
415
encoder_compute_budget = self.max_num_encoder_input_tokens
416
# Spec decode-related.
417
scheduled_spec_decode_tokens: dict[str, list[int]] = {}
418
+ # Whether the running batch contains any prefill requests.
419
+ prefill_scheduled = False
420
421
# For logging.
422
scheduled_timestamp = time.monotonic()
@@ -573,6 +575,7 @@ class Scheduler(SchedulerInterface):
575
576
# Schedule the request.
577
scheduled_running_reqs.append(request)
578
+ prefill_scheduled |= request.is_prefill_chunk
579
request_id = request.request_id
580
req_to_new_blocks[request_id] = new_blocks
581
num_scheduled_tokens[request_id] = num_new_tokens
@@ -778,6 +781,7 @@ class Scheduler(SchedulerInterface):
781
encoder_inputs_to_schedule = None
782
external_load_encoder_input = []
783
new_encoder_compute_budget = encoder_compute_budget
784
+ pad_spec_decode = False
785
786
if load_kv_async:
787
# KVTransfer: loading remote KV, do not allocate for new work.
@@ -793,6 +797,23 @@ class Scheduler(SchedulerInterface):
797
# `request.num_prompt_tokens` to consider the resumed
798
# requests, which have output tokens.
799
num_new_tokens = request.num_tokens - num_computed_tokens
800
+
801
+ # Pad new decode requests to uniform spec decoding size to
802
+ # preserve full cudagraph for this step.
803
+ if (
804
+ (self.num_spec_tokens > 0 and self.dynamic_sd_lookup is None)
805
+ and num_new_tokens == 1
806
+ and (scheduled_running_reqs and not prefill_scheduled)
807
+ ):
808
+ num_new_tokens = 1 + self.num_spec_tokens
809
+ if (
810
+ num_new_tokens > token_budget
811
+ or num_computed_tokens + num_new_tokens > self.max_model_len
812
+ ):
813
+ # Prefer to not schedule than schedule un-padded here.
814
+ break
815
+ pad_spec_decode = True
816
+
817
threshold = self.scheduler_config.long_prefill_token_threshold
818
if 0 < threshold < num_new_tokens:
819
num_new_tokens = threshold
@@ -957,6 +978,10 @@ class Scheduler(SchedulerInterface):
978
token_budget -= num_new_tokens
979
request.status = RequestStatus.RUNNING
980
request.num_computed_tokens = num_computed_tokens
981
+ if pad_spec_decode:
982
+ scheduled_spec_decode_tokens[request_id] = [
983
+ -1
984
+ ] * self.num_spec_tokens
985
# Only track requests that will still be prefilling after this chunk.
986
if num_computed_tokens + num_new_tokens < request.num_tokens:
987
self._inflight_prefills.add(request)