[Bugfix] MTP: emit one spec-decode row per decode seq to fix IndexError - #1444
Merged
Conversation
A chunked (prompt-not-done) prefill can be popped by the decode loop when
the cross-DP PrefillDelayer vetoes prefill for a tick: Phase 1/2 are
skipped, num_seqs_prefill==0, so the prefill-only early return doesn't
fire. Such a partial was re-queued at the HEAD of `running` (extendleft),
pinning it at running[0]. Once it finishes prefill it becomes the batch's
position-0 deferred seq, shifting the fresh decode seqs to positions 1..N;
TokenIDProcessor.prepare_input_ids then takes the [deferred | new] path and
indexes the compacted scheduled_spec_decode_tokens array by those shifted
positions, running off the end:
IndexError: index N is out of bounds for axis 0 with size N
Fix: re-queue skipped partial prefills at the TAIL (extend), so they never
occupy position 0 and the new decode seqs stay contiguous from 0 (safe
[new | deferred] slice path). Their prefill still resumes: Phase 1 scans
all of `running`.
Add tests/test_scheduler_partial_prefill_tail.py driving the real
Scheduler.schedule() with a vetoing delayer, asserting the skipped partial
lands at the running tail.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Signed-off-by: yhl-amd <yhl-amd@users.noreply.github.com>
yhl-amd
force-pushed
the
fix/mtp-spec-decode-index-dpa
branch
from
July 3, 2026 16:30
b2e9a4f to
e8b6b23
Compare
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Signed-off-by: yhl-amd <yhl-amd@users.noreply.github.com>
valarLip
approved these changes
Jul 4, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Motivation
The scheduled DeepSeek-V4-Pro DPA MTP3 8k1k benchmark crashes during decode:
(all 8 DP ranks die → benchmark step fails; e.g. run 28461535343).
Technical Details
Scheduleronly records ascheduled_spec_decode_tokensentry for decode seqswhose
spec_token_idsis non-empty:ScheduledBatch.__init__then turns that dict into a positional array vianp.asarray(list(values())), so its length is number of drafting seqs, notnumber of decode seqs. But
TokenIDProcessor.prepare_input_idsindexes it byfull-batch sequence positions (
new_curr_indices) in the[deferred | new]layout. A decode seq without drafts — e.g. one that just transitioned from
prefill and sits at the batch tail — makes the array shorter than the batch, and
the positional index runs off the end.
Failure flow
Root mismatch: the scheduler numbers the array by drafting-seq order
(compacted,
0..126), while the model runner indexes it by batch position(
127). The dropped seq sits at the tail, so the lookup overruns.Regression history (bisected)
This is a latent defect from #219 exposed by a later scheduling change,
not a same-PR break. The
DeepSeek-V4-Pro DPA MTP3 8k1k / c=1024job passed on2026-06-26/27/28 and started failing on 2026-06-30:
scheduled_spec_decode_tokensfrom a dict (consumed missing-safe via.get(req_id, [])) into a positional numpy array while keeping thesize > 0filter that drops rows. A draft-less decode seq is harmless unless it lands
at the batch tail.
scheduler.py/model_runner.pyin the passing→failing window. Its scheduler refactorchanged prefill/decode admission ordering (new
skipped_partial_prefills+running.extendlefthandling), which — with 8k chunked prefill — routes afreshly-prefilled, draft-less seq to the
[deferred | new]tail position andinto the positional-index path, triggering the latent bug.
bisect across the two SHAs; the fix here is at the contract layer so it holds
regardless of scheduling order.)
The
--enable-dp-attention --method mtp+ long-prefill (8k) + high-concurrencycombination is what makes the tail layout common (all three are necessary; the
same job with 1k input, or without MTP, passes).
Prior-art note: the pre-#219 code may have been latently broken too
While bisecting we also looked at the original (pre-#219) implementation, and it
looks like the draft-less decode seq was never handled fully correctly there
either -- it just wasn't reachable:
mtp_k + 1tokens for everydecode seq, regardless of whether it has drafts (
num_new_tokens = self.mtp_k + 1is unconditional, outside the
if seq.spec_token_idsgate). So the computewidth per decode seq is identical across all versions; this fix adds no extra
compute for the placeholder seq -- those token slots were already scheduled.
scheduled_spec_decode_tokenswas a dict consumed via.get(req_id, []), and the[deferred | new]branch didnew_token_ids.extend([tokens[-1]] + draft_tokens). For a draft-less seq thatyields 1 token, while the scheduler reserved
mtp_k + 1slots for it -- alatent length mismatch. It appears this never fired because the
[deferred | new]tail layout with a draft-less seq did not occur until[Feature] OFFLOAD: standalone LMCache CPU/NVMe KV-offload connector #1318's scheduling change (consistent with the bisect: the job was green
through 06-28).
In other words, "a draft-less decode seq flowing through the
[deferred | new]path" was never actually exercised before; #219 turned the dict into a positional
array (making the missing row fatal instead of silently
.get-defaulted), and#1318 first made that path reachable. This fix handles the case explicitly at the
contract layer, so it is correct regardless of which path (front/tail) or
scheduling order produces a draft-less decode seq.
Fix
Give every decode seq a row. A seq with no drafts does not accept draft
tokens this step (
num_new_token + num_rejected == 1), so the draft-slot valuesare don't-cares; reuse the seq's own scheduled decode tokens (guaranteed valid
token ids). Rows for seqs that already had drafts are unchanged, so the common
path is byte-for-byte identical and the array is now dense
(num_decode_seqs, mtp_k).Test Plan / Result
IndexError: index 127 ... size 127against the realScheduledBatchon CPU (compacted(127, 3)array indexed at position 127).tests/test_scheduler_spec_decode_dense.pydriving the realScheduler.schedule()decode path:(1, 3)→AssertionError(the compaction bug)(2, 3), positional indexing of every batchposition succeeds, drafting seqs' rows unchanged.
pytest tests/test_scheduler.py tests/test_arg_utils_spec.py tests/test_sequence.py→ 66 passed (no regressions).
GPUs were fully occupied); the crashing operation is pure CPU numpy and is
covered by the repro + regression test above. Happy to attach an e2e serve log
once GPUs free up.