Skip to content

qwen4exp : port NextN/MTP speculative decoding to master (+50% decode at 70k) - #28104

Closed
JayToltTech wants to merge 6 commits into
ggml-org:masterfrom
JayToltTech:feat/qwen4exp-mtp-upstream
Closed

qwen4exp : port NextN/MTP speculative decoding to master (+50% decode at 70k)#28104
JayToltTech wants to merge 6 commits into
ggml-org:masterfrom
JayToltTech:feat/qwen4exp-mtp-upstream

Conversation

@JayToltTech

Copy link
Copy Markdown

Ports qwen4exp NextN/MTP speculative decoding onto current master, working and fast.

Related: #27836 (an earlier MTP attempt, different implementation), #28060 / #28061 (a Vulkan livelock found on the way, submitted standalone).

Result

AMD Strix Halo (gfx1151), Vulkan/RADV, Qwen3.8-Flash-Next UD-Q4_K_XL, Q4_K_M MTP head, temp 0:

build short ctx 70k ctx
master, no MTP 25.7 10.69
this PR, no MTP 24.48 10.92
this PR, with MTP 25.83 16.08

+50% decode at 70k, draft acceptance 70-80%. The gain grows with context, which is the point: qwen4exp runs full attention on 12 of 48 layers at ~24 KiB/token, so verifying several drafted tokens per forward pass amortises the per-step cost that otherwise dominates at depth.

At temp 1.0 / top-p 0.95 / top-k 20 on the reference tree the same approach holds up across generation lengths (50/100/200/400 tokens: 18.32 / 15.01 / 15.92 / 15.61 tok/s), so this is not a temp-0-only artifact.

Commits

Four from @Nathanw1014's strix-halo-vulkan, authorship preserved — the MTP implementation is entirely theirs:

Two of mine, both required to make it work on master:

qwen4exp : defer the output-row gather past the h_nextn export — the drafter consumes one hyper-connection row per prompt token (common_speculative_impl_draft_mtp::process() memcpys n_tokens-1 rows out of the nextn buffer), but master's graph drops non-output rows inside the last layer, before the export. The reader then walks off the end:

ggml-backend.cpp:283: GGML_ASSERT(offset + size <= ggml_nbytes(tensor) && "tensor read out of bounds") failed
  ggml_backend_tensor_get_async / llama_context::decode

I skip the early gather while a drafter is attached and re-apply it right after the export. Marking the target context masked instead does not work — the flag also gates a second gather in the export and the drafter has no output-indexed path; that route segfaults in the memcpy.

server : keep speculative checkpoints on device — the substantive one. qwen4exp is a recurrent hybrid, so the target cannot partially seq_rm and the server takes a full recurrent-state checkpoint every speculative round, restoring it on every rejection. Through the host path that serialises every GDN layer conv+state row, the 4-stream residual and the PLE history with one synchronous backend read per tensor. Cost is flat in context and swamps everything: 201 ms/token decode, roughly 600 ms of each 825 ms round. The six speculative call sites now request LLAMA_STATE_SEQ_FLAGS_ON_DEVICE; the library already implemented that path, the server just never asked. 4.77 -> 25.83 tok/s short, 4.33 -> 16.08 at 70k.

Prompt-cache checkpoints are deliberately left host-resident — they retain several historical states rather than one live round.

The ON_DEVICE flag and mechanism are Gaetan Puleo's (c8b681b6f, carried as 08a3255). Diagnosis of which call sites mattered: Claude Fable 5.

Note for #27836

That PR reports ~4.7 tok/s at 90% acceptance — the same profile this had before the checkpoint fix. If it is hitting the same host-path checkpointing, the fix may be independent of its graph work.

Caveats

  • Single-backend validation: Vulkan on gfx1151 only. No CUDA, Metal or CPU numbers.
  • Remaining ~8% gap to strix-halo-vulkan is likely the PLE host-side gather (an in-graph ggml_get_rows over a CPU-resident table, splitting the graph GPU->CPU->GPU per ubatch); not addressed here.
  • Draft head is a community sidecar (dzannotti/Qwen3.8-Flash-Next-MTP-GGUF, Q4_K_M); the converter does not emit one.

Nathanw1014 and others added 6 commits August 30, 2026 17:46
The qwen4exp converter drops the MTP head, but community sidecar drafters now
exist (agentionai's Qwen3.8-Flash-Next-MTP-Q8_0: arch qwen4exp, block_count 49,
nextn_predict_layers 1, upstream tensor naming, hc_norm gammas verified
(1+w)-folded against the raw HF checkpoint by range read - exact +1.0 offset).

Follows the deepseek4 pattern: optional NEXTN_PREDICT_LAYERS KV with a
tensor-presence downgrade, mtp_only detection when the trunk is absent, trunk
tensors TENSOR_NOT_REQUIRED in that case, NextN tensors TENSOR_SKIP unless the
context requests them.

qwen4exp-specific differences from deepseek4, both dictated by the file format:
- the NextN block is always a full-attention QSA layer (gated attention, own
  nh=4 indexer, full 512-expert MoE, both HC pairs); is_recr() is derived from
  full_attention_interval and would misclassify blk.48 as linear attention, so
  the MTP layer forces the full-attention branch.
- nextn.hnorm is hc-space ({hc_dim}, 10240), not {n_embd}: the draft head
  consumes the target's 4-stream hyper-connection state.

A standalone load of a sidecar now aborts with a clear message instead of
segfaulting in the first hc_mix (the trunk tensors are null by design; the
mainline graph cannot be built from a drafter file).

Draft graph and --spec-type draft-mtp wiring are the follow-up commit; this one
is loader-only and mainline GGUFs are unaffected (KV absent -> n_layer_nextn 0,
identical load path).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit 23d8b2c)
(cherry picked from commit 3543908)
…o end

Second half of the MTP wiring (loader was the previous commit). Verified end to
end on the 64 GB box: Q4_K_M target + agentionai's community MTP sidecar,
--spec-type draft-mtp, coherent temp-0 generation through the Vulkan lane.

- graph_mtp: [enorm(embd(tok)) repeated across streams ; grouped-RMS hnorm(h)]
  -> eh_proj per stream -> one HC-wrapped full-attention block -> the shared
  head mixer (which doubles as the output norm). h arrives hc-space: the
  mainline graph exports the 4-stream residual under cparams.embeddings_nextn.
- the MTP context holds a PLAIN attention cache over the nextn layer(s), dense,
  no recurrent state - the deepseek32 pattern. Discovered the hard way: a
  hybrid memory with an empty recurrent layer set fails its buffer allocation.
- exports hand the scheduler REAL nodes: a naked reshape view gets no backend
  assignment and the h_nextn extraction asserts.
- the draft runs dense attention (QSA gate is null-safe now); the reference
  precedent is deepseek32, whose MTP head also runs dense.

Correctness only at this point: no acceptance-rate or throughput claims - the
smoke ran under heavy memory pressure and the interactive-trap log spin.
Measured cleanly it may or may not pay; that is the next session's A/B.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
(cherry picked from commit b5b7c8c)
(cherry picked from commit 39817c4)
This reverts commit a17e843 (the v0.6.9 revert). The stall that motivated
the revert was not in the state-save path: it was a replay re-verification
livelock in the server's full-checkpoint rollback, fixed in the previous
commit. With that in place, full checkpoints restore token-exact MTP rollback
on recurrent targets again.

Verified on gfx1151 Vulkan (Qwen3.6-35B-A3B Native-MTP Q6_K, greedy): the
v0.6.8 repro completes 800 tokens in 12 s, a 3000-token request runs through
the former stall horizon to EOS at 62 tok/s, and repeat runs are bit-identical.
CPU spec-vs-nospec is token-exact through the canonical 192-token matrix.

Assisted-by: Claude Fable 5
(cherry picked from commit f25eefe)
…store

With full-checkpoint rollback, a partial draft acceptance restores the
pre-round state and re-decodes the accepted tokens to rebuild it. The replay
went through the same verification as a fresh draft. On backends where logits
change with batch shape or memory layout (Vulkan), that re-verification can
reject a token the original verification accepted; the rejection restores the
same checkpoint and replays again, and the slot loops on one position without
emitting anything. qwen35moe with --spec-type draft-mtp stalled this way a few
hundred tokens into long generations (the v0.6.8 MTP hang): the loop repeated
"accepted 2/3, restore at pos 995" every 27 ms with the GPU at 90 percent.

Accept the replayed tokens without re-verifying and sample only the
continuation from the final position. The replayed prefix was accepted by the
verification that triggered the restore; the replay exists to rebuild state.
On backends with batch-shape invariant logits the re-verification always
agreed, so behavior there is unchanged (verified bit-identical on CPU with
and without this change, 800-token greedy pair, 118 restore rounds).

Assisted-by: Claude Fable 5
(cherry picked from commit 9c5d899)
The MTP drafter consumes one hyper-connection row per prompt token: its
prefill path memcpys n_tokens-1 rows straight out of the nextn buffer. The
mainline graph dropped the non-output rows inside the last layer, before the
export, so the exported residual carried only n_outputs rows and the reader
walked off the end -- GGML_ASSERT(offset + size <= ggml_nbytes(tensor)) on the
first decode.

Skip the early gather while a drafter is attached and re-apply it immediately
after the export, so the drafter sees every row and the output head still sees
only the output rows. Costs one extra layer of compute on dropped rows in the
last layer, which is why the gather sits where it does when no drafter is
attached.

Setting the target context masked instead does not work: the flag also gates a
second gather in the export, and the drafter has no output-indexed path.
qwen4exp is a recurrent hybrid, so the target context cannot partially seq_rm
and the server classifies it SEQ_RM_TYPE_FULL. Every speculative round then
takes a full recurrent-state checkpoint, and every rejected draft restores one.
Through the host path that means serializing each GDN layer conv+state row, the
4-stream hyper-connection residual and the PLE history into a host vector with
one synchronous backend read per tensor, then pushing it all back.

The cost is flat in context and swamps everything else: with the MTP drafter
attached, decode ran 201 ms/token against 29 ms/token on a tree that keeps the
state on device, and a 120-token generation spent roughly 600 ms of each 825 ms
round in checkpoint traffic. It also inflates the reported prompt eval time,
which runs to first token and so absorbs the first save: 827 ms for an
11-token prompt.

Request ON_DEVICE at the six speculative checkpoint sites so the state stays in
device buffers. The library already implements this path; only the server never
asked for it. Measured on gfx1151 Vulkan, Qwen3.8-Flash-Next UD-Q4_K_XL with a
Q4_K_M MTP head: 4.77 -> 25.83 tok/s at short context, 4.33 -> 16.08 at 70k,
draft acceptance unchanged at 70-80 percent.

The prompt-cache checkpoints are deliberately left host-resident: they retain
several historical states rather than one live round.

Diagnosed by Claude Fable 5. The ON_DEVICE flag and the mechanism come from
Gaetan Puleo (c8b681b6f), carried in Nathanw1014/llama.cpp as 08a3255.
Copilot AI lite review requested due to automatic review settings August 31, 2026 17:31
@JayToltTech
JayToltTech requested review from a team and CISC as code owners August 31, 2026 17:31

Copilot AI left a comment

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.

Copilot was unable to review this pull request because the user who requested the review has reached their quota limit.

@JayToltTech

Copy link
Copy Markdown
Author

Converted to draft, and holding pending @Nathanw1014's permission.

A maintainer rightly pointed out on #28061 that I should not submit someone else's work without asking first. That applies here too: four of the six commits are @Nathanw1014's, and I filed this without asking. My mistake, and I have closed the other PR.

@Nathanw1014 — this stays a draft until you say otherwise. Three options, all fine by me: you take it over and submit it yourself (I will close this), you give the go-ahead and I take it out of draft, or you would rather it not be upstreamed at all and I close it. No obligation either way.

Two of the commits here are mine and are not affected by that — I am noting them separately in case they are useful to anyone independently of the rest:

  • server : keep speculative checkpoints on device — the six-line change that took this from 4.77 to 25.83 tok/s. Detail in the description and in my comment on qwen4exp : add NextN/MTP draft head (--spec-type draft-mtp) for Qwen3.8-Flash-Next #27836. The ON_DEVICE flag and mechanism are Gaetan Puleo's (c8b681b6f); what is mine is only identifying that the speculative call sites needed it while the prompt-cache ones do not.
  • qwen4exp : defer the output-row gather past the h_nextn export — required on current master, where the drafter otherwise reads past the end of the exported residual.

@llbzow — your dual-A6000 report of "acceptance matches, throughput regresses ~2.2x" on CUDA may be the same cause. The checkpoint traffic I measured is a per-round host round-trip of the full recurrent state, which is not Vulkan-specific; I only measured it on Vulkan because that is the hardware I have. If you are able to test LLAMA_STATE_SEQ_FLAGS_ON_DEVICE at those six call sites on your setup, that would be a genuinely useful independent data point, and it would tell us whether this is a backend quirk or general.

@ggml-gh-bot

ggml-gh-bot Bot commented Aug 31, 2026

Copy link
Copy Markdown

Hi @JayToltTech, thanks for your contribution!

Per our contribution guidelines, the automated PR checker found the following issue(s) that need your attention:

  • PR Template not respected: Please respect the template when creating a new pull request. Make sure to fill out all required sections.

  • AI-generated content: While code is allowed to be generated by AI, please write the PR description and commit messages on your own without the help of AI.


Please note that maintainers reserve the right to make final decisions on PRs. If you believe there is a mistake, please comment below.

@ggml-gh-bot ggml-gh-bot Bot added the draft PR will be changed to draft by github-actions bot label Aug 31, 2026
sodre90 added a commit to sodre90/llama.cpp that referenced this pull request Aug 31, 2026
Superseded by PR ggml-org#28104's port, which additionally fixes the checkpoint
round-trip and replay-livelock issues this graph-only version was
blocked on (get_idx() segfault under the reworked hybrid-memory graph).

This reverts commit 482228e.
@JayToltTech JayToltTech closed this Sep 1, 2026
@JayToltTech

Copy link
Copy Markdown
Author

@Nathanw1014 closing this out rather than leave it hanging on you. I should have asked before filing it, not after.

The MTP work is yours. Take it upstream whenever suits, or don't. My two commits on the branch (the h_nextn gather fix, and the on-device checkpoint flag) are free for you to use or drop as you see fit, no credit needed.

Branch is still on my fork if it's any use: feat/qwen4exp-mtp-upstream.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

draft PR will be changed to draft by github-actions bot model Model specific server

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants