models: Qwen3.8-Flash-Next MTP - #28243
danielhanchen wants to merge 14 commits into
Conversation
| // target for a draft head that declares nextn_shared_target_tensors; must outlive this model | ||
| const struct llama_model * model_shared; |
There was a problem hiding this comment.
The llama_context_params already support ctx_other. Reuse that logic instead of adding this.
| // a captured graph hard-codes its shapes, so with one key per split an alternating shape | ||
| // (a speculative verify batch) resets warmup forever. O(1) on purpose: walking nodes undoes the | ||
| // point of a cuda graph. A shape this fails to separate re-captures as before, so it cannot regress. | ||
| static uint64_t ggml_cuda_graph_get_key(ggml_cgraph * cgraph) { | ||
| // unlike the previous key this dereferences nodes[0], so an empty graph is not safe here | ||
| if (cgraph->n_nodes <= 0) { | ||
| return 0; | ||
| } | ||
|
|
||
| uint64_t key = (uint64_t) (uintptr_t) cgraph->nodes[0]; | ||
|
|
||
| auto mix = [&key](uint64_t v) { | ||
| key = (key ^ v) * 0x100000001b3ull; | ||
| }; | ||
|
|
||
| mix(cgraph->n_nodes); | ||
|
|
||
| for (int d = 0; d < GGML_MAX_DIMS; d++) { | ||
| mix(cgraph->nodes[0]->ne[d]); | ||
| mix(cgraph->nodes[cgraph->n_nodes - 1]->ne[d]); | ||
| } | ||
|
|
||
| return key; |
There was a problem hiding this comment.
Better keep the CUDA changes for a follow-up PR.
|
@ggerganov thanks will check when I wake :)) |
|
Works great on dual consumer GPUs (5090 + 5060 Ti, 32 GB RAM): +25-40%, acceptance 0.77 9950X, 32 GB DDR5, RTX 5090 + RTX 5060 Ti (gen5 x8/x8), Ubuntu, CUDA 13.3, PR @ 2857e51.
Acceptance 0.767 (16849/21960), mean len 2.53. One minor issue I encountered: the fitter can't measure the shared head ( Thanks for all of your hard work! |
Validation: 2× RTX A6000 48GB (PCIe, no NVLink), Windows Server 2019, CUDA 12.6 (sm_86) — both heads draft at ~0.37 acceptance, no config beats baselineBuilt this PR @ Target: Arm 1 — shared Q4_K_M sidecar, Arm 2 — same but without Arm 3 — standalone Q8_0 grafted head (the drluoto file that gave 82–95% acceptance on #27836 @ Same draft file, same target, same prompts: 0.82–0.95 acceptance on #27836 vs ~0.37 on this branch — so something between Side note: draft compute buffer (2253 MiB) initially OOMs on device 1 next to the 90 GB target, recovered by |
The CUDA graph cache keyed captured graphs by the raw memory address of their first node (cgraph->nodes[0]). A captured graph hard-codes its shapes, but speculative decoding constantly alternates between different batch shapes (draft steps, verify batches, catch-up decodes) on the same context - when a new shape happens to reuse the same first- node address as a stale cached graph for a different shape, capture either reuses the wrong graph or thrashes, permanently resetting warmup instead of ever converging to steady-state replay. Hash node count and both endpoint tensors' shapes into the key instead (O(1) - walking all nodes would defeat the point of a CUDA graph), add LRU eviction capped at 64 graphs so the map can't grow unbounded now that distinct shapes get distinct entries. A shape this still fails to separate re-captures exactly as before, so it can't regress anything. Cherry-picked from ggml-org#28243 (open, unmerged), which found this while working on qwen4exp MTP performance - the effect is generic to any speculative-decoding workload on this fork, not qwen4exp-specific, so pulling in just this piece rather than the rest of that PR (which also reworks qwen4exp trunk/draft tensor sharing and doesn't fix the mixer/export-timing bugs already fixed on this branch). Verified: qwen4exp MTP speculative decoding still produces correct, byte-identical (temp 0) output after this change. Speed effect is hard to isolate cleanly from the dominant MoE-cache warmup effect already documented on this branch, but the fix is justified on its own correctness merits regardless of measured delta. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
| throw std::runtime_error(format("%s: this model is a draft head without its own '%s'; " | ||
| "load it as a draft of its target model, not on its own", __func__, name.c_str())); |
There was a problem hiding this comment.
This triggers when loading with --fit on as the draft model is loaded independently as part of that. Everything still works though: it's later loaded again together with the target model.
Metal data point on this exact head (2857e51): dn=2 is greedy-identical, dn=5 is slower and divergesAll validations above are CUDA, so here is Apple Silicon. M5 Pro 64 GB, Metal, built at
Three things worth separating:
Happy to rerun on any later head; the harness is three commands. |
|
I have been trying this and the baseline from master actually has faster token gen than with MTP on, both shared and standalone. Using 5090+A6000(Ampere) + 128GB DDR5. |
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.
A NextN/MTP draft exported with --mtp carries the token embeddings, output norm and lm head so it can be loaded as a standalone model. For every current sidecar those three tensors are most of the file: ggml-org/Qwen3.8-27B-GGUF mtp-Qwen3.8-27B-Q4_0.gguf is 1.565 GiB, of which 1.332 GiB (85%) is the copy, against 0.223 GiB for the MTP block itself. Add an opt-in --mtp-shared-embd that leaves them out and marks the file with nextn_shared_target_tensors. The loader then resolves those names against the already loaded target model. The graph side needs no change: the nextn blocks of twelve archs already fall back to model.tok_embd and model.output. The borrow is gated on the new key, so a sidecar published before this change cannot reach it and keeps its current behaviour. Shapes are checked against the target and a mismatch is refused, as is loading such a file on its own.
_QwenMtpMixin is not a ModelBase subclass, so it re-declares the attributes it reads off cls for the type checker. filter_tensors reads cls.mtp_shared_embd without a matching declaration, which ty reports as unresolved-attribute. The declaration is a bare annotation, matching no_mtp and mtp_only above it. That creates no class attribute, so it cannot shadow ModelBase.mtp_shared_embd even though the mixin precedes the model class in the MRO; a default value here would have. Assisted-by: Claude
A draft-only export declares the full block count but ships the MTP block alone, so the trunk tensors load as null and only the MTP graph is buildable. Context reservation builds the trunk graph, which walked those nulls and segfaulted. A shared-embedding draft is caught earlier by the borrow check, since it has no token_embd of its own. A self-contained draft keeps one, so it passed that check and reached here. Assisted-by: Claude
Reuses the existing llama_context_params::ctx_other plumbing instead of adding a model_shared load parameter and a nextn_shared_target_tensors metadata key. A draft-only export now simply omits token_embd/output, and the qwen4exp MTP graph resolves them against the target context at graph build, following dflash and gemma4-assistant. Loading such a file on its own reports that it needs -md. Assisted-by: Claude
Assisted-by: Claude
2857e51 to
2c96729
Compare
This comment was marked as spam.
This comment was marked as spam.
|
Pulled the latest commits on this PR and started getting this error in the logs and it seems MTP wasn't activated |
|
Thank you danielhanchen for the rebasing! Please verify whether the fourth commit 3fb9b98
from #28097 is still required after this PR. It fixes two bugs: erroneous search for the draft tensors, erroneous search path for the draft file. |
|
Will address issues and review :) |
Upstream PR ggml-org#28243 adds shared-MTP support for qwen4exp and keeps unsloth's original naming: eh_proj stays whole, and the mixer tensors stay hc_head_norm/down/up. This fork splits eh_proj and renames the mixer, which is what scripts/mtp/mtp_sidecar.py converts to. If that PR lands, unsloth's sidecar loads on upstream unconverted and the head published alongside this fork does not. Nothing breaks today, but the names should not be treated as standard. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Two conflicts. src/llama-model.cpp is operand order only, identical semantics. src/models/models.h: the fork declared a protected graph(..., bool mtp) constructor that nothing defined or called any more (the fork lost its qwen4exp graph_mtp in the 0190529 upstream merge and kept only the declaration), so the PR's no_build_t tag constructor replaces it rather than coexisting with a dead overload.
… names A draft-only export from the ROCmFPx fork (agentionai Qwen3.8-Flash-Next-MTP- ROCmFP4-FAST) has no blk.N.nextn.hc_head_* tensors: that converter wrote the MTP block's hyper-connection mixer as output_hc_norm/down/up, since the file has no trunk of its own, and its graph_mtp reads model.hc_head_*. Upstream's head (ggml-org#28243) loads nextn.hc_head_* and failed with "tensor not found". When the file is draft-only and the nextn copies are absent, load them as not required and point the MTP layer at the trunk-named tensors instead. The unsloth shared-Q8_0 head, which carries nextn.hc_head_*, is unaffected. With this head on gfx1151 (Vulkan, cm1 fixed), fixed n_max 4: 33.2 / 61.6 / 52.2 t/s prose / json / code at acceptance 0.38 / 0.94 / 0.75; the fpx binary with the same pairing does 27.9 / 51.8 / 43.3.
Assisted-by: Claude
…card Add artifacts/unavailable.json and render support so a reference cell the host cannot produce for hardware reasons (llama.cpp OOM-killed at -c 133760 with the 104 GiB model) reads n/a instead of TODO; missing software features such as the MTP sidecar (ggml-org/llama.cpp#28243) stay TODO. Drop prose that restates the tables and the loading commands.
| _MTP_MIXER_PREFIX = "mtp.hyper_connection_mixer." | ||
|
|
||
| @classmethod | ||
| def filter_tensors(cls, item): | ||
| name, gen = item | ||
| _, _, mixer_suffix = name.partition(cls._MTP_MIXER_PREFIX) |
There was a problem hiding this comment.
| _MTP_MIXER_PREFIX = "mtp.hyper_connection_mixer." | |
| @classmethod | |
| def filter_tensors(cls, item): | |
| name, gen = item | |
| _, _, mixer_suffix = name.partition(cls._MTP_MIXER_PREFIX) | |
| @classmethod | |
| def filter_tensors(cls, item): | |
| name, gen = item | |
| _, _, mixer_suffix = name.partition("mtp.hyper_connection_mixer.") |
| // the trunk must keep at least one block: n_layer() == n_layer_all - n_layer_nextn | ||
| if (hparams.n_layer_nextn >= hparams.n_layer_all) { | ||
| throw std::runtime_error(format("%s must be less than %s, got %u", | ||
| ml.llm_kv(LLM_KV_NEXTN_PREDICT_LAYERS).c_str(), | ||
| ml.llm_kv(LLM_KV_BLOCK_COUNT).c_str(), hparams.n_layer_nextn)); | ||
| } | ||
|
|
||
| ml.get_key_or_arr(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, hparams.n_ff_exp_arr, hparams.n_layer_all, false); |
There was a problem hiding this comment.
| // the trunk must keep at least one block: n_layer() == n_layer_all - n_layer_nextn | |
| if (hparams.n_layer_nextn >= hparams.n_layer_all) { | |
| throw std::runtime_error(format("%s must be less than %s, got %u", | |
| ml.llm_kv(LLM_KV_NEXTN_PREDICT_LAYERS).c_str(), | |
| ml.llm_kv(LLM_KV_BLOCK_COUNT).c_str(), hparams.n_layer_nextn)); | |
| } | |
| ml.get_key_or_arr(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, hparams.n_ff_exp_arr, hparams.n_layer_all, false); | |
| ml.get_key_or_arr(LLM_KV_EXPERT_FEED_FORWARD_LENGTH, hparams.n_ff_exp_arr, hparams.n_layer_all, false); |
It can only be <= at this point, and since any incorrect value would lead to a non-working model I don't see any reason to check for equality (which would just not process any layers at all).
Build llama.cpp from the open Qwen3.8-Flash-Next MTP pull request (ggml-org/llama.cpp#28243, commit 6fcaa16f) as a parallel devshell package exposed as llama-server-mtp; bench.json's speculative.reference.server selects it for the speculative cells only, and the card says so. Gufo MTP matches llama.cpp MTP within 2–11% on prose at d0/4K and leads by 5–36% on the repetitive workload; llama.cpp's MTP server is OOM-killed from the 12K prefix and at C6–C8, which the driver now records as unavailable so render prints n/a for those cells automatically. Add the image-encoder runner: prefill time of a request carrying a gradient PNG on both servers with the same BF16 projector; Gufo is +151% at 256x256 and +55% at 1024x1024. Download the Flash-Next projector into the model directory. Restore the aggregate delivered tok/s metric in render (a subagent had switched it to per-request decode-rate sums) and re-render the Qwen3.8 27B concurrency tables on it.
| parser.add_argument( | ||
| "--mtp-shared-embd", action="store_true", | ||
| help="With --mtp, leave the token embeddings, output norm and LM head out of the draft and take them from the target model at load time. Much smaller draft, but it needs a llama.cpp new enough to read it.", | ||
| ) |
There was a problem hiding this comment.
How much space do we save? My estimate is about 1GB of Q8_0?
If this is correct, my recommendation is to remove this sharing functionality in order to reduce the complexity. It is not worth it and additionally, am efficient MTP setup actually benefits from not sharing the target tensors and instead using fast low-bit quantizations such as Q4_0 for the token embeddings and the lm head.
There was a problem hiding this comment.
I also don’t think we need to set this explicitly for MTP. I feel it should be less than 1 GB.
During MTP conversion, we can export the MTP head either together with the target model or separately. In the former case, the MTP head does not need its own token_embd or lm_head, since it can access them later through ctx_other. In the latter case, the exported one will be a self-contained draft model by design.
Squash of upstream PR ggml-org#28243 (net diff vs bb3c853), including the 09-21 review fixes: draft load path, nextn_layer_offset. Assisted-by: opencode
|
With this PR, I don't really understand why, but it seems that the size of the mtp weights is not taken into account. I can work around it by increasing Otherwise, I'm getting a nice performance boost. |
I think that might be a different issue, for me almost all models need adjustments when |
ruixiang63
left a comment
There was a problem hiding this comment.
It would be great if we could minimize unnecessary changes and stay consistent with the current MTP design. #26725 might be a good reference.
| LOG_INF("%s: loading draft model '%s'\n", __func__, model_path.c_str()); | ||
|
|
||
| llama_model * model_dft = llama_model_load_from_file(params.model.path.c_str(), mparams); | ||
| llama_model * model_dft = llama_model_load_from_file(model_path.c_str(), mparams); |
There was a problem hiding this comment.
This isn't a fix, both expressions are the same string. I'd suggest just dropping this hunk from the PR.
There was a problem hiding this comment.
@ruixiang63 Are you sure about that? See 3fb9b98
There was a problem hiding this comment.
Have you encountered any issues with MTP on the current master branch? If so, please open a separate issue.
This change does not seem relevant to the current Qwen3.8-Flash-Next MTP implementation.
| parser.add_argument( | ||
| "--mtp-shared-embd", action="store_true", | ||
| help="With --mtp, leave the token embeddings, output norm and LM head out of the draft and take them from the target model at load time. Much smaller draft, but it needs a llama.cpp new enough to read it.", | ||
| ) |
There was a problem hiding this comment.
I also don’t think we need to set this explicitly for MTP. I feel it should be less than 1 GB.
During MTP conversion, we can export the MTP head either together with the target model or separately. In the former case, the MTP head does not need its own token_embd or lm_head, since it can access them later through ctx_other. In the latter case, the exported one will be a self-contained draft model by design.
| if args.mtp: | ||
| model_class.mtp_only = True | ||
|
|
||
| if args.mtp_shared_embd: |
| if is_mtp and cls.no_mtp: | ||
| return None | ||
| if cls.mtp_only and not is_mtp and name not in ( | ||
| if cls.mtp_only and not is_mtp and (cls.mtp_shared_embd or name not in ( |
There was a problem hiding this comment.
Based on the suggestion below, I don’t think we need mtp_shared_embd anymore.
| llama_set_embeddings_nextn(ctx_dft, true, /*masked*/ true); | ||
|
|
||
| is_mem_shared = llama_get_ctx_other(ctx_dft) == ctx_tgt; | ||
| char arch[64] = {0}; |
There was a problem hiding this comment.
see https://github.com/ggml-org/llama.cpp/pull/28243/changes#r4082502758. If we follow how other models support MTP, this will not be needed. e.g. #26725
| if is_mtp and cls.no_mtp: | ||
| return None | ||
| if cls.mtp_only and not is_mtp and name not in ( | ||
| if cls.mtp_only and not is_mtp and (cls.mtp_shared_embd or name not in ( |
There was a problem hiding this comment.
this will not be needed as well.
| } | ||
|
|
||
| if (model.arch == LLM_ARCH_EAGLE3 || model.arch == LLM_ARCH_DFLASH) { | ||
| if (model.arch == LLM_ARCH_EAGLE3 || model.arch == LLM_ARCH_DFLASH || model.arch == LLM_ARCH_QWEN4EXP) { |
There was a problem hiding this comment.
Here is not necessary as well. Let's follow the current MTP support design.
SYCL validation: qwen4exp MTP on 2× Arc Pro B70Setup: Qwen3.8-Flash-Next (UD-IQ3_XXS), two B70s, My build carries one SYCL patch (PR 28796), and I did not use Except for the
|
Brings in the qwen4exp NextN/MTP draft head and cross-model tensor borrowing, so Unsloth's shared-* MTP heads can draft for Qwen3.8-Flash-Next via --spec-type draft-mtp.
…, final numbers
Overview
Enables 1.3 to 2x faster MTP support for Qwen3.8-Flash-Next + shared MTP modules (re-uses embed_tokens to save disk space and VRAM / RAM). Builds on top of #27836
Additional information
Requirements