Apple M5 (base · 10 GPU cores · 24 GB) · measurement harness + evolutionary kernel search · 2026-07-19 → 07-27

Where MLX leaves performance on the table

On an NVIDIA GPU you can read the instruction-set manual and disassemble what ran. Apple publishes no equivalent. The instruction set, the latencies, the register file, the occupancy rules, the cache behaviour: all undocumented. A Metal kernel compiles to an intermediate form, and the driver turns that into machine code you never see. What the public knows comes from community reverse engineering, which runs two to three generations behind the silicon because every generation re-breaks the encodings.

Two traditions work against that void. Driver projects decode the encodings, because a compiler backend must emit machine code: applegpu and Asahi Linux, a multi-year effort repeated every generation. The other decodes nothing and recovers what it needs by measurement: metal-benchmarks, Rigel on the M4 tensor path, the A19 and M5 Neural Accelerator benchmarks.

This work belongs to the second, and its claim is narrow. To beat the software running on undocumented hardware you need two trustworthy measurements, not a specification. An honest ceiling to divide by, and a fitness signal that survives being wrong. Given those, the chip stops being a black box to decode and becomes an oracle to query. Nothing here required knowing how the hardware works inside. Two of the numbers below could be checked against an independent measurement, and both agree: the matrix ceiling to 3%, the shader ceiling to 5%.

What that bought is a map of where Apple’s own inference stack sits against its measured ceilings, and a working evolutionary kernel search. The headline is not a kernel. It is that Apple’s matrix-multiply kernels are already excellent and its attention kernels are not. Reaching a number worth trusting was the hard part: three audit rounds, each overturning the one before it, and eight ways a plausible number can describe something that is not happening does.

census · audit v1 / v2 / v3 · 3 campaigns · 320 candidates logged · generated 2026-07-27 14:29

Step one · measure the machine, not the spec sheet

Every "% of roofline" here divides by a number we measured. Apple publishes 153 GB/s for this chip. It sustains 120.5, so the spec overstates by 27%. Taking the published figure on trust would have inflated every gap on this page and aimed the project at the wrong target.

The M5’s Neural Accelerators are real matrix hardware rather than a shader trick. The matmul path measures 15.4 TFLOPS fp16 against 6.1 on the general-purpose path, a 2.5× gap that only dedicated silicon explains. That shader figure uses packed operands. An earlier probe issued scalar half instructions, read 4.3, and would have flattered the gap to 3.6×. int8 measures 18.6 TFLOPS, above the fp16 number. M4 and earlier have no such units, so every "% of NA ceiling" here belongs to this generation alone.

Scattered reads collapse to 7.4 GB/s, 16× below streaming. That is physics rather than an MLX defect: a random 4-byte read wastes most of its cache line. It is also why SSD-streamed MoE decode was ruled out here on measurement instead of on argument.

The machine, measured

Sustained read bandwidth120.5 GB/s
…vs Apple’s spec figure153 GB/s  (−21%)
fp16 matmul · NA path15.4 TFLOPS
int8 matmul · NA path18.6 TFLOPS
Shader fp16 ceiling (packed)6.1 TFLOPS
Ridge point128 FLOP/byte
Gather floor @ 1 GB7.4 GB/s
Kernel dispatch floor2.90 µs
Decode sits ~40× below the ridge and prefill ~51× above it. Both are far from the crossover, which is why these conclusions survive a change of chip tier.

The opportunity map · every op scored against its own roofline, dependent-chain timing

0%25%50%75%100%lm_head · GEMVlm_head · GEMV: 101.6% of roofline102%saturatedMoE FFN · gather_qmm (decode)MoE FFN · gather_qmm (decode): 91.4% of roofline91%banked*attn projections · quantized_matmulattn projections · quantized_matmul: 89.7% of roofline90%bankedprefill attention · hd=64prefill attention · hd=64: 86.5% of roofline87%healthyprefill GEMM · M=2048prefill GEMM · M=2048: 84.9% of roofline85%thinsdpa decode · hd=128sdpa decode · hd=128: 84.4% of roofline84%okprefill attention · hd=128prefill attention · hd=128: 77.7% of roofline78%thinsdpa decode · hd=256sdpa decode · hd=256: 63.4% of roofline63%POTHOLEsdpa decode · hd=64sdpa decode · hd=64: 52.4% of roofline52%POTHOLEprefill attention · hd=256prefill attention · hd=256: 34.1% of roofline34%THE PRIZEsdpa decode · hd=192sdpa decode · hd=192: 33.1% of roofline33%CLIFF
≥85%: saturated, do not campaign70–85%: thin<70%: real headroomdashed = 90% reference
The split is clean, and it is the project’s central finding. Every matmul-family kernel sits at 85–102% of roofline. Every kernel that misses is an attention kernel. Apple has tuned GEMM hard; attention varies by 51 points across head dimensions alone. That is software maturity, which is the one gap category that is an opportunity.

Controlled sweep · head_dim alone (H=32, KVH=4 fixed)

0%25%50%75%100%hd=64hd=96hd=128hd=192hd=256L=32k hd=64: 52.4%L=32k hd=96: 68.3%L=32k hd=128: 84.4%L=32k hd=192: 33.1%L=32k hd=256: 63.4%L=32kL=4k hd=64: 39.1%L=4k hd=96: 50.4%L=4k hd=128: 64.5%L=4k hd=192: 33.5%L=4k hd=256: 73.3%L=4khead dimension
hd=192 is not interpolation. It runs at 33% at both context lengths while its neighbours reach 63–84%, and it is slower in absolute time than hd=256 (311µs vs 190µs at L=4k) while moving 25% fewer bytes. Reproducible at σ=0.17%. Confirmed by reading MLX’s dispatch source after publication: the fused kernels are compiled for a short list of head dimensions and everything else silently falls back: hd=192 has no fused path at all. hd=192 ships in real Gemma and DeepSeek configs.

Prefill attention · % of fp16 NA ceiling · never audited before v3

0%30%60%90%120%hd=64 · 512/4k: 94.5%95hd=128 · 512/4k: 80.8%81hd=256 · 512/4k: 42.7%43512/4khd=64 · 512/32k: 88.9%89hd=128 · 512/32k: 79.2%79hd=256 · 512/32k: 36.5%37512/32khd=64 · 2k/4k: 113.2%113hd=128 · 2k/4k: 101.0%101hd=256 · 2k/4k: 40.7%412k/4khd=64 · 2k/32k: 86.5%87hd=128 · 2k/32k: 77.7%78hd=256 · 2k/32k: 34.1%342k/32kchunk tokens / KV prefix · dashed = 100% of ceiling
hd=64hd=128hd=256
The largest gap the project has found. At hd=256 prefill attention runs at 34–43%, while hd=128 reaches 78–101% and hd=64 reaches 87–113%. One model spends 209 ms per chunk against another’s 92 ms with half the heads. It sits on the time-to-first-token path, which is what agentic coding is bottlenecked on, and it is compute-bound, which suits the M5’s strongest axis.

Campaign #1 · MoE FFN

0.96×
evolved champion against its own seed, re-verified. The reported best of 1.0595× did not survive independent re-evaluation. Winner’s curse: best-of-60 on a ±3–5% noise floor selects noise.

Campaign #2 · sdpa decode

1.23×
geomean, re-verified 3× fresh after the fitness harness was rebuilt on dependent chains. Took MLX’s hd=256 kernel from 63.8% → 87.8% of roofline: +24 points over Apple’s own kernel.

Shipped end-to-end

1.03×
real mlx-lm serving, 32k context, outputs bit-identical. Modest because stock already ran at 91% of measured peak. The whole B=1 game had 9% left in it.

What that kernel is worth

1.04×
decode attention, on the architecture most favourable to it. Prefill attention on the same model is worth 1.96×. The largest %-gap and the largest prize were never the same op.

Where the time goes · Qwen3-Coder-30B at 32k context

Time to first token · processing a 32k prompt52 sattention: 68% of 52 s68%attentionmatrix multiplies: 26% of 52 s26%matrix multipliesnorms · rotary · dispatch: 5% of 52 snorms · rotary · dispatchSustained generation · one token at 32k45 msattention · reading the KV cache: 58% of 45 ms58%attention · reading the KV cachereading model weights: 33% of 45 ms33%reading model weightskernel dispatch: 9% of 45 ms9%kernel dispatch
attention, measured at 34–84% of its ceilingmatrix multiplies , measured at 85–102% and effectively saturatedfixed overheadeach bar normalised to its own total; the two phases differ by three orders of magnitude in absolute time
The Iron Law of Performance”: time  =  instructions × cycles-per-instruction × seconds-per-cycle. Performance is a product of terms measured in time, and the only way to improve it is to reduce one of them.Emer & Clark, 1984 (VAX-11/780 characterisation); popularised by Hennessy & Patterson, Computer Architecture: A Quantitative Approach.
A percentage-of-roofline score has already divided time out. It reports how well an operation uses the machine while it runs, and says nothing about how long it runs. On its own it cannot price a fix. The Iron Law supplies the missing step: multiply the efficiency back by the share of time the operation occupies. Attention takes 68% of time-to-first-token and 58% of every generated token, which is why a 30-point gap there outweighs a 50-point gap somewhere that runs for 9% of the time.

Optimising the op with the biggest roofline gap is not the same as optimising the op that costs the most time.

Why the largest gap was not the largest prize

0%25%50%75%100%prefill attention 34% → 90%prefill attention 34% → 90%: 88.7% of roofline89%1.89×decode attention 63% → 95%decode attention 63% → 95%: 3.4% of roofline3%1.03×fuse launch-bound opsfuse launch-bound ops: 1.1% of roofline1%1.01×
bar length = percentage points of end-to-end gain, each fix measured alone against the same baseline; all three together give 2.06×
Decode attention had the gap we found first. Prefill attention has the time. Nothing in the repo distinguished them until a value model existed, and by then campaign #2 was finished. The op we spent the most on is worth 1.04× even where it matters most.

Which model architectures are attention-bound

head_dim256, the precondition
attentionfull, every layer
query headsmany
KV headsfew (strong GQA)
MoEfine-grained, ~1.5B active
contextas long as possible
workloadprefill-heavy
layer countirrelevant
Two trends pull against each other. GQA leaves this target intact: prefill attention FLOPs scale with query heads and are independent of KV heads, so the dominant KV-reduction technique costs it nothing. Hybrid, sliding-window and linear attention do threaten it, because “full attention on every layer” is what the field is designing out. The target is real today and it erodes.

The fixed cost nobody had measured · per decode layer

0%10%20%30%rms_norm ×4rms_norm ×4: 28.6% of roofline29%17.9 µsrope ×2rope ×2: 14.8% of roofline15%9.3 µsrouter top-krouter top-k: 14.8% of roofline15%9.2 µskv updatekv update: 13.6% of roofline14%8.5 µsresidual add ×2residual add ×2: 12.1% of roofline12%7.5 µsswigluswiglu: 10.1% of roofline10%6.3 µssoftmaxsoftmax: 6.0% of roofline6%3.8 µs
share of the 62 µs/layer these seven ops consume · dispatch floor 2.90 µs/kernel
Seven operations that move almost no data cost 3.00 ms per token across 48 layers, which is 75% of the 4.02 ms fixed overhead. Their launch fraction runs 0.62 to 0.77, so most of what they cost is being a separate kernel at all. At single-sequence generation this is the largest remaining non-bandwidth cost in the step, and kernel tuning cannot reach it. Only merging the operations does.

Addendum · what changed after publication (07-25 → 07-27)

The MoE “banked” verdict above is regime-limited, and the correction is the largest single revision to the map. Those 90–97% readings are decode. At prefill, the routed-expert kernel re-runs its entire inner loop once per distinct expert inside a fixed 64-row tile, so useful arithmetic is ≈ min(1, R/64) where R = chunk × top-k / experts, a caller-side parameter. A 256-expert top-8 model prefilling 512-token chunks presents R=16 and runs at 21%. Measured 5%→87% across R, and the law predicts it to within 0.02 everywhere. The control that makes it causal: identical runs aligned to the tile boundary vs shifted by half a tile, same arithmetic and same bytes, measure 11.94 vs 6.85 TF. Only tile phase differs. Fix (BM=32 dispatch keyed on R) measured 1.3–1.45× at kernel level, 1.066× end-to-end, and is offered upstream as ml-explore/mlx#3925.

The hd=256 prefill pothole has since been fixed locally: a fused flash kernel on the Neural Accelerators, found by the evolutionary search seeded from a hand-written kernel, runs at 80% of the ceiling: 2.3× the fallback this page measured at 34%. With it integrated (quality-gated against garbage output) and the chunk size it unblocks, the 35B hybrid’s time-to-first-token at 32k went 70.2 s → 35.5 s across the week, every factor a same-day paired measurement. That story is Part 2: what the search found, what it cost, and what analysis did versus what the search did.

The last unexplained seconds now have names. The time-to-first-token budget closes at 94%: the largest remaining term is the gated-delta-net recurrence (4.05 s), a kernel that is latency-bound: 1024 sequential dependent steps per thread, running at 3% of both the compute and bandwidth ceilings simultaneously. No amount of tuning fixes a formulation; that one needs the chunked delta-rule algorithm, and it is open.

The asterisk on “banked*” in the opportunity map is this addendum. A verdict about an operation is a verdict about a regime; the same kernel was saturated at decode and a pothole at prefill, and the probe that would have caught it costs one parameter sweep.

The challenge of producing reliable data · eight hazards and the lessons they left

A kernel can compute nothing at all, at full speed, with no error
A tile configuration outside every if-constexpr branch of a Metal helper compiles the matmul away entirely. The kernel launches, reads its inputs, and stores zeros.
The “corrupt” output was exactly |0 − ref| at four sigma of the reference, which is what all-zeros looks like after a subtraction. Gate correctness before timing anything, and when output is wrong, test the null output before debugging arithmetic.
Dtype at an integration boundary is caller contract, exactly like memory layout
The model’s checkpoint was bf16 end to end; the kernel was fp16. Its pointer casts reinterpreted the bits, and a full end-to-end arm generated fluent-rate nonsense that was timed as if it were the kernel.
The wrong-dtype arm read as a 2.1× slowdown; it could as easily have read as a speedup. With an output-quality gate (greedy decoding, prefix agreement between arms) and a cast at the boundary, the same patch measured 1.13× faster. An invocation counter proves code ran; only a quality gate proves it computed the right thing.
Timing an operation in isolation measures the wrong regime
Called on its own, an operation overlaps with nothing and the GPU hides its latency. Called the way generation runs, with each step consuming the last one's output, the same kernel behaves differently.
Measured headroom moved by 20 to 65 points between the two methods. One operation read as “38 to 45% utilised” in isolation and 87 to 92% in a dependent chain. That is the difference between a large opportunity and none at all. Time operations in the dependency structure the real workload has.
Best-of-N selects noise whenever the effect is near the noise floor
Search over hundreds of candidates, keep the best score, and the winner is largely the luckiest measurement rather than the best program.
At sub-millisecond kernel times the paired-measurement noise ran ±3 to 5%, the same size as the gains being chased. The defence is an independent re-measurement of the apparent winner, in a separate session, with promotion gated on beating the incumbent by more than the noise.
An optimisation that silently never runs looks exactly like a perfect null result
Integration code that fails to bind reports the same numbers as a correct change with no effect. Nothing in the output distinguishes them.
A patch bound only one model family's namespace, so a whole day of “optimised versus stock” comparisons were stock versus stock, and read as clean parity. Count invocations and fail loudly when a requested code path never executes.
Benchmarks allocate ideal memory; production hands you whatever it has
A harness naturally allocates fresh, contiguous, well-aligned tensors. Real serving passes views into over-allocated buffers.
Kernel benchmarks said parity-to-better and end-to-end said 0.47×. The kernels demanded contiguous input, so the framework copied the entire cache on every call, every layer, every token. Reproduce the caller's memory layout, not a convenient one.
A confident root cause needs a falsification test, not a confirmation
The first plausible explanation for a failure tends to survive, because the tests run against it are the ones it passes.
Two crashes were attributed to concurrent GPU load and a lock was built. The next crash happened with the lock held and nothing else running. Worse, the experiment meant to settle it changed two variables at once. Controlling one factor while quietly moving another invalidates the inference.
A model can be wrong by omission, and omissions leave no trace
A wrong number gets caught by a sanity check. A missing term does not: the model stays self-consistent, its output stays plausible, and nothing anywhere reads as an error.
The value model counted the attention projections of a hybrid model's 10 attention layers and its experts, and counted nothing at all for the other 30 layers. Predicted time-to-first-token came out three times optimistic, and the error surfaced only when a scaling test contradicted it. Enumerate what a model does not count, not only what it does.
A probe can faithfully measure an operation the system never performs
Standing in for production code with something that looks equivalent produces a real, reproducible, low-variance number about the wrong thing.
A cache probe used a concatenation and scored a reassuring 92% of bandwidth. The real cache preallocates and writes one slot, an operation with entirely different cost. Drive the production code path, not a reimplementation of it.

What is new here

The M5 matrix path is fully drivable from a hand-written kernel
Others have measured what these units deliver. The result here is an engineering one: the whole tensor-op path is reachable from mx.fast.metal_kernel, bit-exact against mx.matmul, with cooperative-tensor epilogues working. An evolutionary search can therefore emit kernels that use the new silicon without rebuilding the framework. int8 runs at 18.6 TFLOPS, above the fp16 number, which inverts the usual assumption that fp16 is the fast path. Packed 4-bit operands are consumed natively. fp8 does not exist in the toolchain. Both ceilings agree with published A19 per-core measurements scaled to this chip: matrix 15.4 against 15.0 predicted, shader 6.1 against 6.4. That is the first independent check this project has had, and it caught a real defect in our own shader probe.
A measured roofline for the base M5, not a spec-sheet one
120.5 GB/s sustained against Apple's published 153, so the spec overstates by 21%. Ridge point 128 FLOP/byte. Scattered-read floor 7.4 GB/s, 16 times below streaming. Every efficiency claim about this chip rests on these three numbers, and they had to be measured before anything else could mean anything.
MLX attention efficiency mapped against head dimension
Holding head count and context fixed and varying only head width gives 52%, 68%, 84%, 33% and 63% at hd = 64, 96, 128, 192 and 256. The curve is not monotonic. hd=192 is a reproducible cliff at sigma 0.17% which is slower in absolute time than the larger hd=256 while moving 25% fewer bytes. That is the signature of a missing entry in a specialised-shape dispatch table rather than a slow kernel.
Prefill attention scored for the first time
Earlier audits scored only the matrix multiplies in the prompt-processing path. At hd=256 attention runs at 34 to 43% of the accelerator ceiling, while hd=64 and hd=128 reach 78 to 113%. On a model whose every layer uses full attention it is also roughly 70% of prefill arithmetic at long context. On a hybrid it is not. Measured on a 40-layer model with only 10 attention layers, doubling the context multiplied time-to-first-token by 1.94 and then 1.63 rather than by four, and prefill throughput held near-constant: linear per-token work dominates and attention is a minor term. Which architecture is in front of you decides whether this gap is worth anything.
The fixed per-token cost is dispatch, and it is measurable
A kernel launch costs 2.9 microseconds on my machine whatever the work inside it. Seven small operations per layer, among them the norms, the rotary embedding and the router, account for 3.0 ms per token across 48 layers. That is 75% of all non-bandwidth time, spent by operations that move almost no data.
Gap size and value are different quantities
An operation 30 points below its roofline can be worth 1.04× end-to-end while another is worth 1.96×, because end-to-end gain is weighted by each operation's share of the work and not by how far it sits from its own ceiling. Ranking optimisation targets by gap alone is a category error.

How these numbers were made

rulebecause
Dependent-chain timingdecode is a dependency chain; independent calls overlap and hide latency
Rotating buffersso the cache cannot serve what 48 distinct layers would miss
Measured ceilingsnever the spec sheet, which overstates by 21% here
σ reported per probepromotion needs to beat the incumbent by >max(2σ, 1%)
Proof the code raninvocation counters; a silent no-op is indistinguishable from parity
Product memory layoutstrided views, not fresh contiguous tensors
Held-out shapesplus bit-determinism and a fresh-random adversarial verifier
320 candidates logged, including every failure and safety rejection. 114 from campaign #1, 138 from campaign #2, 24 scored zero. The failures are the dataset: a search that records only its winners cannot show you a winner’s curse.
Dotted terms carry definitions. Hover, tap, or tab to them. Like this.