[Bugfix][Spec Decode][Structured Output] DSpark: fix the grammar bitmask mapping when the draft budget is zero - #52436
Conversation
Adaptive verification with structured outputs crashes when the chosen draft budget is zero. The scheduler sizes the grammar bitmask from the scheduled drafts (len(drafts) + 1 rows per request), but the zero-budget branch of compact_batch rewrites cu_num_logits_np to the exact bonus-only layout for _iter_request_chunks. apply_grammar_bitmask derived its bitmask -> logits mapping from those rewritten offsets, producing one row per request instead of one per scheduled draft, and tripping the `num_masks == len(mapping)` assert. The other two budget regimes keep cu_num_logits_np at the scheduled layout, so the old expression happened to yield the right row count there; only the zero-budget rewrite changes that field's meaning. Size the mapping from num_draft_tokens_per_req + num_bonus_tokens instead. That comes from the same scheduled_spec_decode_tokens the scheduler used and is never touched by compaction, so all three regimes agree. Rows the compacted device layout has no room for are already masked by position_is_active in the kernel. Signed-off-by: oops-oom <73481342@qq.com> Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
There was a problem hiding this comment.
Thanks for the PR; great catch! im wondering if theres a way to fix this without having to branch on num_draft_tokens_per_req experimenting with that here: #52477 but overall I think this approach is reasonable (we can land this in the interim)
|
✅ @oops-oom, CI is now available for this PR.
|
|
/ci run |
|
✅ Triggered Buildkite CI #84063 for commit |
|
Hi @oops-oom, the pre-commit checks have failed. Please run: uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-filesThen, commit the changes and push to your branch. For future commits, |
|
/ci retry |
|
✅ Queued 1 failed job(s) for retry in Buildkite CI #84063. |
|
@LucasWilkinson Thanks for the review, and for the |
|
/ci run |
|
✅ Triggered Buildkite CI #84068 for commit |
|
/ci retry |
|
✅ Queued 1 failed job(s) for retry in Buildkite CI #84068. |
|
/ci retry |
|
✅ Queued 1 failed job(s) for retry in Buildkite CI #84068. |
|
/ci retry |
|
✅ Queued 1 failed job(s) for retry in Buildkite CI #84068. |
|
/ci run |
|
✅ Triggered Buildkite CI #84079 for commit |
|
Hi @oops-oom, the pre-commit checks have failed. Please run: uv pip install pre-commit>=4.5.1
pre-commit install
pre-commit run --all-filesThen, commit the changes and push to your branch. For future commits, |
|
/ci run |
|
✅ CI is already running for this commit: https://buildkite.com/vllm/ci/builds/84079 |
|
/ci cancel |
|
✅ Requested cancellation of 1 CI build for |
|
/ci run |
|
✅ Triggered Buildkite CI #84080 for commit |
|
/ci cancel |
|
✅ Requested cancellation of 1 CI build for |
|
/ci run |
|
✅ Triggered Buildkite CI #84082 for commit |
The pre-commit run failed on a transient PyPI 502 while resolving opentelemetry-sdk; no code hooks reported issues. Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: oops-oom <73481342@qq.com>
|
/ci run |
|
✅ Triggered Buildkite CI #84084 for commit |
…ask mapping when the draft budget is zero (vllm-project#52436) Signed-off-by: oops-oom <73481342@qq.com> Co-authored-by: oops-oom <73481342@qq.com> Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com> Signed-off-by: Zhu, Zufang <zufang.zhu@intel.com>
Purpose
Fixes a crash when DSpark adaptive verification (
enable_adaptive_verification, added in #47808) is combined with structured outputs and the chosen draft budget is zero.Scenario: serving a structured-outputs request (JSON schema / grammar) with DSpark adaptive verification enabled crashes on an assert as soon as the drafter's confidence drops far enough that adaptive verification decides to verify zero drafts this step.
When is
draft_budget == 0? (not an edge case)That is not an exotic corner. Zero is the routine choice whenever drafts stop paying for their verification cost. Per step, the controller picks
draft_budget = argmax_b (est_accepted_tokens(b)/cost(b))overb ∈ [0, max_draft_budget](adaptive_verification.py:329).b = 0("verify no drafts, sample only the bonus token") is a normal output of that argmax, selected whenever verifying even the most-confident draft is net-negative for throughput — i.e. whenever the drafter's confidence is low. That happens routinely (high-entropy tokens, warm-up, grammar-constrained steps), so a sustained DSpark + structured-outputs workload hits it repeatedly and the crash reproduces under ordinary load. This is exactly the behavior #47808 introduced.Root cause
The scheduler and the worker disagree on how many grammar bitmask rows exist:
len(drafts) + 1rows per request (vllm/v1/structured_output/__init__.py), and it has no knowledge of the device-side budget trimming.compact_batch()'s zero-budget branch rewritescu_num_logits_npto the exact bonus-only layout (adaptive_verification.py:361). That rewrite exists to serve_iter_request_chunks, which slices the compacted logits with these offsets.apply_grammar_bitmaskderived its bitmask → logits mapping from those rewritten offsets, so it produced one row per request instead of one per scheduled draft and trippedassert num_masks == len(mapping)(structured_outputs.py:102).For a batch of 2 verification requests with 2 drafts each plus 1 bonus token, the scheduler emits 6 rows while the old expression yields 2:
Why the other two budget regimes were unaffected
Only the zero-budget branch rewrites
cu_num_logits_np; the other two return it untouched, still holding the scheduled layout that the scheduler used. Exercising all three branches through the realcompact_batch:cu_num_logits_npbudget == num_drafts[0, 3, 6]0 < budget < num_drafts[0, 3, 6]budget == 0[0, 1, 2]So the old code was correct in two regimes only because that field happened to carry scheduled-layout semantics there.
Fix
Size the mapping from
num_draft_tokens_per_req + num_bonus_tokensinstead. That array comes from the samescheduled_spec_decode_tokensthe scheduler used (model_runner.py:1132) and is never touched by compaction, so all three regimes agree and the row count no longer depends on a field whose meaning shifts.Rows that the compacted device layout has no room for are already masked by
position_is_activein the kernel (structured_outputs.py:144), which resolves true per-request offsets from the GPU-sidecu_num_logits. This is the mechanism #47808 introduced; the fix just stops the host side from second-guessing it.Test Plan
Added
test_zero_budget_keeps_one_grammar_row_per_scheduled_drafttotests/v1/spec_decode/test_adaptive_verification.py..venv/bin/python -m pytest tests/v1/spec_decode/test_adaptive_verification.py -v # related suites touching the same cu_num_logits_np contract: .venv/bin/python -m pytest tests/v1/worker/test_gpu_rejection_sampler_chunking.py \ tests/v1/worker/test_gpu_batch_ordering.py -v pre-commit run --all-filesTest Result
Verified the new test actually fails without the fix by reverting only the helper body while keeping its signature: