Measure attention projections instead of serving them, and record the amortisation trap - #30
Merged
Merged
Conversation
AndreSlavescu
force-pushed
the
projection-headroom
branch
from
July 30, 2026 04:09
9a5df07 to
68f2adc
Compare
… amortisation trap The q, k, v and o projections are about a quarter of a decode token and have no meTile path, so they look like the obvious next thing to serve. At matched bf16 they are not worth serving, and the reason is the same one the standing goal already records: MLX's decode GEMVs run at the hardware streaming limit. At one row MLX reads these weights at 121 to 254 GB/s against a 120.6 GB/s streaming ceiling, so for three of four shapes the weights are cache-resident and DRAM is not even the constraint. meTile measures 0.993x, 0.721x, 1.007x and 0.996x there, and 0.93x to 1.03x at eight rows. A win appears only at thirty-two rows, 1.33x to 1.56x, which is batch or prefill rather than decode, and two of the four shapes stop being bit-exact there. Routing that would trade the logit-equality contract for a win outside the case the task was about. So this closes as measured rather than built. Adding the path would deliver parity at decode along with a code path, a tuning surface and a bit-exactness risk. The measurement is the deliverable, and getting it wrong was instructive enough to encode. A first version sized the inner dispatch count for 64 MB of weight traffic per eval. For a 4.7 MB projection that is thirteen dispatches, about 390 us of work against a roughly 200 us mx.eval round trip, so nearly a third of every sample was overhead -- and because the two sides pay it differently it reported MLX at 52 GB/s with a 2.3x gap and meTile at 1.828x. Both were fiction. Targeting a gigabyte per eval puts the round trip near 2%, and the same shape then reads 146 GB/s at 0.993x. That is the third time this session that insufficient amortisation manufactured a win: module reloads inside a timing loop reported the scheduling pass at 0.83x to 0.92x when it was measuring the Metal compiler, and a per-call API reported the int4 multi-row QMV at 0.05x when the published path uses a prepared executor. The rule that would have caught all three: if a measurement of a small kernel shows a large difference, suspect the harness before believing it. benchmarks/projection_headroom.py takes the traffic target as a flag, so lowering it reproduces the error rather than leaving the claim to be trusted. 680 pass, 7 skipped. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AndreSlavescu
force-pushed
the
projection-headroom
branch
from
July 30, 2026 04:10
68f2adc to
05411c7
Compare
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.
The q, k, v and o projections are ~25% of a decode token with no meTile path, so they look like the
obvious next thing to serve. At matched bf16 they aren't worth serving, for the reason the standing goal
already records: MLX's decode GEMVs run at the hardware streaming limit.
A gap below 1.0x means the weights are cache-resident and DRAM isn't even the constraint. Eight rows
is parity. A win appears only at 32 rows — batch or prefill, not decode — and two of four shapes stop
being bit-exact there, so routing it would trade the logit-equality contract for a win outside the case
the task was about.
So this closes as measured rather than built. Adding the path would deliver parity at decode plus a
code path, a tuning surface, and a bit-exactness risk.
The measurement is the deliverable, and I got it wrong first
A first version sized the inner dispatch count for 64 MB of traffic per eval. For a 4.7 MB projection
that's 13 dispatches — about 390 µs of work against a ~200 µs
mx.evalround trip, so nearly a third ofevery sample was overhead. Because the two sides pay it differently, it reported MLX at 52 GB/s with a
2.3x gap and meTile at 1.828x. Both were fiction. At a gigabyte per eval the round trip is ~2% and the
same shape reads 146 GB/s at 0.993x.
That's the third time this session insufficient amortisation manufactured a win:
Metal compiler.
executor (real answer: 1.45–1.73x).
The rule that would have caught all three: if a measurement of a small kernel shows a large difference,
suspect the harness before believing it.
benchmarks/projection_headroom.pytakes the traffic target as a flag, so lowering it reproduces theerror rather than leaving the claim to be trusted.
680 pass, 7 skipped.
🤖 Generated with Claude Code