Skip to content

perf(speculative): quantize the MTP drafter's projections at load - #1203

Merged
inureyes merged 1 commit into
mainfrom
perf/issue-1185-quantize-mtp-drafter
Aug 17, 2026
Merged

perf(speculative): quantize the MTP drafter's projections at load#1203
inureyes merged 1 commit into
mainfrom
perf/issue-1185-quantize-mtp-drafter

Conversation

@inureyes

Copy link
Copy Markdown
Member

Summary

The MTP drafter is read once per drafted token, so its cost is weight traffic. qwen3.8-27b-mtp-bf16 is 810 MiB for one decoder layer plus an fc projection. 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-4bit target, --draft-block-size 3 --temp 0, two reps per arm, alternated:

bf16 drafter 4-bit drafter
draft_block 10.40, 10.71 ms/round 2.70, 2.69
accept hook 10.35, 10.60 ms/round 2.74, 2.71
verify forward 40.08, 43.50 ms/round 40.58, 40.05
acceptance, n=120 0.6831 0.6601
acceptance, n=300 0.6500 0.6589
tok/s, n=120 37.90, 35.57 49.71, 50.33
tok/s, n=300 35.24, 34.76 47.68, 45.68

The 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_path quantizes 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_weights and 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 .scales sibling 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:

arm acceptance draft_block
bf16 checkpoint, load-time conversion 0.6601 2.74 ms/round
pre-converted 4-bit checkpoint 0.6601 2.70 ms/round
bf16 checkpoint, MLXCEL_MTP_QUANTIZE_DRAFTER=0 0.6831 10.43 ms/round

scripts/tools/quantize_mtp_drafter.py still 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 to UnifiedLinear and 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 qmv on 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:

component ms/round share before #1199
verify forward 40.02 64.0% 35.24 / 60.2%
draft_block 10.54 16.9% 10.67 / 18.2%
accept hook 10.33 16.5% 10.60 / 18.1%

The 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 --lib is 1472 passed / 4 failed, matching baseline; the 22 qwen3_5_mtp drafter tests pass.
  • cargo fmt --check and cargo clippy --all-targets --features metal,accelerate -- -D warnings clean.
  • Byte-identity against classic decode confirmed with the switch on and off.
  • The exactness gate passes with no override, so these are shipping-configuration numbers.

Not covered

Only the qwen3_5_mtp drafter. 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

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
@inureyes inureyes added type:performance Performance improvements priority:high High priority area:inference Generation, sampling, decoding (incl. speculative, DRY) area:core mlxcel-core: MLX FFI, primitives, KV cache, layers platform:macos macOS (Apple Silicon) specific status:review Under review labels Aug 17, 2026
@inureyes
inureyes merged commit a60a28d into main Aug 17, 2026
8 checks passed
@inureyes
inureyes deleted the perf/issue-1185-quantize-mtp-drafter branch August 17, 2026 13:19
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area:core mlxcel-core: MLX FFI, primitives, KV cache, layers area:inference Generation, sampling, decoding (incl. speculative, DRY) platform:macos macOS (Apple Silicon) specific priority:high High priority status:review Under review type:performance Performance improvements

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant