MTP: separate sidecar draft model, llama.cpp b10435 (Qwen 3.8) - #86
Merged
Conversation
Qwen 3.8 ships MTP as a sidecar: ggml-org/Qwen3.8-27B-GGUF pairs Qwen3.8-27B-Q4_K_M.gguf, which has zero nextn layers, with the head alone in mtp-Qwen3.8-27B-Q4_0.gguf. MTP.init/2 built both contexts from the one model, so it refused that pair outright with "this GGUF contains no MTP head". The NIF already took two independent contexts and upstream's draft-mtp impl only requires the hidden widths to agree, so the restriction was purely on the Elixir side. MTP.init/2 now takes :draft_model — the equivalent of upstream's -hf <target> -hfd <draft> --spec-type draft-mtp. No new arch work was needed: Qwen 3.8 loads under the existing qwen35 architecture. Mismatched pairings are refused before any context is built, including a target/draft hidden-width mismatch. That one matters because upstream compares the widths with a GGML_ASSERT, an unconditional ggml_abort that would take the VM down rather than return an error, so Model.n_embd_out/1 is exposed to check it first. stats/1 gains timing_us.ckpt. A hybrid target cannot roll back part of a sequence, so every speculative iteration snapshots the whole recurrent state — 48 SSM layers beside 16 attention ones on Qwen 3.8, ~150 MiB. That cost was folded into :other, whose documented cause is Metal GPU-sync waits. Splitting it out dropped :other for one M1 Max run from 8.7s to 0.17s and showed ckpt was 6.9s of the 16.3s total. Measured, Q4_K_M target + Q4_0 head, 120-token greedy: n_draft acceptance M1 Max GB10 1 75.0% 0.89x 1.24x 3 40-44% 0.68x 1.09x 4-5 30-32% 0.56x 0.96x So MTP is a net loss on Metal for this model and a win on GB10 at n_draft: 1 — unlike the 35B MoE, whose optimum was 2. Check ckpt against total before trusting speculation on any hybrid model. The GB10 column is warm-cache; a cold first run read ~19GB and reported a 4.18 tok/s baseline against 10.83 warm, which inverts the comparison. Two things deliberately not changed: - --spec-default, which stacks n-gram speculation on top of a model-based drafter, stays unreachable: the NIF pins COMMON_SPECULATIVE_TYPE_DRAFT_MTP. - At n_draft >= 4 greedy output diverges from baseline, deterministically and always at the same token. This is not a rollback bug. Plain greedy decode with no speculation flips at the same position purely from batch row count (1-3 rows " computational", 4+ " latency"): ggml-metal-ops.cpp takes the mul_mv_ext path for Q4_K only at ne11 >= 4 and picks r1ptg by ne11, and the verify batch is 1 + n_draft rows. It is a near-tie decided by reduction order. Confirmed on Metal only. llama.cpp a94d563 (b10423) -> 9e40df6 (b10435), LLAMA_COMMIT bumped to match. Verified on macOS (Metal): 428 passed, 149 excluded, plus 6 passed with --include mtp_sidecar against the Qwen 3.8 pair. On a DGX Spark (CUDA 13.0, sm_121a): 428 passed. mix format, --warnings-as-errors and credo --strict clean.
The `:smoke` guard test asserted `message =~ "-MTP"`, which was the old
message's way of naming the remedy ("publishers typically ship as a
separate -MTP build"). Adding `:draft_model` reworded that message to
offer both routes out — an MTP-preserving conversion, or the publisher's
head-only sidecar — and dropped the bare "-MTP" token, so the assertion
failed in CI while the guard itself was working correctly.
Assert on what the test is actually for: that the message names the
remedies rather than only the diagnosis. Both routes are now checked, so
neither can be dropped from the message unnoticed, and the assertion no
longer pins one incidental phrasing.
Reproduced with the CI job's own models and flags
(SmolLM2-135M-Instruct-Q8_0, `--include smoke --include embeddings
--include slow`): 556/558 before, 557/558 after.
The remaining local failure is `embed_batch matches per-text embed`, and
it is neither new nor CI's. It reports byte-identical
0.0024415459483861923 against a 1.0e-3 tolerance on a Metal build, on a
CPU build, and with vendor/llama.cpp reverted to the pre-bump b10423 — so
it is not backend numerics and not the submodule bump. macOS compiles
ggml-blas against Accelerate for every backend, so batched embeddings go
through Accelerate sgemm while the single-text reference path uses ggml's
own kernel; Linux CI has no Accelerate and takes the same kernel on both
sides, which is why CI reported one failure and not two.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Why
Qwen 3.8 ships MTP as a sidecar pair.
ggml-org/Qwen3.8-27B-GGUFpublishesQwen3.8-27B-Q4_K_M.ggufwith zero nextn layers, and the head alone inmtp-Qwen3.8-27B-Q4_0.gguf:n_layer_nextnn_embd_outQwen3.8-27B-Q4_K_M.ggufmtp-Qwen3.8-27B-Q4_0.ggufMTP.init/2built both contexts from the one model, so it refused that pairoutright with "this GGUF contains no MTP head". The NIF already accepted two
independent contexts, and upstream's
draft-mtpimpl only requires the hiddenwidths to agree — the restriction was purely on the Elixir side.
No new architecture work was needed: Qwen 3.8 loads under the existing
qwen35arch, and
llama-model.cppalready listsLLM_ARCH_QWEN35inmtp_on_hybrid_qwen.What changed
MTP.init/2takes:draft_model— the equivalent of upstream's-hf <target> -hfd <draft> --spec-type draft-mtp:Mismatched pairings are refused before any context is built: a sidecar without
load_mtp: true, an ordinary model passed as:draft_model, and a target/drafthidden-width mismatch. That last one matters — upstream compares the widths with a
GGML_ASSERT, an unconditionalggml_abortthat would take the VM down rather thanreturn an error — so
Model.n_embd_out/1is exposed to check it first.stats/1gainstiming_us.ckpt. A hybrid target cannot roll back part of asequence, so every speculative iteration snapshots the whole recurrent state
(48 SSM layers beside 16 attention ones, ~150 MiB). That cost was folded into
:other, whose documented cause is Metal GPU-sync waits. Splitting it out dropped:otherfor one M1 Max run from 8.7 s to 0.17 s and showedckptwas6.9 s of the 16.3 s total.
Also:
Model.n_layer_nextn/1(wraps an existing NIF that was only reachable viaLlamaCppEx.NIF),vendor/llama.cppa94d563(b10423) →9e40df6(b10435)with
LLAMA_COMMITbumped to match.Measured
Q4_K_M target + Q4_0 head, 120-token greedy generations:
n_draftMTP is a net loss on Metal for this model, and a win on GB10 at
n_draft: 1—unlike the 35B MoE already documented in the README, whose optimum was 2. Check
ckptagainsttotalbefore trusting speculation on any hybrid model.Deliberately not changed
--spec-defaultstays unreachable. It stacks n-gram speculation on top of amodel-based drafter; the NIF pins
COMMON_SPECULATIVE_TYPE_DRAFT_MTP. Untested here.n_draft >= 4greedy output diverges from baseline — deterministically,always the same token. This is not a rollback bug. Plain greedy decode with
speculation switched off entirely flips at the same position purely from batch row
count (1–3 rows →
" computational", 4+ →" latency"):ggml-metal-ops.cpptakes the
mul_mv_extpath for Q4_K only atne11 >= 4and picksr1ptgbyne11, and the verify batch is1 + n_draftrows. A near-tie decided byreduction order.
Verification
--include mtp_sidecaragainst the Qwen 3.8 pairsm_121a): 428 passedmix format,mix compile --warnings-as-errors,mix credo --strictcleanNew gate, since the pair needs two files that
:mtp's single-file model cannot satisfy:Review notes
one warm sweep, not the interleaved n=11 methodology used for the 35B MoE figures.
Worth re-running interleaved before those numbers are leaned on.
4.18 tok/s baseline against 10.83 warm, which inverts the comparison entirely.
was never probed.