Skip to content

feat(gguf): auto-detect + export Qwen3.5/3.8 MTP self-speculative head from GGUF - #529

Open
justinchuby wants to merge 5 commits into
sapper/qwen38-27bfrom
roy/qwen38-mtp-head-export
Open

feat(gguf): auto-detect + export Qwen3.5/3.8 MTP self-speculative head from GGUF#529
justinchuby wants to merge 5 commits into
sapper/qwen38-27bfrom
roy/qwen38-mtp-head-export

Conversation

@justinchuby

@justinchuby justinchuby commented Aug 21, 2026

Copy link
Copy Markdown
Member

What

Recovers the Multi-Token-Prediction (MTP / "nextn") self-speculative head that the Qwen3.8-27B ONNX export was silently dropping, and emits it automatically whenever the source GGUF ships the head — no CLI flag, no config toggle. The MTP sidecar is a purely-additive artifact (mtp/model.onnx + shared embedding/lm_head + SpeculatorConfig metadata) that existing consumers ignore, so it is driven entirely by source-tensor presence: emit MTP iff available.

Auto-detect (presence-driven, no flag)

The decision is made by has_mtp_head(config) — true when the config carries _gguf_mtp_block_indices, which gguf_to_config populates from the source GGUF. Exact presence check:

  • Metadata key <arch>.nextn_predict_layers > 0 (for Qwen3.8-27B: qwen35.nextn_predict_layers = 1, block_count = 65), which yields _gguf_mtp_block_indices = [decoder_layers .. decoder_layers + nextn - 1] (here [64]), and
  • the trailing block's blk.<N>.nextn.* tensors — nextn.eh_proj (the fc), nextn.enorm, nextn.hnorm, nextn.shared_head_norm — plus that block's own attention (attn_q/k/v/output, attn_q_norm/k_norm, attn_norm, post_attention_norm) and FFN (ffn_gate/up/down) sublayer.

Behavior:

  • Source has the nextn head -> always emit the mtp/model.onnx sidecar + the backbone hidden_states.<N-1> seed output + a SpeculatorConfig (proposal_type: mtp) block in inference_metadata.yaml.
  • Source has no nextn head -> emit nothing; text-only output is byte-for-byte identical to today.
  • --static-cache skips the head (it needs the dynamic concat-grow cache), logged as info; those exports are unchanged.

MTP dims are derived entirely from GGUF/source metadata (hidden size, heads, head_dim, KV heads, rope, quant) — no hardcoding.

Root cause

The dense Qwen3.5/3.8 GGUF (e.g. unsloth/Qwen3.8-27B) ships a trained MTP head as the trailing blk.<N> block (for 27B: blk.64), tagged by <arch>.nextn_predict_layers. gguf_to_config read that key only to subtract the block from num_hidden_layers, and the text-only backbone build never made a second pass — so the head tensors were dropped.

Changes

  • integrations/gguf/_config_mapping.py — surface _gguf_nextn_predict_layers / _gguf_mtp_block_indices (the trailing block previously only subtracted).
  • integrations/gguf/_mtp.py (new) — has_mtp_head (presence check), map_gguf_mtp_to_hf_names (GGUF blk.<N>.nextn.* + attn/ffn -> Qwen35MtpModel stems), derive_mtp_config (all dims inherited from the backbone config — no hardcoding), build_mtp_head_from_gguf (reuses Qwen35MtpModel/Qwen35MtpTask and the existing quantized/dequantized loaders via an injectable name_mapper; fc is quantized on the same main-layer path). The two pre_fc_norm_* OffsetRMSNorm +1 offsets missed by the generic norm strip are handled explicitly.
  • integrations/gguf/_builder.py — steps 4b (seed output) and 10 (attach pkg.mtp_head) gate on has_mtp_head(config) alone (auto-detect); static_cache skips the head via a logged info. The quantized/dequantized loaders gained optional name_mapper / warn_unmapped params (backward compatible).
  • __main__.py — when pkg.mtp_head is present, save it into output_dir/mtp/ and write speculator metadata. (No new CLI flag.)
  • integrations/onnx_genai/inference_metadata.pywrite_mtp_speculator_metadata (proposal_type: mtp, target_hidden_output, mtp_hidden, single-layer KV).
  • integrations/gguf/_mtp_test.py (new) — mapping, config derivation, tiny-GGUF head emission, and auto-detect (nextn GGUF auto-emits the head with mtp_hidden output; plain GGUF omits it).

Validation (partial — stated explicitly)

  • Unit + tiny-GGUF: 9 MTP tests PASS, incl. auto-detect through build_from_gguf (source with nextn -> pkg.mtp_head attached; source without -> absent). Emitted head initializers include fc.weight_t, pre_fc_norm_embedding.weight, pre_fc_norm_hidden.weight, norm.weight, layers.0.self_attn.q_proj.weight_t; graph output mtp_hidden; every initializer has backing weights. _config_mapping / _tensor_mapping suites still pass (74 total).
  • Real 16 GB GGUF blk.64 shape contract VERIFIED against the metadata-derived head — all 15 head tensors map, backbone excluded:
    • attn_q = (12288, 5120) = (2NHHD, H) -> doubled-Q output gating
    • attn_k/attn_v = (1024, 5120) = (NKVHD, H); attn_output = (5120, 6144) = (H, NHHD)
    • nextn.eh_proj = (5120, 10240) = (H, 2H) — the fc GEMM (Q8_0 while projections are Q4_0; mixed quant handled per-tensor)
  • Not run: a full 27B int4 head export through onnxruntime (heavy; the local venv's older onnx_ir lacks max_shard_size_bytes, which also breaks the pre-existing test_default_quantized_package_save_reload — unrelated to this change).

Real artifact produced + two export-path fixes

Producing the real int4/bf16 27B artifact surfaced two defects that made the MTP sidecar silently degenerate. Both are now fixed (commit 987c8d04):

  1. Qwen35TextModel.forward ignored output_layer_indices — it always returned a 2-tuple, so the backbone never emitted the hidden_states.{N-1} seed the head consumes; speculator.target_hidden_output pointed at a non-existent output. Added the same capture path as base.py TextModel (returns the selected per-layer post-residual hidden states so HybridCausalLMTask emits hidden_states.{idx}).
  2. build_from_gguf dropped the _gguf_* nextn metadata under --dtype — an explicit dtype (or quantization) triggers dataclasses.replace, which returns a fresh config without the plain _gguf_* attributes, so has_mtp_head saw nothing and skipped the head. Now captured into locals right after gguf_to_config (like _gguf_model_type) and re-attached before step 4b. Regression test added (test_mtp_survives_dtype_replace).

write_mtp_speculator_metadata also now emits embedding_weights, lm_head_weights, and vocab_size.

Full export — VALIDATED end-to-end

Command:

python -m mobius build-gguf /home/justinchu/qwen38-gguf/Qwen3.8-27B-Q4_0.gguf \
  --output /home/justinchu/qwen38-27b-int4-mtp-cuda --dtype bf16 --runtime onnx-genai
  • Layout: model.onnx (2.2 MB) + model.onnx.data (17.4 GB), mtp/model.onnx (38 KB) + mtp/model.onnx.data (354 MB), inference_metadata.yaml, tokenizer.json. Total 17 GB.
  • Main model exposes hidden_states.63 = [batch, sequence_len, 5120] — exactly speculator.target_hidden_output.
  • MTP head I/O: inputs inputs_embeds, hidden_states, attention_mask, position_ids, past_key_values.0.{key,value}; outputs mtp_hidden + present.0.{key,value} (single full-attention layer KV). fc.weight_t = (10240, 5120); q/k/v/o + gate/up/down projections are int4 MatMulNBits (UINT8 packed, block-32) following the main-layer path; eh_proj stays bf16 (Q8_0 in source). Shared embedding/lm_head are reused from the main model (named in the speculator block), not duplicated.
  • SpeculatorConfig block: proposal_type: mtp, num_speculative_tokens: 1, model_path: mtp/model.onnx, mtp_hidden_output: mtp_hidden, kv_mode: hidden_threaded, embedding_weights: model.embed_tokens.weight, lm_head_weights: lm_head.weight, target_hidden_output: hidden_states.63, hidden_size: 5120, vocab_size: 248320.
  • Tests: 53 pass (MTP + qwen35 backbone + qwen35-mtp suites), including the new seed-output and dtype-replace regressions.

Runtime schema-conformance fix (metadata block)

The first emitted metadata used a speculator: block whose field names the onnx-genai runtime cannot parse, so InferenceMetadata.speculative deserialized to None and the sidecar was silently ignored (text-only decode). Now conformed exactly to the authoritative schema (onnx-genai crates/onnx-genai-metadata: schema/generation.rs SpeculatorConfig, parser.rs resolve_mtp, config.rs validate_resolved_mtp_config) — commit b6747e63:

  • Top-level key speculator -> speculative (the runtime key; alias speculator_config). speculator was unknown -> dropped.
  • model_path -> model; hidden_size -> target_hidden_size.
  • kv_mode: hidden_threaded (engine-internal enum) -> proposal_local (only valid MtpKvMode for this k=1 head).
  • embedding_weights/lm_head_weights flat strings -> nested embedding/lm_head MtpTargetInitializer objects {source: target_initializer, name: ...}.
  • Added target_hidden_layout: BSH (rank-3 [batch,seq,hidden] seed) + hc_mult: 1 (resolve_mtp requires hc_mult>0; validate_resolved_mtp_config pins it to 1 for BSH).
  • Omit mtp_state_output (an hc_mult==1 head has no recurrent Hyper-Connection state; the sidecar emits only mtp_hidden + present.0.{key,value}).

Emitted block:

speculative:
  proposal_type: mtp
  num_speculative_tokens: 1
  model: mtp/model.onnx
  target_hidden_layout: BSH
  hc_mult: 1
  mtp_hidden_output: mtp_hidden
  kv_mode: proposal_local
  embedding: {source: target_initializer, name: model.embed_tokens.weight}
  lm_head: {source: target_initializer, name: lm_head.weight}
  target_hidden_output: hidden_states.63
  target_hidden_size: 5120
  vocab_size: 248320

Test TestMtpSpeculatorMetadata asserts the exact keys/values and validates the emitted YAML against onnx-genai's published inference_metadata.schema.json. (The existing 17 GB artifact's metadata was hand-patched for the benchmark; not re-exported.)

Why

Unblocks MTP self-speculative decode for Qwen3.8-27B batch=1 decode on H200. The engine side already landed in onnx-genai #1598 (recurrent-state commit-by-accepted-prefix); mtp.rs/MtpDecodeSession + SpeculatorConfig consume exactly this sidecar. Any Qwen3.5/3.8 GGUF that ships the head now exports it end-to-end automatically.

Co-authored-by: Copilot 223556219+Copilot@users.noreply.github.com

The dense Qwen3.5/3.8 GGUF (e.g. unsloth/Qwen3.8-27B) ships a trained
multi-token-prediction ("nextn") head as the trailing blk.<N> block,
tagged by <arch>.nextn_predict_layers. gguf_to_config read that key only
to subtract the block from num_hidden_layers, so the text-only backbone
build silently dropped the head and the exported ONNX had no
self-speculative drafter.

Recover the head as a mtp/ sidecar:
- _config_mapping: surface _gguf_nextn_predict_layers / _gguf_mtp_block_indices.
- _mtp.py (new): map blk.<N>.nextn.* + attn/ffn to Qwen35MtpModel stems,
  derive Qwen35MtpConfig (all dims inherited from the backbone; no hardcoding),
  and build the head via the existing quantized/dequantized loaders through an
  injectable name_mapper. The two pre_fc_norm_* OffsetRMSNorm +1 offsets missed
  by the generic pass are stripped explicitly.
- _builder: expose hidden_states.<N-1> as the head seed and attach pkg.mtp_head.
- __main__: save pkg.mtp_head to output_dir/mtp/ and write speculator metadata.
- inference_metadata: write_mtp_speculator_metadata (proposal_type: mtp).
- _mtp_test.py (new): mapping, config derivation, and tiny-GGUF head emission.

Verified the real Qwen3.8-27B GGUF blk.64 head tensor shapes match the
metadata-derived head (doubled-Q gating: attn_q = 2*NH*HD rows; eh_proj = H x 2H).

Unblocks MTP self-speculative decode for Qwen3.8-27B (engine side: onnx-genai #1598).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@@ -0,0 +1,217 @@
# Copyright (c) Microsoft Corporation.
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

Performance Comparison

Comparing 6b39f00b6747e6

Model Metric Baseline Current Delta
bert (feature-extraction) model_size_bytes 359 KB 359 KB +0.0%
bert (feature-extraction) num_nodes 60 60 +0.0%
falcon model_size_bytes 364 KB 364 KB +0.0%
falcon num_nodes 66 66 +0.0%
gemma2 model_size_bytes 428 KB 428 KB +0.0%
gemma2 num_nodes 105 105 +0.0%
gpt2 model_size_bytes 388 KB 388 KB +0.0%
gpt2 num_nodes 54 54 +0.0%
llama model_size_bytes 425 KB 425 KB +0.0%
llama num_nodes 60 60 +0.0%
llama (static-cache) model_size_bytes 425 KB 425 KB +0.0%
llama (static-cache) num_nodes 56 56 +0.0%
mamba (ssm-text-generation) model_size_bytes 296 KB 296 KB +0.0%
mamba (ssm-text-generation) num_nodes 94 94 +0.0%
phi3 model_size_bytes 421 KB 421 KB +0.0%
phi3 num_nodes 58 58 +0.0%
phi3 (static-cache) model_size_bytes 421 KB 421 KB +0.0%
phi3 (static-cache) num_nodes 54 54 +0.0%
qwen2 model_size_bytes 425 KB 425 KB +0.0%
qwen2 num_nodes 60 60 +0.0%
qwen2 (static-cache) model_size_bytes 425 KB 425 KB +0.0%
qwen2 (static-cache) num_nodes 56 56 +0.0%
qwen3_5_moe (hybrid-text-generation) model_size_bytes 506 KB 506 KB +0.0%
qwen3_5_moe (hybrid-text-generation) num_nodes 264 264 +0.0%
qwen3_5_text (hybrid-text-generation) model_size_bytes 458 KB 458 KB +0.0%
qwen3_5_text (hybrid-text-generation) num_nodes 126 126 +0.0%
qwen3_5_vl (hybrid-qwen-vl) model_size_bytes 977 KB 977 KB +0.0%
qwen3_5_vl (hybrid-qwen-vl) num_nodes 428 428 +0.0%
t5 (seq2seq) model_size_bytes 836 KB 836 KB +0.0%
t5 (seq2seq) num_nodes 166 166 +0.0%
whisper (speech-to-text) model_size_bytes 1008 KB 1008 KB +0.0%
whisper (speech-to-text) num_nodes 128 128 +0.0%

No performance regressions.

The Qwen3.5/3.8 MTP self-speculative head is now gated behind an explicit
opt-in flag instead of being emitted whenever the GGUF ships a nextn block.
Default behavior is unchanged: a text-only export with no head sidecar, so
existing exports are byte-for-byte identical.

- build_from_gguf gains include_mtp: bool = False. Both the backbone
  hidden-state seed (output_layer_indices=[N-1], step 4b) and the head
  sidecar build (step 10) are gated on include_mtp AND has_mtp_head. When
  include_mtp=True but the GGUF has no nextn head, a warning is logged and
  the build proceeds text-only; when a head is present but the flag is off,
  an info log points at --include-mtp. Rejects include_mtp with static_cache
  or mmproj (ValueError).
- CLI: add `--include-mtp` (store_true, off by default), threaded through
  _cmd_build_gguf; the mtp/ sidecar save + SpeculatorConfig metadata write
  already key off pkg.mtp_head, so they only fire when the flag is set.
  CLI-level SystemExit guards mirror the build_from_gguf conflicts.
- Tests: gating coverage (default omits head; --include-mtp attaches it with
  mtp_hidden output; static_cache conflict raises; no-head GGUF warns).

MTP dims are still derived entirely from GGUF/source metadata; the flag only
gates whether the subgraph is emitted. One driver now serves both the
text-only and MTP paths.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@github-actions

github-actions Bot commented Aug 21, 2026

Copy link
Copy Markdown

🏗️ Architecture Diff

Comparing 6b39f00b6747e6

Model Sub-model Changes Status
bert (feature-extraction) model 0
falcon model 0
gemma2 model 0
gemma4 (gemma4) decoder 0
gemma4 (gemma4) embedding 0
gemma4 (gemma4) vision_encoder 0
gemma4_text model 0
gpt2 model 0
llama model 0
llama (static-cache) model 0
mamba (ssm-text-generation) model 0
phi3 model 0
phi3 (static-cache) model 0
qwen model 0
qwen (static-cache) model 0
qwen2 model 0
qwen2 (static-cache) model 0
qwen2_moe model 0
qwen2_moe (static-cache) model 0
qwen3 model 0
qwen3 (static-cache) model 0
qwen3_5_moe (hybrid-text-generation) model 0
qwen3_5_text (hybrid-text-generation) model 0
qwen3_5_vl (hybrid-qwen-vl) decoder 0
qwen3_5_vl (hybrid-qwen-vl) embedding 0
qwen3_5_vl (hybrid-qwen-vl) vision_encoder 0
qwen3_moe model 0
qwen3_moe (static-cache) model 0
qwen3_next (hybrid-text-generation) model 0
t5 (seq2seq) decoder 0
t5 (seq2seq) encoder 0
whisper (speech-to-text) decoder 0
whisper (speech-to-text) encoder 0

No architecture changes detected.


Legend: ⚪ No change · 🔵 Minor (attrs/inits) · 🟡 Moderate (nodes added/removed) · 🔴 Major (interface changed)

@justinchuby justinchuby changed the title feat(gguf): emit Qwen3.5/3.8 MTP self-speculative head sidecar feat(gguf): opt-in Qwen3.5/3.8 MTP self-speculative head export (--include-mtp) Aug 21, 2026
Supersedes the opt-in flag: the MTP self-speculative head is a purely
additive sidecar (mtp/model.onnx + shared embedding/lm_head + SpeculatorConfig
metadata) that existing consumers ignore, so there is no reason to gate it
behind a user flag. Emit it iff the source GGUF actually ships the head.

- Presence check (has_mtp_head): the config carries _gguf_mtp_block_indices,
  derived in gguf_to_config from <arch>.nextn_predict_layers > 0 (the trailing
  blk.<N>.nextn.* head block). When present, always emit the sidecar + the
  backbone hidden_states.<N-1> seed + SpeculatorConfig. When absent, skip —
  text-only sources produce byte-identical output to before.
- Remove build_from_gguf's include_mtp parameter and the CLI --include-mtp
  flag plus its conflict guards; steps 4b/10 gate on has_mtp_head alone.
  static_cache still skips the head (its dynamic-cache requirement) via a
  logged info, keeping static exports unchanged.
- Tests: replace flag-gating with auto-detect coverage (nextn GGUF auto-emits
  the head with mtp_hidden output; plain GGUF omits it).

Dims remain fully derived from GGUF/source metadata (no hardcoding).

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
@justinchuby justinchuby changed the title feat(gguf): opt-in Qwen3.5/3.8 MTP self-speculative head export (--include-mtp) feat(gguf): auto-detect + export Qwen3.5/3.8 MTP self-speculative head from GGUF Aug 21, 2026
justinchuby and others added 2 commits August 21, 2026 05:25
…etadata across dtype replace

Two defects prevented the real int4/bf16 Qwen3.8-27B export from emitting a
usable MTP self-speculative sidecar:

1. Qwen35TextModel.forward ignored output_layer_indices, so the backbone never
   produced the hidden_states.{N-1} seed the head consumes (it always returned a
   2-tuple). Add the same capture path as base.py TextModel: return the selected
   per-layer post-residual hidden states so HybridCausalLMTask emits
   hidden_states.{idx}. Without this the speculator.target_hidden_output
   referenced a non-existent output.

2. build_from_gguf lost the private _gguf_* MTP metadata whenever an explicit
   --dtype (or quantization) triggered dataclasses.replace, which returns a
   fresh config without the plain attributes gguf_to_config set. Auto-detection
   (has_mtp_head) then saw nothing and silently skipped the head. Capture the
   nextn metadata into locals right after gguf_to_config (like _gguf_model_type)
   and re-attach it onto the final config before step 4b.

Also extend write_mtp_speculator_metadata with embedding_weights,
lm_head_weights, and vocab_size so the SpeculatorConfig block names the shared
tables and vocab the runtime needs.

Tests: regression for the dtype-replace path and an assertion that the backbone
exposes hidden_states.{N-1} when the source ships the nextn head.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
…latorConfig schema

The emitted `speculator:` block used field names the onnx-genai runtime cannot
parse, so InferenceMetadata.speculative deserialized to None and the MTP sidecar
was silently ignored (text-only decode). Conform exactly to the authoritative
schema in onnx-genai crates/onnx-genai-metadata (schema/generation.rs
SpeculatorConfig, parser.rs resolve_mtp, config.rs validate_resolved_mtp_config):

- Publish under the top-level `speculative` key (was `speculator`, an unknown
  key that is dropped).
- `model_path` -> `model`; `hidden_size` -> `target_hidden_size`.
- `kv_mode: hidden_threaded` (engine-internal enum) -> `proposal_local`
  (the only valid MtpKvMode for this k=1 head).
- `embedding_weights`/`lm_head_weights` flat strings -> `embedding`/`lm_head`
  MtpTargetInitializer objects {source: target_initializer, name: ...}.
- Add `target_hidden_layout: BSH` (rank-3 [batch,seq,hidden] seed) and
  `hc_mult: 1` (resolve_mtp requires hc_mult>0; validate pins it to 1 for BSH).
- Omit `mtp_state_output` (hc_mult==1 head has no recurrent HC state; the
  sidecar emits only mtp_hidden + present.0.{key,value}).

Test: assert the emitted YAML has the exact schema keys/values and validates
against onnx-genai's published inference_metadata.schema.json.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants