A high-throughput and memory-efficient inference and serving engine for LLMs
Mirrored from https://github.com/tduncan-lum/Nvidia-Optimization-Stack---VLLM.git
Synced Nov 10, 16:30 UTC
[Perf] Async Scheduling + Speculative Decoding + Structured Outputs (#29821)
Signed-off-by: Benjamin Chislett <bchislett@nvidia.com> Signed-off-by: Nick Hill <nickhill123@gmail.com> Co-authored-by: Nick Hill <nickhill123@gmail.com>
Benjamin Chislett committed
Jan 6, 2026 at 13:50 UTC
f7008ce1c4c1cf92d8cc13ad7e966d4b82a32c65
8 files changed
+184
-54
tests/v1/e2e/test_async_scheduling.py
+11
-3
@@ -30,8 +30,9 @@ example_prompts = [first_prompt, "In one word, the capital of France is "] + [
30
31
default_params = dict(
32
temperature=0.0, # greedy
33
- max_tokens=23,
34
- min_tokens=18,
33
+ max_tokens=30,
34
+ # spec decoding currently doesn't support min_tokens
35
+ # min_tokens=28,
36
)
37
38
@@ -86,7 +87,7 @@ def test_without_spec_decoding(
87
run_tests(monkeypatch, MODEL, test_configs, test_sampling_params)
88
89
89
-def test_with_spec_decoding(monkeypatch: pytest.MonkeyPatch):
90
+def test_with_spec_decoding(sample_json_schema, monkeypatch: pytest.MonkeyPatch):
91
"""Test consistency and acceptance rates with some different combos of
92
preemption, executor, async scheduling, prefill chunking,
93
spec decoding model length.
@@ -100,9 +101,16 @@ def test_with_spec_decoding(monkeypatch: pytest.MonkeyPatch):
101
# Set small draft model len to force doesn't-fit-in-drafter case.
102
spec_config_short = spec_config | {"max_model_len": 50}
103
104
+ struct_outputs = StructuredOutputsParams(json=sample_json_schema)
105
+
106
test_sampling_params = [
107
dict(),
108
dict(logprobs=2),
109
+ dict(structured_outputs=struct_outputs),
110
+ dict(
111
+ structured_outputs=struct_outputs,
112
+ logprobs=2,
113
+ ),
114
]
115
116
# test_preemption, executor, async_scheduling,
vllm/v1/core/sched/async_scheduler.py
+3
@@ -12,10 +12,12 @@ logger = init_logger(__name__)
12
class AsyncScheduler(Scheduler):
13
def _update_after_schedule(self, scheduler_output: SchedulerOutput) -> None:
14
super()._update_after_schedule(scheduler_output)
15
+ has_structured_output_requests = False
16
pending_structured_output_tokens = False
17
spec_decode_tokens = scheduler_output.scheduled_spec_decode_tokens
18
for req_id in scheduler_output.num_scheduled_tokens:
19
request = self.requests[req_id]
20
+ has_structured_output_requests |= request.use_structured_output
21
pending_structured_output_tokens |= (
22
request.use_structured_output and request.num_output_placeholders > 0
23
)
@@ -33,6 +35,7 @@ class AsyncScheduler(Scheduler):
35
# We will update the actual spec token ids in the worker process.
36
request.spec_token_ids = [-1] * self.num_spec_tokens
37
38
+ scheduler_output.has_structured_output_requests = has_structured_output_requests
39
scheduler_output.pending_structured_output_tokens = (
40
pending_structured_output_tokens
41
)
vllm/v1/core/sched/interface.py
+20
-1
@@ -86,7 +86,26 @@ class SchedulerInterface(ABC):
86
87
@abstractmethod
88
def update_draft_token_ids(self, draft_token_ids: "DraftTokenIds") -> None:
89
- """Update the draft token ids for the scheduled requests."""
89
+ """Update requests with newly generated draft token ids, applying
90
+ structured output grammar validation if needed.
91
+
92
+ Args:
93
+ draft_token_ids: The input draft token ids for each request.
94
+ """
95
+ raise NotImplementedError
96
+
97
+ @abstractmethod
98
+ def update_draft_token_ids_in_output(
99
+ self, draft_token_ids: "DraftTokenIds", scheduler_output: "SchedulerOutput"
100
+ ) -> None:
101
+ """Update scheduler output with newly generated draft token ids, applying
102
+ structured output grammar validation if needed.
103
+
104
+ Args:
105
+ draft_token_ids: The input draft token ids for each request.
106
+ scheduler_output: Update the given scheduler_output
107
+ with the corresponding draft token ids.
108
+ """
109
raise NotImplementedError
110
111
@abstractmethod
vllm/v1/core/sched/output.py
+7
@@ -181,10 +181,17 @@ class SchedulerOutput:
181
# Only used for v2 model runner.
182
preempted_req_ids: set[str] | None = None
183
184
+ # Whether any of the scheduled requests use structured output.
185
+ # Set only in async scheduling case.
186
+ has_structured_output_requests: bool = False
187
+
188
# Whether the scheduled requests have all the output tokens they
189
# need to perform grammar bitmask computation.
190
pending_structured_output_tokens: bool = False
191
192
+ # Used for adjusting acceptance rate calculation.
193
+ num_invalid_spec_tokens: dict[str, int] | None = None
194
+
195
# KV Cache Connector metadata.
196
kv_connector_metadata: KVConnectorMetadata | None = None
197
vllm/v1/core/sched/scheduler.py
+54
-7
@@ -1130,6 +1130,8 @@ class Scheduler(SchedulerInterface):
1130
spec_decoding_stats,
1131
num_draft_tokens=num_draft_tokens,
1132
num_accepted_tokens=num_accepted,
1133
+ num_invalid_spec_tokens=scheduler_output.num_invalid_spec_tokens,
1134
+ request_id=req_id,
1135
)
1136
1137
stopped = False
@@ -1168,7 +1170,13 @@ class Scheduler(SchedulerInterface):
1170
struct_output_request = request.structured_output_request
1171
assert struct_output_request is not None
1172
assert struct_output_request.grammar is not None
1171
- struct_output_request.grammar.accept_tokens(req_id, new_token_ids)
1173
+ ok = struct_output_request.grammar.accept_tokens(req_id, new_token_ids)
1174
+ if not ok:
1175
+ logger.warning(
1176
+ "Unexpected: grammar rejected tokens %s for request %s.",
1177
+ new_token_ids,
1178
+ req_id,
1179
+ )
1180
1181
if num_nans_in_logits is not None and req_id in num_nans_in_logits:
1182
request.num_nans_in_logits = num_nans_in_logits[req_id]
@@ -1330,11 +1338,46 @@ class Scheduler(SchedulerInterface):
1338
# Add newly generated spec token ids to the request.
1339
if self.structured_output_manager.should_advance(request):
1340
metadata = request.structured_output_request
1333
- request.spec_token_ids = metadata.grammar.validate_tokens( # type: ignore[union-attr]
1334
- spec_token_ids
1335
- )
1336
- else:
1337
- request.spec_token_ids = spec_token_ids
1341
+ spec_token_ids = metadata.grammar.validate_tokens(spec_token_ids) # type: ignore[union-attr]
1342
+ request.spec_token_ids = spec_token_ids
1343
+
1344
+ def update_draft_token_ids_in_output(
1345
+ self, draft_token_ids: DraftTokenIds, scheduler_output: SchedulerOutput
1346
+ ) -> None:
1347
+ num_invalid_spec_tokens: dict[str, int] = {}
1348
+
1349
+ sched_spec_tokens = scheduler_output.scheduled_spec_decode_tokens
1350
+ for req_id, spec_token_ids in zip(
1351
+ draft_token_ids.req_ids,
1352
+ draft_token_ids.draft_token_ids,
1353
+ ):
1354
+ request = self.requests.get(req_id)
1355
+ if request is None or request.is_finished():
1356
+ # The request may have been finished. Skip.
1357
+ continue
1358
+
1359
+ placeholder_spec_tokens = sched_spec_tokens.get(req_id)
1360
+ if not placeholder_spec_tokens:
1361
+ continue
1362
+
1363
+ orig_num_spec_tokens = len(placeholder_spec_tokens)
1364
+ # Trim drafts to scheduled number of spec tokens
1365
+ # (needed for chunked prefill case for example).
1366
+ del spec_token_ids[orig_num_spec_tokens:]
1367
+ # Filter out spec tokens which do not adhere to the grammar.
1368
+ if self.structured_output_manager.should_advance(request):
1369
+ metadata = request.structured_output_request
1370
+ assert metadata is not None and metadata.grammar is not None
1371
+ spec_token_ids = metadata.grammar.validate_tokens(spec_token_ids)
1372
+ # Pad to original number of spec tokens.
1373
+ num_invalid_tokens = orig_num_spec_tokens - len(spec_token_ids)
1374
+ if num_invalid_tokens:
1375
+ spec_token_ids.extend([-1] * num_invalid_tokens)
1376
+ num_invalid_spec_tokens[req_id] = num_invalid_tokens
1377
+
1378
+ sched_spec_tokens[req_id] = spec_token_ids
1379
+
1380
+ scheduler_output.num_invalid_spec_tokens = num_invalid_spec_tokens
1381
1382
def get_request_counts(self) -> tuple[int, int]:
1383
"""Returns (num_running_reqs, num_waiting_reqs)."""
@@ -1513,11 +1556,15 @@ class Scheduler(SchedulerInterface):
1556
spec_decoding_stats: SpecDecodingStats | None,
1557
num_draft_tokens: int,
1558
num_accepted_tokens: int,
1559
+ num_invalid_spec_tokens: dict[str, int] | None,
1560
+ request_id: str,
1561
) -> SpecDecodingStats | None:
1517
- if not self.log_stats:
1562
+ if not self.log_stats or not num_draft_tokens:
1563
return None
1564
if spec_decoding_stats is None:
1565
spec_decoding_stats = SpecDecodingStats.new(self.num_spec_tokens)
1566
+ if num_invalid_spec_tokens:
1567
+ num_draft_tokens -= num_invalid_spec_tokens.get(request_id, 0)
1568
spec_decoding_stats.observe_draft(
1569
num_draft_tokens=num_draft_tokens, num_accepted_tokens=num_accepted_tokens
1570
)
vllm/v1/engine/core.py
+12
@@ -466,6 +466,18 @@ class EngineCore:
466
# in a field and do it immediately once step_with_batch_queue is
467
# re-called. The latter slightly favors TTFT over TPOT/throughput.
468
if deferred_scheduler_output:
469
+ # If we are doing speculative decoding with structured output,
470
+ # we need to get the draft token ids from the prior step before
471
+ # we can compute the grammar bitmask for the deferred request.
472
+ if self.use_spec_decode:
473
+ draft_token_ids = self.model_executor.take_draft_token_ids()
474
+ assert draft_token_ids is not None
475
+ # Update the draft token ids in the scheduler output to
476
+ # filter out the invalid spec tokens, which will be padded
477
+ # with -1 and skipped by the grammar bitmask computation.
478
+ self.scheduler.update_draft_token_ids_in_output(
479
+ draft_token_ids, deferred_scheduler_output
480
+ )
481
# We now have the tokens needed to compute the bitmask for the
482
# deferred request. Get the bitmask and call sample tokens.
483
grammar_output = self.scheduler.get_grammar_bitmask(
vllm/v1/engine/input_processor.py
+1
-2
@@ -158,12 +158,11 @@ class InputProcessor:
158
or params.presence_penalty != 0.0
159
or params.repetition_penalty != 1.0
160
or params.bad_words_token_ids
161
- or params.structured_outputs
161
)
162
):
163
raise ValueError(
164
"async scheduling with spec decoding doesn't yet support "
166
- "penalties, bad words or structured outputs in sampling parameters."
165
+ "penalties or bad words in sampling parameters."
166
)
167
168
def _validate_params(
vllm/v1/worker/gpu_model_runner.py
+76
-41
@@ -626,6 +626,7 @@ class GPUModelRunner(
626
627
# Cached outputs.
628
self._draft_token_ids: list[list[int]] | torch.Tensor | None = None
629
+ self._draft_token_req_ids: list[str] | None = None
630
self.transfer_event = torch.Event()
631
self.sampled_token_ids_pinned_cpu = torch.empty(
632
(self.max_num_reqs, 1),
@@ -638,15 +639,30 @@ class GPUModelRunner(
639
# with dedicated stream for overlapping and event for coordination.
640
self.valid_sampled_token_count_event: torch.Event | None = None
641
self.valid_sampled_token_count_copy_stream: torch.cuda.Stream | None = None
641
- if self.use_async_scheduling and self.num_spec_tokens:
642
- self.valid_sampled_token_count_event = torch.Event()
643
- self.valid_sampled_token_count_copy_stream = torch.cuda.Stream()
644
- self.valid_sampled_token_count_cpu = torch.empty(
645
- self.max_num_reqs,
646
- dtype=torch.int64,
647
- device="cpu",
648
- pin_memory=self.pin_memory,
649
- )
642
+ # We also copy the drafted tokens to the CPU asynchronously,
643
+ # in case we need them for structured outputs.
644
+ self.draft_token_ids_event: torch.Event | None = None
645
+ self.draft_token_ids_copy_stream: torch.cuda.Stream | None = None
646
+ self.valid_sampled_token_count_cpu: torch.Tensor | None = None
647
+ self.draft_token_ids_cpu: torch.Tensor | None = None
648
+ if self.num_spec_tokens:
649
+ self.draft_token_ids_event = torch.Event()
650
+ self.draft_token_ids_copy_stream = torch.cuda.Stream()
651
+ self.draft_token_ids_cpu = torch.empty(
652
+ (self.max_num_reqs, self.num_spec_tokens),
653
+ dtype=torch.int64,
654
+ device="cpu",
655
+ pin_memory=self.pin_memory,
656
+ )
657
+ if self.use_async_scheduling:
658
+ self.valid_sampled_token_count_event = torch.Event()
659
+ self.valid_sampled_token_count_copy_stream = torch.cuda.Stream()
660
+ self.valid_sampled_token_count_cpu = torch.empty(
661
+ self.max_num_reqs,
662
+ dtype=torch.int64,
663
+ device="cpu",
664
+ pin_memory=self.pin_memory,
665
+ )
666
667
# Ephemeral state transferred between execute_model() and sample_tokens().
668
self.execute_model_state: ExecuteModelState | None = None
@@ -1036,15 +1052,8 @@ class GPUModelRunner(
1052
self.input_batch.spec_token_ids[req_index].clear()
1053
self.input_batch.spec_token_ids[req_index].extend(spec_token_ids)
1054
1039
- # there are no draft tokens with async scheduling,
1040
- # we clear the spec_decoding info in scheduler_output and
1041
- # use normal sampling but rejection_sampling.
1055
if self.use_async_scheduling:
1056
req_state.prev_num_draft_len = num_spec_tokens
1044
- if num_spec_tokens and self._draft_token_ids is None:
1045
- scheduler_output.total_num_scheduled_tokens -= num_spec_tokens
1046
- scheduler_output.num_scheduled_tokens[req_id] -= num_spec_tokens
1047
- scheduler_output.scheduled_spec_decode_tokens.pop(req_id, None)
1057
# Add the new or resumed requests to the persistent batch.
1058
# The smaller empty indices are filled first.
1059
for request in reqs_to_add:
@@ -1291,7 +1300,6 @@ class GPUModelRunner(
1300
# because input_ids dtype is torch.int32,
1301
# so convert draft_token_ids to torch.int32 here.
1302
draft_token_ids = self._draft_token_ids.to(dtype=torch.int32)
1294
- self._draft_token_ids = None
1303
1304
self.input_ids.gpu.scatter_(
1305
dim=0,
@@ -3100,20 +3108,6 @@ class GPUModelRunner(
3108
"after execute_model() returns None."
3109
)
3110
3103
- # self._draft_token_ids is None when `input_fits_in_drafter=False`
3104
- # and there is no draft tokens scheduled. so it need to update the
3105
- # spec_decoding info in scheduler_output with async_scheduling.
3106
- # use deepcopy to avoid the modification has influence on the
3107
- # scheduler_output in engine core process.
3108
- # TODO(Ronald1995): deepcopy is expensive when there is a large
3109
- # number of requests, optimize it later.
3110
- if (
3111
- self.use_async_scheduling
3112
- and self.num_spec_tokens
3113
- and self._draft_token_ids is None
3114
- ):
3115
- scheduler_output = deepcopy(scheduler_output)
3116
-
3111
num_scheduled_tokens = scheduler_output.total_num_scheduled_tokens
3112
with (
3113
record_function_or_nullcontext("gpu_model_runner: preprocess"),
@@ -3360,6 +3354,8 @@ class GPUModelRunner(
3354
) -> ModelRunnerOutput | AsyncModelRunnerOutput | IntermediateTensors:
3355
kv_connector_output = self.kv_connector_output
3356
self.kv_connector_output = None
3357
+ self._draft_token_ids = None
3358
+ self._draft_token_req_ids = None
3359
3360
if self.execute_model_state is None:
3361
# Nothing to do (PP non-final rank case), output isn't used.
@@ -3414,6 +3410,7 @@ class GPUModelRunner(
3410
spec_decode_metadata,
3411
spec_decode_common_attn_metadata,
3412
)
3413
+ self._copy_draft_token_ids_to_cpu(scheduler_output)
3414
3415
spec_config = self.speculative_config
3416
use_padded_batch_for_eagle = (
@@ -3458,6 +3455,12 @@ class GPUModelRunner(
3455
self._copy_valid_sampled_token_count(
3456
next_token_ids, valid_sampled_tokens_count
3457
)
3458
+ # Since we couldn't run the drafter,
3459
+ # just use zeros for the draft tokens.
3460
+ self._draft_token_ids = torch.zeros(
3461
+ 1, device=self.device, dtype=torch.int32
3462
+ ).expand(len(self.input_batch.req_ids), self.num_spec_tokens)
3463
+ self._copy_draft_token_ids_to_cpu(scheduler_output, zeros_only=True)
3464
3465
with record_function_or_nullcontext("gpu_model_runner: bookkeep"):
3466
(
@@ -3529,19 +3532,50 @@ class GPUModelRunner(
3532
return async_output
3533
3534
def take_draft_token_ids(self) -> DraftTokenIds | None:
3532
- if not self.num_spec_tokens:
3535
+ if not self.num_spec_tokens or not self._draft_token_req_ids:
3536
return None
3537
+ req_ids = self._draft_token_req_ids
3538
+ draft_token_ids = self._get_draft_token_ids_cpu(len(req_ids))
3539
+ return DraftTokenIds(req_ids, draft_token_ids)
3540
3535
- req_ids = self.input_batch.req_ids
3536
- if self._draft_token_ids is None:
3537
- return DraftTokenIds(req_ids, [[] for _ in req_ids])
3541
+ def _copy_draft_token_ids_to_cpu(
3542
+ self, scheduler_output: "SchedulerOutput", zeros_only: bool = False
3543
+ ) -> None:
3544
+ struct_output = scheduler_output.has_structured_output_requests
3545
+ if self.use_async_scheduling and not struct_output:
3546
+ # Draft tokens don't need to be copied to the CPU if async
3547
+ # scheduling is in use and there are no structured output reqs.
3548
+ return
3549
+ # We must also set the corresponding request ids.
3550
+ self._draft_token_req_ids = self.input_batch.req_ids.copy()
3551
3539
- if isinstance(self._draft_token_ids, torch.Tensor):
3540
- draft_token_ids = self._draft_token_ids.tolist()
3541
- else:
3542
- draft_token_ids = self._draft_token_ids
3543
- self._draft_token_ids = None
3544
- return DraftTokenIds(req_ids, draft_token_ids)
3552
+ draft_token_ids: torch.Tensor = self._draft_token_ids
3553
+ if not torch.is_tensor(draft_token_ids):
3554
+ return
3555
+ assert self.draft_token_ids_event is not None
3556
+ assert self.draft_token_ids_copy_stream is not None
3557
+ assert self.draft_token_ids_cpu is not None
3558
+ default_stream = torch.cuda.current_stream()
3559
+ num_reqs = draft_token_ids.shape[0]
3560
+ with torch.cuda.stream(self.draft_token_ids_copy_stream):
3561
+ if not zeros_only:
3562
+ # Trigger async copy of draft token ids to cpu.
3563
+ self.draft_token_ids_copy_stream.wait_stream(default_stream)
3564
+ self.draft_token_ids_cpu[:num_reqs].copy_(
3565
+ draft_token_ids, non_blocking=True
3566
+ )
3567
+ else:
3568
+ # No copy needed, just zero-out cpu tensor.
3569
+ self.draft_token_ids_cpu[:num_reqs] = 0
3570
+ self.draft_token_ids_event.record()
3571
+
3572
+ def _get_draft_token_ids_cpu(self, num_reqs: int) -> list[list[int]]:
3573
+ if isinstance(self._draft_token_ids, list):
3574
+ return self._draft_token_ids
3575
+ assert self.draft_token_ids_event is not None
3576
+ assert self.draft_token_ids_cpu is not None
3577
+ self.draft_token_ids_event.synchronize()
3578
+ return self.draft_token_ids_cpu[:num_reqs].tolist()
3579
3580
def _copy_valid_sampled_token_count(
3581
self, next_token_ids: torch.Tensor, valid_sampled_tokens_count: torch.Tensor
@@ -3556,6 +3590,7 @@ class GPUModelRunner(
3590
self.valid_sampled_token_count_copy_stream.wait_stream(default_stream) # type: ignore
3591
counts = valid_sampled_tokens_count
3592
counts_cpu = self.valid_sampled_token_count_cpu
3593
+ assert counts_cpu is not None
3594
counts_cpu[: counts.shape[0]].copy_(counts, non_blocking=True)
3595
self.valid_sampled_token_count_event.record()
3596