perf(speculative): quantize the MTP drafter's projections at load - #1203
Merged
Conversation
The drafter is read once per drafted token, so its cost is weight traffic: 810 MiB of bf16 for one decoder layer plus an fc projection. Quantizing its eight 2-D projections to the scheme its config declares (affine, group 64, 4-bit) takes that to 228 MiB. Measured on M5 Max against qwen3.8-27b-4bit, two reps per arm, alternated: - draft_block 10.40/10.71 -> 2.70/2.69 ms per round, 3.9x cheaper - accept hook 10.35/10.60 -> 2.74/2.71 ms per round - verify forward unchanged, as it must be - 37.90/35.57 -> 49.71/50.33 tok/s at n=120, and 35.24/34.76 -> 47.68/45.68 at n=300, so about 1.19x to 1.5x against classic decode Acceptance does not move: 0.6831 to 0.6601 at 120 tokens and 0.6500 to 0.6589 at 300. Two lengths disagreeing in sign is noise, not degradation. Output is byte-identical to classic decode with the drafter dense or quantized. That is structural: the drafter proposes and the target verifies every proposal, so drafter numerics cannot reach the output and the only exposure is acceptance. Done at load rather than as a second checkpoint, so every existing bf16 drafter gets it without being re-downloaded and there is no artifact to publish and version-match. A tensor whose .scales sibling already exists is left alone, so a pre-converted checkpoint still loads unchanged, and one whose contraction axis is not a multiple of the group size stays dense rather than failing the load. Verified to reproduce offline conversion exactly: same acceptance, same per-round drafter cost. MLXCEL_MTP_QUANTIZE_DRAFTER=0 keeps the checkpoint's precision, for an acceptance A/B on a pairing this has not been measured on. scripts/tools/quantize_mtp_drafter.py converts offline, for publishing a checkpoint or holding both on disk at once. Refs #1185
6 tasks
This was referenced Aug 17, 2026
inureyes
added a commit
that referenced
this pull request
Aug 20, 2026
…ator (#1217) (#1254) ## What this does Re-measures the batch-capable B=1 MTP pairing on M3 Ultra and moves `mtp_b1_default` off the `has_neural_accelerator` proxy onto the mechanism the round-cost model already attributed the host ordering to: the `use_qmv_wide` split at Apple GPU generation 15. ## Why the old gate was wrong The gate ran batch-capable targets only where `has_neural_accelerator` held, which is M5 and nothing else. That rested on two numbers from #165: about 1.2 to 1.4x on M5 Max against a 0.75 to 0.96x regression on M1 Ultra. Both predate #1194, #1199, #1203, #1208 and #1215, and M3 Ultra had never been run on this pairing at all, so a generation-15 part was grouped with generation 13 by a binary proxy. Neither bench script could reach the pairing the gate governs. `bench_speculative.sh` covered the 12B and Qwen pairings and `bench_block_width.sh` the same two, so the founding numbers were not reproducible through the #1215 protocol. That is most of why they went stale without anyone noticing. Both scripts gain a `gemma31b` case here. ## Measured Gemma 4 31B + bf16 assistant, M3 Ultra (512 GB), 2026-08-20, current main, block 4, greedy, under `with_indexers_paused.sh`. Round cost is `emitted per verify / speedup`, in this host's own classic decode steps. | Output | acceptance | emitted/verify | classic | MTP | speedup | round cost | |---|---:|---:|---:|---:|---:|---:| | enumeration | 1.000 | 3.990 | 31.5 | 83.6 | **2.65x** | 1.51 | | source code | 0.882 | 3.646 | 31.8 | 76.6 | **2.41x** | 1.51 | | prose | 0.656 | 2.956 | 31.7 | 61.9 | **1.95x** | 1.52 | Spreads were 0.5% to 1.0% on both arms of every row against the harness limit of 4%. Three prompts with nothing in common agree on the round cost to within 0.7%, and the pairing clears its break-even by roughly double on all of them. Single-stream acceptance is 0.66 to 1.00, not the too-low value the old code comment asserted. Width sweep on the code row, 8 interleaved rounds: peak at width 5 with width 4 tied inside its 1.7% spread, everything from 3 to 8 gaining more than 2x. Fit `round cost = 0.83 + 0.170 K` classic steps, largest residual 0.06. ## Why generation 13 still declines This is the part the sweep changed my mind on, so it is worth stating plainly. The tempting move is to take M1 Ultra's published block-4 round cost of 2.71, note this pairing emits 2.96 to 3.99 tokens per verify, and conclude post-#1203 M1 Ultra would now clear break-even. That is not sound: 2.71 belongs to the 12B pairing with a 4-bit drafter, and the sweep shows this pairing's slope is 1.9x steeper (`0.83 + 0.170 K` against the 12B pairing's `1.14 + 0.090 K` on the same host). The two lines merely cross at K = 4, which is the only reason their block-4 costs match. Carrying that slope ratio onto generation 13's `1.35 + 0.346 K` puts a block-4 round near 3.6 classic steps there, which the emitted tokens would only just cover, consistent with the founding 0.75 to 0.96x. So the generation-13 decline reads as sound rather than merely stale and it stays. The estimate extrapolates across a pairing and a generation at once, so it is a reason to leave M1/M2 alone, not a result; it is written down in falsifiable form so an M1 Ultra run can settle it. The new predicate is strictly more permissive than the one it replaced, so no host loses a path it previously had. A unit test pins that property so the safety argument cannot rot. ## Verified through the real dispatch path Unit tests cover `mtp_b1_default` as a pure function, which is not the same as showing the running scheduler consults it. Same host, same checkpoints, server rather than the offline CLI, `MLXCEL_MTP_ADAPTIVE=0` so the static gate decides: | Binary | `MLXCEL_ENABLE_MTP_B1` | Scheduler outcome | |---|---|---| | `main` at `9e2c6675` | unset | declined, classic decode | | this branch | unset | ran the burst (block 4, 80 tokens over 21 rounds, acceptance 0.921) | | this branch | `0` | declined, classic decode | Worth recording separately: the offline `mlxcel generate` path never consults this gate. `mtp_b1_default` has exactly one caller, `Scheduler::mtp_b1_should_run`, and `MtpPolicy` is built only in the server worker, so the bench harness runs the burst unconditionally on every host. That is what makes it the right instrument for deciding the gate, and it is also why `MLXCEL_ENABLE_MTP_B1=1` and `MLXCEL_MTP_ADAPTIVE=0` are inert there. ## Checks `cargo fmt --check` clean, `cargo clippy --all-targets` clean, 361 `server::batch` tests pass, plus the new `hardware` and `mtp_b1_default` unit tests. ## Not done here Issue #1217 deliberately stays open, because two of its acceptance criteria need hardware this host is not. No closing keyword is used anywhere in this PR or its commit. - **M1 Ultra rows for this pairing.** No generation-13 host was available. Nothing here changes M1 or M2 behaviour, and the prediction above is left falsifiable for that run. - **M5 Max re-measurement.** Its ~1.2 to 1.4x is the founding, pre-#1203 figure. M5 keeps its path either way so the gate does not depend on it, but the M3 Ultra rows now exceed it, which is itself a reason to re-run it. - **M3 Ultra Qwen rows** were already measured on current main under this protocol in #1215 and are in `docs/benchmarks.md` (1.67x at block 3, full width sweep). Not re-run. Full record and method: `docs/benchmark_results/mtp-b1-gate-m3ultra-2026-08-20.md`. Refs #1217.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The MTP drafter is read once per drafted token, so its cost is weight traffic.
qwen3.8-27b-mtp-bf16is 810 MiB for one decoder layer plus anfcprojection. Quantizing its eight 2-D projections to the scheme its own config declares (affine, group 64, 4-bit) takes that to 228 MiB, 0.281x.Phase 3 of #1185. Full record in
docs/benchmark_results/mtp-drafter-quantization-m5max-2026-08-17.md.Result
M5 Max,
qwen3.8-27b-4bittarget,--draft-block-size 3 --temp 0, two reps per arm, alternated:draft_blockThe drafter step is 3.9x cheaper and the verify forward is untouched, which is the shape the cost model predicted. Against classic decode on the same cooled host (32.33, 31.87, 30.79 tok/s), MTP goes from 1.19x to about 1.5x.
Acceptance does not move. It falls 3.4% at 120 tokens and rises 1.4% at 300. Two lengths disagreeing in sign is noise; a real degradation would not reverse.
Output is unchanged, and that is structural
Classic decode, MTP with the dense drafter, and MTP with the quantized drafter produce byte-identical text at temperature 0.
That is not a tolerance argument. The drafter only proposes and the target verifies every proposal, so a worse draft is rejected and a better one accepted, and neither reaches the output. Quantization can cost acceptance and nothing else, which is why acceptance is the thing measured above.
Why a load-time conversion and not a checkpoint
Requiring a converted checkpoint means publishing one per drafter and keeping it version-matched. Instead
Qwen35MtpDraftModel::from_pathquantizes its own dense projections, so every existing bf16 drafter gets this without being re-downloaded and there is no artifact to ship.Placed after
sanitize_weightsand before the bf16 to f16 pass. The ordering is load-bearing: that pass deliberately skips quantization auxiliaries, so running it first would leave scales as f16 where every shipped checkpoint keeps them bf16.Two skips keep it from being a new failure mode. A tensor whose
.scalessibling already exists is left alone, so a pre-converted checkpoint loads unchanged. One whose contraction axis is not a multiple of the group size stays dense rather than failing the load.Verified equivalent to converting offline:
draft_blockMLXCEL_MTP_QUANTIZE_DRAFTER=0scripts/tools/quantize_mtp_drafter.pystill converts offline, for publishing a checkpoint or holding both on disk for an A/B. It reads the scheme from the target rather than choosing one, because the loader passes the drafter config's parameters toUnifiedLinearand a drafter quantized any other way would be read with the wrong ones.The baseline this is measured against moved
#1185's numbers predate #1199, which buys back temperature-0 exactness on generation 15+ by forcing plain
qmvon the verify path. That made the verify forward 13.6% more expensive, so the issue's own instruction to re-take Phase 0 applies. Re-taken on a cooled host with no overrides:draft_blockThe ceiling moved with it: a round with a free drafter is 41.6 ms emitting 2.33 tokens, so 56.0 tok/s or 1.77x classic, not the 1.93x in the issue.
Test plan
-p mlxcel-core --libis 1472 passed / 4 failed, matching baseline; the 22qwen3_5_mtpdrafter tests pass.cargo fmt --checkandcargo clippy --all-targets --features metal,accelerate -- -D warningsclean.Not covered
Only the
qwen3_5_mtpdrafter. The Gemma 4 assistant drafter has the same shape of opportunity and is untouched here, deliberately: it needs its own acceptance A/B before it gets the same default, and this PR should not change two pairings on one measurement.Phases 2a and 2b are worth re-ranking after this. They target the LM head, 21.4% of a drafter step that now costs 2.7 ms per round rather than 10.5, so the absolute prize has shrunk about fourfold and most of what remains is on the verify side.
Refs #1185