qwen35: apply the Hadamard inverse to the MTP token-embedding lookup - #205
Conversation
The MTP graph in qwen35 does its own ggml_get_rows() on the token embedding table but never restores the primal basis, while llm_graph_context::build_inp_embd() does exactly that for the trunk (llama-graph.cpp, "a Hadamard-latent embedding table stores rotated rows; restore the primal basis right after the lookup"). On a Hadamard-folded model the draft head therefore consumes embeddings in the rotated basis, and llama_verify_hadamard_graph (correctly) refuses to build the graph, so in-file MTP cannot start at all: W llama_verify_hadamard_graph: latent lookup 'mtp_tok_embd-64' consumed by op=RMS_NORM name='norm-64' E llama_init_from_model: failed to initialize the context: Hadamard-latent table 'token_embd.weight' is read without the inverse transform E common_speculative_init_result: failed to create MTP context Reproduced with prism-b10683-d8f26ee (newest release with published assets, see PrismML-Eng#193) and still present on b10709-9a9394a, on ProCreations/Ternary-Bonsai-2-27B-MTP (the PQ2_0 target with one MTP layer in the same file) with: llama-server -m Ternary-Bonsai-2-27B-PQ2_0-MTP-Q8_0.gguf --spec-type draft-mtp Fix: apply the same rot + signs inverse used by the trunk path, keyed on the table that was actually looked up (layer.nextn.embed_tokens when present, otherwise model.tok_embd). The same change ships as runtime/bonsai-mtp-embedding.patch in ProCreations/Ternary-Bonsai-2-27B-MTP (and their patched source archive); this is the equivalent for current prism. Credit for finding it belongs there.
|
Independent verification on gfx1100 / ROCm 6.4 / HIP (RX 7900 XTX, Windows 11), 2026-09-19: Without this patch, With the patch applied (ProCreations' bonsai-mtp-embedding.patch, which matches this PR's change), the same file loads and Also noted while testing: |
|
Thanks for the independent check on completely different hardware — RDNA3 / ROCm / Windows against our Ada / CUDA / WSL, same failure line, same fix. That is worth more than another data point from me. The two speedups line up once you look at the no-spec baselines:
Speculation pays more where the target decodes slower: at 41.6 t/s the ternary path is closer to instruction/compute bound, so the draft's roughly fixed cost is a smaller fraction of a round. Our long-context sweep shows the same shape for a different reason — as depth grows decode falls 87 -> 35 t/s while acceptance rises 66% -> 84%, so the MTP multiple holds at 1.16-1.38x instead of decaying with depth. Your Harness, raw JSON and the depth sweep: https://github.com/zhaoyilun/bonsai2-27b-mtp-repro (per-prompt single runs, temp 0 / top_k 1 / seed 7 / n_predict 128). |
|
Independent confirmation on Ampere / sm_86 (2× RTX 3090, CUDA 12.8, Linux) — a third architecture after the RDNA3/ROCm and Ada/CUDA reports above. Built Before this PR — identical failure to the one described: After: the MTP context creates and drafts. Draft-depth sweep
Two notes that may be useful to others reproducing this: The optimum is n=4 here, and the cliff past it is sharp — 89.58 → 73.21 is −18% for one extra draft token. We had initially measured only at The optimum is workload-dependent. Code and table/format continuation peak at n=4 (89.58 and 90.47 t/s); free-form prose peaks at n=2 (67.85) and by n=5 has fallen to 49.69 — within 2% of the no-speculation baseline, i.e. paying the full draft cost for nothing. A single default cannot serve both.
Thanks for the fix. |
bri-prism
left a comment
There was a problem hiding this comment.
Agent review: posted by the maintainer's coding agent at their request.
No findings in the embedding inverse-transform change. The translation unit passes a Clang C++17 syntax check against same-base headers, including without the extra include present in #217.
#217 contains the same functional fix. Please consolidate the two so only one lands. This addresses a clear correctness gap and is a priority merge candidate, subject to the normal model/build gates. Model-backed MTP execution was not rerun here.
Reviewed commit: 518ad108f0b72bac4f397a486695a5786e33a58d.
The MTP graph does its own row lookup on the embedding table and never restores the primal basis, while the trunk (build_inp_embd) does. On a prism.hadamard model that mismatch trips the graph check: Hadamard-latent table 'token_embd.weight' is read without the inverse transform so a Bonsai 2 gguf with an embedded MTP block refuses to load at all. Factor the inverse out of build_inp_embd into build_hadamard_inverse_embd and call it from both paths, against whichever table was actually read (layer.nextn.embed_tokens, else model.tok_embd). Models without folded weights have an empty inverse map, so this is a no-op for them. Same fix as PrismML-Eng#205, adapted to this tree. Measured on Ternary-Bonsai-2-27B-PQ2_0-MTP-Q8_0 (RTX 5060 Ti, sm_120, CUDA 12.8, greedy, 4K context): the file now loads, and decode goes from 37.4 t/s with the drafter off to 71.1 t/s at --spec-draft-n-max 8, with draft acceptance 0.247 and mean draft length 2.98. The vendor default of 2 leaves ~13% on the table here (61.7 t/s); the curve peaks at 8 and falls off by 16 (52.9 t/s). Speculative decoding verifies every draft against the target, so output is unchanged.
| // a Hadamard-latent embedding table stores rotated rows; restore the primal | ||
| // basis right after the lookup (h = s * (H z)), exactly like the trunk path in | ||
| // llm_graph_context::build_inp_embd. Without this the draft head consumes | ||
| // embeddings in the rotated basis and llama_verify_hadamard_graph refuses the graph. |
Consolidating with #217I read #217 line by line against this one. The executable change is identical: the same So there is no functional difference to choose on, and the only question is which PR is the cheaper place to land it. I suggest this one:
I have asked @sudoingX on #217 to let this PR be the single landing point. So that nothing from #217 is lost, three things from it belong here and I am folding them in:
We are asking for this one to land, and #217's author has been told the same so a duplicate does not sit in the queue. You have the final call; our recommendation is this PR. |
|
Thanks — this is the useful kind of report, because it corrects us rather than confirms us. We stopped at n=2 and n=3 and concluded the curve had flattened (82.4 vs 83.0 t/s total, "past ~3 you are mostly trading acceptance for round count"). Two rungs is not a sweep. Your n=4 peak is what sweeping actually looks like, and "worth sweeping rather than inheriting a depth" lands on us — we inherited ours from the Ada protocol. The two sweeps disagree, and the disagreement is the interesting part:
At n=3 our absolute throughput is nearly identical (82.57 vs 83.0 t/s) while acceptance differs by 23 points — which is your own caveat doing work: acceptance is a property of the prompt set as much as of the model, so it is not a number that crosses tables. Our 12-prompt set deliberately carries four free-form reasoning-prose prompts sitting at 48-58% on their own, which drags the aggregate to 0.68; your code / prose / table set has no such tail. If where the curve turns is set by how fast acceptance decays with draft depth, then a set with a low-acceptance tail should peak earlier and fall off harder — which is exactly the asymmetry between our two tables. That is a sharper version of your "a single default cannot serve both": the default may depend not on the workload alone but on the mix a server actually serves. The cliff we never saw, because we never went past 3 — 89.58 -> 73.21 for one more draft token is the number that makes the case for sweeping, and we did not have it. Also for the axis we did sweep, so the two don't get conflated: ours is context length, not draft depth. Decode falls 87 -> 35 t/s from 4k to 131k while acceptance rises 66% -> 84%, so the MTP multiple holds at 1.16-1.38x rather than decaying. Noted on #216 / #189: at n=4 you get Q=5, so #189's |
|
Follow-up review and local testing at 518ad10: no findings. Recommend landing this fix once CI passes; #217 has the same executable change, so only one needs to land. Validation performed on ARM CPU:
These are local build, numerical and graph-coverage tests, not full-model generation or fresh CUDA/HIP measurements. Ubuntu and Windows CI are currently pending. #210 does not patch this MTP lookup, so merging #210 alone will not resolve it; after both land the duplicated lookup logic can use build_embd_rows(). |

What
--spec-type draft-mtpcannot start at all on a Hadamard-folded model when the MTP block lives in the target file. The MTP graph (qwen35::graph_mtp) does its ownggml_get_rows()on the token embedding table but never restores the primal basis, while the trunk path (llm_graph_context::build_inp_embd) does exactly that two lines after its own lookup.llama_verify_hadamard_graphthen (correctly) refuses the graph:Why it is worth fixing
Packaging the MTP block inside the target GGUF is not just convenient, it is what makes MTP profitable here:
-m <bundle> --spec-type draft-mtp(no-md) builds the draft context against the target, so the draft shares the target'stoken_embd/outputinstead of carrying its own 248320x5120 copies. A separate-mdsidecar duplicates the vocabulary (~92% of its bytes), pushing the per-token draft cost ratio to rho ~ 0.43, which turns even 40% acceptance into a net loss (measured: 5.16 t/s vs 16.6 t/s no-spec). Sharing the vocabulary drops rho to ~0.06 and the same head becomes a win.So this one guard currently gates the only cheap speculative configuration on folded models.
Repro (fails before, works after)
Reproduced on prism-b10683-d8f26ee (the newest release with published assets — see #193) and still present on prism-b10709-9a9394a; also reported for the same model on Metal in #203's environment. The file is
ProCreations/Ternary-Bonsai-2-27B-MTP(the unchanged PQ2_0 target plus one MTP layer in the same file).Fix
Apply the same
rot+signsinverse the trunk uses, keyed on the table actually looked up (layer.nextn.embed_tokenswhen present, otherwisemodel.tok_embd). 14 lines, no new API.Verification
Built for sm_89 (Ada) from this branch, then:
creating MTP draft context against the target model), no hadamard error;--spec-type noneon RTX 4080 SUPER 16 GB: median 1.338x decode, aggregate acceptance 68.1% (1.23-1.70x; code/format 1.5-1.7x, free reasoning prose 1.25-1.3x), and the model still answers correctly.Raw JSON, harness, build script, launch units and the long-context depth sweep: https://github.com/zhaoyilun/bonsai2-27b-mtp-repro (dataset mirror: https://huggingface.co/datasets/zhaokeqi/bonsai2-27b-mtp-repro), discussion: https://huggingface.co/prism-ml/Ternary-Bonsai-2-27B-gguf/discussions/23.
Credit
The same change ships as
runtime/bonsai-mtp-embedding.patchinProCreations/Ternary-Bonsai-2-27B-MTP, whose patched source archive already contains it. This PR is the equivalent against currentprism(the guard and the trunk helper have moved slightly since b10683, e.g. the verifier now reports the consuming op). If you would rather take the fix from them or fold it into a wider pass over every latent table the draft graph reads, please do — the point is that in-file MTP is unusable on official binaries until this lands.