Summary
On M5-class hardware the MTP speculative round is no longer bound by the target's multi-token verify forward. It is bound by the drafter. A single drafter step costs about 10.5 ms against a full 27B target decode step of 30.3 ms, while the drafter only touches 1.56 GB of weights against the target's 16.05 GB. The drafter therefore runs at roughly 149 GB/s effective memory bandwidth where the target achieves 530 GB/s, a 3.6x efficiency gap that is pure overhead.
Closing that gap is worth an estimated 1.19x to 1.8x on the measured Qwen 3.8 27B pairing without touching acceptance or exactness. Going past that needs the algorithmic work in the last section.
Measured on Apple M5 Max 128 GB, macOS 26.6.1, branch feature/issue-1165-qwen3-5-mtp-drafter at ef3aa95e (#1182), models/qwen3.8-27b-4bit + models/qwen3.8-27b-mtp-bf16, offline CLI, 300 tokens, temperature 0, Time Machine stopped, 90 s cooldown between runs.
This applies to Gemma 4 MTP as well: the round loop, the Drafter trait hooks, and the sampling helper are shared. The numbers below are from the Qwen pairing because that is where the profile was captured.
The measured split
Classic decode: 32.96 tok/s, 30.3 ms/token.
MTP block 3: 39.37 tok/s, 1.19x. MTP block 4: 33.87 tok/s, 1.03x.
Per-round split from the round-loop diagnostics (block 3, 133 rounds, 2.248 tokens per round, 57.35 ms per round):
| phase |
ms/round |
share |
| verify forward (T=3) |
35.27 |
61.5% |
draft_block (1 drafter step at this block size) |
10.70 |
18.7% |
| accept hook + sampling + argmax (residual, uninstrumented) |
11.22 |
19.6% |
| verify finalize (rollback) |
0.16 |
0.3% |
| speculative walk, shared-kv re-arm |
<0.01 |
~0% |
Block 4 (126 rounds, 2.373 tokens per round, 71.88 ms per round): verify 39.95 ms (55.6%), draft_block 20.88 ms over 2 steps (29.0%), residual 10.85 ms (15.1%), finalize 0.20 ms.
The per-step drafter cost is flat at about 10.5 ms: block 3 runs one step inside draft_block for 10.70 ms, block 4 runs two for 20.88 ms (10.44 each). Per round there are two drafter forwards in total, one in draft_block and one in accept_verified_tokens.
The comparison that identifies the problem
Same pairing, same protocol, block 4, against the M1 Ultra record in docs/benchmark_results/qwen38-mtp-m1ultra-2026-08-16.md:
| phase |
M1 Ultra |
M5 Max |
change |
| verify forward (T=4) |
137.6 ms |
39.95 ms |
3.44x faster |
| draft (per round) |
19.9 ms |
20.88 ms |
no change |
| accept hook |
~13 ms |
10.85 ms |
1.2x faster |
| round total |
173 ms |
71.88 ms |
2.41x faster |
The drafter step did not get faster at all across a GPU generation that made the target's multi-token forward 3.44x cheaper. Whatever dominates the drafter step is not memory bandwidth and not arithmetic.
The cost model
Per drafter step the weights actually read are:
| tensor set |
bytes |
| drafter weights (15 tensors, f16) |
849 MB |
borrowed target lm_head (weight 635.7 + scales 39.7 + biases 39.7) |
715.2 MB |
| embedding row lookup |
negligible |
| total |
1.564 GB |
At the target's demonstrated 530 GB/s that is 2.95 ms. Measured 10.5 ms. About 7.5 ms per step is overhead, and there are two steps per round, so roughly 15 ms of a 57.35 ms round, or 26%, is drafter overhead.
Note also that the borrowed LM head is 46% of the drafter's memory traffic. Qwen 3.8's vocabulary is 248,320 and the target is untied, so every drafter step pays a 248,320 x 5,120 projection to produce a single token id.
What the code does per drafter step
From src/lib/mlxcel-core/src/drafter/qwen3_5_mtp/model.rs:
forward_hidden_stack uploads the token ids host to device via ffi::from_slice_i32 (line 356).
- Embedding lookup, two pre-fc norms, a concat, the
fc projection, one full decoder layer, final norm.
project_logits runs the full 248,320-wide LM head (line 389).
sample_one calls ffi::fused_sample, then ffi::eval followed by ffi::item_i32 (lines 400-411).
Step 4 is a full GPU pipeline flush plus a device-to-host scalar readback, and step 1 is a host-to-device upload, so there is a complete host round trip inside the autoregressive draft loop, once per drafted token. set_seed_from_hidden does the same thing again in the accept hook. At block 3 that is two flushes per round; at block 4, three.
This is the same class of problem #1179 identifies on the verify side ("projects one position at a time across the cxx bridge"), on the draft side instead.
Proposed work
Phase 0: attribute the 7.5 ms before optimizing
Nothing below should be built on the cost model alone. Add timers inside the drafter step and report ms for: id upload, layer forward, LM head projection, fused_sample, eval + item_i32 readback. Also add a accept_hook_ms counter to MtpDiagnostics, which today has no counter at all, so the accept hook is invisible and lands in the residual.
Acceptance: a diagnostics line that accounts for the full 10.5 ms and says which of the five components dominates.
Phase 1: remove the host round trip from the draft loop
Keep the drafted token id on device across the block. fused_sample already produces a device array; feed it straight into the embedding lookup for the next step instead of round-tripping through item_i32 and from_slice_i32. Materialize the whole block of ids in one eval at the end of draft_block, which is the pattern argmax_from_hidden_positions already uses on the verify side per #1179.
This requires an embedding forward that accepts a device id array, and a forward_hidden_stack variant that takes ids as &MlxArray rather than &[i32].
Expected: removes K-1 pipeline flushes per round at block K. Cannot change output, because the drafted ids are identical either way and every draft is verified by the target regardless.
Phase 2: stop paying the full-vocab LM head per drafter step
Two independent options, both safe for exactness:
2a. Fuse projection and argmax. At temperature 0 the 248,320-wide logits vector is materialized only to take its argmax. A fused LM-head-plus-argmax kernel never writes the logits. Saves a write and a read of the logits buffer plus a dispatch, and lets the projection stay in registers.
2b. Reduced draft vocabulary. This is what EAGLE-3 does with draft_vocab_size plus a d2t index mapping, and it is the single largest term available: restricting the drafter's projection to a static high-frequency subset of, say, 32,768 ids cuts 715 MB to about 92 MB per step. Derive the subset offline from token frequency over a calibration corpus and ship it beside the drafter, falling back to the full head when absent.
The correctness argument for 2b is important and worth stating explicitly: a draft that is wrong is rejected by the target, so drafter numerics cannot affect output. The only exposure is acceptance rate, which is measurable. If the true next token falls outside the subset, that round simply accepts fewer drafts.
Phase 3: quantize the drafter
The drafter ships bf16 and loads as f16, 849 MB. A 4-bit conversion brings it to roughly 250 MB including scales and biases. Combined with 2b a drafter step reads about 342 MB instead of 1.564 GB.
Same correctness argument as above: the drafter proposes, the target verifies, so quantization risks acceptance and nothing else. Needs a converted checkpoint (mlxcel-surgery is the natural home) and an acceptance A/B against the f16 drafter before it becomes a default.
Phase 4: cheap structural cleanups found while reading
accept_verified_tokens builds hidden_cat with a chain of concatenate calls (model.rs:659-674). The positions pushed are always the contiguous range [keep, accepted] of verify_hidden, so this can be one slice instead of n concatenations and n-1 intermediate buffers.
forward_hidden_stack rebuilds a causal mask on every multi-token call. It is a function of (s, cache_offset) and can be cached.
What this is projected to buy
Holding acceptance, block size, and the verify forward at their measured values and varying only the drafter step cost:
| scenario |
drafter step |
round |
tok/s |
vs classic |
| measured today |
10.5 ms |
57.35 ms |
39.4 |
1.19x |
| Phase 1+4, drafter at target bandwidth efficiency |
2.95 ms |
~42 ms |
53 |
~1.61x |
| plus Phase 2b (32k draft vocab) |
1.78 ms |
~40 ms |
57 |
~1.71x |
| plus Phase 3 (4-bit drafter) |
0.65 ms |
~38 ms |
60 |
~1.82x |
| verify-forward-only ceiling |
0 |
35.27 ms |
63.7 |
1.93x |
These are projections from the cost model, not measurements. They are included to rank the phases, and Phase 0 exists to test them.
Going past 1.93x
The table above converges on a hard ceiling: at 2.248 emitted tokens per round and a 35.27 ms verify forward, no amount of drafter optimization gets past 1.93x. Beyond that the round has to emit more tokens, not cost less.
The relevant measurement is that this hardware verifies T=4 for 1.29x the price of T=1, and T=8 for 2.48x (from verify_forward_cost_scaling, see the M5 Max report on #1182). So there is real headroom to verify more positions than a linear chain of 3 uses.
Tree drafting (EAGLE-2 / SpecInfer style) is the lever. Instead of one linear chain of K-1 proposals, propose a small tree, branching where the drafter is least confident, and verify the whole tree in one target forward with a tree attention mask. With verify at T=4 costing 39.95 ms, lifting emitted-per-round from 2.373 to about 3.2 would put the pairing near 2.4x. This is a substantially larger piece of work than Phases 0-4: it needs a tree mask on the verify path, a tree-aware walk, and tree-aware rollback.
Two honest caveats on comparing against vLLM and SGLang:
- Their headline speculative numbers generally come from CUDA deployments where decode is not purely memory-bandwidth-bound at B=1, so the arithmetic that makes a small drafter nearly free there does not transfer unchanged.
- EAGLE-3 style drafters used in those stacks ship a reduced draft vocabulary by design. Our drafter borrows a 248,320-wide head from the target, which is why Phase 2b is called out separately rather than folded into general cleanup.
Scope
In scope: Phases 0 through 4, on the shared MTP round loop and the Drafter trait, validated on the Qwen 3.8 27B pairing and regression-checked on Gemma 4.
Out of scope for this issue: tree drafting (file separately once Phases 0-4 land and the profile is re-measured), the verify-side early-exit walk (#1179), and anything that changes the target's numerics.
Blocker: MTP now declines on the hardware these numbers come from
This issue was filed before #1186 characterized the temperature-0 exactness failure and before #1189 turned the gate into a measured probe. With that probe in place, MTP fails closed on Apple GPU generation 15 and newer, which is exactly the hardware the entire profile below was measured on. So the work here cannot be validated through the default path until #1187 lands.
That does not invalidate the measurements. The round-cost split, the per-step drafter cost, and the M1 Ultra comparison were all taken through the production offline CLI before the gate change, and the drafter's cost has nothing to do with exactness: the drafter proposes, the target verifies, and no phase below alters the target's numerics.
It does change how the phases have to be run:
Sequencing suggestion: land Phase 0 (the timers) regardless, since it is measurement-only and useful to #1187 as well. Hold Phases 1 through 4 until #1187 has settled what the verify side costs, or accept that their headline percentages will be restated afterwards.
Acceptance criteria
References
Summary
On M5-class hardware the MTP speculative round is no longer bound by the target's multi-token verify forward. It is bound by the drafter. A single drafter step costs about 10.5 ms against a full 27B target decode step of 30.3 ms, while the drafter only touches 1.56 GB of weights against the target's 16.05 GB. The drafter therefore runs at roughly 149 GB/s effective memory bandwidth where the target achieves 530 GB/s, a 3.6x efficiency gap that is pure overhead.
Closing that gap is worth an estimated 1.19x to 1.8x on the measured Qwen 3.8 27B pairing without touching acceptance or exactness. Going past that needs the algorithmic work in the last section.
Measured on Apple M5 Max 128 GB, macOS 26.6.1, branch
feature/issue-1165-qwen3-5-mtp-drafteratef3aa95e(#1182),models/qwen3.8-27b-4bit+models/qwen3.8-27b-mtp-bf16, offline CLI, 300 tokens, temperature 0, Time Machine stopped, 90 s cooldown between runs.This applies to Gemma 4 MTP as well: the round loop, the
Draftertrait hooks, and the sampling helper are shared. The numbers below are from the Qwen pairing because that is where the profile was captured.The measured split
Classic decode: 32.96 tok/s, 30.3 ms/token.
MTP block 3: 39.37 tok/s, 1.19x. MTP block 4: 33.87 tok/s, 1.03x.
Per-round split from the round-loop diagnostics (block 3, 133 rounds, 2.248 tokens per round, 57.35 ms per round):
draft_block(1 drafter step at this block size)Block 4 (126 rounds, 2.373 tokens per round, 71.88 ms per round): verify 39.95 ms (55.6%),
draft_block20.88 ms over 2 steps (29.0%), residual 10.85 ms (15.1%), finalize 0.20 ms.The per-step drafter cost is flat at about 10.5 ms: block 3 runs one step inside
draft_blockfor 10.70 ms, block 4 runs two for 20.88 ms (10.44 each). Per round there are two drafter forwards in total, one indraft_blockand one inaccept_verified_tokens.The comparison that identifies the problem
Same pairing, same protocol, block 4, against the M1 Ultra record in
docs/benchmark_results/qwen38-mtp-m1ultra-2026-08-16.md:The drafter step did not get faster at all across a GPU generation that made the target's multi-token forward 3.44x cheaper. Whatever dominates the drafter step is not memory bandwidth and not arithmetic.
The cost model
Per drafter step the weights actually read are:
lm_head(weight635.7 +scales39.7 +biases39.7)At the target's demonstrated 530 GB/s that is 2.95 ms. Measured 10.5 ms. About 7.5 ms per step is overhead, and there are two steps per round, so roughly 15 ms of a 57.35 ms round, or 26%, is drafter overhead.
Note also that the borrowed LM head is 46% of the drafter's memory traffic. Qwen 3.8's vocabulary is 248,320 and the target is untied, so every drafter step pays a 248,320 x 5,120 projection to produce a single token id.
What the code does per drafter step
From
src/lib/mlxcel-core/src/drafter/qwen3_5_mtp/model.rs:forward_hidden_stackuploads the token ids host to device viaffi::from_slice_i32(line 356).fcprojection, one full decoder layer, final norm.project_logitsruns the full 248,320-wide LM head (line 389).sample_onecallsffi::fused_sample, thenffi::evalfollowed byffi::item_i32(lines 400-411).Step 4 is a full GPU pipeline flush plus a device-to-host scalar readback, and step 1 is a host-to-device upload, so there is a complete host round trip inside the autoregressive draft loop, once per drafted token.
set_seed_from_hiddendoes the same thing again in the accept hook. At block 3 that is two flushes per round; at block 4, three.This is the same class of problem #1179 identifies on the verify side ("projects one position at a time across the cxx bridge"), on the draft side instead.
Proposed work
Phase 0: attribute the 7.5 ms before optimizing
Nothing below should be built on the cost model alone. Add timers inside the drafter step and report ms for: id upload, layer forward, LM head projection,
fused_sample,eval+item_i32readback. Also add aaccept_hook_mscounter toMtpDiagnostics, which today has no counter at all, so the accept hook is invisible and lands in the residual.Acceptance: a diagnostics line that accounts for the full 10.5 ms and says which of the five components dominates.
Phase 1: remove the host round trip from the draft loop
Keep the drafted token id on device across the block.
fused_samplealready produces a device array; feed it straight into the embedding lookup for the next step instead of round-tripping throughitem_i32andfrom_slice_i32. Materialize the whole block of ids in oneevalat the end ofdraft_block, which is the patternargmax_from_hidden_positionsalready uses on the verify side per #1179.This requires an embedding forward that accepts a device id array, and a
forward_hidden_stackvariant that takes ids as&MlxArrayrather than&[i32].Expected: removes K-1 pipeline flushes per round at block K. Cannot change output, because the drafted ids are identical either way and every draft is verified by the target regardless.
Phase 2: stop paying the full-vocab LM head per drafter step
Two independent options, both safe for exactness:
2a. Fuse projection and argmax. At temperature 0 the 248,320-wide logits vector is materialized only to take its argmax. A fused LM-head-plus-argmax kernel never writes the logits. Saves a write and a read of the logits buffer plus a dispatch, and lets the projection stay in registers.
2b. Reduced draft vocabulary. This is what EAGLE-3 does with
draft_vocab_sizeplus ad2tindex mapping, and it is the single largest term available: restricting the drafter's projection to a static high-frequency subset of, say, 32,768 ids cuts 715 MB to about 92 MB per step. Derive the subset offline from token frequency over a calibration corpus and ship it beside the drafter, falling back to the full head when absent.The correctness argument for 2b is important and worth stating explicitly: a draft that is wrong is rejected by the target, so drafter numerics cannot affect output. The only exposure is acceptance rate, which is measurable. If the true next token falls outside the subset, that round simply accepts fewer drafts.
Phase 3: quantize the drafter
The drafter ships bf16 and loads as f16, 849 MB. A 4-bit conversion brings it to roughly 250 MB including scales and biases. Combined with 2b a drafter step reads about 342 MB instead of 1.564 GB.
Same correctness argument as above: the drafter proposes, the target verifies, so quantization risks acceptance and nothing else. Needs a converted checkpoint (
mlxcel-surgeryis the natural home) and an acceptance A/B against the f16 drafter before it becomes a default.Phase 4: cheap structural cleanups found while reading
accept_verified_tokensbuildshidden_catwith a chain ofconcatenatecalls (model.rs:659-674). The positions pushed are always the contiguous range[keep, accepted]ofverify_hidden, so this can be one slice instead of n concatenations and n-1 intermediate buffers.forward_hidden_stackrebuilds a causal mask on every multi-token call. It is a function of(s, cache_offset)and can be cached.What this is projected to buy
Holding acceptance, block size, and the verify forward at their measured values and varying only the drafter step cost:
These are projections from the cost model, not measurements. They are included to rank the phases, and Phase 0 exists to test them.
Going past 1.93x
The table above converges on a hard ceiling: at 2.248 emitted tokens per round and a 35.27 ms verify forward, no amount of drafter optimization gets past 1.93x. Beyond that the round has to emit more tokens, not cost less.
The relevant measurement is that this hardware verifies T=4 for 1.29x the price of T=1, and T=8 for 2.48x (from
verify_forward_cost_scaling, see the M5 Max report on #1182). So there is real headroom to verify more positions than a linear chain of 3 uses.Tree drafting (EAGLE-2 / SpecInfer style) is the lever. Instead of one linear chain of K-1 proposals, propose a small tree, branching where the drafter is least confident, and verify the whole tree in one target forward with a tree attention mask. With verify at T=4 costing 39.95 ms, lifting emitted-per-round from 2.373 to about 3.2 would put the pairing near 2.4x. This is a substantially larger piece of work than Phases 0-4: it needs a tree mask on the verify path, a tree-aware walk, and tree-aware rollback.
Two honest caveats on comparing against vLLM and SGLang:
Scope
In scope: Phases 0 through 4, on the shared MTP round loop and the
Draftertrait, validated on the Qwen 3.8 27B pairing and regression-checked on Gemma 4.Out of scope for this issue: tree drafting (file separately once Phases 0-4 land and the profile is re-measured), the verify-side early-exit walk (#1179), and anything that changes the target's numerics.
Blocker: MTP now declines on the hardware these numbers come from
This issue was filed before #1186 characterized the temperature-0 exactness failure and before #1189 turned the gate into a measured probe. With that probe in place, MTP fails closed on Apple GPU generation 15 and newer, which is exactly the hardware the entire profile below was measured on. So the work here cannot be validated through the default path until #1187 lands.
That does not invalidate the measurements. The round-cost split, the per-step drafter cost, and the M1 Ultra comparison were all taken through the production offline CLI before the gate change, and the drafter's cost has nothing to do with exactness: the drafter proposes, the target verifies, and no phase below alters the target's numerics.
It does change how the phases have to be run:
qmvreduction on the verify path, the verify forward cost changes and the round split below shifts with it. Re-take the Phase 0 baseline after that lands rather than reusing these numbers.Sequencing suggestion: land Phase 0 (the timers) regardless, since it is measurement-only and useful to #1187 as well. Hold Phases 1 through 4 until #1187 has settled what the verify side costs, or accept that their headline percentages will be restated afterwards.
Acceptance criteria
accept_hook_msdiagnostic, and the reported components sum to the measureddraft_ms.docs/benchmark_results/gains an M5 Max record, and the M1 Ultra record is re-measured for the phases that land, since the drafter is the component that did not scale across generations.References