[BugFix] Fix `num_output_placeholders` preemption underflow (#48245)
Signed-off-by: Chris Eastwood <chris.eastwood@pwn4g3.dev> Signed-off-by: Nick Hill <nickhill123@gmail.com> Signed-off-by: Yifan Qiao <yifanqiao@inferact.ai> Co-authored-by: Chris Eastwood <chris.eastwood@pwn4g3.dev> Co-authored-by: Yifan Qiao <yifanqiao@inferact.ai>
Nick Hill committed
Jul 29, 2026 at 06:41 UTC
a0c092ee72c0dcefbb3b3e74f97ac62d842e5f4b
5 files changed
+385
-77
tests/v1/core/test_async_scheduler.py
+301
-35
@@ -1,6 +1,7 @@
1
# SPDX-License-Identifier: Apache-2.0
2
# SPDX-FileCopyrightText: Copyright contributors to the vLLM project
3
-from collections import deque
3
+from collections import defaultdict, deque
4
+from collections.abc import Callable
5
from unittest.mock import Mock
6
7
import pytest
@@ -329,46 +330,311 @@ def test_abort_request_when_structured_output_fsm_cannot_advance():
330
assert not scheduler.running
331
332
332
-def test_no_placeholder_underflow_on_discarded_spec_frame():
333
- num_spec = 5
333
+class PipelinedEngine:
334
+ """Drive a real AsyncScheduler like EngineCore.step_with_batch_queue:
335
+ schedule until the batch queue is full, then process the oldest step's
336
+ output. Async PP runs pp_size+1 concurrent batches, so up to pp_size
337
+ steps are in flight at each schedule() call -- the window in which
338
+ preemption must handle output that has not yet returned. (Single-GPU e2e
339
+ tests can never create this window: at PP=1, exactly one step is in
340
+ flight and it is processed before a preempted request can resume.)
341
+
342
+ The model runner is emulated with the V2 runner's own bookkeeping, from
343
+ only what the scheduler serializes to it: slots flushed on
344
+ preempted_req_ids, resumed requests re-added from the NewRequestData
345
+ snapshot, sampling when a step reaches the end of the runner's own view
346
+ of the sequence. This makes preemption races observable: a stale token
347
+ delivered after a resume is scheduled extends the scheduler's sequence
348
+ but not the runner's.
349
+
350
+ Every sample emits a globally unique token tagged with its sampled
351
+ position, so tests can assert exact delivery.
352
+ """
353
+
354
+ def __init__(
355
+ self,
356
+ scheduler: AsyncScheduler,
357
+ queue_size: int,
358
+ accept_drafts: Callable[[int, str, int], int] | None = None,
359
+ ):
360
+ self.scheduler = scheduler
361
+ self.queue_size = queue_size
362
+ self.accept_drafts = accept_drafts
363
+ # In-flight steps: (scheduler_output, new_reqs snapshot) in FIFO order.
364
+ self.queue: deque[tuple[SchedulerOutput, list[tuple[str, int, int]]]] = deque()
365
+ # Runner-side request state: req_id -> [seq_len, num_computed] as the
366
+ # runner sees them (its own sampled tokens, not the scheduler's).
367
+ self.runner_view: dict[str, list[int]] = {}
368
+ # All tokens the fake runner ever sampled, per request, in order.
369
+ self.emitted: dict[str, list[int]] = defaultdict(list)
370
+ # Sequence position each (globally unique) token was sampled for.
371
+ self.emitted_position: dict[int, int] = {}
372
+ self.step_idx = 0
373
+ self._next_token = 1000
374
+
375
+ def _schedule(self) -> bool:
376
+ scheduler_output = self.scheduler.schedule()
377
+ self.step_idx += 1
378
+ # Snapshot what NewRequestData serializes at schedule time (both new
379
+ # and resumed requests for the V2 runner).
380
+ new_reqs = [
381
+ (r.req_id, len(r.prefill_token_ids), r.num_computed_tokens)
382
+ for r in scheduler_output.scheduled_new_reqs
383
+ ]
384
+ # Enqueue empty steps too (the engine executes them), so the runner
385
+ # still observes their preempted/finished request ids in step order.
386
+ self.queue.appendleft((scheduler_output, new_reqs))
387
+ return True
388
+
389
+ def _process_oldest_step(self) -> None:
390
+ scheduler_output, new_reqs = self.queue.pop()
391
+ # Worker-side state updates, in step order: flush preempted/finished
392
+ # slots, then (re-)add new/resumed requests.
393
+ for req_id in scheduler_output.preempted_req_ids or ():
394
+ self.runner_view.pop(req_id, None)
395
+ for req_id in scheduler_output.finished_req_ids or ():
396
+ self.runner_view.pop(req_id, None)
397
+ for req_id, seq_len, num_computed in new_reqs:
398
+ self.runner_view[req_id] = [seq_len, num_computed]
399
+
400
+ req_ids = list(scheduler_output.num_scheduled_tokens.keys())
401
+ sampled_token_ids: list[list[int]] = []
402
+ for req_id in req_ids:
403
+ num_scheduled = scheduler_output.num_scheduled_tokens[req_id]
404
+ view = self.runner_view.get(req_id)
405
+ if view is None:
406
+ # Slot already flushed (request finished/aborted mid-flight).
407
+ sampled_token_ids.append([])
408
+ continue
409
+ seq_len, num_computed = view
410
+ end = num_computed + num_scheduled
411
+ if end < seq_len:
412
+ # Partial prefill by the runner's own bookkeeping: no sample.
413
+ view[1] = end
414
+ sampled_token_ids.append([])
415
+ continue
416
+ drafts = scheduler_output.scheduled_spec_decode_tokens.get(req_id, ())
417
+ num_accepted = (
418
+ min(self.accept_drafts(self.step_idx, req_id, len(drafts)), len(drafts))
419
+ if drafts and self.accept_drafts
420
+ else 0
421
+ )
422
+ num_rejected = len(drafts) - num_accepted
423
+ tokens = list(range(self._next_token, self._next_token + 1 + num_accepted))
424
+ self._next_token += 1 + num_accepted
425
+ self.emitted[req_id].extend(tokens)
426
+ sampled_token_ids.append(tokens)
427
+ # Rejected drafts roll back computed; the sampled tokens extend
428
+ # the runner's sequence.
429
+ view[1] = end - num_rejected
430
+ view[0] = view[1] + 1
431
+ for offset, token in enumerate(tokens):
432
+ self.emitted_position[token] = view[0] - len(tokens) + offset
433
+ model_runner_output = ModelRunnerOutput(
434
+ req_ids=req_ids,
435
+ req_id_to_index={req_id: i for i, req_id in enumerate(req_ids)},
436
+ sampled_token_ids=sampled_token_ids,
437
+ logprobs=None,
438
+ prompt_logprobs_dict={},
439
+ pooler_output=[],
440
+ )
441
+ self.scheduler.update_from_output(scheduler_output, model_runner_output)
442
+
443
+ def run(
444
+ self,
445
+ max_steps: int = 2000,
446
+ before_step: Callable[[int, "PipelinedEngine"], None] | None = None,
447
+ ) -> None:
448
+ for i in range(max_steps):
449
+ if not self.scheduler.has_requests() and not self.queue:
450
+ return
451
+ if before_step is not None:
452
+ before_step(i, self)
453
+ scheduled = (
454
+ self.scheduler.has_requests()
455
+ and len(self.queue) < self.queue_size
456
+ and self._schedule()
457
+ )
458
+ if scheduled and len(self.queue) < self.queue_size:
459
+ # Queue not yet full: the engine returns without blocking.
460
+ continue
461
+ if self.queue:
462
+ self._process_oldest_step()
463
+ raise AssertionError("engine loop did not converge")
464
+
465
+
466
+def _create_async_pp_scheduler(
467
+ num_spec: int, pp_size: int = 3, num_blocks: int = 5
468
+) -> AsyncScheduler:
469
scheduler = create_scheduler(
470
async_scheduling=True,
336
- num_speculative_tokens=num_spec,
337
- speculative_method="ngram_gpu",
471
+ num_speculative_tokens=num_spec or None,
472
+ speculative_method="ngram_gpu" if num_spec else None,
473
+ use_v2_model_runner=True,
474
+ num_blocks=num_blocks,
475
+ block_size=16,
476
+ max_num_batched_tokens=512,
477
)
339
- req = create_requests(num_requests=1, max_tokens=20)[0]
340
- req.num_computed_tokens = req.num_tokens
341
- scheduler.requests[req.request_id] = req
342
- scheduler.running.append(req)
343
- req.status = RequestStatus.RUNNING
478
+ # Emulate PP at the scheduler level; constructing with
479
+ # pipeline_parallel_size>1 requires that many visible GPUs. Drive with
480
+ # queue_size=pp_size+1 (V2 async PP runs pp_size+1 concurrent batches).
481
+ scheduler.pp_size = pp_size
482
+ scheduler.use_pp = pp_size > 1
483
+ return scheduler
484
+
485
+
486
+def _assert_ordered_subset(delivered: list[int], emitted: list[int]) -> None:
487
+ """Delivered tokens must be an order-preserving subset of the emitted
488
+ tokens with no duplicates (tokens are globally unique)."""
489
+ it = iter(emitted)
490
+ for token in delivered:
491
+ assert token in it, f"token {token} delivered out of order or twice"
492
+
493
+
494
+def _assert_positions_consistent(req, engine: PipelinedEngine) -> None:
495
+ """The i-th delivered output token must be one the runner sampled for
496
+ exactly sequence position prompt_len + i: catches a preempted request's
497
+ stale output landing on a position the resumed request resampled (or
498
+ vice versa), which token-stream equality alone cannot see."""
499
+ for i, token in enumerate(req.output_token_ids):
500
+ expected = req.num_prompt_tokens + i
501
+ actual = engine.emitted_position[token]
502
+ assert actual == expected, (
503
+ f"output {i} of {req.request_id}: token sampled for position "
504
+ f"{actual}, delivered as position {expected}"
505
+ )
506
345
- req.num_output_placeholders = 1
346
- req.async_tokens_to_discard = num_spec
347
- computed_before = req.num_computed_tokens
507
349
- scheduler_output = SchedulerOutput(
350
- scheduled_new_reqs=[],
351
- scheduled_cached_reqs=CachedRequestData.make_empty(),
352
- num_scheduled_tokens={req.request_id: num_spec + 1},
353
- total_num_scheduled_tokens=num_spec + 1,
354
- scheduled_encoder_inputs={},
355
- scheduled_spec_decode_tokens={req.request_id: [10] * num_spec},
356
- num_common_prefix_blocks=[],
357
- finished_req_ids=set(),
358
- free_encoder_mm_hashes=[],
508
+@pytest.mark.parametrize("num_spec", [0, 3])
509
+def test_kv_pressure_preemption_with_inflight_output(num_spec: int):
510
+ """KV-pressure preemption of requests with in-flight async output.
511
+
512
+ PP=3 + async scheduling (batch queue of 4), a block pool small enough
513
+ that decodes contend and preempt mid-flight, and staggered arrivals so
514
+ the batch queue actually pipelines. A preempted request's in-flight steps
515
+ still return: their tokens must be delivered exactly once, their stale
516
+ spec-rejection counts must not corrupt the rolled-back counters, and the
517
+ resume must not resample a position that output later delivers.
518
+
519
+ Regression for the num_output_placeholders underflow EngineCore crash:
520
+ with the fix reverted, the num_spec=3 variant fails with exactly
521
+ ``assert request.num_output_placeholders >= 0`` when a stale spec output
522
+ returns after the preempted request was resumed and sampled.
523
+ """
524
+ max_tokens = 24
525
+ scheduler = _create_async_pp_scheduler(num_spec)
526
+ requests = create_requests(
527
+ num_requests=8, num_tokens=8, max_tokens=max_tokens, ignore_eos=True
528
)
360
- model_runner_output = ModelRunnerOutput(
361
- req_ids=[req.request_id],
362
- req_id_to_index={req.request_id: 0},
363
- sampled_token_ids=[[999]],
364
- logprobs=None,
365
- prompt_logprobs_dict={},
366
- pooler_output=[],
529
+ pending = list(requests)
530
+ for _ in range(2):
531
+ scheduler.add_request(pending.pop(0))
532
+
533
+ # Observe that the scenario under test actually occurs.
534
+ preempts_with_inflight_output = 0
535
+ orig_preempt = scheduler._preempt_request
536
+
537
+ def counting_preempt(request, timestamp, **kwargs):
538
+ nonlocal preempts_with_inflight_output
539
+ if request.num_in_flight_tokens > 0:
540
+ preempts_with_inflight_output += 1
541
+ return orig_preempt(request, timestamp, **kwargs)
542
+
543
+ scheduler._preempt_request = counting_preempt
544
+
545
+ def add_requests(step: int, engine: PipelinedEngine):
546
+ if pending:
547
+ scheduler.add_request(pending.pop(0))
548
+
549
+ engine = PipelinedEngine(
550
+ scheduler,
551
+ queue_size=4,
552
+ # Deterministically vary spec acceptance so stale outputs carry
553
+ # nonzero rejection counts.
554
+ accept_drafts=lambda step, req_id, n: (step + int(req_id)) % (n + 1),
555
)
556
+ engine.run(before_step=add_requests)
557
369
- scheduler.update_from_output(scheduler_output, model_runner_output)
558
+ assert preempts_with_inflight_output > 0, (
559
+ "test did not exercise preemption with in-flight output"
560
+ )
561
+ for req in requests:
562
+ assert req.is_finished()
563
+ assert req.num_output_tokens == max_tokens
564
+ # Lossless: delivered tokens are exactly the sampled tokens, in order
565
+ # (the excluded tail was emitted after the request finished).
566
+ emitted = engine.emitted[req.request_id]
567
+ assert list(req.output_token_ids) == emitted[:max_tokens]
568
+ _assert_positions_consistent(req, engine)
569
+
570
+
571
+@pytest.mark.parametrize("pp_size", [1, 3])
572
+def test_reset_prefix_cache_with_inflight_output_under_kv_pressure(pp_size: int):
573
+ """reset_prefix_cache(reset_running_requests=True) resumes requests in
574
+ the same step it preempts them, so in-flight output must be dropped (the
575
+ resume resamples those positions).
576
+
577
+ pp_size=1: regression for the frame-based discard this fix replaces,
578
+ which with spec decode drained one *token* count per output frame and
579
+ over-discarded, corrupting the fresh frames after the resume.
580
+ pp_size=3: back-to-back resets, so the second re-preempts requests whose
581
+ dropped stale share is still in flight -- it must be recorded once (not
582
+ accumulated) and stay dropped.
583
+ """
584
+ max_tokens = 24
585
+ scheduler = _create_async_pp_scheduler(num_spec=3, pp_size=pp_size)
586
+ requests = create_requests(
587
+ num_requests=8, num_tokens=8, max_tokens=max_tokens, ignore_eos=True
588
+ )
589
+ pending = list(requests)
590
+ for _ in range(2):
591
+ scheduler.add_request(pending.pop(0))
592
+
593
+ # Observe re-preemptions with an undrained stale share (the
594
+ # double-count hazard).
595
+ repreempts_with_stale = 0
596
+ orig_preempt = scheduler._preempt_request
597
+
598
+ def counting_preempt(request, timestamp, **kwargs):
599
+ nonlocal repreempts_with_stale
600
+ if getattr(request, "num_stale_output_tokens", 0) > 0:
601
+ repreempts_with_stale += 1
602
+ return orig_preempt(request, timestamp, **kwargs)
603
+
604
+ scheduler._preempt_request = counting_preempt
605
+
606
+ resets = 0
607
+ reset_steps = {6, 14} if pp_size == 1 else {6, 7, 18, 19}
608
+
609
+ def before_step(step: int, engine: PipelinedEngine):
610
+ nonlocal resets
611
+ if pending:
612
+ scheduler.add_request(pending.pop(0))
613
+ if step in reset_steps and (engine.queue or scheduler.running):
614
+ scheduler.reset_prefix_cache(reset_running_requests=True)
615
+ resets += 1
616
+
617
+ engine = PipelinedEngine(
618
+ scheduler,
619
+ queue_size=pp_size + 1,
620
+ accept_drafts=lambda step, req_id, n: (step + int(req_id)) % (n + 1),
621
+ )
622
+ engine.run(before_step=before_step)
623
371
- assert req.num_output_placeholders == 1
372
- assert req.num_computed_tokens == computed_before
373
- assert req.async_tokens_to_discard == num_spec - 1
374
- assert req.status == RequestStatus.RUNNING
624
+ assert resets > 0, "test did not exercise reset_prefix_cache"
625
+ if pp_size > 1:
626
+ # The re-preempt-while-stale-pending window needs pipeline depth.
627
+ assert repreempts_with_stale > 0, (
628
+ "test did not exercise re-preemption with an undrained stale share"
629
+ )
630
+ for req in requests:
631
+ assert req.is_finished()
632
+ assert req.num_output_tokens == max_tokens
633
+ # Dropped tokens are never delivered; order must be preserved with
634
+ # no duplicates.
635
+ _assert_ordered_subset(
636
+ list(req.output_token_ids), engine.emitted[req.request_id]
637
+ )
638
+ _assert_positions_consistent(req, engine)
639
+ # All stale shares fully drained by the end.
640
+ assert getattr(req, "num_stale_output_tokens", 0) == 0
tests/v1/core/test_scheduler.py
+16
-1
@@ -1107,7 +1107,7 @@ def test_preemption_re_records_prefix_cache_query():
1107
request = create_requests(num_requests=1)[0]
1108
scheduler.add_request(request)
1109
1110
- scheduler.schedule()
1110
+ scheduler_output = scheduler.schedule()
1111
stats = scheduler.kv_cache_manager.prefix_cache_stats
1112
assert stats is not None
1113
assert (stats.requests, stats.preempted_requests) == (1, 0)
@@ -1116,6 +1116,21 @@ def test_preemption_re_records_prefix_cache_query():
1116
scheduler._preempt_request(request, 0.0)
1117
assert request.status == RequestStatus.PREEMPTED
1118
1119
+ scheduler.update_from_output(
1120
+ scheduler_output,
1121
+ ModelRunnerOutput(
1122
+ req_ids=[request.request_id],
1123
+ req_id_to_index={request.request_id: 0},
1124
+ sampled_token_ids=[[1000]],
1125
+ logprobs=None,
1126
+ prompt_logprobs_dict={},
1127
+ pooler_output=[],
1128
+ ),
1129
+ )
1130
+ assert request.num_stale_output_tokens == 0
1131
+ stats = scheduler.kv_cache_manager.prefix_cache_stats
1132
+ assert stats is not None
1133
+
1134
scheduler.schedule()
1135
assert request.status == RequestStatus.RUNNING
1136
assert stats.preempted_requests == 1
vllm/v1/core/sched/async_scheduler.py
+6
-11
@@ -49,23 +49,18 @@ class AsyncScheduler(Scheduler):
49
request.next_decode_eligible_step = self.current_step + self.pp_size
50
51
def _update_request_with_output(
52
- self, request: Request, new_token_ids: list[int]
52
+ self, request: Request, new_token_ids: list[int], is_stale: bool = False
53
) -> tuple[list[int], bool]:
54
- if request.async_tokens_to_discard > 0:
55
- # The request was force-preempted in reset_prefix_cache; drop one
56
- # stale in-flight async output frame per call until the counter
57
- # is drained.
58
- request.async_tokens_to_discard -= 1
59
- return [], False
60
-
54
status_before_update = request.status
55
new_token_ids, stopped = super()._update_request_with_output(
56
request, new_token_ids
57
)
58
66
- # Update the number of output placeholders.
67
- request.num_output_placeholders -= len(new_token_ids)
68
- assert request.num_output_placeholders >= 0
59
+ # Placeholders were zeroed at preemption; a stale delivery must not
60
+ # decrement them (it would underflow).
61
+ if not is_stale:
62
+ request.num_output_placeholders -= len(new_token_ids)
63
+ assert request.num_output_placeholders >= 0
64
65
# Cache the new tokens. Preempted requests should be skipped.
66
if status_before_update == RequestStatus.RUNNING:
vllm/v1/core/sched/scheduler.py
+56
-29
@@ -694,6 +694,17 @@ class Scheduler(SchedulerInterface):
694
step_skipped_waiting.prepend_request(request)
695
continue
696
697
+ if (
698
+ request.num_stale_output_tokens > 0
699
+ and not request.drop_stale_output
700
+ ):
701
+ # Deliverable stale output still in flight: resuming now
702
+ # could resample a position that output later delivers.
703
+ # It drains within the pipeline depth.
704
+ request_queue.pop_request()
705
+ step_skipped_waiting.prepend_request(request)
706
+ continue
707
+
708
# Check that adding the request still respects the max_loras
709
# constraint.
710
if (
@@ -1244,11 +1255,17 @@ class Scheduler(SchedulerInterface):
1255
1256
return new_block_ids_to_zero or None
1257
1247
- def _preempt_request(self, request: Request, timestamp: float) -> None:
1258
+ def _preempt_request(
1259
+ self, request: Request, timestamp: float, drop_stale_output: bool = False
1260
+ ) -> None:
1261
"""Preempt a request and put it back to the waiting queue.
1262
1263
NOTE: The request should be popped from the running queue outside of this
1264
method.
1265
+
1266
+ drop_stale_output: drop (rather than deliver) any in-flight output; used
1267
+ by reset_prefix_cache, whose same-step resume would otherwise deliver
1268
+ tokens out of order.
1269
"""
1270
assert request.status == RequestStatus.RUNNING, (
1271
"Only running requests can be preempted"
@@ -1260,6 +1277,18 @@ class Scheduler(SchedulerInterface):
1277
request.num_computed_tokens = 0
1278
if request.spec_token_ids:
1279
request.spec_token_ids = []
1280
+ # Async scheduling: mark all in-flight output as stale. Its tokens are
1281
+ # still delivered on return (dropping them would perturb spec-decode
1282
+ # acceptance) but must not mutate the reset counters; each step drains
1283
+ # its share in update_from_output. num_in_flight_tokens already
1284
+ # includes any undrained stale share, so assign rather than accumulate.
1285
+ # An undrained drop-mode share stays dropped: its positions have
1286
+ # already been resampled.
1287
+ request.drop_stale_output = drop_stale_output or (
1288
+ request.drop_stale_output and request.num_stale_output_tokens > 0
1289
+ )
1290
+ request.num_stale_output_tokens = request.num_in_flight_tokens
1291
+ request.num_output_placeholders = 0
1292
request.num_preemptions += 1
1293
if self.log_stats:
1294
request.record_event(EngineCoreEventType.PREEMPTED, timestamp)
@@ -1687,8 +1716,14 @@ class Scheduler(SchedulerInterface):
1716
for req_id, num_tokens_scheduled in num_scheduled_tokens.items():
1717
assert num_tokens_scheduled > 0
1718
request = self.requests.get(req_id)
1719
+ output_is_stale = False
1720
if request is not None:
1721
request.num_in_flight_tokens -= num_tokens_scheduled
1722
+ # Drain any stale share (see _preempt_request) in lockstep.
1723
+ if request.num_stale_output_tokens > 0:
1724
+ output_is_stale = True
1725
+ request.num_stale_output_tokens -= num_tokens_scheduled
1726
+ assert request.num_stale_output_tokens >= 0
1727
if failed_kv_load_req_ids and req_id in failed_kv_load_req_ids:
1728
# skip failed or rescheduled requests from KV load failure
1729
continue
@@ -1702,6 +1737,10 @@ class Scheduler(SchedulerInterface):
1737
# In this case, we use is_finished() to check.
1738
continue
1739
1740
+ # Drop-mode stale output (same-step resume) is discarded entirely.
1741
+ if output_is_stale and request.drop_stale_output:
1742
+ continue
1743
+
1744
req_index = model_runner_output.req_id_to_index[req_id]
1745
generated_token_ids = (
1746
sampled_token_ids[req_index] if sampled_token_ids else []
@@ -1710,28 +1749,22 @@ class Scheduler(SchedulerInterface):
1749
scheduled_spec_token_ids = (
1750
scheduler_output.scheduled_spec_decode_tokens.get(req_id)
1751
)
1713
- # Skip a stale frame still pending discard (async_tokens_to_discard
1714
- # > 0): its pre-reset rejection count would underflow the counters.
1715
- if (
1716
- scheduled_spec_token_ids
1717
- and (generated_token_ids or self.num_sampled_tokens_per_step == 0)
1718
- and request.async_tokens_to_discard == 0
1752
+ if scheduled_spec_token_ids and (
1753
+ generated_token_ids or self.num_sampled_tokens_per_step == 0
1754
):
1755
num_draft_tokens = len(scheduled_spec_token_ids)
1756
num_sampled = self.num_sampled_tokens_per_step
1757
num_accepted = max(len(generated_token_ids) - num_sampled, 0)
1758
num_rejected = num_draft_tokens - num_accepted
1724
- # num_computed_tokens represents the number of tokens
1725
- # processed in the current step, considering scheduled
1726
- # tokens and rejections. If some tokens are rejected,
1727
- # num_computed_tokens is decreased by the number of rejected
1728
- # tokens.
1729
- if request.num_computed_tokens > 0:
1730
- request.num_computed_tokens -= num_rejected
1731
- # If async scheduling, num_output_placeholders also includes
1732
- # the scheduled spec tokens count and so is similarly adjusted.
1733
- if request.num_output_placeholders > 0:
1734
- request.num_output_placeholders -= num_rejected
1759
+ # Rejections roll back num_computed_tokens (and, under async
1760
+ # scheduling, num_output_placeholders, which covers the spec
1761
+ # tokens). A stale rejection count predates the preemption
1762
+ # rollback and must not apply.
1763
+ if not output_is_stale:
1764
+ if request.num_computed_tokens > 0:
1765
+ request.num_computed_tokens -= num_rejected
1766
+ if request.num_output_placeholders > 0:
1767
+ request.num_output_placeholders -= num_rejected
1768
spec_decoding_stats = self.make_spec_decoding_stats(
1769
spec_decoding_stats,
1770
num_draft_tokens=num_draft_tokens,
@@ -1757,7 +1790,7 @@ class Scheduler(SchedulerInterface):
1790
# Check for stop and update request status.
1791
if new_token_ids:
1792
new_token_ids, stopped = self._update_request_with_output(
1760
- request, new_token_ids
1793
+ request, new_token_ids, is_stale=output_is_stale
1794
)
1795
elif request.pooling_params and pooler_output is not None:
1796
# Pooling stops as soon as there is output.
@@ -1899,6 +1932,7 @@ class Scheduler(SchedulerInterface):
1932
if stopped_preempted_reqs:
1933
# This is a rare case and unlikely to impact performance.
1934
self.waiting.remove_requests(stopped_preempted_reqs)
1935
+ self.skipped_waiting.remove_requests(stopped_preempted_reqs)
1936
1937
error_req_ids = set(self.grammar_compile_error_reqs)
1938
self.grammar_compile_error_reqs.clear()
@@ -2041,8 +2075,9 @@ class Scheduler(SchedulerInterface):
2075
return False
2076
2077
def _update_request_with_output(
2044
- self, request: Request, new_token_ids: list[int]
2078
+ self, request: Request, new_token_ids: list[int], is_stale: bool = False
2079
) -> tuple[list[int], bool]:
2080
+ # is_stale is only used by the AsyncScheduler override.
2081
# Append generated tokens and check for stop. Note that if
2082
# a request is still being prefilled, we expect the model runner
2083
# to return empty token ids for the request.
@@ -2388,15 +2423,7 @@ class Scheduler(SchedulerInterface):
2423
# running queue in FIFO order.
2424
while self.running:
2425
request = self.running.pop()
2391
- self._preempt_request(request, timestamp)
2392
- # For async scheduling, any output frames already in flight at
2393
- # preemption time are now stale and must be discarded when they
2394
- # return. num_output_placeholders is exactly that count: 0 if
2395
- # the engine has drained (e.g. pause_generation(keep) waited
2396
- # for idle), 1 for vanilla async mid-step, or 1 + spec/PP frames
2397
- # otherwise.
2398
- request.async_tokens_to_discard = request.num_output_placeholders
2399
- request.num_output_placeholders = 0
2426
+ self._preempt_request(request, timestamp, drop_stale_output=True)
2427
2428
# Clear scheduled request ids cache. Since we are forcing preemption
2429
# + resumption in the same step, we must act as if these requests were
vllm/v1/request.py
+6
-1
@@ -149,7 +149,12 @@ class Request:
149
150
# Used in async scheduling.
151
self.num_output_placeholders = 0
152
- self.async_tokens_to_discard = 0
152
+ # Tokens of output in flight when the request was preempted: delivered
153
+ # on return, but must not mutate the reset counters.
154
+ self.num_stale_output_tokens = 0
155
+ # Drop the stale output instead, for same-step preempt + resume
156
+ # (reset_prefix_cache).
157
+ self.drop_stale_output = False
158
159
# Tokens of steps whose output is not yet processed (async scheduling
160
# and PP run ahead of the GPU); `num_computed_tokens` counts them