Attach Qwen3.6 MTP head for speculative decoding + fix cache-rewind for hybrid recurrent models#1468
Attach Qwen3.6 MTP head for speculative decoding + fix cache-rewind for hybrid recurrent models#1468h9q2cyxvgm-ui wants to merge 2 commits into
Conversation
Implements loading and wiring for the community-published "qwen3_5_mtp"
drafter checkpoints (e.g. mlx-community/Qwen3.6-27B-MTP-bf16), which are
architecturally incompatible with mlx_lm's generic --draft-model mechanism:
they have no embedding table or output head of their own (borrow the
target model's) and their forward pass needs the target's hidden state as
input, not just token ids.
- models/qwen3_5.py: MTPHead module (enorm/hnorm/fc/layer/norm, matching
the published DeepSeek-V3-style MTP design), lazily attached in
sanitize() only when a checkpoint actually bundles "mtp." weights (the
config can declare mtp_num_hidden_layers > 0 as an architecture
capability while the file itself doesn't carry the tensors — building
eagerly from config alone breaks loading every existing checkpoint,
verified via regression test against unsloth/Qwen3.6-27B-UD-MLX-6bit).
- utils.py: load_mtp_head() attaches a *separately published* MTP
checkpoint onto an already-loaded target model (as opposed to the
bundled case above), plus is_mtp_head_checkpoint() to detect one from
its config.
- generate.py: mtp_speculative_generate_step(), parallel to
speculative_generate_step but chaining the attached MTP head against the
target's own hidden states instead of calling an independent peer model.
Dispatch in stream_generate updated to route here when a model has
has_mtp=True and no separate draft_model was supplied.
- server.py: ModelProvider._load() detects an MTP-head checkpoint via
is_mtp_head_checkpoint() and attaches it instead of loading it as a
peer draft model.
Verified so far:
- unsloth/Qwen3.6-27B-UD-MLX-6bit still loads and generates correctly
(no regression — this was broken once during development, see above).
- load_mtp_head() successfully attaches mlx-community/Qwen3.6-27B-MTP-bf16
(hidden_size match, block_size=3 read from config).
BLOCKED on a separate, pre-existing issue (not introduced by this change):
speculative_generate_step's can_trim_prompt_cache() check fails for this
model family with "Speculative decoding requires a trimmable prompt cache
(got {'ArraysCache'})" — reproduced with the *original*, unmodified
peer-model mechanism too (plain draft_model=, no MTP code involved), using
a small Qwen3-0.6B as an unrelated peer draft. So speculative decoding of
any kind currently cannot run on Qwen3.6 in mlx-lm, independent of this
MTP work.
ArraysCache (models/cache.py) holds hybrid linear-attention layers'
recurrent state as a fixed-size array, not an append-only buffer like
KVCache — is_trimmable() correctly returns False because there's nothing
to slice. Making speculative decoding work on this model family requires
teaching ArraysCache to checkpoint/restore its state around the
speculate/verify/reject cycle instead of trimming it. That's the next
piece of work, and it's bigger than this commit: it touches cache
infrastructure shared by every hybrid linear-attention model, not just
Qwen3.6.
Not yet done:
- ArraysCache checkpoint/restore support + wiring it into
speculative_generate_step / mtp_speculative_generate_step's rewind path.
- End-to-end correctness test (temp=0 speculative vs plain generation must
match exactly) — blocked on the above, could not run past the cache error.
- Speed measurement.
- mlx_lm.generate CLI / plain generate() path for --draft-model pointing
at an MTP checkpoint (only server.py's ModelProvider was wired up).
…d it
Unblocks the pre-existing bug where any speculative decoding on Qwen3.6
(and every other hybrid linear-attention model) fails with "Speculative
decoding requires a trimmable prompt cache (got {'ArraysCache'})" —
independent of the MTP work, reproduced with the original peer-model
draft_model= mechanism too.
ArraysCache (models/cache.py) holds recurrent state (conv/ssm state, gated
delta state, etc.) as a fixed-size array, not an append-only buffer like
KVCache, so is_trimmable() correctly returns False — there's nothing to
slice back to an earlier position. But every model that uses ArraysCache
replaces its slots wholesale via __setitem__ (verified across all ~15
hybrid model files, not just qwen3_5), never mutating an array in place,
so a shallow list-copy is a safe, cheap snapshot.
- models/cache.py: is_checkpointable()/checkpoint()/restore() added to
_BaseCache (default: unsupported) and implemented on ArraysCache.
New module functions can_rewind_prompt_cache(), checkpoint_prompt_cache(),
rewind_prompt_cache().
- generate.py: speculative_generate_step() and
mtp_speculative_generate_step() now gate on can_rewind_prompt_cache()
instead of can_trim_prompt_cache(). When the cache mixes trimmable KV
layers with checkpoint-only recurrent layers, a rejected round can't
just trim — recurrent state has no notion of "state as of token k"
other than recomputing it. Fix: checkpoint every non-trimmable layer
before each round; on partial rejection, undo the *whole* round (trim
KV back to the pre-round offset for free, restore checkpointed layers)
and replay exactly the tokens that turned out correct through the full
model in one extra forward call. This is unavoidable — a monolithic
forward touches every layer together, so cheaply trimming KV while
separately patching recurrent layers isn't possible, they have to move
in lockstep. Also fixed a shape bug in mtp_speculative_generate_step
(last_token/hidden rank was inconsistent between round 1 and later
rounds — only reachable once this fix let execution get this far for
the first time) and added a guard so an exception during draft
generation (before a checkpoint is taken) doesn't crash the finally-
block cleanup.
Verified: temp=0 speculative output is token-for-token identical to plain
generation, for both the peer-draft-model path (small Qwen3-0.6B draft)
and the attached-MTP-head path, on unsloth/Qwen3.6-27B-UD-MLX-6bit. Full
existing test suite has the same pre-existing failures before and after
(confirmed via git stash) — none caused by this change.
Speedup is modest and inconsistent (0.9x-1.2x depending on generation
length) rather than a clear win: any rejected round costs a full extra
replay forward, and 3 of every 4 layers in this architecture
(full_attention_interval=4) are the non-trimmable recurrent kind, so a
large fraction of total compute gets redone on reject.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
Follow-up data point on the speedup caveat above, since it's worth being precise about what's actually causing it. I ran a controlled comparison: same prompt, same
At every setting, the dense model comes out ~0.15–0.2x ahead of the hybrid model — despite the hybrid model's MTP head actually guessing right more often at each setting. That's consistent with the theory in the description above: it isn't that this rewind approach is poorly implemented, it's that any rejected round on a hybrid model pays a real, structural cost (a full extra forward pass to rebuild the recurrent state) that a pure-KV model simply doesn't pay. A better guesser doesn't fully compensate for a more expensive miss. Practical takeaway for anyone picking this up: tuning Also flagging, found while running this comparison and unrelated to anything in this PR: |
Fixes #1462.
Two things bundled together because the second was needed to actually exercise/verify the first end to end:
1. Attach the MTP head instead of loading it as a peer draft model
The community-published
qwen3_5_mtpdrafter checkpoints (e.g.mlx-community/Qwen3.6-27B-MTP-bf16) are DeepSeek-V3-style MTP heads: no embedding table or output head of their own (they borrow the target model's), and their forward pass needs the target's hidden state as input, not just token ids. That's architecturally incompatible with mlx_lm's generic--draft-modelmechanism, which only knows how to call an independent peer model with token ids — hence theModel type qwen3_5_mtp not supportederror in #1462.models/qwen3_5.py:MTPHeadmodule, lazily attached insanitize()only when a checkpoint's weights actually bundlemtp.*keys — not eagerly from config alone, since the config can declaremtp_num_hidden_layers > 0as an architecture capability while the specific file doesn't carry the tensors (every currently-published Qwen3.6 checkpoint does this).utils.py:load_mtp_head(model, mtp_path)attaches a separately published MTP checkpoint onto an already-loaded target model;is_mtp_head_checkpoint(config)detects one.generate.py:mtp_speculative_generate_step(), parallel tospeculative_generate_stepbut chaining the attached MTP head against the target's own hidden states.stream_generatedispatches here whenmodel.has_mtpand no separatedraft_modelwas given.server.py:ModelProvider._load()detects an MTP-head checkpoint and attaches it instead of peer-loading it.2. Give
ArraysCachecheckpoint/restore so speculative decoding can actually runOnce loading worked, a second and independent bug showed up: any speculative decoding on Qwen3.6 — including the original, unmodified peer-model mechanism, no MTP code involved — fails with
"Speculative decoding requires a trimmable prompt cache (got {'ArraysCache'})".ArraysCacheholds this model family's hybrid linear-attention layers' recurrent state as a fixed-size array, not an append-only buffer likeKVCache, sois_trimmable()correctly returnsFalse— there's nothing to slice back to an earlier position after a rejected draft token.models/cache.py:is_checkpointable()/checkpoint()/restore()added to_BaseCache(default: unsupported) and implemented onArraysCache(a shallow list-copy ofself.cacheis a safe, cheap snapshot — verified across every hybrid-model file that usesArraysCachethat cache slots are always replaced wholesale via__setitem__, never mutated in place). New module functionscan_rewind_prompt_cache(),checkpoint_prompt_cache(),rewind_prompt_cache().generate.py: bothspeculative_generate_step()andmtp_speculative_generate_step()gate oncan_rewind_prompt_cache()instead ofcan_trim_prompt_cache(). When the cache mixes trimmable KV layers with checkpoint-only recurrent layers, a rejected round can't just trim (recurrent state has no notion of "state as of token k" other than recomputing it) — instead it checkpoints every non-trimmable layer before each round, and on partial rejection undoes the whole round (trims KV back to the pre-round offset for free, restores checkpointed layers) and replays exactly the tokens that turned out correct through the full model in one extra forward call. This is unavoidable — a monolithic forward touches every layer together, so cheaply trimming KV while separately patching recurrent layers isn't possible; they have to move in lockstep. This is generic infra shared by every hybrid linear-attention model in mlx_lm, not just Qwen3.6.mtp_speculative_generate_step(only reachable once the cache fix let execution get this far for the first time) and adds a guard so an exception during draft generation doesn't crash thefinally-block cleanup.Verified
Temp=0 speculative output is token-for-token identical to plain generation, for both the peer-draft-model path (small
Qwen3-0.6Bdraft) and the attached-MTP-head path, onunsloth/Qwen3.6-27B-UD-MLX-6bit. Full existing test suite has the same pre-existing failures before and after this change (confirmed viagit stashon a clean checkout).Honest caveat on speedup
Modest and inconsistent right now — roughly 1.2x on a 40-token generation, ~0.9x (slightly slower) on 200 tokens, at ~66% draft acceptance either way (with the MTP head; see the follow-up PR for how that acceptance rate was fixed). The reason:
full_attention_interval=4means 3 of every 4 layers are the non-trimmable recurrent kind, so any rejected round costs a full extra replay forward through most of the network. Correctness is solid; whether this specific rewind strategy is the right performance tradeoff for hybrid-recurrent models in general is a fair thing to discuss — happy to iterate (e.g. tuningnum_draft_tokensdown, or a cheaper reject-recovery path) based on feedback.Also open
A follow-up PR fixes a real accuracy bug in
MTPHeaditself (wrong concat order + wrong hidden-state norm stage) that took MTP draft acceptance from 0% to ~66% — separated out since it's a standalone correctness fix independent of the cache work above, and depends on theMTPHeadclass this PR introduces.🤖 Generated with Claude Code