Skip to content

feat(spec): capture the Qwen3.8 MTP draft steps in CUDA graphs (MRoPE) - #2663

Merged
zhuyuhua-v merged 14 commits into
mainfrom
lingzha/qwen38-mtp-draft-graph-mrope
Nov 6, 2026
Merged

zhuyuhua-v merged 14 commits into
mainfrom
lingzha/qwen38-mtp-draft-graph-mrope

Conversation

@ZLkanyo009

@ZLkanyo009 ZLkanyo009 commented Oct 21, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Capture the Qwen3.8 MTP draft's steps 1..k-1 in CUDA graphs. On main, EagleProposer._declare_draft_graphs declares nothing for an MRoPE model, so every Qwen3.8 draft step runs eagerly.

This removes MRoPE's carve-out from the draft-graph mechanism: StagedInput assumed a leading batch axis, and MRoPE positions are [3, N].

On main d176cb98a, measured one server at a time on one GPU over 16 alternating rounds:

  • At bs=1 a decode iteration goes from 9.07 ms to 8.13 ms (−9.7%).
  • At bs=2 it is −1.7%.
  • At bs 4, 8 and 16 there is no difference beyond noise (−0.1%, +0.5%, +0.6%, none significant).

On current main this gain is gone at bs=1. Since #2610 Qwen3.8 decode at bs ≤ 4 runs on mono, whose own draft program already removes the eager draft's host time: at ab4ada40a, bs=1 is 4.41 ms on main and 4.44 ms with this PR (three alternating sessions each; +0.7%, smaller than the session spread). bs ≥ 5 has not been re-measured on current main. The numbers below are from the older main.

Main's eager draft got cheaper since this PR was first opened (11.21 → 8.08 ms then; among others #2688, and #2670 combining the status D2H), so above bs=2 the host time the graphs remove is hidden behind the GPU anyway. Numbers and a profile are below.

Motivation

This is part of the Qwen3.8-Flash-Next (amd/Qwen3.8-Flash-Next-Quark-MXFP4) work on MI355X, with the goal of matching the GB300 AgentX curve at low concurrency: https://github.com/wuhuikx/LLM-Booster/issues/263

At bs=1 each of the three eager draft steps launches about 60 kernels. A torch profile on main still shows the GPU idle for about 3.1 ms per iteration, almost all of it inside the draft and between the draft and the next decode; with this PR that idle is about 0.1 ms. The profiler inflates host time, so without it the exposed part on main is about 0.9 ms per iteration (the bs=1 wall-time difference).

Changes

The recording and replay are main's DraftGraph on ReqGraph (#2688), unchanged. This PR only lets the staging layer take an input whose batch is not its leading axis, and opts the Qwen3.8 MTP head in.

  • atom/spec_decode/draft_graph.py
    • StagedInput gains batch_dim (default 0, so every other drafter's inputs are unchanged): the axis the batch sits on.
    • bind() allocates max_num_seqs at that axis, and stage(), buffer() and the tail padding all narrow along it. There is one code path for every layout; nothing in DraftGraph branches on it.
    • MRoPE positions ([3, n], batch_dim=1) live in a [3, max_num_seqs] buffer; each batch is narrow(1, 0, bs), a [3, bs] view with stride (max_num_seqs, 1). Padding repeats the last column.
    • buffer() on a batch_dim=1 role returns the front of the staged view at the same address and stride, so a producer can fill it in place like any other role.
    • stage() names an undeclared role and the declared ones, as buffer() already did, instead of raising a bare KeyError.
  • atom/spec_decode/eagle_proposer.py: an MRoPE model gets the mid-step draft graph when its draft model declares draft_graph_supports_mrope = True, with positions staged at batch_dim=1. Other MRoPE models are unchanged and still run the draft eagerly. Qwen3.5 MTP (qwen3_5_mtp.py) is not opted in: its draft graph has not been validated on GPU.
  • atom/models/qwen4_exp_mtp.py: Qwen4ExpMTP sets main's existing draft-graph hooks:
    • draft_graph_supports_mrope = True,
    • reuse_draft_graph_step_buffers = True,
    • draft_graph_hidden_state_shape = (hc_count, hidden_size). The draft carries the whole hyper-connection bundle between steps.
  • Tests:
    • tests/test_draft_graph.py: batch_dim=1 positions staged as a [3, bs] view of the fixed buffer (stride (max_num_seqs, 1)) with the last column repeated, beside an in-place-filled leading input; buffer() on a batch_dim=1 role is the front of the staged view and staging a fill of it pads after it; an undeclared role named in the error; batch_dim=1 and leading sources disagreeing on the batch. Main's test for a source whose leading axis is not the batch now matches the message's "batch at axis 0".
    • tests/test_qwen4_exp_mtp.py: the real Qwen4ExpMTP class through EagleProposer._declare_draft_graphs under MRoPE (positions declared StagedInput(shape=(3,), dtype=int64, batch_dim=1), (hc_count, hidden) hidden states, buffer reuse on), and an MRoPE draft that has not opted in still declares nothing.

Every reader of the draft's positions takes the strided view: row stride(0) and token stride(-1) go to the kernel, or only shape[-1] is read. The audited readers are listed in #2663 (comment). Main's target decode already hands Qwen4ExpAttention a [3, tokens] view with stride running_tokens (backends.py, pinned by test_decode_mrope_storage_matches_padded_graph_stride), and an uncaptured batch runs eagerly on the same staged view, so a replay and an eager step see the same layout.

Dependency: ROCm/aiter#6833

The draft head's topk_select(k=1) is recorded in these graphs. Before ROCm/aiter#6833, topk_select(end=None) took its row lengths from an 8-entry LRU, so capturing more than 8 batch sizes freed a tensor an earlier graph had baked in (wrong draft ids, then a memory fault). An earlier revision of this PR worked around that in embed_head.py. #6833 fixes it at the root (no length tensor is read when end is None), so the workaround is gone and embed_head.py is untouched.

#6833 is in aiter main since 2026-10-22 and in the wheel ATOM CI installs (f40355cd5). A capture-then-replay probe of topk_select(k=1) at 12 batch sizes against torch.argmax gives 0 mismatches on the wheel used below.

Setup

MI355X, TP1, image rocm/atom-dev:nightly_202610221638; FP8 KV, block 64, PTPC, MTP 3, no offload.

  • Wall time and profile (the Performance section): one GPU, one server at a time.
  • Accuracy and AgentX C1: main f324de48 and this PR merged with it, run at the same time on two GPUs of one node.
    • That shares the host CPU, the resource this PR saves, so those runs are only good for accuracy and a rough C1 comparison.
    • The earlier bs 1/2/4 wall times from that setup (bs=1: 8.98 / 8.75 ms) understated the bs=1 gain, and are superseded by the table below.

Two aiter builds:

Accuracy (earlier head, older main f324de48; for this head see Test plan)

GSM8K, real MTP-3 verification (no forced acceptance), 1319 questions, 5-shot chat, greedy:

concurrency strict acceptance tokens / forward
main 64 0.9765 72.73% 3.182
this PR 64 0.9757 72.11% 3.163
main (tuned) 1 0.9780 72.72% 3.182
this PR (tuned) 1 0.9765 72.36% 3.171

The outputs are not token-identical between the two, but that is true of main against itself. Even at concurrency 1 with greedy decoding, two runs of main on the first 300 questions give identical text for only 17 of 300; this PR against main gives 19 of 300. The divergence comes from near-ties in the target's verify logits.

To check whether the graphs change the drafts, split acceptance by draft position. Draft token 0 is produced eagerly in both trees; only steps 1..k-1 are graphed.

P(token 0 accepted) P(token 1 | token 0) P(token 2 | token 1)
main, conc 64 84.38% 85.97% 84.45%
this PR, conc 64 83.96% 85.58% 84.24%
main, conc 1 84.33% 85.98% 84.60%
this PR, conc 1 84.10% 85.77% 84.37%
main run A, 300 q 84.50% 85.19% 84.16%
main run B, 300 q 84.11% 85.54% 84.27%

The ungraphed token 0 differs by as much as the graphed tokens 1 and 2, and two runs of main differ by the same amount. A wrong replay would show up as a drop in tokens 1 and 2 only.

Performance (earlier head, older main d176cb98a; for this head see Test plan)

Forced acceptance length 2.32 (AgentX rules).

Decode iteration wall time, tuned aiter, one server at a time (bs concurrent streams, 64k-token random prompts, 4000 output tokens, median chunk interval while all are decoding). Each value is the median and range over 16 sessions per side. Paired Δ = this PR / main − 1 within a round:

bs main (ms) this PR (ms) paired Δ, mean ± SE PR slower / faster sign-flip p
1 9.07 (8.73–9.25) 8.13 (8.10–8.61) −9.7% ± 0.4% 0 / 16 < 0.001
2 9.07 (8.92–9.29) 8.95 (8.80–8.98) −1.7% ± 0.4% 3 / 13 0.001
4 10.31 (10.05–10.93) 10.31 (10.17–10.44) −0.1% ± 0.5% 8 / 8 0.85
8 12.29 (12.04–12.60) 12.36 (12.12–12.61) +0.5% ± 0.4% 9 / 7 0.23
16 15.50 (15.07–15.77) 15.46 (15.27–16.00) +0.6% ± 0.6% 9 / 7 0.29

Once a size is captured, the draft replays it unconditionally, so the staging cost is paid at every captured size. That cost is small:

  • Each graphed step makes one D2D copy of input_ids, about 2 µs on the GPU. The hidden states already live in the staged buffer.
  • At bs 16 the GPU is saturated either way. There the branch costs about 60 µs per iteration under the profiler (+0.4%), mostly gaps around the two graph launches. That is below the session-to-session noise.

Profile, tuned aiter (torch profiler, 3 s of steady decode per bs, averaged per iteration). The profiler inflates host time, so compare within a pair only:

bs GPU wall (µs) busy idle draft kernels
1 main 12,160 9,074 3,086 1,570
1 this PR 9,263 9,146 117 1,608
8 main 14,668 13,346 1,323 2,088
8 this PR 14,312 14,182 130 2,480
16 main 16,437 16,346 92 2,483
16 this PR 16,498 16,371 127 2,472

At bs 8 the branch's draft kernels run longer under the profiler: they are the same kernels, back to back, while main's are spread out by profiler-inflated host gaps. Without the profiler the bs 8 wall time is the same.

AgentX C1, 1 hour each (inferencex-agentx-mvp, seed 42; main f324de48, both servers running at the same time):

aiter tput/GPU p90 norm interactivity p90 ITL interactivity ITL p50 / p90 (ms) requests (errors)
CI wheel main 13,476 99.75 182.13 4.55 / 5.49 538 (0)
CI wheel this PR 13,368 101.11 180.47 4.56 / 5.54 539 (0)
tuned main 14,398 113.89 213.40 4.15 / 4.69 557 (0)
tuned this PR 14,360 115.08 217.97 3.91 / 4.59 557 (0)

Not changed

  • Qwen3.5 MTP (qwen3_5_mtp.py) and other MRoPE drafts on attention_mha are not opted in. Before one is, try_mrope_qk_fused's fallback rotary_emb and triton_fused_qkv_norm_rope_cache (which assumes stride(-1) == 1) need checking for a strided [3, bs].
  • With mono on, the S1 address check in draft_runner.py no longer runs at the captured bs 1/2/4 (it is a diagnostic, not a correctness check).
  • tests/test_qwen4_exp_mtp.py still skips on CPU CI (module-level aiter / triton imports); moving the declaration test means reworking those imports.
  • TP>1 / DP attention with this draft graph is not tested.

How to use

There is no new switch. Qwen3.8 with MTP captures the draft graph whenever draft CUDA graphs are enabled (the default). ATOM_DRAFT_CUDAGRAPH=0 still disables them.

Test plan

Validation of the current head (19cc0e7ea, after the batch_dim refactor). The Accuracy and Performance sections above are from an earlier head on an older main.

At 19cc0e7ea (this head), MI355X, image rocm/atom-dev:nightly_202610221638, aiter wheel 4d1980603 from the main manifest:

  • black 26.10.1 and ruff 0.17.2 are clean.
  • Full CPU unit suite (.github/scripts/run_unit_tests.sh): 4 failed / 11195 passed / 1163 skipped. Three failures (test_graph_marker ×2, test_sparse_indexer_side_effect_op) are main's on the same host. The fourth, test_multiprocess_metrics, is a spawned metrics process exiting 1 under the suite's load; it passes alone (3 passed) and does not touch draft graphs.
  • GPU unit tests: test_qwen4_exp_mtp.py, test_draft_graph.py, test_qwen38_mono_prep.py, test_qwen38_mono_ple.py, test_region_graph.py, test_graph_holders.py: 121 passed / 0 failed / 0 skipped. The 18 draft graph / MTP / DSpark / region graph / mono test files (the above plus test_glm5_next_mtp_routing.py, test_dspark_integration.py, test_lm_head_argmax_out.py, test_mtp_draft_warmup_workplan.py, test_mtp_draft_kv_allocation.py, test_gdn_draft_kv_slots.py, test_h2d_draft_publication.py, test_prepare_mtp_decode_contract.py, test_cudagraph_capture_bounds.py, test_dspark.py, test_model_runner_staging_fence.py, test_rollout_memory_manager_sleep.py): 337 passed / 19 skipped / 0 failed (main ab4ada40a: 331 / 19 / 0).
  • Replay against eager on the same inputs (a probe around DraftGraph.run; GSM8K, concurrency 16, mono on and off, every batch size 1–16 that occurred). Each replayed step was snapshotted and re-run: replayed again, run eagerly on the same staged [3, bs] view, and run eagerly with positions.contiguous(). Re-running a step is not bit-reproducible on this stack, in eager as much as in replay: two eager runs of one step on the same inputs never give bit-identical hidden states, and give different draft ids in 1–6% of steps. Replay against eager, and the strided view against a contiguous copy, differ exactly as much as these controls (e.g. this head, mono on, bs 5–16: draft ids equal in 672/708 replays against eager, 674/708 strided against contiguous, 672/708 replay against replay, 685/708 eager against eager). The previous head's flat layout (this branch at 9cd4ea52e, before the refactor) gives the same numbers.
  • GSM8K, all 1319 questions, concurrency 64, real MTP-3 verification: this head 0.9788 strict, acceptance 72.56%, 3.177 tokens / forward; the branch before the refactor (9cd4ea52e) 0.9795, 72.18%, 3.165. Within run-to-run spread (72.1–72.7% across earlier runs).
  • bs=1 decode iteration, wheel aiter, forced acceptance 2.32, one server at a time on one GPU, three alternating sessions each (median, range): this head 4.41 ms (4.37–4.45), before the refactor 4.42 ms (4.30–4.47), so the refactor costs nothing. Current main ab4ada40a against this head, three alternating sessions each: 4.41 ms (4.34–4.42) against 4.44 ms (4.44–4.46); see the Summary. aea839bcc (9.44 ms) and 528b871be (9.46 ms) sit on the older main, before perf(qwen): integrate Mono decode and optimized TP1 MTP3 serving #2610 put Qwen3.8 decode on mono, so they are not comparable with the current tree.
  • Earlier, on the older main and with the previous layout: GSM8K and AgentX C1 on main f324de48, bs 1/2/4/8/16 decode wall time and profiles on main d176cb98a, as in the sections above.

ZLkanyo009 and others added 2 commits October 21, 2026 06:12
Co-authored-by: Cursor <cursoragent@cursor.com>
@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every eligible PR before approval:

  • ✅ Pre Checkin: Black, Ruff, catalog schema validation, non-GPU unit tests

Heavy model tests:

  • ✅ Run after the PR is approved and Pre Checkin passes
  • ✅ Run immediately when an approval review is submitted
  • ✅ Can be requested before approval with labels
Label Tests
ci:full Run all heavy PR model tests: native ATOM, vLLM, and SGLang
ci:atom Run native ATOM model accuracy tests
ci:vllm Run ATOM vLLM OOT model accuracy tests
ci:sglang Run ATOM SGLang model accuracy tests

Heavy jobs are skipped when the PR is not approved and no matching ci:* label is present.
Add labels via the sidebar or gh pr edit 2663 --add-label <label>

@zovonoir

Copy link
Copy Markdown
Contributor

Hi, it seems this feature enable MTP graph on ATOM standalone server, not sglang-plugin, I am not sure if it will conflict with #2645

@yitingw1

Copy link
Copy Markdown
Contributor

Read it end to end. Clean PR — small, focused, and the accuracy table is the right kind of evidence: acceptance identical bucket-by-bucket (72.36%, 3.171 toks/fwd, 0/1/2/3 distribution all within 0.1pp) proves the draft proposes the same tokens, which a single aggregate score wouldn't. I checked the load-bearing claim and it holds: buf[:3*bs].view(3, bs) is the same layout as the runner's _mrope_positions_view = as_strided((3,n),(n,1)), so the kernels inside the graph see exactly what eager hands them. Choosing a flat buffer over a [3, max_num_seqs] sliced [:, :bs] matters for precisely that reason — worth a sentence in the code, since the simpler-looking option is the wrong one.

Nothing blocking. Four small things.

1. _argmax_row_lens keys the cache on rows but the value encodes width.
aiter keys _full_rows on (rows, width, device). Today width = vocab/tp is constant per head instance so this is safe, but if it ever isn't, the failure is an argmax silently truncated to a stale row length — no error, just wrong ids, which is the same symptom class the PR is fixing. Key on (rows, width), or add a cheap assert lens[0] == width.

2. self.__dict__.setdefault("_row_lens_by_rows", {}) doesn't need to dodge __setattr__.
A plain dict isn't a Tensor/Parameter/Module, so nn.Module.__setattr__ already falls through to object.__setattr__. self._row_lens_by_rows = {} in __init__ reads the same, is greppable, and puts the "never evicted" promise somewhere other than one method's docstring.

3. Nothing pins the thing being fixed.
The three new test_draft_graph.py cases are good and cover the batch-last staging properly. Two gaps:

(a) address stability of _argmax_row_lens. The bug is "an address moved," and no test would go red if this regressed to the LRU. Five lines on CPU:

first = head._argmax_row_lens(torch.zeros(4, 16))
for rows in range(1, 20):          # well past 8
    head._argmax_row_lens(torch.zeros(rows, 16))
assert head._argmax_row_lens(torch.zeros(4, 16)).data_ptr() == first.data_ptr()

(b) the new gating in _declare_draft_graphs. MRoPE + flag declares, MRoPE without it doesn't — that's the behavior switch of this PR, and it rides on getattr(self.model, "draft_graph_supports_mrope", False). Rename the attribute and the feature stops firing silently with every test still green. Same shape for reuse_draft_graph_step_buffers and draft_graph_hidden_state_shape; the last one is the one I'd worry about, since the generic fallback reads hc_mult and Qwen4Exp's is hc_count, so a typo there yields a plausible-looking shape rather than an error.

4. bs=8/16 are reported as "0" but look like a small consistent cost.
12.26 → 12.37 (+0.9%) and 15.26 → 15.47 (+1.4%), and GPU idle at bs=16 goes 98 → 174 µs — up, not down. Direction and magnitude both fit the new per-step [3, bs] staging copies (two mid-steps × copy + tail broadcast). One sample each, so this is plausibly noise and I wouldn't hold the PR for it — but worth a line in the table rather than a zero, because there's no batch gate on the draft side: is_captured(running_bs) replays unconditionally once a size has been captured, unlike the target path where ForwardMode.decide re-decides every step. If that 1.4% is real, large batches pay staging for a window that has no idle left to recover.

Checked and clean, so nobody redoes it: passing end= does not change aiter's backend selection here (_available short-circuits on not fp32, and logits are bf16, so argmax is the only candidate and ragged never enters the decision; _dispatch's argmax arm ignores it too) — semantics match _full_rows exactly. _argmax_row_lens allocates eagerly rather than inside a capture, since DraftGraph.warmup runs _forward_and_epilogue before torch.cuda.graph(...). The other five topk_select call sites (sampler.py ×2, rejection_sampler.py ×3) are on the eager verify/output path, so there's no sibling of this bug to fix. And [3, bs] survives the downstream: _enter_decode_metadata index_selects on the last axis, _advance_decode_metadata turns fuse_mtp off at ndim≠1 and increments all three rows, and qwen4_exp_attn.prepare_mtp_decode reads positions.shape[-1].

valarLip added a commit to ROCm/aiter that referenced this pull request Oct 22, 2026
…m(out=); one amax floor (#6833)

* fix(topk): read no length tensor when end is None

topk_select built its default `end` from an 8-entry LRU keyed on the row
count. Capturing a ninth count evicted, and freed, the tensor an earlier
graph had baked in; replaying that graph read reused memory (wrong draft
tokens, then a memory fault, ROCm/ATOM#2663).

Every backend now takes a full-rows build that reads each row's length
from the runtime width, and is handed an empty sentinel (`row_lens.py`)
in place of the tensor. Widths that are a whole number of vectors also
compile the per-element row test out. Explicit lengths are clamped to
the width, so an `end` past the row no longer selects the zeros an
out-of-bounds load returns.

The streaming selector's launch logic moves out of topk_select into
aiter/ops/flydsl/topk/topk_per_row_radix_stream.py, beside the other
backends' host wrappers.

* feat(gemm): tgemm.mm(out=) writes into the caller's buffer

`gemm_a16w16_out` (a mutating custom op) and `hipb_mm(out=)` let a
caller whose consumer reads one fixed address take the GEMM result
there. Every backend (asm, flydsl, opus, triton, skinny, hipBLASLt)
writes into `out` directly; the untuned torch paths and a skinny call
with a wider `out` take the result by copy. `-t out` in
test_gemm_a16w16 checks each backend lands in `out` and matches its own
`out=None` answer.

* refactor(quant): one amax floor, aiter::kAmaxFloor, for dynamic quantizers

The kernels seeded a group's amax with 1e-6, 1e-8, 1e-10 or 1e-12
depending on the file, so a fused quantizer and the reference one
disagreed on all-zero groups (graph warmup, padded rows). They now share
`kAmaxFloor = 1e-10` from csrc/include/quant_amax_floor.h, which is also
ATOM's AMAX_FLOOR. The per-token KV cache quant floors the amax rather
than the inverse scale, so the stored scale is the one used and never 0.

The Triton dual RMSNorm + MXFP8 quant takes the same floor as an opt-in
`amax_floor` (default 0, unchanged behaviour).
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@valarLip Thanks for the root fix in ROCm/aiter#6833. It supersedes the compute_argmax_token workaround in this PR (embed_head.py), and our ROCm/aiter#6761, which I'm closing.

The main part of this PR is independent of that bug, though. It lets an MRoPE draft (the Qwen3.8-Flash-Next MTP head) capture draft steps 1..k-1 in CUDA graphs: the [3, N] positions are staged in one flat buffer, and each captured size views the first 3*bs elements as a contiguous [3, bs], the same layout as the runner's _mrope_positions_view. On current main (after #2688), EagleProposer._declare_draft_graphs still returns () when use_mrope is set, so every Qwen3.8 draft step runs eagerly and the GPU waits on the host at small batch sizes.

From the tables above: a bs=1 decode iteration goes from 11.21 ms to 8.08 ms, AgentX C1 p90 ITL interactivity improves by 31%, and C2 throughput by 35%. Acceptance is identical bucket by bucket. In our MI355X Qwen3.8 runs this is the main reason the low-concurrency points (c1–c8) reach the GB300 curve.

Was the close meant for the whole PR? Two options on my side:

  1. Reopen this PR. I'll drop the embed_head.py change now that aiter#6833 makes end=None graph-safe, and re-validate on top of refactor: one graph mechanism per region, settled DP step shapes, cheaper V4.1 decode prep #2688 (the branch still merges cleanly with main, but DraftGraph changed under it).
  2. If you'd rather have MRoPE draft graphs built differently on the region-graph mechanism from refactor: one graph mechanism per region, settled DP step shapes, cheaper V4.1 decode prep #2688, I'm happy to rework it that way. Let me know which you prefer.

@valarLip

Copy link
Copy Markdown
Collaborator

you can keep this PR with some clean, the close should be done by my agent...

@ZLkanyo009 ZLkanyo009 reopened this Oct 27, 2026
@github-actions

Copy link
Copy Markdown
Contributor

🏷️ CI Guide

Runs automatically on every eligible PR before approval:

  • ✅ Pre Checkin: Black, Ruff, catalog schema validation, non-GPU unit tests

Heavy model tests:

  • ✅ Run after the PR is approved and Pre Checkin passes
  • ✅ Run immediately when an approval review is submitted
  • ✅ Can be requested before approval with labels
Label Tests
ci:full Run all heavy PR model tests: native ATOM, vLLM, and SGLang
ci:atom Run native ATOM model accuracy tests
ci:vllm Run ATOM vLLM OOT model accuracy tests
ci:sglang Run ATOM SGLang model accuracy tests

Heavy jobs are skipped when the PR is not approved and no matching ci:* label is present.
Add labels via the sidebar or gh pr edit 2663 --add-label <label>

ZLkanyo009 and others added 5 commits October 27, 2026 07:43
… is graph-safe

ROCm/aiter#6833 makes `topk_select(end=None)` read no row-length tensor,
so the LM head no longer needs to hold one per captured row count. Back
to main's `compute_argmax_token`.

Also fold the batch-last capacity helper into `buffer()` and cover the
Qwen3.8 MTP draft-graph declaration under MRoPE.

Co-authored-by: Cursor <cursoragent@cursor.com>
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@valarLip Reopened and cleaned up as you suggested.

What changed

Validation on main f324de48 (MI355X, TP1, Qwen3.8 MXFP4; main and this branch run at the same time on two GPUs of one node). What main gained since then (logging, CI labels, GLM-5.2 mono, DP dispatch backpressure, PCP fixes) doesn't touch the draft-graph path; the CPU suite and lint were rerun on the final merge.

  • GSM8K 1319, real MTP-3 verification: 0.9757 vs main 0.9765 at concurrency 64, and 0.9765 vs 0.9780 at concurrency 1. Acceptance is 72.1–72.4% vs 72.7%.
  • Outputs are not token-identical, but main isn't identical to itself either: two greedy concurrency-1 runs of main match on 17 of 300 questions. Split by draft position, the eagerly produced token 0 differs from main by as much as the graphed tokens 1 and 2, so I read the acceptance gap as run-to-run noise, not the graphs.
  • Honest caveat on performance: the gain on current main is much smaller than when I first opened this. bs=1 decode goes from about 9.0 ms to 8.75 ms (about 3%, reproduced with the GPUs swapped). bs 2 and 4 show no difference. AgentX C1 is flat: tput 14,360 vs 14,398, p90 ITL interactivity 218 vs 213, ITL p50 3.91 vs 4.15 ms with the tuned aiter build, and fully flat with the CI wheel.
  • A bs=1 profile shows the graphs still remove the draft's GPU idle (about 2.1 ms down to 0.1 ms per iteration under the profiler). Main's eager draft host path has become cheap enough that, without the profiler, only about 0.3 ms of it is exposed.
  • GPU unit tests (16 draft / graph / MTP files): 320 passed / 0 failed. CPU suite: same 3 inductor g++ failures as main. black and ruff are clean.

Full numbers are in the updated description. Given the smaller win, I'm fine either way: merge it as a small bs=1 improvement that keeps MRoPE drafts on the same graph path as the others, or close it if you'd rather not carry the batch-last staging. PTAL, thanks.

@yitingw1

Copy link
Copy Markdown
Contributor

Reopen + the embed_head.py drop is the right shape, and the new Qwen4ExpMTP test closes my earlier worry properly — I checked it by mutation rather than by reading: renaming hc_count to hc_mult (what the generic fallback reads) and removing draft_graph_supports_mrope each turn it red. The batch_last generalisation holds up after the main merge too — _mrope_positions_view is still as_strided((3, n), (n, 1)), so buf[:3*bs].view(3, bs) is the same contiguous layout, and numel() // prod(shape) gives max_num_seqs for both orientations. 88 passed here.

On where the win went — I think you're missing a name. #2670 ("reuse speculative output buffers and combine status D2H") merged 10-22, the day after you opened this. Combining those D2H status reads removes the same stall draft graphs remove: the host blocks on the readback, the GPU drains waiting for the next launch batch. Two changes, one pool of headroom.

That's also why it isn't a subtraction. The win is max(0, host_time - gpu_time) — a clamp, not a delta. Once something pushes host time under GPU time, further host savings are worth exactly zero, so 2.2 ms off the baseline took essentially all of your 3.1 rather than leaving 0.9. Same reason the profile still shows 2.1 → 0.1 ms: the profiler's own host overhead puts you back in the host-bound regime where the win exists. That 2.1 ms is the win on a slow host.

One thing you didn't account for: the branch itself went 8.08 → 8.75 ms. Most likely the methodology — running main and the branch at the same time on two GPUs of one node shares the host CPU, which is the exact resource under measurement.

The one number I'd ask for: bs 8 and 16, from the same microbenchmark you already ran for bs 1/2/4. The draft side replays unconditionally once a size is captured (is_captured(running_bs), with no equivalent of the target's per-step ForwardMode.decide), so the benefit is bs=1 but the staging cost is paid at every captured size — that's the only place this can be net negative. Your first table had +0.9% at bs=8 and +1.4% at bs=16 with GPU idle going 98 → 174 µs, one sample each, so it may well be noise; it just hasn't been re-checked since. I'd skip re-running AgentX, though — a clean axis for a 3% bs=1 question isn't worth the machine time, and I don't think that number should decide this anyway.

Because I think you're underselling this. On main, _declare_draft_graphs returns () for use_mrope — MRoPE is the only model shape carved out of the draft-graph mechanism, and it's carved out solely because StagedInput assumed a leading batch axis. Generalising that to batch_last removes the special case. That value doesn't decay as main gets faster, and if this closes, the next person wanting MRoPE draft graphs rediscovers the same [3, N] problem. I'd merge on that basis with the 3% as a bonus, not the other way round — modulo bs 8/16.

Separately, and not this PR's problem: two greedy c1 runs of main matching on 17 of 300 questions deserves its own issue. At TP1/c1 the batching is fixed, so that's kernel-level nondeterminism — and every accuracy A/B in this repo is read against it.

yifehuan pushed a commit that referenced this pull request Oct 30, 2026
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@yitingw1 thanks, re-measured with one server at a time. cc @valarLip

Agreed on #2670: combining the status D2H removes the same host stall, and with the max(0, host - gpu) clamp the two savings don't add.

You were right about the method, too. Last time main and the branch ran at the same moment on two GPUs of one node, so they shared the host CPU, which is exactly what this PR saves.

Method.

  • One MI355X, one server at a time. Main and the branch run one after another on the same GPU and port, 16 rounds in ABBA order (main, PR, PR, main, ...). Each session starts a fresh server and runs bs 1, 2, 4, 8, 16.
  • Same microbenchmark as the bs 1/2/4 numbers: bs concurrent streams, 64k-token random prompts, 4000 output tokens, forced acceptance 2.32, median chunk interval while all streams decode.
  • Tuned aiter, as before: tree dad9579de9 = aiter 905967361 + [Config] [Perf] Tune gfx950 BF16 GEMMs for Qwen3.8-Flash-Next (TP1, MTP 3) aiter#6711 #6713 #6762.
  • Main is d176cb98a, the newest main this aiter can run. feat(attention): FlyDSL paged decode for MiniMax-M3 on gfx1250 #2710 (c77199359) imports pa_decode_gluon_supports_arch, which that aiter tree doesn't have. The branch is c0dfd87ef merged with d176cb98a, so the two trees differ only by this PR. The 5 later main commits don't touch atom/spec_decode.
  • Other agents had jobs on other GPUs of the node for most rounds. Host CPU was 1–7% busy (236 cores; one session saw 17%), and the alternation puts both sides under the same load. Rounds 13–14 ran with the rest of the node idle and give the same picture.

Decode iteration wall time (ms; median and range over 16 sessions per side; paired Δ = PR / main − 1 within a round):

bs main this PR paired Δ, mean ± SE PR slower / faster sign-flip p
1 9.07 (8.73–9.25) 8.13 (8.10–8.61) −9.7% ± 0.4% 0 / 16 < 0.001
2 9.07 (8.92–9.29) 8.95 (8.80–8.98) −1.7% ± 0.4% 3 / 13 0.001
4 10.31 (10.05–10.93) 10.31 (10.17–10.44) −0.1% ± 0.5% 8 / 8 0.85
8 12.29 (12.04–12.60) 12.36 (12.12–12.61) +0.5% ± 0.4% 9 / 7 0.23
16 15.50 (15.07–15.77) 15.46 (15.27–16.00) +0.6% ± 0.6% 9 / 7 0.29

So bs 8 and 16 are not net negative beyond noise. At 95% the cost is at most about +1.3% (bs 8) and +1.8% (bs 16).

  • The first table's +0.9% / +1.4% were single samples. bs 16 alone moves by about ±2% between server restarts: main ranges 15.07–15.77.
  • Run alone, the branch is back at 8.13 ms at bs=1, which matches the original 8.08, and main is at 9.07. So the bs=1 gain is about 10%, not the 3% I reported from the simultaneous runs. Main also moved since then (f324de48 → d176cb98a), so I can't attribute all of that difference to the method.

Profile (torch profiler, 3 s of steady decode per bs, µs per iteration; the profiler inflates host time, so compare only within a row pair):

bs GPU wall busy idle draft kernels
1 main 12,160 9,074 3,086 1,570
1 this PR 9,263 9,146 117 1,608
8 main 14,668 13,346 1,323 2,088
8 this PR 14,312 14,182 130 2,480
16 main 16,437 16,346 92 2,483
16 this PR 16,498 16,371 127 2,472
  • Staging per graphed step (steps 1 and 2) is small.
    • It is one D2D copy of input_ids: 2.0–2.2 µs on the GPU. The hidden states already live in the staged buffer (reuse_draft_graph_step_buffers), so they are not copied.
    • On the host it takes 26–35 µs before hipGraphLaunch, and the launch itself takes 34–62 µs. All of it is queued behind the GPU.
    • Including step 0's [3, bs] positions copy, the staging copies add up to about 9 µs of GPU time per iteration, 0.06% of a bs=16 iteration.
  • bs 16: the GPU is saturated on both sides. The branch costs +61 µs per iteration (+0.4%). About 35 µs of that is idle time in the gaps around the two graph launches, and the kernel time is identical. That is the structural cost of replaying at a GPU-bound size, and it sits below the session-to-session noise above.
  • bs 8: under the profiler, the branch's draft kernels take about 400 µs longer. They are the same kernels, just slower, because main's eager draft leaves 1.3 ms of profiler-inflated host gaps, so its kernels run on an otherwise idle GPU. Without the profiler the bs 8 wall time is the same on both sides, as the table shows.

No code change. Restricting replay to some sizes would trade a ≤0.4% structural cost for a new rule, and the data doesn't justify that. I've updated the PR description with these numbers and the single-GPU method.

On the c1 nondeterminism (17 of 300 identical between two greedy runs of main): agreed, we'll track it separately.

yitingw1
yitingw1 previously approved these changes Oct 30, 2026
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@valar-cop review this pr

@valar-cop valar-cop 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.

LGTM, but the accuracy jobs are red, please rerun..

any perf number? gsm8k? and no qwen3.8/qwen4 mtp coverage in ci yet

ci Accuracy (DeepSeek-V4-Flash TP4) failed — ci machine/env issue, not the pr (medium): All six jobs of the vLLM accuracy matrix failed in the same run while the same head's ATOM Test and Pre Checkin workflows passed, and each failing job's log is empty (0 bytes). DeepSeek-V4-Flash TP4 runs no speculative decoding, so the diff (ATOM's standalone draft-graph staging plus Qwen3.8 MTP only) cannot reach it; this is a run-level/build-environment failure.

ci Accuracy (GLM-5.2-MXFP4-MTP TP4) failed — ci machine/env issue, not the pr (medium): Same run-level failure as the rest of the accuracy matrix; the vLLM plugin uses vLLM's own MTP proposer, not atom/spec_decode/draft_graph.py, so this diff is not on the path being tested. Log is empty.

ci Accuracy (MiniMax-M3-MXFP4 EAGLE3 TP4) failed — ci machine/env issue, not the pr (medium): Same run-level failure. EAGLE3 here goes through vLLM's spec-decode patch, and the EAGLE changes in this diff are inert for a non-MRoPE target (mrope is False, so the gate and the StagedInput shapes are byte-identical to main). Log is empty.

ci Accuracy (Kimi-K3 DSpark DCP8 TP8) failed — ci machine/env issue, not the pr (medium): Same run-level failure. DSpark uses DSparkProposer/dspark_proposer.py, whose StagedInputs keep the default batch_last=False; this diff does not touch that file or its layout. Log is empty.

ci Accuracy (DeepSeek-V4.1-Flash DSpark TP4) failed — ci machine/env issue, not the pr (medium): Same run-level failure; DeepSeek-V4.1 DSpark goes through the vLLM DSpark speculator and dspark_proposer.py, neither of which this diff changes. Log is empty.

ci Accuracy (Qwen3.5-397B-A17B-MXFP4 TP4) failed — ci machine/env issue, not the pr (medium): Same run-level failure. This matrix entry passes no --speculative-config, so no drafter and no draft graph is built at all; the diff cannot affect it. Log is empty.

Comment thread atom/spec_decode/draft_graph.py Outdated
if running_bs > scheduled_bs:
out[scheduled_bs:].copy_(src[-1]) # broadcasts; no expand needed
if batch_last:
out[..., scheduled_bs:].copy_(src[..., -1:])

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

docstring still says row, now it's the last real sequence for batch_last.. please reword

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@valar-cop I followed your suggestion on atom/spec_decode/draft_graph.py:237. cc @valarLip

The _stage_one docstring now says it tail-repeats the last real sequence, which is the last row, or the last column of a batch_last input (the [3, N] MRoPE positions). The rest of the paragraph says "sequence" instead of "row".

(commit 4b28fdd56, docstring only; the head is now 80bd51de8 after a merge of main. black and ruff are clean. On CPU, tests/test_draft_graph.py passes 28/28 and tests/test_glm5_next_mtp_routing.py passes 2, with 5 skipped for aiter. test_qwen4_exp_mtp, test_dspark and test_region_graph skip on CPU because they need triton, aiter or a GPU. Pre Checkin is green on 80bd51de8.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

yes, that reads right now, pad repeats the last real sequence in both layouts and tests/test_draft_graph.py:195 pins the batch-last one.

lgtm from me. note the vllm accuracy matrix is still skipped on this head, it needs the approval or a ci:full label to run.

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@valar-cop thanks for the review. cc @valarLip

The docstring is fixed in 4b28fdd56 (reply inline), and the branch is merged with main at 80bd51de8. On that head Pre Checkin, ATOM Test, ATOM vLLM Test and ATOM SGLang Test are green. The push dismissed the earlier approval; the only change since that review is the docstring plus the main merge.

Accuracy rerun. I reran the failed vLLM accuracy jobs on the old head c0dfd87ef (run 41022598167, attempt 2). Neither failure is in the draft graph path:

On 80bd51de8, the Heavy CI gate skipped the accuracy matrix (reason=not-approved), because it only runs on an approved review or with ci:full / ci:vllm. I can't trigger it from my side. It will run once the PR is approved again or one of those labels is added.

Perf. These are the single-GPU numbers from my earlier reply, which has the method and the full trace breakdown: #2663 (comment)

  • Setup: one MI355X, one server at a time, 16 ABBA rounds, tuned aiter. Main is d176cb98a, the branch is this PR merged with it. Forced acceptance 2.32.
  • Each value is the median decode iteration wall time over 16 sessions.
bs main (ms) this PR (ms) paired Δ sign-flip p
1 9.07 8.13 −9.7% < 0.001
2 9.07 8.95 −1.7% 0.001
4 10.31 10.31 −0.1% 0.85
8 12.29 12.36 +0.5% 0.23
16 15.50 15.46 +0.6% 0.29
  • bs 4/8/16 are within noise.
  • The trace says the same. Staging copies cost about 9 µs of GPU time per iteration. At bs=1, main's eager draft leaves about 3.1 ms of GPU idle per iteration under the profiler, and the branch leaves 0.12 ms. At bs 16 the GPU is saturated, and graph replay costs about +60 µs (+0.4%), which is below the session-to-session noise.

GSM8K. Full GSM8K (1319 questions), on one MI355X (node 102):

  • Setup: Qwen3.8-Flash-Next-Quark-MXFP4, TP1, MTP 3 with real verification, no offload, max-num-seqs 160, FP8 KV cache.
  • The branch was 4823b477 and main was f324de48. The PR's own diff has not changed since then except for this docstring; the later commits are main merges.
this PR main acceptance (PR / main)
concurrency 64, CI aiter wheel 0.9757 0.9765 72.1% / 72.7%
concurrency 1, greedy, tuned aiter 0.9765 0.9780 72.4% / 72.7%
  • All four results are within the 0.9757–0.98 reference range, and the gaps are 1 and 2 questions (stderr 0.0042).
  • Greedy outputs aren't bit-identical even between two runs of main (17 of 300 questions match), so I compared accuracy and acceptance rather than text.

CI coverage. You're right. Qwen3.8 appears only in the SGLang plugin's nightly accuracy catalog, without speculative decoding. The native ATOM and vLLM accuracy matrices have no Qwen3.8 / Qwen4 MTP model, so CI doesn't exercise this PR's draft path. Adding that job is outside this PR, so I've left it as a follow-up. For now the PR's coverage is tests/test_qwen4_exp_mtp.py, which needs a GPU and passed in the GPU unit-test run at 4823b477, plus the GSM8K numbers above.

@valar-cop review this pr

@valar-cop valar-cop 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.

LGTM, but please make the batch-last producer-fill invariant explicit..

any test for draft step at running_bs > scheduled_bs? gsm8k?

ci green.

)
# A producer may have written straight into this exact fixed-storage
# view. Do not turn that into a self-copy launch.
if src.data_ptr() != dst.data_ptr() or src.stride() != dst.stride():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

batch_last + producer fill gives an overlapping self-copy with wrong strides.. assert it or document it?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@valar-cop I followed your suggestion on atom/spec_decode/draft_graph.py:234. It's both asserted and documented now. cc @valarLip

You're right that it's real. On CPU I filled buffer("pos", 2) and staged at running_bs=4. That took the copy branch (same data_ptr, strides (2, 1) vs (4, 1)) and scrambled rows 1 and 2 of the positions without any error. 3→8 and 5→6 do the same.

  • _stage_one now asserts when the source is a view inside the role's own fixed storage but isn't the exact view it writes. The exact view (same pointer and stride) still skips the self-copy.
  • buffer's docstring states the invariant. A leading-batch producer fills buffer(role, scheduled_bs). A batch_last producer fills buffer(role, running_bs)[..., :scheduled_bs], which has the staged stride.
  • I asserted rather than cloned because no caller reaches this. The only batch_last input is the MRoPE positions, which _enter_decode_metadata builds with a fresh index_select, and nothing fills them through buffer.

(commit 528b871be; new tests test_a_batch_last_producer_fills_the_running_view and test_a_batch_last_fill_of_a_narrower_view_is_refused in tests/test_draft_graph.py, and the second one fails on the old guard. CPU: the draft-related suites pass, 180 passed / 46 skipped; black and ruff are clean.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

lgtm. the guard now refuses before dst.copy_, and the 0 <= ptr - buf range check catches the narrow-view overlap that used to scramble rows.

checked the callers: only hidden_states (eagle_proposer.py:778) and the dspark/v4 mono anchors fill a buffer, all leading-batch at running_bs, and positions comes from a fresh index_select (eagle_proposer.py:551), so nothing trips it today. assert over clone is fine.

@valar-cop valar-cop 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.

LGTM, but the batch_last self-copy guard needs a fix before it bites..

any perf number? gsm8k? and the padded replay / DP path is only shown TP1

ci green.

)
# A producer may have written straight into this exact fixed-storage
# view. Do not turn that into a self-copy launch.
if src.data_ptr() != dst.data_ptr() or src.stride() != dst.stride():

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

stride differs when running_bs != scheduled_bs, so this guard takes the copy branch over overlapping storage.. latent today but hidden_states would corrupt silently. assert no alias or copy via clone?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

@valar-cop I followed your suggestion on atom/spec_decode/draft_graph.py:234: it now asserts there is no alias, in the same commit as the other thread. cc @valarLip

One correction on scope: hidden_states can't hit this. It's a leading-batch input, so buffer("hidden_states", bs) is buf[:bs], and its strides don't depend on bs. At running_bs != scheduled_bs the producer's view is exactly out[:scheduled_bs], so the self-copy is skipped. I checked this on CPU at 2→4, 3→8 and 5→6: same pointer, same strides, and both the real rows and the pad tail come out correct. Only a batch_last input has a batch-dependent stride.

Every source that lives inside a staged buffer today is the exact view:

  • EagleProposer's step-0 hidden_states, which index_select writes into buffer(..., scheduled_bs).
  • The next step's graphed_ids[:scheduled_bs] and ret_hidden_states[:scheduled_bs].
  • DSpark's anchors, which are filled at buffer(..., running_bs) and staged at running_bs.

So the assert doesn't fire on any current path, and every copy that ran before still runs. A future alias fails loudly instead of corrupting.

(commit 528b871be; test_a_draft_step_wider_than_the_schedule_pads_every_input stages ids, MRoPE positions and producer-filled hidden states at scheduled_bs=3 / running_bs=8, both eager and replayed. It checks the real rows, the pad tail and the fixed addresses.)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Verified against the code: hidden_states is a leading-batch input (StagedInput(shape=hidden_shape), eagle_proposer.py:159), so buffer(role, scheduled_bs) is always buf[:scheduled_bs], and dst is that same slice at any running_bs — only the batch-last view has a stride that moves with bs. The assert plus the narrow-view test is the right shape.

lgtm, my mistake on hidden_states.

A batch_last view's stride is its batch, so buffer(role, scheduled_bs)
overlaps the running_bs view at a different layout. Staging it took the
copy branch over that overlap and scrambled the positions without an
error. No caller fills a batch_last input today (MRoPE positions are a
fresh index_select), so assert instead of cloning, and say in `buffer`
which view a producer fills for each layout.

Tests: the batch_last fill at running_bs, the refused narrower view, and
a draft step at running_bs > scheduled_bs, eager and replayed.
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@valar-cop thanks. cc @valarLip

Both reviews on 80bd51de8 point at the same staging guard. It's fixed in 528b871be (replies inline), and Pre Checkin is green on that head.

Batch-last producer fill / self-copy guard. The bug was real but latent. On CPU, a batch_last input filled through buffer(role, scheduled_bs) and staged at a larger running_bs went through copy_ over overlapping storage and silently scrambled the positions. _stage_one now refuses any view of a role's own storage other than the exact one it writes. buffer's docstring states which view a producer fills for each layout. No current caller fills a batch_last input, so I chose an assert over a clone.

Test at running_bs > scheduled_bs. tests/test_draft_graph.py has 4 new CPU tests:

  • test_a_draft_step_wider_than_the_schedule_pads_every_input runs twice, eager and replayed. It covers a mid-step at scheduled_bs=3 / running_bs=8 with ids, MRoPE positions and producer-filled hidden states.
  • test_a_batch_last_producer_fills_the_running_view covers the supported batch_last fill.
  • test_a_batch_last_fill_of_a_narrower_view_is_refused covers the refused fill, and it fails on the old guard.

Perf / GSM8K. I didn't re-measure, because the change can't move either number:

  • It adds a pointer-range check and an assert on the host staging path, outside the graph.
  • No current source trips it, and every copy that ran before still runs, so the kernels and the replayed graph are the same as on 80bd51de8.

The round-1 numbers still apply (details in #2663 (comment)):

  • Decode wall time on one MI355X: bs1 −9.7% and bs2 −1.7%; bs 4/8/16 are within noise.
  • Full GSM8K, this PR / main: 0.9757 / 0.9765 at concurrency 64, and 0.9765 / 0.9780 at concurrency 1 with greedy decoding. All are within the reference range.

Only TP1 is shown. That's true. Everything I measured for this PR ran at TP1 on one MI355X: wall time, the profile, GSM8K, the serving run and the GPU unit tests. I haven't run Qwen3.8 MTP with padded replay at TP>1 or with DP attention, so I'll leave that as a follow-up, along with the CI coverage from round 1. The replay decision is keyed only on the DP-agreed running_bs (is_captured, which this PR doesn't change), and the existing CPU tests pin that dummy and real ranks replay in lockstep. That tests the mechanism, though, not a TP>1 run of this model.

vLLM accuracy matrix. It's still skipped by the Heavy CI gate on this head. It needs a reviewer to approve again, or a ci:full / ci:vllm label, and I'm not adding labels myself.

@valar-cop review this pr

@valar-cop valar-cop 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.

LGTM, one minor thing on the stage() diagnostic

any perf number for tp>1? and the atom test on this head is still pending

ci green.

Comment thread atom/spec_decode/draft_graph.py Outdated
count that cannot disagree with the tensor it describes.
"""
counts = {t.shape[0] for t in srcs.values()}
counts = {self._batch_of(role, t) for role, t in srcs.items()}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

undeclared role now raises bare KeyError instead of the old assert message? let's keep the diagnostic..

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Please fix this.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Fixed in 2daa454. stage() and buffer() now check the roles before anything else, through one _assert_declared. It names each undeclared role and lists the declared ones (... has no staged input 'missing'; declared roles are ['row']) instead of raising a bare KeyError. test_staging_an_undeclared_role_names_it covers it; on 528b871be it fails with KeyError: 'missing'.

@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

@yitingw1 @valarLip your approval (on c0dfd87ef) was dismissed by the follow-up pushes for valar-cop's review. The changes since then:

  • a docstring fix (4b28fdd56) and a merge of main;
  • 528b871be: an assert in _stage_one against staging a non-exact view of its own fixed storage, the batch_last producer-fill invariant written down, and 4 CPU tests, including a draft step with running_bs > scheduled_bs.

valar-cop is LGTM on the current head 528b871be (review); its remaining note on the stage() KeyError diagnostic is minor. The accuracy matrix stays gated until the PR is re-approved or gets ci:vllm.

Could you take another look and re-approve?

@valar-cop

valar-cop commented Nov 2, 2026 •

Copy link
Copy Markdown

ZhiweiYan-96 added a commit that referenced this pull request Nov 2, 2026
…ffers

Builds on #2663, which stages MRoPE positions batch-last and lets a draft
model opt into mid-step graphs with draft_graph_supports_mrope. Qwen3_5MTP
now sets it, so draft steps 1..k-1 on Qwen3.5 are warmed and captured
instead of running eager (about 1.7-1.8 ms of host time each at bs=1).

Qwen3_5MTP also opts into reuse_draft_graph_step_buffers, as DeepSeekMTP
does: the captured epilogue writes each mid-step's hidden states and argmax
ids into the pass's staged inputs, so a replay needs no clones and the next
stage no copies. At k=5 the draft mid-steps go from 20 copies to 9 per
decode step. Safe for the hybrid model: the draft is one full_attention
decoder layer that reads input_ids and hidden_states only at the top of its
forward, and it touches no GDN conv/SSM state or checkpoint slot.
ATOM_QWEN35_MTP_REUSE_STEP_BUFFERS=0 restores the clone-and-restage path.
…buffer()

stage() counted batches through self.inputs[role] before anything checked
the role, so a typo surfaced as a bare KeyError. stage() and buffer() now
share one assert that names the undeclared and the declared roles.

The overlapping self-copy guard in _stage_one is replaced at its source:
only buffer() could hand out a batch_last view whose stride differs from
the one stage writes, and nothing fills a batch_last input in place. So
buffer() refuses batch_last roles, and _stage_one's copy test, buffer()'s
capacity and its docstring go back to main's.

Also folds _view into its one caller, trims docstrings, and drops tests of
the removed API; the batch_last staging test now uses a producer-filled
leading input beside the positions.
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Whole-PR cleanup in 2daa454ec, on top of 528b871be. The runtime path (staging, capture, replay) is unchanged; what changed is the error path, docstrings and tests.

Diff against main, before → after:

528b871be 2daa454ec
source code +58 / −19 +53 / −15
source comments and docstrings +31 / −17 +14 / −12
test code +149 / −1 +73 / −1
test comments and docstrings +14 / −1 +8 / −7
lines added 252 148

draft_graph.py:174 (@yitingw1, from @valar-cop's comment). stage() and buffer() now check the roles first: one assert names each undeclared role and lists the declared ones, instead of a bare KeyError. test_staging_an_undeclared_role_names_it fails with the KeyError on 528b871be.

Reverted.

  • The address-range assert round 2 added to _stage_one. The overlap it caught came from handing stage a buffer() view of a batch-last input at a narrower batch: a batch-last view's stride depends on the batch, so that view is not the front of the wider one. buffer() now refuses batch-last roles (test_a_batch_last_input_has_no_producer_view). No caller fills one in place: the only batch-last input, MRoPE positions, is a fresh index_select (eagle_proposer.py:550), staged once (:819-821), then incremented in place inside the staged buffer (:591). _stage_one's copy condition is main's again.
  • buffer()'s capacity as numel() // prod(shape). buffer() only serves leading-batch inputs now, so main's buf.shape[0] is right.
  • Round 1's "row" → "sequence" wording in the _stage_one and buffer() docstrings is main's again. The only addition is "(its last column if batch_last)".
  • test_a_draft_step_wider_than_the_schedule_pads_every_input is folded into test_batch_last_input_is_contiguous_and_tail_repeats_the_last_column. That test now stages a producer-filled leading input next to the positions, which covers the same padding.

Deleted or merged.

  • The _view helper, which had one caller, is folded into _stage_one.
  • The StagedInput and _declare_draft_graphs docstrings are shorter.
  • The tests of the removed batch-last buffer() path are gone: the producer-fill test, the narrower-view refusal and the capacity-in-sequences test.
  • test_batch_last_sources_must_agree_with_leading_ones is merged into test_sources_that_disagree_on_the_batch_are_refused.
  • tests/test_qwen4_exp_mtp.py: the MRoPE test is now test_mrope_draft_graph_declares_positions_batch_last. It asserts the declared StagedInput instead of binding and staging again, which test_draft_graph.py already covers.

Verification at 2daa454ec.

  • black 26.10.1 and ruff are clean.
  • Full CPU unit suite: 3 failed / 10215 passed / 1112 skipped. The 3 failures (test_graph_marker ×2, test_sparse_indexer_side_effect_op) are main's on the same host.
  • tests/test_qwen4_exp_mtp.py on MI355X (aiter wheel 4d1980603 from the main manifest): 60 passed. On CPU this file skips because there is no aiter.
  • GSM8K was not rerun. The PR's staging, capture and replay code is the same as in the GSM8K and GPU unit runs in the description; since then the branch has merged main and changed only asserts, docstrings and tests.

@yitingw1 draft_graph.py:174 is fixed, please re-approve. cc @valarLip

@valar-cop

Copy link
Copy Markdown

@ZLkanyo009 yes, _assert_declared fixes it — stage() names the role before _batch_of touches self.inputs, and tests/test_draft_graph.py:160 pins the message.

lgtm on the rest. buffer() refusing batch_last is the right call, and with that the range assert had no path left: the only fills are leading-batch (eagle_proposer.py:777, dspark_proposer.py:172, deepseek_v4/mono/session.py:861).

atom test is still pending on this head, wait for it before merge..

Keep the change to main's code to what batch_last needs:

- stage() names undeclared roles with its own assert; buffer() keeps
  main's role check, with only the batch-last refusal added after it.
- The staging ndim message keeps main's "leading axis is not the batch"
  wording, so main's test for it keeps its match; its docstring changes
  only the two sentences batch_last made untrue.
- The batch-last disagreement case is its own test again rather than a
  second case inside main's test.
- The _declare_draft_graphs docstring keeps main's paragraph and adds
  one sentence for draft_graph_supports_mrope.
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Pushed 2daa454ec..aea839bcc: one commit that keeps this PR's changes to main's existing code to what batch_last needs. Staging, capture and replay are unchanged; only the error path, docstrings and tests moved.

Diff against main, before → after:

2daa454ec aea839bcc
files 5 5
source code +53 / −15 +49 / −11
source comments and docstrings +14 / −12 +12 / −7
test code +73 / −1 +75 / −0
test comments and docstrings +8 / −7 +8 / −2
total (git diff --stat) +148 / −35 +144 / −20

Reverted, by file

  • atom/spec_decode/draft_graph.py
    • _assert_declared is gone. stage() now has its own assert that names undeclared roles and the declared ones, and buffer() keeps main's role check unchanged. The fix for draft_graph.py:174 is the same; only the helper the earlier replies and @valar-cop's 05:14 comment named no longer exists. buffer() still refuses batch-last roles, added after main's check.
    • The ndim message keeps main's "whose leading axis is not the batch" wording, so main's test for it keeps its match=. It drops only "MRoPE positions are the case that reaches here", which this PR makes untrue.
  • atom/spec_decode/eagle_proposer.py: the _declare_draft_graphs docstring keeps main's paragraph word for word and adds one sentence for draft_graph_supports_mrope.
  • tests/test_draft_graph.py
    • Main's test_sources_that_disagree_on_the_batch_are_refused is back as it was. The batch-last case is again a separate test, test_batch_last_sources_must_agree_with_leading_ones: the last round merged it in, but that changed an existing test.
    • Main's test_staging_a_source_whose_leading_axis_is_not_the_batch_fails_loudly: its code and match= are main's. Two docstring sentences change because this PR makes them untrue: "Every staged source" becomes "Every leading-batch source", and "a drafter must not hand them to a pass at all" becomes "a drafter must declare them batch_last".
  • atom/models/qwen4_exp_mtp.py and tests/test_qwen4_exp_mtp.py: unchanged.

No shared interface changed: StagedInput.batch_last defaults to False, and the signatures of stage, buffer and bind are unchanged, so no other caller or model needs a line changed.

Not changed

  • Qwen3.5 MTP (qwen3_5_mtp.py) is not opted in; its draft graph has not been validated on GPU.
  • _stage_one called directly with an undeclared role still raises KeyError. It is private, and stage is its only caller.
  • Storing every role flat would remove bind's branch, but it would change main's storage and tests.
  • The draft graph under TP>1 and DP attention is untested, as noted in round 2.

Verification

  • black 26.10.1 and ruff 0.17.2 are clean.
  • tests/test_draft_graph.py: 29 passed.
  • Draft / MTP related CPU tests: 199 passed / 33 skipped.
  • Full CPU unit suite at aea839bcc: 3 failed / 10216 passed / 1112 skipped. The 3 failures (test_graph_marker ×2, test_sparse_indexer_side_effect_op) are main's on the same host.
  • GPU and GSM8K were not rerun. tests/test_qwen4_exp_mtp.py passed 60/60 on MI355X at 2daa454ec, and since then neither that file nor the code it runs has changed outside asserts, error messages and docstrings.

Waiting on: CI on aea839bcc (running). The vLLM accuracy matrix runs only after a re-approve.

@yitingw1 @valar-cop cc @valarLip

@valar-cop

Copy link
Copy Markdown

@ZLkanyo009 lgtm, the diagnostic survived the revert — stage() names the undeclared roles itself at draft_graph.py:164 and buffer() keeps main's check, so that note is resolved.

still wait for atom test on aea839bcc, it's queued..

yifehuan added a commit that referenced this pull request Nov 3, 2026
install_mono_decode wraps the loaded Qwen4ExpMTP as well (MonoDraftModel,
as V4.1 wraps its DSpark model). A draft step of 1-4 requests runs the
drafter's BF16 layer as one mono program (DraftRunner): the first step
attends every verify row and runs the MLP on each request's last row only,
later steps run one row. Any other step of at most 16 rows runs the fallback
draft (fused QSA attention, ATOM's BF16 MLP, one-row CK experts built at
startup), anything larger ATOM's layer. The draft ids come from a Group64
W4 screen with a global error certificate refined on the BF16 rows: the BF16
head's argmax for 1, 2 and 4 rows, ATOM's argmax head otherwise.

EagleProposer: a draft model that sets draft_returns_head_input returns
(state, head input) from forward_with_head (with selected_rows on step 0 of a
decode) and maps the head input to ids (compute_draft_ids_from_head), in a
declared draft pass and eagerly alike. Qwen3.8 is an MRoPE model, so on main
its draft steps 1+ have no declared pass and run eagerly: the program is one
launch a layer, so they cost the same as in a captured graph (measured with
#2663 applied: request TPOT within 1%). The recipe, the environment variable
table and CLAUDE.md name the deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yifehuan added a commit that referenced this pull request Nov 3, 2026
install_mono_decode wraps the loaded Qwen4ExpMTP as well (MonoDraftModel,
as V4.1 wraps its DSpark model). A draft step of 1-4 requests runs the
drafter's BF16 layer as one mono program (DraftRunner): the first step
attends every verify row and runs the MLP on each request's last row only,
later steps run one row. Any other step of at most 16 rows runs the fallback
draft (fused QSA attention, ATOM's BF16 MLP, one-row CK experts built at
startup), anything larger ATOM's layer. The draft ids come from a Group64
W4 screen with a global error certificate refined on the BF16 rows: the BF16
head's argmax for 1, 2 and 4 rows, ATOM's argmax head otherwise.

EagleProposer: a draft model that sets draft_returns_head_input returns
(state, head input) from forward_with_head (with selected_rows on step 0 of a
decode) and maps the head input to ids (compute_draft_ids_from_head), in a
declared draft pass and eagerly alike. Qwen3.8 is an MRoPE model, so on main
its draft steps 1+ have no declared pass and run eagerly: the program is one
launch a layer, so they cost the same as in a captured graph (measured with
#2663 applied: request TPOT within 1%). The recipe, the environment variable
table and CLAUDE.md name the deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
yitingw1
yitingw1 previously approved these changes Nov 4, 2026
yifehuan added a commit that referenced this pull request Nov 4, 2026
* feat(qwen38): mono layer programs for Qwen3.8-Flash-Next

Whole decoder layers as one resident FlyDSL program each (gfx950): stages
(blocks/: linear, hyper-connection, GDN with the classic and the ReplaySSM
verify, QSA with its indexer, MoE, select-row, prefetch) composed by
recipe.compose_layer with the frozen per-scenario schedules, placed on the
256 resident CTAs (program, layer), handing off through tagged mailbox
regions (device/), emitted as one kernel (emit, kernel), and the weight
layouts the stages read, checked at bind time (weights/). The geometry is
read from the checkpoint's config.json and checked against the one the
kernels are written for.

The package follows the other mono models (atom/models/<model>/mono/); the
model itself stays the single-file qwen4_exp.py.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(qwen38): serve the target's MTP verify steps on mono

install_mono_decode (models.registry.mono_decode_installers) wraps the loaded
Qwen4ExpForConditionalGeneration: a target MTP3 verify step of 1-4 requests
runs its 48 decoder layers as 48 launches (36 GDN, 12 QSA programs; the PLE
layer runs ATOM's PLE prelude first), reading and writing ATOM's GDN pools,
ReplaySSM records and QSA caches in place, so a request moves between mono
and Native steps. Every other step runs ATOM's model.

Mono keeps its own A8W4 copy of the routed experts, so the PLE n-gram table
(~95 GB BF16) goes to page-locked host memory, read over UVA (ple.py, chosen
at the table's construction): in HBM it would leave no room for the
per-request state at --max-num-seqs 160. #2771 does the same for every
deployment; this copy goes once it merges.

config_refusal checks the deployment once, at install: the full 48-layer
Quark MXFP4 checkpoint, MI355X with all 256 CUs, TP1, MTP3, FP8 KV with BF16
index caches on 64-token blocks, an fp32 GDN state, BF16 draft caches, either
GDN verify (ReplaySSM, ATOM's default, up to 64 records, or classic), no KV
connector but LMCache CPU offload (resident_grid_refusals takes the
connectors a model admits). Every served shape is built at startup; one that
does not build stops the server (ATOM_MONO_ENABLE=0 serves it Native).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* feat(qwen38): run the MTP drafter on mono

install_mono_decode wraps the loaded Qwen4ExpMTP as well (MonoDraftModel,
as V4.1 wraps its DSpark model). A draft step of 1-4 requests runs the
drafter's BF16 layer as one mono program (DraftRunner): the first step
attends every verify row and runs the MLP on each request's last row only,
later steps run one row. Any other step of at most 16 rows runs the fallback
draft (fused QSA attention, ATOM's BF16 MLP, one-row CK experts built at
startup), anything larger ATOM's layer. The draft ids come from a Group64
W4 screen with a global error certificate refined on the BF16 rows: the BF16
head's argmax for 1, 2 and 4 rows, ATOM's argmax head otherwise.

EagleProposer: a draft model that sets draft_returns_head_input returns
(state, head input) from forward_with_head (with selected_rows on step 0 of a
decode) and maps the head input to ids (compute_draft_ids_from_head), in a
declared draft pass and eagerly alike. Qwen3.8 is an MRoPE model, so on main
its draft steps 1+ have no declared pass and run eagerly: the program is one
launch a layer, so they cost the same as in a captured graph (measured with
#2663 applied: request TPOT within 1%). The recipe, the environment variable
table and CLAUDE.md name the deployment.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* fix(qwen38): one REPLAY_MAX, token-major GDN conv pools only, the hub-id install test

- REPLAY_MAX is defined once, in kernels/geometry.py (CPU-safe); blocks/gdn.py
  and config.py import it instead of keeping two copies.
- GDNPools takes ATOM's conv pool as it is, token-major [slots, W, conv_dim]
  (gdn_attn's mamba_k_cache): the branch that transposed a [slots, conv_dim, W]
  view never ran in serving, and a channel-major pool is refused with the
  layout mono reads.
- test_install_reads_a_hub_id_checkpoint_from_its_local_directory is back,
  for the install-time drafter wrapping: the resolved directory reaches
  load_geometry, DraftRunner and TargetRunner, and the drafter is wrapped.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
StagedInput says which axis is the batch (batch_dim, default 0) instead
of whether it is last. bind() allocates max_num_seqs at that axis, and
stage(), buffer() and the tail repeat narrow along it: one path for every
layout, with no branch on it.

MRoPE positions are now a [3, max_num_seqs] buffer, viewed per batch as
[3, bs] with stride (max_num_seqs, 1). Every reader of the draft's
positions indexes them by stride (rows by stride(0), tokens with
stride(-1) == 1) or reads only shape[-1].

A batch-1 view is the front of a wider one at the same stride, so
buffer() no longer refuses batch_dim=1 roles; a test stages a producer
fill of one.
@ZLkanyo009

Copy link
Copy Markdown
Contributor Author

Pushed aea839bcc..19cc0e7ea: merged main (ab4ada40a, no conflicts) and replaced StagedInput.batch_last with batch_dim.

Why. Review feedback on the previous head: the batch_last branches spread across DraftGraph (allocation, batch counting, buffer(), staging, padding) made the staging layer hard to read.

New design. StagedInput.batch_dim: int = 0 says which axis is the batch; every other drafter keeps the default and is unchanged. bind() allocates max_num_seqs at that axis, and stage(), buffer() and the tail padding all narrow along it, so DraftGraph has one code path and no branch on the layout. _batch_of and buffer()'s batch-last refusal are gone. MRoPE positions now live in a [3, max_num_seqs] buffer; each batch is narrow(1, 0, bs), a [3, bs] view with stride (max_num_seqs, 1). A buffer() view at scheduled_bs is the front of the running_bs view at the same address and stride, so a producer can fill a batch_dim=1 role in place too (new test).

@yitingw1, this is the layout you argued against on 10-22 ("a flat buffer over a [3, max_num_seqs] sliced [:, :bs] … the simpler-looking option is the wrong one", #2663 (comment)). Your reason was that the kernels inside the graph must see what eager hands them. That still holds, and the strided view is safe:

  • An uncaptured batch, and every batch with ATOM_DRAFT_CUDAGRAPH=0, runs DraftGraph.run's eager path on the same staged view, so a replay and an eager step get the same layout. The stride is now (max_num_seqs, 1) at every batch size instead of depending on bs.
  • Main's target decode already hands the same Qwen4ExpAttention a strided [3, tokens] view: backends.py returns self._copy_mrope_to_gpu(width)[:, :scheduled_tokens], and test_decode_mrope_storage_matches_padded_graph_stride pins stride(0) == 4 * width.
  • Every reader of the draft's positions takes strides or only the length:
reader how it reads the draft's positions
qwen4_exp/ops/qsa.py qsa_apply_mrope (attention q/k, indexer project_qk) stride(0) between axes and stride(-1) between tokens, passed to the kernel
qsa_attention.canonical_rope_positions → qsa_store_rows (rope_position_cache; normalize_compressed_keys reads the cached first_positions from there) transposed view; the store kernel uses the value strides
mono fallback draft kernels/qsa_epilogue.qsa_norm_rope_cache, kernels/qsa_index (bs 5–16) stride(0) and stride(-1)
mono DraftRunner.serve (bs 1–4) does not read positions (qsa.logical_positions)
mono draft_fallback.attention fallback module(positions, mixed) into Qwen4ExpAttention, as above
qwen4_exp_attn / gdn_attn prepare_mtp_decode shape[-1] only
eagle_proposer: warmup copy_, _enter_decode_metadata index_select, _advance_decode_metadata += 1 in-place / indexed ops on the view
Qwen4ExpAttention._profile_attention profile path only
triton_mrope.try_mrope_qk_fused, triton_fused_qkv_norm_rope_cache attention_mha only (not Qwen3.8); both pass stride(0)
  • On GPU, a probe around DraftGraph.run snapshotted each replayed step and re-ran it on the same staged inputs: replayed again, eagerly on the same [3, bs] view, and eagerly with positions.contiguous() (GSM8K at concurrency 16, mono on and off, every batch size 1–16 that occurred). Bit-identity turned out not to be a usable bar: re-running one step is not bit-reproducible on this stack, in eager as much as in replay. Two eager runs on the same inputs never give bit-identical hidden states and give different draft ids in 1–6% of steps. Replay against eager, and strided against contiguous, differ exactly as much as those controls. For example, this head with mono on at bs 5–16, draft ids were equal in:

    • 672/708 steps for replay against eager;
    • 674/708 for strided against contiguous;
    • 672/708 for replay against replay;
    • 685/708 for eager against eager.

    The branch's previous flat layout gives the same numbers. So within the stack's own run-to-run noise there is no difference between replay and eager, or between the two layouts.

Review-pr self-check (an independent review of the local head before this push): no must-change. Fixed: a docstring sentence in eagle_proposer.py that contradicted main's paragraph above it, and a test that hard-coded max_num_seqs. Recorded in the description's new "Not changed" section: MRoPE drafts on attention_mha (Qwen3.5) are not opted in, and two readers there need checking before one is; the mono S1 address check no longer runs at the captured bs 1/2/4; tests/test_qwen4_exp_mtp.py still skips on CPU CI.

Lines vs merge-base (source code / source comments / test code / test comments): aea839bcc +49/−11, +12/−7, +75/−0, +8/−2 (+144/−20); now +39/−15, +10/−8, +81/−1, +10/−2 (+140/−26). Main's test for a source whose leading axis is not the batch matches the new message ("batch at axis 0"), and main's comment "(MRoPE) runs here, eagerly" in propose loses "(MRoPE)", which this PR makes untrue.

Verification at 19cc0e7ea:

  • black 26.10.1 / ruff 0.17.2 clean.
  • CPU full suite: 4 failed / 11195 passed / 1163 skipped. The failures are main's 3 (test_graph_marker ×2, test_sparse_indexer_side_effect_op) plus test_multiprocess_metrics, a spawned process exiting under load that passes alone and does not touch draft graphs.
  • GPU unit tests (MI355X, aiter wheel 4d1980603), the 18 draft graph / MTP / DSpark / region graph / mono files: 337 passed / 19 skipped / 0 failed (main ab4ada40a: 331 / 19 / 0).
  • GSM8K, all 1319 questions, concurrency 64, real MTP-3:
    • this head: 0.9788 strict, acceptance 72.56%;
    • the branch before the refactor: 0.9795, 72.18%.
  • bs=1 decode iteration (three alternating sessions each, one GPU):

Pending: CI running; re-approval; the 4 lgtm'd valar-cop threads still need resolving before merge.

@yitingw1 please re-approve, cc @valarLip

@zhuyuhua-v
zhuyuhua-v merged commit b90ae99 into main Nov 6, 2026
62 checks passed
@zhuyuhua-v
zhuyuhua-v deleted the lingzha/qwen38-mtp-draft-graph-mrope branch November 6, 2026 02:21
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants