Skip to content

qwen4exp : add NextN/MTP draft head (--spec-type draft-mtp) for Qwen3.8-Flash-Next - #27836

Draft
rmonsurate wants to merge 3 commits into
ggml-org:masterfrom
rmonsurate:qwen4exp-mtp
Draft

qwen4exp : add NextN/MTP draft head (--spec-type draft-mtp) for Qwen3.8-Flash-Next#27836
rmonsurate wants to merge 3 commits into
ggml-org:masterfrom
rmonsurate:qwen4exp-mtp

Conversation

@rmonsurate

@rmonsurate rmonsurate commented Aug 27, 2026

Copy link
Copy Markdown

Overview

Adds the NextN/MTP draft head for qwen4exp. Tested in the Fabric harness using Qwen3.8-Flash-Next. This is a follow-up PR to #27742.

The combiner must be run per hyper-connection stream on the wide hidden state. If you do mean pooling first, the acceptance rate drops catastrophically — other people might hit this, so just leaving this here.

Converter support is included (--mtp).

Additional information

Measured on M3 Max 128GB, UD-IQ4_XS (temp-0 output byte-identical with MTP on/off):

config acceptance tok/s
baseline (no speculation) 27.43
--spec-draft-n-max 2 89.2% 37.22 (+35.7%)
--spec-draft-n-max 3 85.7% 38.83 (+41.6%)

Prior art: #27739.

Requirements

  • I have read and agree with the contributing guidelines
  • AI usage disclosure: YES - the implementation, tests, and benchmark runs were produced with Claude Code under my direction; I reviewed the changes. This PR description is my own writing (dictated).

Adds the MTP head's own hyper-connection mixer tensor names and lists the
NextN tensors under the qwen4exp architecture.
Adds --spec-type draft-mtp support for Qwen3.8-Flash-Next.

The MTP head folds the next token's embedding into the trunk's wide
hyper-connection residual, runs one trunk-style block (dense attention +
MoE) over it, and collapses the result with its own mixer before reusing
the trunk's LM head.

- read nextn_predict_layers so n_layer() excludes the MTP block
- load the trailing block through the existing trunk path: is_recr() and
  is_ple() are already false past the trunk, so it needs no special casing
- eh_proj fuses the checkpoint's fc_embedding and fc_hidden side by side,
  so one matmul computes fc_embedding@e + fc_hidden@h
- the head carries its own hyper-connection mixer, mirroring the trunk's
  hc_head_*, which stands in for the output norm qwen4exp does not have
- export the wide pre-collapse residual as t_h_nextn from both graphs, so
  the driver can feed it back for the next draft step
- route MTP contexts to a plain KV cache filtered to the trailing layer

The draft block attends densely for now: the trunk's QSA only prunes
context past a 2048-token budget, so dense is a numerical superset and
drafts are verified either way. Indexer tensors are still loaded.
The MTP block is one trunk-shaped block (dense attention + MoE wrapped in
hyper-connections) plus a head-level combiner, so once _QwenMtpMixin renames
mtp.layers.0.* to the trailing block index its tensors ride the existing
qwen4exp mappings unchanged. Two head-level pieces need handling:

- fc_embedding and fc_hidden fuse into the eh_proj the shared NextN code
  expects, since W_e@e + W_h@h == [W_e|W_h] @ concat(e, h)
- mtp.hyper_connection_mixer.* is the head's own copy of the trunk's
  hc_head_* output mixer, unindexed in the checkpoint and per-block in the
  GGUF

compress_ratios is read with length block_count, so it gains a trailing 0
for the MTP block, which attends densely.

--no-nextn drops the head; --mtp exports it on its own.
@rmonsurate
rmonsurate requested a review from CISC as a code owner August 27, 2026 23:24
@github-actions github-actions Bot added model Model specific conversion labels Aug 27, 2026
@ggml-gh-bot

ggml-gh-bot Bot commented Aug 27, 2026

Copy link
Copy Markdown

Hi @rmonsurate, 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 27, 2026
@github-actions
github-actions Bot marked this pull request as draft August 27, 2026 23:29
@github-actions github-actions Bot removed the draft PR will be changed to draft by github-actions bot label Aug 27, 2026
@cat5edopeHA

Copy link
Copy Markdown

AMD HIP validation + tuning report (2× Radeon AI PRO R9700, gfx1201, TheRock 7.14)

Thanks for this PR — it fixes the exact decode crash we hit on the #27742 merge (the ggml_backend_tensor_get_async readback assert, both local and over RPC). We built 1d8de7c1b (GGML_HIP + GGML_RPC + GGML_HIP_GRAPHS, TheRock 7.14) and can confirm:

MTP works on AMD HIP. Load (creating MTP draft context), generation, no SIGABRT. Temp-0 draft acceptance 59–69% at --spec-draft-n-max 2 on a methodology-prose workload (your prose reference: 69.1% — close match).

Notes for the converter / file format

The only community export we could find (Qwen3.8-Flash-Next-UD-Q4_K_XL + MTP block, dzannotti) was old-format: missing nextn.hc_head_*, 8-byte aligned data section, and split.tensors.count not matching the file. The merged reader is strict (32-byte padded data start + contiguous offsets + exact count) and rejects it. We grafted the three mtp.hyper_connection_mixer.* tensors from the official Qwen/Qwen3.8-Flash-Next checkpoint into shard 5 (F32 norm with the +1 zero-centre shift, F16 mixer weights, hc_lr=320) and realigned — works perfectly. Worth a warning in the converter docs that the head must be part of the same file (not a sidecar) and that alignment/count checks are enforced at load.

Benchmarks (Q4_K_XL 5-shard, q8 KV, 16K ctx, temp 0, thinking off, 3 reps, harness-measured)

config TG t/s vs no-spec
no-spec (local, b2048/ub512/t4) 21.8
dn=2 · ubatch=1024 · t=8 25.5 / 25.2 (verify) +17%
dn=2 · ubatch=1024 23.9 +9%
dn=2 · t=8 23.8 +9%
dn=2 baseline 22.0–22.1 +1%
dn=3 / dn=4 20.7 / 20.6 −3…−5%
  • Over RPC (coordinator + CUDA worker) MTP is a net loss (dn=2: 21.7 vs no-spec 24.9, −13%) — every draft step pays the full dense 248K-vocab LM head plus a worker hop, so the tuned win is local-only.
  • Vulkan (RADV 26.1.8) is ~2× slower overall on this box (no-spec 10.6, best MTP 14.05) — HIP is the right backend here.
  • Greedy identity (MTP vs no-spec, temp 0): not byte-identical on HIP — outputs diverge mid-generation (first divergence around token ~60–120). Acceptance is unaffected. Likely numerics (our grafted mixer is F16/F32 vs the trunk's Q8_1 hc_head_*) or backend-specific — as you noted, identity is worth re-checking per backend; on ROCm it does not hold with this export.
  • Local temp-0 acceptance at dn=2: 59–69%; at dn=3: 51–58%; dn=4: 42–47%.

Happy to share the full per-rep matrix or the graft script if useful. Thanks again for the fix.

@freqmod

freqmod commented Aug 28, 2026

Copy link
Copy Markdown

I could not get this to work, so i asked qwen 3.8 to help me a bit. Now it is working for me, see https://github.com/freqmod/llama.cpp/tree/qwen4exp-mtp if you want to be inspired. i got it to patch the source code and the script to build the files so i don't have to download the whole model to convert it

NB: this is posted here mostly if anyone tinkering wants to see and use it while the pr gets approved. It is not following the llama.cpp ai policy as it is mostly done by qwen 3.8 27t so it is probably not useful for merging into the review. I quickly skimmed trough the diff and it looks reasonable to me. also it works on my computer whatever that is worth.

@gabrielfreire

Copy link
Copy Markdown

Can't get this to work either, it seems like the GGUFs do not come with MTP heads enabled or something, anybody knows which GGUF I should download to get this working ?

Aristo94 added a commit to Aristo94/EngramHalo.cpp that referenced this pull request Aug 28, 2026
@flobob45

Copy link
Copy Markdown

Vulkan validation report (gfx1151 / Strix Halo, RADV) — works, but draft path is a net loss on this backend

Built 1d8de7c (GGML_VULKAN, RDNA 3.5, 96 GB unified). Model: Qwen3.8-Flash-Next-UD-Q3_K_XL with a self-grafted MTP head (same approach as the report above: mtp.hyper_connection_mixer.*blk.48.nextn.hc_head_*, fc_embedding|fc_hidden fused into eh_proj, embed/lm_head copies kept).

What works:

  • Load + creating MTP draft context: clean, no SIGABRT (same spot where the older community patch segfaulted).
  • Temp-0 acceptance at --spec-draft-n-max 2: 90% (36/40) on a short structured prompt — consistent with your numbers, so the graft/converter path is sound.

What doesn't (Vulkan-specific):

  • Net throughput loss even at 90% acceptance: 5.1 t/s with MTP vs 21.4 t/s no-spec (short ctx, -fa on -ub 2048). The fixed per-step draft cost dominates on this backend — consistent with the RADV numbers in the report above, but worse: on gfx1151 it never crosses break-even.
  • At temp > 0 (default sampling) generation collapses super-linearly with length: 5 tokens complete in 0.7 s, but 60 tokens did not finish in 350 s (GPU pegged at ~87% throughout). Looks like every rejected draft forces a full recurrent-state (DeltaNet) rebuild, so cost per step grows with position. Temp-0 mostly avoids it (60 tokens in 11.5 s) but the per-step overhead remains.

Question: is the draft context expected to checkpoint/reuse the recurrent state across draft steps on rejection, or is the full replay by design for hybrid archs? Happy to run additional Vulkan datapoints if useful.

@gabrielfreire

gabrielfreire commented Aug 28, 2026

Copy link
Copy Markdown

Would the work this guy is doing be of any interest

https://github.com/Nathanw1014/llama.cpp/commits/strix-halo-vulkan

It just works out of the box, I'm getting 21-30t/s with MTP on a EVO X2 Windows 11 128GB unified memory with Vulkan. Quite impressive as I wasn't expecting to get that performance at all.

I am using unsloth/Qwen3.8-Flash-Next-UD-Q4_K_XL with a separate MTP model Qwen3.8-Flash-Next-MTP-Q4_K_M.gguf

I tried using this PR but couldn't even load the MTP draft

@crusaderky

Copy link
Copy Markdown
Contributor

This doesn't work for me, but the reason is trivial: it looks for blk.0, whereas all GGUFs on hf (which correctly follow convention) ship blk.38.

This patch fixes it (cherry-pick this commit): crusaderky@a82a58a (beware: unreviewed AI slop).

This branch + the above commit work for me with on CUDA RTX3090, using this presets.ini:

[Qwen3.8-Flash-Next]
hf = unsloth/Qwen3.8-Flash-Next-GGUF:UD-IQ4_XS
ngl = 99
n-cpu-moe = 48
load-mode = mmap
jinja = true

ctx-size = 262144
flash-attn = on
kv-unified = true
cache-type-k = q8_0
cache-type-v = q8_0

# MTP drafter
spec-draft-hf = agentionai/Qwen3.8-Flash-Next-MTP-Q8_0-GGUF:Q8_0
spec-type = draft-mtp
spec-draft-ngl = 99
spec-draft-n-max = 3
spec-draft-p-min = 0.6

# Thinking mode
temperature = 1.0
top-p = 0.95
top-k = 20
min-p = 0.0
presence-penalty = 0.0
repeat-penalty = 1.0

@drluoto

drluoto commented Aug 28, 2026

Copy link
Copy Markdown

Data point supporting your combiner warning, from Strix Halo (Ryzen AI Max+ 395 / gfx1151, ROCm 7.1):

We tested an independent MTP port for qwen4exp (apepojken/llama.cpp branch qwen4exp-spec-mtp @ 472b7584, sidecar jockevaupptaget/.../mtp-Qwen-Qwen3.8-Flash-Next-Q8_0.gguf) before finding this PR. It shows exactly the failure mode your description predicts — and it is prompt-length-dependent, which made it easy to miss:

prompt tokens output (greedy)
133 correct
718 correct
2668 We need respond優惠提琴违法违规青岛含义制宜宁德… (multilingual noise)
6568 same degeneration

Identical at --spec-draft-n-max 6 and 2, so not draft depth. Speed on the corrupted runs measured 42.7 tok/s vs 16.8 baseline — i.e. the 2x headline reproduces while the output carries no information. Anyone benchmarking tok/s without reading output past ~1k prompt tokens will report success.

Since that branch's issues are disabled, leaving the repro here as it seems relevant to "the combiner must be run per hyper-connection stream... If you do mean pooling first, the acceptance rate drops catastrophically."

Happy to run this PR on gfx1151 (128 GB Strix Halo, UD-IQ4_XS) and report accept rates / tok/s / long-prompt correctness if useful — that hardware currently has no published numbers.

@drluoto

drluoto commented Aug 28, 2026

Copy link
Copy Markdown

Follow-up from the gfx1151 side: I tried to produce and run a standalone draft sidecar with this PR's --mtp export and hit what looks like a converter/loader contradiction. Sharing findings + a small patch, and one question about the intended sidecar layout.

Setup: fetched only the MTP-relevant tensors from Qwen/Qwen3.8-Flash-Next via HTTP-range reads on the safetensors (5.2 GB instead of 360), converted with this branch's --mtp --outtype q8_0.

Issue 1 — mtp_only whitelist drops the head mixer. The export kept only embed/norm/lm_head, but llama_model_qwen4exp requires the model-level output_hc_norm/_down/_up at load. One-line fix that got me past it, in conversion/qwen.py:

            keep = name in (
                "model.embed_tokens.weight", "model.norm.weight", "lm_head.weight",
                "embed_tokens.weight", "norm.weight",
            ) or name.startswith("model.hyper_connection_mixer.")

(The checkpoint names it model.language_model.hyper_connection_mixer.*; I renamed at fetch time — the VL prefix-strip may or may not cover this in a full conversion.)

Issue 2 — the exported sidecar can't be loaded standalone. The export writes block_count=49, nextn_predict_layers=1, tensors at blk.48.*. But common_speculative_init_result loads -md as a complete model, and the qwen4exp loader then requires all 48 trunk blocks:

llama_model_load: error loading model: check_tensor_dims: tensor 'blk.0.hc_attn_norm.weight' not found

block_count=1, nextn=1 is also impossible (GGML_ASSERT(n_layer_nextn < n_layer_all)). So as far as I can tell there is no metadata layout the current export could emit that the current loader accepts standalone.

Question: what is the intended usage — was your M3 Max test run with the draft head still merged in the target gguf (self-speculative, no -md), with the sidecar as an "extra model" overlay on a no-nextn target, or standalone -md? If overlay/self-draft is the intent, day-1 community GGUFs (Unsloth's were converted --no-nextn) can't use MTP without reconversion, which is worth calling out in the PR text.

Happy to test any layout on Strix Halo — target UD-IQ4_XS, 128 GB, ROCm 7.1. The ngram-mod baseline here is 33 tok/s on file-edit workloads, so an MTP acceptance rate like your 89% would be a big deal on this hardware.

julianmb added a commit to julianmb/haloq38flash that referenced this pull request Aug 29, 2026
unsloth ships this checkpoint at 83.8 gib (ud-q3_k_xl) / 87.2 gib (ud-iq4_xs) vs our
115.5 gib iq4_xs from the same f16. the gap is the 51b n-gram embedding table, which
llama-quantize keeps at high precision. that, not the engine or the context size, is
what made mtp not fit in 128g. correction posted to the engramhalo thread.

also records that qwen4exp mtp now exists upstream (ggml-org/llama.cpp#27836,
apepojken/llama.cpp@32af70900) and that our sidecar renumbering used the wrong contract.
@drluoto

drluoto commented Aug 29, 2026

Copy link
Copy Markdown

Delivering the promised gfx1151/ROCm numbers — this PR + @crusaderky's loader fix works on Strix Halo, and it composes with ngram-mod and with the hipCUB TOP_K path.

Setup: Ryzen AI Max+ 395 / Radeon 8060S, ROCm 7.1, target unsloth UD-IQ4_XS (93.7 GB), 128 GB unified. Branch qwen4exp-mtp @ 1d8de7c1b + crusaderky's a82a58a (detached-head loading) + the mtp_only whitelist fix from my previous comment. Sidecar: self-converted Q8_0 via this PR's --mtp export from range-fetched checkpoint tensors (5.2 GB pulled instead of 360). With the loader fix, the pre-existing community sidecar (jockevaupptaget) loads too.

Correctness first: greedy output at 2.7k-token prompts is clean in every configuration (this is where the earlier independent MTP port degenerated into multilingual noise), and arithmetic/tool-calling/vision all check out at 24k-token context.

Decode tok/s on real coding workloads (llama-server, greedy, 718-line Python file; A = rewrite file with small edit, B = targeted bugfix, C = write new code, D = prose):

config @8k ctx A B C D
ngram-mod only (prod baseline) 25.6 19.0 20.2 22.9
draft-mtp only (n-max 3) 31.4 29.0 25.8 22.3
draft-mtp,ngram-mod 40.6 40.2 25.4 22.2
draft-mtp,ngram-mod + hipCUB TOP_K (#26592) 47.1 24.7 31.7 24.1
config @24K ctx A C
ngram-mod only 21.3 16.1
draft-mtp,ngram-mod 26.3 18.8
+ hipCUB TOP_K 28.6 25.4

Baseline without any speculation is 16.8 tok/s, so the full stack is 2.8x on file rewrites at 8k and +58% on code-writing at 24k. (hipCUB runs with GGML_CUDA_DISABLE_GRAPHS=1 on ROCm 7.1 — per Geramy in #26592 the rocPRIM capture guard lands in 7.13+.)

B's variance across runs (24.7–40.2) tracks generation-content differences between builds at temp 0, not a regression — flagging it so nobody reads the last column pair as a CUB penalty.

This configuration is now our production stack. Thanks @rmonsurate and @crusaderky — happy to run any follow-up variant on this hardware.

@flobob45

Copy link
Copy Markdown

Follow-up to my Vulkan report above — we've now run the same grafted model through HIP/ROCm 7.2.3 on the same gfx1151 box (containerized, GGML_HIP_NO_VMM=ON, MMQ_MFMA=ON, ROCWMMA_FATTN=OFF), and the picture inverts completely:

MTP works great on ROCm (same spot where Vulkan collapsed):

  • default sampling: 25.2–27.5 t/s vs 21.6 no-spec (+17%), acceptance 60–74%, and a 1800-token generation completes with no super-linear blowup (Vulkan died at ~60 tokens)
  • temp 0: 35.7 t/s, acceptance 86% (dn=2) — consistent with your numbers
  • long context holds up: 82% acceptance at 66k, 15.3 t/s (vs 11.5 Vulkan no-spec)
  • prefill stays HIP-favored: 322/205 t/s at 25k/40k vs 249/173 Vulkan

Two operational notes for anyone reproducing on Strix Halo:

  • The full merged model must fit in the BIOS carve-out — HIP won't spill to GTT the way RADV does. With a 64 GiB carve-out the 90GB+head model only loads amputated, and hipBLAS handle creation fails (CUBLAS_STATUS_ALLOC_FAILED) even when weights fit exactly. 96 GiB carve-out required for this model.
  • --load-mode dio ballooned host RSS to ~27 GB and got OOM-killed on our 32 GB-visible split; plain mmap is fine. GGML_CUDA_ENABLE_UNIFIED_MEMORY=1 crashed silently at load.

One honest caveat: in our end-to-end agentic workload (browser automation + tool calls, 97-99% prompt cache hits), total run time was statistically identical between ROCm+MTP and Vulkan no-spec at equal power limits — the LLM-side gains dilute into tool/navigation time. The +17-39% is real for LLM-bound workloads (batch, long prefills, sustained generation), which is where this PR shines. Thanks again for the head — acceptance numbers held up beautifully across two backends.

@drluoto

drluoto commented Aug 29, 2026

Copy link
Copy Markdown

For anyone wanting to run this without assembling the pieces by hand, the working combination is now published:

@gabrielfreire this combination is what got the draft loading on our end — the load failure you hit is the detached-head issue crusaderky's commit fixes.

@mjungnickel18

Copy link
Copy Markdown

Follow-up datapoint from the Windows/CUDA side (RTX 3090 24 GB, 96 GB DDR4, UD-Q3_K_XL), testing apepojken's qwen4exp-spec-mtp branch (@843d575) with the prebuilt Q8_0 sidecar:

The combiner fix is confirmed on CUDA. Draft acceptance: 0.84–0.91 on code, 0.75–0.82 on step-by-step reasoning, 0.62 on German prose — versus 0.33–0.40 we measured earlier on a mean-pooling-combiner fork (same GGUF, same prompts). Keeping the hyper-connection streams distinct is clearly the difference.

But on a 24 GB card the speedup mostly evaporates: with --n-cpu-moe the verify batch (n-max 6 → 7 tokens) multiplies the host-RAM expert reads, so 0.88 acceptance netted only +16% on code (19.1 vs 16.5 t/s) and a net loss on prose. n-max sweep 1–16 plateaus at 6. This is a bottleneck the Strix Halo's unified memory simply doesn't have.

It composes beautifully with timadinorth's MoE expert-residency split (timadinorth#1 — hot experts in VRAM instead of whole layers): with ~74% of routed traffic served from VRAM, the verify batches get cheap and the same box reaches 24.1–26.4 t/s on code (+38–52% over baseline) at 0.86–0.91 acceptance.

Combined branch for reproduction: https://github.com/mjungnickel18/llama.cpp/tree/qwen4exp-mtp-plus-moe-residency (MTP branch + residency commit cherry-picked, flags in the branch's service script: -md <sidecar> --spec-type draft-mtp --spec-draft-n-max 6 --spec-draft-p-min 0.75 -ot "exps_cold=CPU" -ub 2048 -ctk/v q8_0 -np 1).

Happy to re-test on this setup once the head lands here upstream.

@sammcj

sammcj commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Adding some data for Apple Silicon.

M5 Max 128GB, Unsloth UD-IQ4_XS with the standalone MTP head GGUF merged into the target's file set (nextn tensors as a 4th split, since current converts drop them). Prompts are C++ source-code continuation; prose lands a few t/s lower with the same ordering.

ctx PP t/s no spec draft-mtp speedup
4K 1010 39.2 44.3 +13%
16K 781 34.3 40.6 +18%
32K 632 29.4 34.1 +16%

Short context: 41.6 t/s no spec → ~70 with draft-mtp (+68%), ~90% acceptance (--spec-draft-n-max 6 --spec-draft-p-min 0.7 --spec-draft-backend-sampling; n-max 8+ regresses, the head recurses usefully to ~6).

One finding: combining with draftless ngram (draft-mtp,ngram-mod) measured consistently worse than draft-mtp alone (~-4 t/s at short context and on echo-heavy prompts, both-orders A/B; within noise at depth on novel code). Since draftless ngram takes per-step priority, its matches displace the higher-acceptance MTP drafts.

@drluoto

drluoto commented Aug 29, 2026

Copy link
Copy Markdown

@sammcj thanks — ran your exact question as an A/B on Strix Halo (gfx1151/ROCm, UD-IQ4_XS, same coding workloads as before, greedy):

@8k ctx, tok/s file rewrite bugfix new code prose
draft-mtp alone, n-max 6 p-min 0.7 33.2 34.0 32.3 26.6
draft-mtp,ngram-mod, same 45.4 30.1 32.3 26.5
@24K file rewrite new code
alone 27.7 28.4
combined 29.5 25.1

So your displacement finding partially replicates on ROCm: MTP-alone wins bugfix (+13%) and novel-code-at-depth (+13%) — the ngram matches do crowd out higher-acceptance MTP drafts there. But on echo-heavy file rewrites the combination still wins big here (45.4 vs 33.2), opposite of your Metal result. Plausibly backend-dependent: on this box ngram's long free drafts amortize better against the wide-residual verify cost than they do against Metal's.

Your n-max 6 --spec-draft-p-min 0.7 tip was worth taking regardless — it beat our flat n-max 3 on nearly every workload (+22% bugfix, +10% prose) and is now our production default, keeping the combo since agentic file-rewrites dominate our traffic. If your workload is mostly novel generation, MTP-alone looks like the right call on both platforms.

dzannotti added a commit to dzannotti/strix-halo-llama.cpp that referenced this pull request Aug 30, 2026
Squashed so the set reverts as one on rebase.

Takes the line descending from the merged ggml-org#27742: ggml-org#27836 (NextN/MTP draft head)
and ggml-org#27941 (danielhanchen's follow-up fixes), plus ggml-org#27977 and fifteen others.
Drops ggml-org#27879 (third-party correctness fixes) -- it conflicts with ggml-org#27836 in
qwen4exp.cpp, and MTP is not optional here.

ggml-org#27836 supplies everything the MTP head needs: it reads nextn_predict_layers,
loads the nextn block behind ml.load_mtp, and adds the MTP graph. Earlier local
patches for those are dropped as redundant.

Still ours: the two --ngram-on-disk bugs from halo-box#8, which live in
code these PRs do not touch. ple_head_offsets was applied twice, and can_reuse
dereferenced rows -- null whenever the table is on disk.
dzannotti added a commit to dzannotti/strix-halo-llama.cpp that referenced this pull request Aug 30, 2026
Squashed so the set reverts as one on rebase.

Takes the line descending from the merged ggml-org#27742: ggml-org#27836 (NextN/MTP draft head)
and ggml-org#27941 (danielhanchen's follow-up fixes), plus ggml-org#27977 and fifteen others.
Drops ggml-org#27879 (third-party correctness fixes) -- it conflicts with ggml-org#27836 in
qwen4exp.cpp, and MTP is not optional here.

ggml-org#27836 supplies everything the MTP head needs: it reads nextn_predict_layers,
loads the nextn block behind ml.load_mtp, and adds the MTP graph. Earlier local
patches for those are dropped as redundant.

Still ours: the two --ngram-on-disk bugs from halo-box#8, which live in
code these PRs do not touch. ple_head_offsets was applied twice, and can_reuse
dereferenced rows -- null whenever the table is on disk.
dzannotti added a commit to dzannotti/strix-halo-llama.cpp that referenced this pull request Aug 30, 2026
Squashed so the set reverts as one on rebase.

Takes the line descending from the merged ggml-org#27742: ggml-org#27836 (NextN/MTP draft head)
and ggml-org#27941 (danielhanchen's follow-up fixes), plus ggml-org#27977 and fifteen others.
Drops ggml-org#27879 (third-party correctness fixes) -- it conflicts with ggml-org#27836 in
qwen4exp.cpp, and MTP is not optional here.

ggml-org#27836 supplies everything the MTP head needs: it reads nextn_predict_layers,
loads the nextn block behind ml.load_mtp, and adds the MTP graph. Earlier local
patches for those are dropped as redundant.

Still ours: the two --ngram-on-disk bugs from halo-box#8, which live in
code these PRs do not touch. ple_head_offsets was applied twice, and can_reuse
dereferenced rows -- null whenever the table is on disk.
dzannotti added a commit to dzannotti/strix-halo-llama.cpp that referenced this pull request Aug 30, 2026
Squashed so the set reverts as one on rebase.

Takes the line descending from the merged ggml-org#27742: ggml-org#27836 (NextN/MTP draft head)
and ggml-org#27941 (danielhanchen's follow-up fixes), plus ggml-org#27977 and fifteen others.
Drops ggml-org#27879 (third-party correctness fixes) -- it conflicts with ggml-org#27836 in
qwen4exp.cpp, and MTP is not optional here.

ggml-org#27836 supplies everything the MTP head needs: it reads nextn_predict_layers,
loads the nextn block behind ml.load_mtp, and adds the MTP graph. Earlier local
patches for those are dropped as redundant.

Still ours: the two --ngram-on-disk bugs from halo-box#8, which live in
code these PRs do not touch. ple_head_offsets was applied twice, and can_reuse
dereferenced rows -- null whenever the table is on disk.
dzannotti added a commit to dzannotti/strix-halo-llama.cpp that referenced this pull request Aug 30, 2026
Squashed so the set reverts as one on rebase.

Takes the line descending from the merged ggml-org#27742: ggml-org#27836 (NextN/MTP draft head)
and ggml-org#27941 (danielhanchen's follow-up fixes), plus ggml-org#27977 and fifteen others.
Drops ggml-org#27879 (third-party correctness fixes) -- it conflicts with ggml-org#27836 in
qwen4exp.cpp, and MTP is not optional here.

ggml-org#27836 supplies everything the MTP head needs: it reads nextn_predict_layers,
loads the nextn block behind ml.load_mtp, and adds the MTP graph. Earlier local
patches for those are dropped as redundant.

Still ours: the two --ngram-on-disk bugs from halo-box#8, which live in
code these PRs do not touch. ple_head_offsets was applied twice, and can_reuse
dereferenced rows -- null whenever the table is on disk.
@ovidiu-morar

Copy link
Copy Markdown

Apple Silicon M5 Pro validation report (Metal, 64 GB): acceptance matches, throughput regresses, small-batch kernel selection looks like the cause

Thanks for the PR. The draft head works on Metal and acceptance matches your numbers, but on this box MTP is a net loss at every draft depth I tried. I think I found where the time goes, details below.

Setup

  • M5 Pro, 64 GB unified, macOS 26.5.2, iogpu.wired_limit_mb=54000
  • Build: this PR's head, 1d8de7c1b, Metal
  • Model: unsloth/Qwen3.8-Flash-Next-GGUF UD-IQ3_XXS (76.3 GiB, 3 shards), head grafted as a 4th shard from the jlkivey Q8_0 export for this PR (graft --verify passed, head bytes identical to the export)
  • The model does not fit fully resident here, so all runs use -ngl 48 -ot "^output=CPU" (output head on CPU, PLE paged via mmap). Same flags on every arm, so the comparison is internal.
  • 79-token prompt, -n 200, temp 0, 3 runs per arm with the arms interleaved; medians below

Results (median of 3)

arm gen t/s acceptance greedy identity vs no-spec
no spec 26.0 (25.6-26.8) reference
dn=2 22.9 (-12%) 128/140 = 91.4%, rate/pos (1.000, 0.829) identical, 3/3 runs
dn=3 24.1 (-7%) 142/171 = 83.0%, rate/pos (1.000, 0.842, 0.649) diverges, 3/3 runs

Acceptance is right where your M3 Max numbers are, so the head and the graft path are fine. The loss is entirely in verification.

Where the time goes

Per-step decode cost, computed as (predicted_ms minus draft dur) / draft rounds, against the no-spec per-token cost, medians of 3:

verify batch size ms per decode step
1 (no spec) 38.4
3 (dn=2) 110.1
4 (dn=3) 127.0

Batch 3 costs 2.9x batch 1, so at that size essentially nothing is batching and the drafted tokens are pure overhead. The step from 3 to 4 costs only 17 ms, which made me look at the kernel selection on this branch:

  • ggml_metal_op_mul_mat_id picks the matrix-matrix path only at ne21 >= 32 (ne21_mm_id_min), so expert weights are read once per token for any realistic verify batch. There is no small-batch path for the _id case.
  • The small-batch mat-mv kernels for plain mul_mat ("efficient for BS [2, ~8]") do not cover IQ3_S/IQ3_XXS at all, and K-quants only qualify at ne11 >= 4. That fits both observations: at batch 3 even the dense tensors take the per-token path, and at batch 4 the K-quant dense tensors reach the small-batch kernels, which is why the marginal cost collapses from 38 ms to 17 ms.

So on this hardware the speedup seems gated on small-batch coverage (an _id small-batch kernel, plus i-quants in the mat-mv list) rather than on anything in this PR. Happy to rerun on any branch that touches those paths.

Greedy identity

dn=2 is token-identical to no-spec over the full 200-token run, in all 3 runs. dn=3 diverges mid-thought around generated char 130 ("Need brief explanation" vs "Need concise explanation") and stays diverged, and the divergence is deterministic: all 3 dn=3 runs produce the same alternate text, byte for byte. Acceptance still reads 100% per step in the logs. So on Metal the temp-0 identity you measured holds at dn=2 but not at dn=3 here, which lines up with the HIP report above seeing divergence too. The determinism makes me suspect verify-batch numerics rather than a race: logits computed in a batch can sum in a different order than the batch-1 baseline and flip a near-tie at argmax, but I have not confirmed that.

Raw statistics lines from run 1:

dn=2: #gen drafts = 70, #acc tokens = 128, #mean acc len = 2.83, #acc rate/pos = (1.000, 0.829), dur(b,g,a) = 0.002, 902.148, 0.088 ms
dn=3: #gen drafts = 57, #acc tokens = 142, #mean acc len = 3.49, #acc rate/pos = (1.000, 0.842, 0.649), dur(b,g,a) = 0.000, 1059.256, 0.063 ms

suntryhe pushed a commit to suntryhe/strixhalo_5090m_llamacpp that referenced this pull request Aug 31, 2026
Port of ggml-org/llama.cpp#27836 (3 commits) plus crusaderky's detached
-head loader fix. Also skips the MoE hot-expert validation for detached
draft heads (no trunk exps tensors) and caps the MTP draft context
batch/ubatch at 1024 so its compute reserve fits alongside the target.

Measured on the dual-GPU CUDA0+ROCm0 rig with the agentionai Q8_0
sidecar: draft acceptance 77.5% (mean 2.55 tok) but decode is a net
34.9 vs 42.8 tok/s without speculation - the hybrid DeltaNet state
rollback per rejected draft plus multi-token verify outweigh the saved
passes when the target already decodes fast. Disabled unless
--spec-type draft-mtp is passed; kept for future single-device or
improved-state-handling scenarios.

Assisted-by: Sisyphus
suntryhe pushed a commit to suntryhe/strixhalo_5090m_llamacpp that referenced this pull request Aug 31, 2026
Port of ggml-org/llama.cpp#27836 (3 commits) plus crusaderky's detached
-head loader fix. Also skips the MoE hot-expert validation for detached
draft heads (no trunk exps tensors) and caps the MTP draft context
batch/ubatch at 1024 so its compute reserve fits alongside the target.

Measured on the dual-GPU CUDA0+ROCm0 rig with the agentionai Q8_0
sidecar: draft acceptance 77.5% (mean 2.55 tok) but decode is a net
34.9 vs 42.8 tok/s without speculation - the hybrid DeltaNet state
rollback per rejected draft plus multi-token verify outweigh the saved
passes when the target already decodes fast. Disabled unless
--spec-type draft-mtp is passed; kept for future single-device or
improved-state-handling scenarios.

Assisted-by: Sisyphus
noonghunna pushed a commit to noonghunna/club-3090 that referenced this pull request Aug 31, 2026
First vision-capable expert-offloaded MoE on this stack, and the fastest
model on it (39.28 narr / 38.57 code canonical) — the reason is expert
BYTE-SIZE (1,638,400 weights per ffn_up vs GLM's 8,388,608), not the quant.

Composes: dual is the validated source; multi4/multi8 are derived by a
5-line delta (service key, container_name, port, device_ids, -ts) and are
NEVER BOOTED — this rig has 2 GPUs, so those are community-validated by
design, not a TODO. All three carry a TUNING block and mark every inherited
2-card constant as inherited.

q8_0 KV (KV_TYPE knob): MEASURED KV -47%, expert-cache slots 9,435 -> 12,437
(+31.8%), decode neutral, VRAM balanced (22608/22640). Boots, serves,
answers correctly. q8 is the serving-grade floor on this stack.

Projector on the HOST (-mmdev none): on CUDA0 it cost 1,114 MiB and loads
AFTER the pool is sized, so nothing budgets for it — 830 MiB headroom with
a drafter active was a latent OOM on the first image request. Host placement
costs ZERO cache slots (9,435 both ways) and GPU1 was byte-identical.

Sampler: the card's two rows live in the registry as sampler_profiles and
are DERIVED, not hardcoded. INSTRUCT ships as the default (matching
qwen3.8-27b) because this template defaults enable_thinking=true AND
reasoning_effort=xhigh. THINKING=1 selects the thinking row and
--reasoning on. Validated on two real boots: default -> reasoning_content
len 0; THINKING -> len 97.

NO drafter machinery: the GGUF carries no MTP head (nextn_predict_layers
absent, verified) and none exists for qwen4exp; engine support is an open
draft PR (ggml-org/llama.cpp#27836). The vestigial -devd/SPEC_N block was
removed rather than left inert.

Catalog-count guards moved with the catalog (registry 103->106, composes
104->107, models 19->20, preflight slug->compose map). test-compose-
registry-disk's message strings said 100/101 while asserting 103/104 —
corrected.

verify-full 10/10, bench-agentic 2x12 turns to 35,254 prompt tok with
TTFT sub-linear (8.0x vs 25.1x context), vision 4/4.

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

llbzow commented Aug 31, 2026

Copy link
Copy Markdown

Validation report: 2× RTX A6000 48GB (CUDA, Windows, WDDM) — head works, acceptance matches, throughput regresses ~2.2×

TL;DR: the draft head loads and drafts correctly on dual-GPU CUDA (acceptance 82–95%, matching the M3 Max numbers), but net throughput drops from ~44–46 t/s to ~19–22 t/s at every draft depth tried. Same direction as @ovidiu-morar's M5 Pro report; opposite of the unified-memory platforms. Detailed config and elimination steps below.

Configuration

  • GPU: 2× NVIDIA RTX A6000 48 GB (Ampere, sm_86), PCIe-connected, no NVLink, driver 573.65 (WDDM driver model — kernel launches go through the OS scheduler)
  • Host: Xeon Platinum 8360Y (48 logical cores), 160 GB RAM, Windows Server 2019, CUDA toolkit 12.6
  • Build: drluoto/llama.cpp strix-halo-flash-next @ 590ac45bc1 (= this PR @ 1d8de7c + crusaderky's detached-head loader fix + whitelist fix + hipCUB TOP_K), -DGGML_CUDA=ON -DGGML_CUDA_FA=ON -DGGML_NATIVE=ON -DCMAKE_CUDA_ARCHITECTURES=86, VS2022 Release
  • Target model: unsloth/Qwen3.8-Flash-Next-GGUF UD-Q3_K_XL (3 shards, ~84 GB). Production layout keeps the 51B per_layer_token_embd on CPU via -ot per_layer_token_embd.weight=CPU; tests were run with it on CPU and fully on GPU (see below)
  • Draft model: drluoto/Qwen3.8-Flash-Next-MTP-GGUF Q8_0 sidecar (4.14 GB, sha256 verified against the repo card)
  • Runtime: llama-server, -ngl 99 --load-mode none -fa on -ctk q8_0 -ctv q8_0 -t 24 -b 2048 -ub 512 -c 8192, greedy (temp 0)

Method

A/B on the same build and machine: real coding prompt (write a complete Python module — thinking model, ~220 prompt tokens after warm cache), 300 completion tokens, warmup request on both arms before the measured run. MTP arm adds -md <sidecar> --spec-type draft-mtp --spec-draft-n-max N --spec-draft-p-min 0.7.

Results

config gen t/s draft acceptance mean accepted len
no spec, embd on CPU (prod layout) 44.4
no spec, embd on GPU 46.2
draft-mtp n-max 6, embd CPU 22.0 85.2% (167/196) 3.28
draft-mtp n-max 6, embd GPU 20.8 82.6% (152/184) 3.38
draft-mtp n-max 6, embd GPU, -ngld 99 20.3 82.6% 3.38
draft-mtp n-max 2, embd GPU 19.4 95.5% (149/156) 2.60

What we ruled out

  • Draft placement: -ngld 99 (explicit full-GPU draft) changes nothing → the draft was already GPU-resident by default.
  • Host-resident embeddings: our production layout keeps the 51B per-layer token embeddings on CPU. Moving them fully into VRAM lifts no-spec decode by only +4% and MTP by ~0 → the CPU gather path is not the bottleneck, with or without speculation.
  • Draft depth: n-max 2 (95.5% acceptance!) is even slower than n-max 6 → verify cost per step is near-flat in draft width, i.e. dominated by fixed per-step work, not by token count.

Analysis

With 85% acceptance and ~3.4 tokens advanced per step, the theory says ~2–3× speedup; we observe ~0.47×. On this machine a single-token decode step (21.6 ms at 46 t/s) is mostly fixed cost — 48 layers × kernel-launch/graph overhead × a PCIe sync at each layer boundary across the two GPUs — while actual weight reads (~2.5 GB of active MoE params at Q3_K) account for only ~3–4 ms of it at 768 GB/s. The verify step inherits the full fixed cost while its weight-read amortization doesn't help on MoE (n+1 tokens routing to distinct experts multiply expert reads anyway), and the draft's autoregressive steps pay their own per-step fixed costs on top. WDDM's higher launch latency likely makes all of this worse than an equivalent Linux/TCC setup.

Net: on platforms where decode is launch/sync-bound rather than bandwidth-bound, MTP's theoretical win is eaten twice — once by verify's fixed cost, once by the draft's own steps. That would explain the split between unified-memory reports (win) and this + the M5 Pro report (loss).

Happy to re-run with a profiling build (GGML_CUDA_PROFILING or per-op timings) if there's a knob that breaks down draft-vs-verify time per step — the machine and model stay available for follow-ups.

Verdict here: functionally correct, not worth enabling for throughput on dual-GPU CUDA + this quant today. Thanks for the PR, and to @drluoto for the packaged branch + verified sidecar — bring-up was genuinely smooth.

@tbtlr

tbtlr commented Aug 31, 2026

Copy link
Copy Markdown

Tried this on a DGX Spark (GB10, sm_121, 128GB unified) and hit a tensor-index mismatch with the community MTP draft head.

Setup: merged this PR cleanly onto master 41ef91f (b10717), built with -DGGML_CUDA=ON -DCMAKE_CUDA_ARCHITECTURES=121a. Draft head from dzannotti/Qwen3.8-Flash-Next-MTP-GGUF (Qwen3.8-Flash-Next-MTP-Q4_K_M.gguf).

Failure:

llama_model_load: error loading model: check_tensor_dims: tensor 'blk.0.hc_attn_norm.weight' not found

Reproduces with both unsloth/Qwen3.8-Flash-Next-GGUF UD-IQ4_XS and argyelan/Qwen3.8-Flash-Next-GGUF IQ4XS-NGQ5 as the target model. Both targets load fine without -md.

Cause: the draft head's hyper-connection tensors are present, but indexed at blk.48 — its absolute position in the full model — rather than blk.0:

$ strings Qwen3.8-Flash-Next-MTP-Q4_K_M.gguf | grep -i "hc_" | head
output_hc_down.weight
output_hc_norm.weight
output_hc_up.weight
blk.48.hc_attn_down.weight
blk.48.hc_attn_inject.weight
blk.48.hc_attn_norm.weight
...

The target model has the same tensors at blk.0, so the loader appears to be applying the target's indexing convention to the draft.

Not sure whether the right fix is here or in the export — flagging in case the --mtp converter in this PR emits relative indices and third-party heads exported against #27739 use absolute.

Command:

LLAMA_ATTN_ROT_DISABLE=1 llama-server
--model Qwen3.8-Flash-Next-IQ4XS-NGQ5-00001-of-00003.gguf
-md Qwen3.8-Flash-Next-MTP-Q4_K_M.gguf -ngld 999
--spec-type draft-mtp --spec-draft-n-max 3 --spec-draft-p-min 0.75
-ngl 999 -fa on -ctk q8_0 -ctv q8_0 -c 32768 --jinja

Worth cross-posting a short version to the dzannotti repo's discussions too, since the fix may belong there.

pinpox added a commit to pinpox/nixos that referenced this pull request Aug 31, 2026
Repin llama-cpp-qwen4exp to Nathanw1014/llama.cpp strix-halo-vulkan
(ad914eb): PR-27742 arch support plus a working NextN/MTP draft path on
Vulkan (upstream PR: ggml-org/llama.cpp#27836 is ROCm-first and loses
throughput under RADV; this fork wins).

Measured on mango (greedy, 32K ctx, UD-IQ4_XS + drluoto Q8_0 sidecar):
file rewrite 22.0 -> 53.4 t/s, new code 27.5 -> 46.8, prose +12%,
7K-prompt +39%; temp-1.0 sampling holds up, greedy output is
prefix-identical to no-spec. n-max 6 beat 3; adding ngram-mod displaced
higher-acceptance MTP drafts, so MTP runs alone.

The sidecar ships the head mixer under both PR-27836 names (output_hc_*)
and legacy names (blk.48.nextn.hc_head_*); the fork loader rejects the 3
unconsumed duplicates, so the deployed file is a gguf-py copy with them
stripped (see Zulip: #topic-ai > MTP for Qwen on FW desktop).

At 262K the sidecar overcommits GTT: RADV fails command submission and
can wedge llama-server in unkillable D-state (amdgpu/TTM livelock).
MTP therefore only runs on the default entry at 131K; the new
:iq4_xs-262k entry keeps the full native context without MTP.
@JayToltTech

Copy link
Copy Markdown

Data point that may be directly useful here: the ~4.7 tok/s at 90% acceptance reported on this PR looks like it may not be the draft graph at all.

I built this PR (plus @crusaderky's detached-head loader fix) on an AMD Strix Halo (gfx1151), Vulkan/RADV, with Qwen3.8-Flash-Next UD-Q4_K_XL and reproduced the profile almost exactly: 4.7 tok/s against a 25.1 baseline at ~90% draft acceptance — 4x slower than no speculation. Matches @flobob45's report above.

Separately I ported @Nathanw1014's MTP implementation onto current master (#28104) and initially got the same profile: 4.77 tok/s, 70-80% acceptance. Two different graphs, same slowdown, which suggested the cost was somewhere shared rather than in either implementation.

It was. 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 therefore takes a full recurrent-state checkpoint of the target, and restores it on every rejected draft. Through the host path that means serializing each GDN layer's conv+state rows, 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 dominates everything else — about 600 ms of each 825 ms round, i.e. 201 ms/token decode. It also inflates the reported prompt eval time, which runs to first token and so absorbs the first save: I was seeing 827 ms of "prefill" for an 11-token prompt.

Requesting LLAMA_STATE_SEQ_FLAGS_ON_DEVICE at the six speculative checkpoint call sites in tools/server/server-context.cpp (not the prompt-cache ones, which legitimately retain several historical states) keeps the state in device buffers. The library already implements that path; the server just never asked for it. On my port:

before after
short ctx 4.77 25.83
70k ctx 4.33 16.08

Acceptance unchanged at 70-80%. The flag and mechanism are Gaetan Puleo's (c8b681b6f, carried in Nathanw1014/llama.cpp as 08a3255).

Worth testing on this branch — it is a six-line server change, independent of the graph work here, and if it is the same bottleneck it would move this PR from "4x slower" to a substantial win without touching the implementation.

Two other things from building this branch, in case they save anyone time:

  • @crusaderky's a82a58a57 is needed for a detached sidecar head, and the root cause is not the block index — a detached head GGUF has no trunk tensors while load_arch_tensors marks them required, so it dies on blk.0.hc_attn_norm.weight. That is the failed to load draft model several people hit above, and it is not a memory problem.
  • On current master the MTP drafter also needs h_nextn exported with all token rows: master drops non-output rows inside the last layer before the export, and draft_mtp::process memcpys n_tokens-1 rows, so it reads past the end (GGML_ASSERT(offset + size <= ggml_nbytes(tensor))). Detail in qwen4exp : port NextN/MTP speculative decoding to master (+50% decode at 70k) #28104.

Environment for all numbers: gfx1151 / RADV, 128 GB unified, -ctk q8_0 -ctv q8_0, Q4_K_M sidecar head from dzannotti/Qwen3.8-Flash-Next-MTP-GGUF, temp 0 unless stated.

Aristo94 added a commit to Aristo94/EngramHalo.cpp that referenced this pull request Aug 31, 2026
@ovidiu-morar

Copy link
Copy Markdown

Correction to my report above: with draft gating, one configuration is slightly net-positive on M5 Pro

I wrote "MTP is a net loss at every draft depth I tried". That was true of the flat settings I tried, and it does not survive the tuning suggested later in this thread. Retested with @sammcj's flags on the same box, model and prompt as my report (M5 Pro 64 GB, Metal, UD-IQ3_XXS grafted, -ngl 48 -ot "^output=CPU", temp 0, 3 interleaved runs per arm, medians):

arm gen t/s vs no-spec temp-0 identity
no spec 26.8 reference
draft-mtp n-max 3, --spec-draft-p-min 0.7 --spec-draft-backend-sampling 28.2 +5% diverges
same, n-max 6 25.4 -5% diverges

Two observations:

  • The gain at n-max 3 comes from the gating, not from different drafts being accepted: the generated text is byte-identical to what flat n-max 3 produced in my earlier runs, so p-min 0.7 is trimming wasted draft work while leaving the output unchanged. Flat n-max 3 measured -7% here; gated n-max 3 measures +5%.
  • n-max 6 does not transfer to this box (M5 Max reports +68% above); consistent with the small-batch verify cost from my original report still dominating at wider drafts on lower-bandwidth Metal. Worth noting for anyone triaging: this box's fa-vec tables are already tuned (M5 Pro is checked off in the metal : tuning Flash-Attention kernel across M-chips #27668 campaign via metal : add fa-vec tunings for M3 Max, M5 and M5 Pro #27863, and that code was in my build), so the remaining gap is small-batch mul_mat/mul_mat_id kernel coverage, not missing FA tuning rows.

So the corrected summary for M5 Pro-class machines: slightly net-positive is achievable with gating at n-max 3, and the temp-0 divergence I reported for n-max ≥3 is still present in every speculative configuration tested, including these. Whether +5% is worth a non-identical greedy stream is a per-use call; for my use it is not.

@ovidiu-morar

Copy link
Copy Markdown

@JayToltTech tested your six-line LLAMA_STATE_SEQ_FLAGS_ON_DEVICE change on Metal (M5 Pro 64 GB, same setup as my report above: UD-IQ3_XXS grafted, temp 0, 3 interleaved runs per arm, medians), since your mechanism would also have explained the near-flat-in-draft-width verify cost I measured. Result: no measurable change on this backend.

arm before fix after fix
no spec 26.8 27.4
n-max 2 flat 22.9 23.8
n-max 3, p-min 0.7, backend-sampling 28.2 28.8
n-max 6, p-min 0.7, backend-sampling 25.4 25.0

All within noise, generated text byte-identical per arm across the two builds. I verified the flag is not a no-op here (the llama_io_write_device path in llama-context.cpp is backend-generic), and the arithmetic agrees: my entire speculative round costs ~112 ms, so a ~600 ms host checkpoint was never hiding in it.

So the two datasets compose rather than conflict: on unified memory the host-path state save is close to free and the remaining cost is per-token verify work (small-batch kernel coverage, per my report); on backends with expensive readback your checkpoint path dominates everything and the six-line change is transformative. Worth keeping both in mind when triaging the platform table on this thread, since the same symptom (fixed per-round cost, flat in draft width) has two different causes on different backends.

@aahmozart

Copy link
Copy Markdown

any ETA on when will this makes its way to master?

@mjungnickel18

Copy link
Copy Markdown

CUDA/Windows datapoint for the LLAMA_STATE_SEQ_FLAGS_ON_DEVICE change: +61% on code, and it flips MTP from a small loss to a small win

@JayToltTech @ovidiu-morar — the CUDA cell of your matrix was still empty, so here it is. Single RTX 3090 (24 GB, sm_86), Windows 11 / WDDM, CUDA 13.1, 96 GB DDR4, 200 W power limit.

Setup: this PR @ 1d8de7c + @crusaderky's a82a58a57 (needed to load a detached sidecar), draft head dzannotti/Qwen3.8-Flash-Next-MTP-GGUF Q4_K_M, target unsloth/UD-Q3_K_XL. -ngl 99 -ngld 99 --n-cpu-moe 40 -c 131072 -np 1 -fa on -ub 2048 -ctk q8_0 -ctv q8_0 --spec-draft-n-max 6 --spec-draft-p-min 0.75. temp 0, 3 runs per cell, medians. Same binary for A/B except the six-line change; no-spec arm is the same build without -md.

decode, tok/s no spec MTP, before MTP, after
code gen (400 tok) 17.0 11.0 17.7
20k-context summary (200 tok) 15.1 14.0 15.4

Draft acceptance is unchanged by the flag (0.84 code / 0.79 long), as expected.

Two things worth separating out:

1. The fix reproduces on CUDA, clearly. +61% on the short-context code arm. So the host round-trip is real on this backend too, and this is now Vulkan ✅ / CUDA ✅ / Metal ❌ (where @ovidiu-morar's ~112 ms round leaves no room for it) — consistent with it being a host-transfer cost that only shows up where the transfer is expensive.

2. But the "4x slowdown" does not reproduce here — we saw 17.0 → 11.0, a 35% loss, not 4x. Worth flagging because it may narrow the mechanism: with --n-cpu-moe 40 this box only keeps 8 of 48 expert layers on the GPU, so a decode step is dominated by host-side expert matmuls. Our whole speculative round is ~450 ms, i.e. there is no room for a 600 ms checkpoint — the checkpoint we are paying must be substantially cheaper than the one you measured on gfx1151. If the cost scales with device-resident state, an offloaded target would naturally see less of it. That would also explain why @llbzow's 2x A6000 (96 GB, nothing offloaded) is at the severe end of the range while we are at the mild end, both on CUDA/WDDM.

Net effect on this box: MTP goes from "not worth enabling" (11.0 vs 17.0 plain) to "roughly break-even, slightly ahead" (17.7 vs 17.0). Modest, but the right side of zero, and the long-context arm gains too.

For completeness, on the same hardware we get a real MTP win by attacking the verify cost instead: pairing the draft head with the hot/cold expert split from timadinorth/llama.cpp#1 puts ~74% of routed expert traffic back in VRAM and lands 24.1-26.4 tok/s on code at 0.86-0.91 acceptance (vs 17.4-17.5 unsplit, no spec). The two are complementary: this flag removes the per-round host checkpoint, residency makes the verify batch itself cheap. Combined branch: https://github.com/mjungnickel18/llama.cpp/tree/qwen4exp-mtp-plus-moe-residency

One note for anyone reproducing on this PR: sidecars are not interchangeable. A head exported by apepojken's converter fails here with done_getting_tensors: wrong number of tensors; expected 35, got 30 — same class of failure as @tbtlr's DGX Spark report. The dzannotti Q4_K_M head loads cleanly.

@JayToltTech

Copy link
Copy Markdown

ON_DEVICE change is on my fork: JayToltTech#1. Six lines in server-context.cpp on current master.

Three backends now. Vulkan gfx1151: 4.33 → 16.08 tok/s at 70k. @mjungnickel18 CUDA: +61%. @ovidiu-morar Metal: neutral, flag verified not a no-op. Win where per-round host round-trips are expensive, neutral where they aren't, no regression anywhere tested.

Not submitting it upstream myself. Anyone who wants it, take it as your own — no credit needed.

@Rhonstin

Rhonstin commented Sep 1, 2026

Copy link
Copy Markdown

Cherry-picked the three commits here onto current master (8887a48, post-#27941) together with #28136, running Qwen3.8-Flash-Next UD-Q3_K_XL with the unsloth MTP drafter (mtp-Qwen3.8-Flash-Next-Q4_K_M.gguf) on an 8-GPU box (2× RTX 3090 + 6× CMP 90HX, all PCIe Gen2 x4, host has no AVX2).

MTP now works on current master, but it needed one fix that lives outside this PR — flagging it here since the rebased version will likely hit the same thing.

Root cause of the ggml_set_rows assert (a->ne[0] == b->ne[0]) on current master

With the post-#27941 memory rewrite, the qwen4exp MTP context gets a plain (non-hybrid) KV cache holding only the NextN layer. That cache is sized from hparams.n_embd_k_gqa(il) for il >= n_layer() — but in llama-model.cpp the per-layer arrays are zero-filled and only populated for the first n_layer() entries:

std::fill(hparams.n_head_kv_arr.begin(), hparams.n_head_kv_arr.end(), 0);
...
ml.get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT,    hparams.n_head_arr,     hparams.n_layer(), false);
ml.get_key_or_arr(LLM_KV_ATTENTION_HEAD_COUNT_KV, hparams.n_head_kv_arr,  hparams.n_layer(), false);

So for the NextN layer (il = 48 here): n_head_kv_arr[48] == 0n_embd_k_gqa(48) == 0 → the MTP cache view is created as cache_k_l48 ne=[0, 4096, 1, 1] → the first ggml_set_rows in build_attn aborts, since the draft's mtp_Kcur-48 view is [512, 1, 1, 1].

On pre-#27941 bases this never fired because the MTP context went through the hybrid memory path, which masks the zeroed geometry.

Fix (works here)

Mirror the last trunk layer's geometry into the NextN layers right after the head-count arrays are loaded — the qwen4exp draft head is structurally a trunk block, so this is exact for it:

// after the LLM_KV_ATTENTION_HEAD_COUNT_KV get_key_or_arr
if (hparams.n_layer_nextn > 0 && hparams.n_layer() > 0) {
    for (uint32_t il = hparams.n_layer(); il < hparams.n_layer_all; ++il) {
        hparams.n_head_arr[il]     = hparams.n_head_arr[hparams.n_layer() - 1];
        hparams.n_head_kv_arr[il]  = hparams.n_head_kv_arr[hparams.n_layer() - 1];
        hparams.n_ff_arr[il]       = hparams.n_ff_arr[hparams.n_layer() - 1];
    }
}

With that (plus the mtp_only/trunk_flags tensor relaxations needed for the unsloth sidecar layout, i.e. the crusaderky approach), the server runs: ~40 t/s short decode, ~18 t/s at 60K context, ~78% draft acceptance, --spec-draft-n-max 2. Happy to share the full patch stack if useful.

MarkShark2 added a commit to MarkShark2/llama.cpp that referenced this pull request Sep 2, 2026
Qwen3.8-Flash-Next ships an MTP block in the checkpoint that the converter
was dropping, so the model had no speculative path at all. Three pieces:

- ggml-org#27836: the qwen4exp NextN/MTP draft head. Converter
  export (fc_embedding|fc_hidden fuse into the single eh_proj the shared
  NextN code expects), the nextn.hc_head_* tensors that stand in for the
  output norm qwen4exp does not have, and the LLM_GRAPH_TYPE_DECODER_MTP
  graph. Resolved against the fork's per-layer n_ff_exp accessor.

- ggml-org#27210: adaptive draft depth, --spec-type
  draft-mtp-adaptive. Carries a delta-net fix that matters well beyond the
  adaptive path: build_conv_state was emitting a snapshot slot for every one
  of the n_rs_seq + 1 rollback depths, including the ones no rollback inside
  the batch can reach. Decode is one token, so all but one slot repeated the
  pre-batch state; the bound turns n_rs_seq into free headroom instead of a
  per-layer kernel-launch tax.

- [fork] mtp_only/trunk_only probing in qwen4exp's load_arch_tensors,
  following the bailingmoe3/deepseek2 pattern. The draft head can now ship as
  its own GGUF: quantized apart from the trunk and pinned to the head GPU
  with --model-draft + --device-draft, rather than riding the trunk's
  tensor-split out onto the RPC fabric. Trunk-only tensors (hc_head_*, the
  PLE table, blk.0..n-1) become NOT_REQUIRED when the file has no blk.0, and
  the nextn block likewise when the file has no eh_proj. token_embd and
  output stay required in both halves, since qwen4exp sets
  mtp_use_dedicated_embeddings=false and the draft graph reuses them.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U3H5motr51eTWujztSXykc
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

conversion model Model specific

Projects

None yet

Development

Successfully merging this pull request may close these issues.