Skip to content

perf(mtp): let greedy chains draft through the coarse lm_head (opt-in) - #4500

Open
tc3oliver wants to merge 1 commit into
jundot:mainfrom
tc3oliver:feat/mtp-greedy-coarse-drafting
Open

tc3oliver wants to merge 1 commit into
jundot:mainfrom
tc3oliver:feat/mtp-greedy-coarse-drafting

Conversation

@tc3oliver

@tc3oliver tc3oliver commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Sampled MTP drafting already scores the vocabulary with a 3-bit copy of lm_head and rescores the top 64 candidates with the real head. Greedy drafting still runs the full head on every draft step. This PR lets greedy use the coarse path too.

  • Off by default, enabled with OMLX_MTP_GREEDY_COARSE=1.
  • Verify is unchanged and still decides every emitted token.
  • Requests with logits processors, sampled requests, and heads the coarse path doesn't support behave as before.

Performance

M4 Max 64 GB, MLX 0.32.4, batched engine, Qwen3.8-27B oQ4e (8-bit lm_head), MTP on, SpecPrefill off, temp 0, one request, cold prompt, 400 output tokens. Measured on this branch after rebasing onto 165be1ff. Decode tok/s, pooled median, flag off vs on, rounds interleaved:

Context Off On Change
4K (3 rounds) 49.40 54.56 +10.4%
16K (5 rounds) 43.99 47.56 +8.1%

Every round improved. Cycle time went from 57.8 to 55.7 ms at 4K and from 64.8 to 61.6 ms at 16K. Tokens per cycle also went up, from 2.81 to 3.04 and from 2.72 to 2.86. I haven't worked out why, so I'd only credit the cycle time to this change.

Raw decode tok/s (3 runs per round)

4K off: 47.85 48.67 49.40 / 47.44 50.55 46.65 / 51.17 49.88 52.13
4K on: 52.60 56.13 52.20 / 54.98 55.94 51.66 / 54.56 53.11 55.41

16K off: 44.81 41.49 43.99 / 46.63 41.22 42.09 / 46.16 43.50 45.57 / 45.91 42.33 43.29 / 46.35 44.33 41.27
16K on: 50.75 50.22 47.29 / 48.62 45.39 47.56 / 43.29 47.73 47.47 / 48.10 45.12 45.47 / 49.91 49.48 42.73

Trade-offs

  • The coarse copy costs 493 MiB and takes about 0.1 s to build on the first draft. Sampled requests already build it.
  • Greedy output can differ from the flag-off run at bf16 near-ties, because a different draft changes the verify width and the Qwen3.5 verify kernels aren't bit-identical across widths. MTP on vs off on main has the same property.
  • On 10 prompts, the top two logits at each first divergence were within one bf16 step. At 16K the rescored pick and the full-head argmax disagreed in 9 of 1,120 draft steps, all of them ties.
  • The greedy path doesn't quantize the MTP fc. Sampled requests still do, and after one runs in the same process fc stays 4-bit.
  • Full test suite: 16,822 passed, 0 failed.
  • Not benchmarked on other models or GPUs.

@tc3oliver
tc3oliver marked this pull request as ready for review October 13, 2026 06:39
Sampled chains already score the vocabulary with a 3-bit copy of the
lm_head and rescore the best 64 candidates with the real weights. Greedy
chains still run the full-vocabulary head on every draft step.

With OMLX_MTP_GREEDY_COARSE=1, greedy chains take the best exactly
rescored coarse candidate instead. Verify is unchanged and still decides
every emitted token. Logits processors, heads the coarse path does not
support and sampled requests behave as before, and the default is off.
The greedy path does not quantize the MTP fc; a sampled request in the
same process still does.
@tc3oliver
tc3oliver force-pushed the feat/mtp-greedy-coarse-drafting branch from 1b0a083 to aafb5ae Compare October 18, 2026 12:12
@yohann-bearzi

Copy link
Copy Markdown
Contributor

Numbers from a second machine and model, in case they help the review.

One caveat first: this is not this branch. It is a smaller always-on variant of the same idea that I had measured before I found this PR. Greedy takes the argmax of coarse.candidates(), there is no flag, and fc goes through the usual 4-bit path. The drafts should match greedy_token() here up to the fc precision.

Setup: M5 Max 128 GB, oMLX v0.7.4.dev1, Qwen3.8-Flash-Next oQ3e with MTP on (adaptive depth), temperature 0, one request at a time, 48 prompts from public benchmarks (HumanEval and others), up to 400 output tokens each. Two runs per arm, in the order A B B A.

Decode tok/s, pooled over the 48 prompts (15,605 tokens per run):

Run 1 Run 2 Pooled
main 106.66 107.02 106.84
greedy drafts through the coarse head 113.85 113.70 113.78

That is +6.5%, with a 95% bootstrap interval over prompts of +5.7% to +7.2%. 47 of the 48 prompts were faster.

Output: the two patched runs gave the same text as each other on 48 of 48 prompts, and the same text as one of the two main runs on 48 of 48. The two main runs differ from each other on 1 prompt, so the change is inside the run-to-run noise here. An earlier run with the oQ4e quant on a late-October base (16 prompts, 2,000 tokens each) gave +6.1% and the same text as MTP off on 16 of 16.

Given that, I think this could be the default path rather than an opt-in. Verify still decides every emitted token, and a server that also gets sampled requests has already built the coarse copy.

@tc3oliver

Copy link
Copy Markdown
Contributor Author

Thanks, that's useful, especially the 48/48 matching output.

I'll keep it opt-in here and flip the default in a follow-up once this lands. @jundot, if you'd rather have it on by default in this PR, I can change it.

@parasew

parasew commented Nov 13, 2026

Copy link
Copy Markdown
Contributor

Tested #4500 rebased onto v0.8.0rc1 (OMLX_MTP_GREEDY_COARSE=0 vs 1) in an ABBA run on a 64 GB M1 Max (Qwen3.6-35B-A3B-oQ4e-mtp, mtp_enabled: true, mtp_fixed_depth: 3, temperature: 0, max_tokens: 400, 1 warmup + 3 hits per prompt):

  • Offline tests (tests/test_mlx_lm_mtp_patch.py): 466 passed, 1 skipped.
  • Decode throughput (off vs on, ABBA medians):
    • code_4k: 118.81 / 119.52 → 127.96 / 128.01 tok/s (+7.4%)
    • code_16k: 105.20 / 105.23 → 110.29 / 110.24 tok/s (+4.8%)
    • prose_4k: 82.47 / 82.83 → 88.89 / 88.81 tok/s (+7.5%)
  • Greedy SHA-256 parity: 8/9 prompt/repeat pairs bit-identical (7a==7b==7c==7d); prose_4k rep 3 differed deterministically between off (7a/7d: f057f8defb10) and on (7b/7c: 228c16ebd62d).

@parasew

parasew commented Nov 14, 2026

Copy link
Copy Markdown
Contributor

Second data point for this PR: M5 Ultra, same probe and ABBA arm order as my M1 Max numbers above. The gain is smaller but it persists on hardware with a wide qmv path.

Setup — v0.8.0rc1 + OMLX_MTP_GREEDY_COARSE=1, MTP depth 3 (mtp_enabled: true, mtp_fixed_depth: 3), fp16 activations, 4-bit oQ4e, greedy, 400 max tokens, scheduler.concurrent_priority: balanced, isolated instance with its own --base-path and cold SSD cache per arm, machine quiet (canary 43.8 TFLOPS / 515 GB/s).

Decode tok/s, mean of 3 hits after a warmup

prompt coarse off coarse on M5 Ultra M1 Max (earlier)
code_4k 329.7 / 331.5 344.3 / 343.7 +4.0 % +7.6 %
code_16k 272.8 / 286.0 295.9 / 295.3 +4.5 % (off arms spread 4.8 %) +4.9 %
prose_4k 238.1 / 238.3 247.9 / 247.5 +4.0 % +8.5 %

Absolute throughput is 2.2–2.7× the M1 numbers, so the relative gain shrinking is expected if part of the win is amortising fixed per-step overhead.

Losslessness: greedy SHA over all four arms is 11/12 identical. The one difference is code_4k repetition 2, and it sits exactly on the on/off boundary (both off arms one hash, both on arms another) — so greedy-coarse is "almost always bit-identical", not guaranteed identical. Same class of result as on M1 (8/9 there, one prose_4k repetition).

Opt-in by default still looks right to me; the effect is real on two GPU generations, but it is a few percent, not a step change.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants