Repository navigation
feat(gguf): load mtp drafts for the qwen3.5 family from nextn blocks and mtp-*.gguf sidecars - #856
Conversation
…and mtp-*.gguf sidecars
8a3929b to
d1b3690
Compare
jason-fxz
left a comment
There was a problem hiding this comment.
Tested d1b3690 against its base 9cacd08 on real GGUF files; LGTM.
Environment: NVIDIA H100 80GB HBM3, driver 580.95.05, CUDA 13.0, Intel Xeon Platinum 8480+ (26 vCPU), torch 2.14.1+cu130, transformers 5.18.0.
Checkpoints:
unsloth/Qwen3.5-2B-MTP-GGUFBF16 and Q8_0 (trunk with its nextn block),unsloth/Qwen3.5-2B-GGUFQ8_0 (no nextn)unsloth/Qwen3.8-27B-GGUFUD-Q4_K_XL andMTP/mtp-Qwen3.8-27B-Q4_0.ggufunsloth/Qwen3.6-35B-A3B-MTP-GGUFUD-Q4_K_XL- bf16 sidecars converted from
Qwen/Qwen3.5-2BandQwen/Qwen3.6-35B-A3Bwith llama.cpp e937b17a2convert_hf_to_gguf.py --mtp --outtype bf16 - HF references:
Qwen/Qwen3.5-2B,Qwen/Qwen3.6-35B-A3B(run withmoe_strategy="offload",moe_cache_auto=True), Qwen3.8-27B, all bf16
Workload: AIME25 problem 1 through the offline LLM API with max_running_req=4, speculative_algorithm="mtp" (and speculative_draft_model_path for sidecars). Greedy runs end naturally (cap 16384) and are compared token by token; speed runs use temperature 1.0, top_p 0.95, top_k 20, natural end, 5 runs, median.
Greedy, token by token:
- spec off, PR vs base: identical on 2B BF16, 2B Q8_0, 2B no-nextn Q8_0, 27B UD-Q4_K_XL and 35B UD-Q4_K_XL (fused).
- HF Qwen3.5-2B with MTP, PR vs base: identical.
- 35B fused vs offload with
moe_cache_rate=0.25: identical with spec off and with MTP. - own nextn vs sidecar draft: identical output on 2B, 27B and 35B.
- 2B no-nextn trunk with
--spec-algo mtpand no sidecar: refused at startup with the nextn ValueError.
Sampled speed (tok/s, median of 5; tokens per step in brackets):
| target | draft | spec off | MTP | HF bf16 MTP, same model |
|---|---|---|---|---|
| 2B Q8_0 | own nextn | 431.7 | 818.7 (3.34) | 753.0 (3.23) |
| 2B no-nextn Q8_0 | bf16 sidecar | 785.3 (3.35) | ||
| 27B UD-Q4_K_XL | own nextn | 86.7 | 161.0 (3.17) | 123.9 (3.10) |
| 27B UD-Q4_K_XL | mtp-Q4_0 sidecar | 163.4 (3.20) | ||
| 35B UD-Q4_K_XL, fused | own nextn | 208.7 | 428.6 (3.22) | 360.6 (3.25) |
| 35B UD-Q4_K_XL, fused | bf16 sidecar | 427.8 (3.23) | ||
| 35B UD-Q4_K_XL, offload 25% | own nextn | 123.8 (3.22) | ||
| 35B UD-Q4_K_XL, hybrid 25% (1024 tokens, 3 runs) | own nextn | 127.5 | 190.9 (3.44) |
ft serve with the 27B trunk, --spec-algo mtp, with and without --spec-draft-model .../MTP/mtp-Qwen3.8-27B-Q4_0.gguf: both answer a chat request correctly, and only the sidecar run maps the sidecar file.
ft serve --model-path Qwen3.8-27B-UD-Q4_K_XL.gguf --spec-algo mtp
ft serve --model-path Qwen3.8-27B-UD-Q4_K_XL.gguf --spec-algo mtp --spec-draft-model MTP/mtp-Qwen3.8-27B-Q4_0.ggufNot covered: a --no-mtp MoE trunk with a sidecar, prism or NVFP4 sidecars, ROCm.
feat(gguf): load mtp drafts for the qwen3.5 family from nextn blocks and mtp-*.gguf sidecars
The GGUF side branch of #828. #844 gave the Qwen3.5 family MTP from safetensors; GGUF targets were the only ones still refused. This adds only weight loading, the verify path is #844's unchanged.
block_countincludes it,{arch}.nextn_predict_layerssays how many). Exactly one nextn layer is accepted; a--no-mtpconversion or a Strata-style MTP pack is refused with a message that names both.nextn.*as fc and norms, the block itself aslayers.0, q output-gate rows packed inattn_q, gate|up fused. The draft's quant comes from the file's own tensor table, so each ggml mix loads as stored; the MoE draft's routed experts load as its one resident bank set.--speculative-draft-model-pathalso takes anmtp-*.ggufsidecar (unsloth'sMTP/releases). Those files keep the trunk's fullblock_countKVs soblk.Naddressing matches; a sidecar is accepted only when it holds exactly the one nextn block, the target's own architecture and hidden size, otherwise the refusal says what the draft of a GGUF target is. A--no-mtptrunk plus a sidecar works: the trunk config's shapes, the sidecar's weights.docs/cli.mdspeculative section updated to match.Verified
pytest tests/models/test_qwen3_5_moe_weight.py tests/models/test_models_registry.py tests/spec/: 67 passed, 104 skipped (skips are pre-existing hardware/checkpoint gates). The new tests cover the block-to-state-dict mapping, quantized packed leaves, resident expert banks, the no-MTP refusal message, and the sidecar draft loading under its own quant. There is no numeric parity test against the HFmtp.*draft of the same checkpoint yet; the mapping is checked tensor by tensor against a synthetic file instead.unsloth/Qwen3.8-27B-GGUFUD-Q5_K_XL,ft serve ... --speculative-algorithm mtp --num-tokens 24576: 191.3 t/s mean steady-state decode at accept len 3.88-4.00 (k=3, at the theoretical max); the same boot without spec runs 73.2 t/s, 2.61x. Both boots answer "What is 15% of 240?" with byte-identical content and reasoning fields ("36"), and a 2048-token long-form prompt is also byte-identical between them.Qwen3.6-35B-A3B-UD-Q4_K_XL.gguf(trunk's own nextn): 442-486 t/s, accept len 3.65-3.85; the draft's nextn experts resident on GPU (0.5 GiB) beside the trunk's 40 offloaded layers.--speculative-draft-model-path .../MTP/mtp-Qwen3.8-27B-Q4_0.gguf: greedy17*23+45/9answers 396, accept len 2.0-3.5, 113 t/s steady state.