Skip to content

Optimize Metal prefill and decode on all Apple Silicon chips #555

Open
ivanfioravanti wants to merge 3 commits into
antirez:mainfrom
ivanfioravanti:codex/m3-metal-prefill-decode
Open

Optimize Metal prefill and decode on all Apple Silicon chips #555
ivanfioravanti wants to merge 3 commits into
antirez:mainfrom
ivanfioravanti:codex/m3-metal-prefill-decode

Conversation

@ivanfioravanti

Copy link
Copy Markdown
Contributor

Summary

This PR improves GGUF inference performance on the Apple M3 Metal backend while preserving the existing mathematical behavior and byte-identical logits.

The work was developed as a sequence of small experiments. Each optimization was retained only after:

  • confirming byte-identical deterministic logits
  • passing the Metal kernel test suite
  • showing a repeatable improvement in order-balanced benchmarks

Main changes

  • Optimize and share RoPE work across heads and tokens
  • Improve router finalization and fuse router weight generation
  • Reduce compressor packing, projection, and state-storage overhead
  • Reuse persistent attention masks and cache zero-prefix prefill masks
  • Fuse attention KV staging and tail padding
  • Fuse output HC collapse, normalization, weight transforms, and expansion paths
  • Improve output vocabulary projection dispatch
  • Reduce intermediate allocations, buffer traffic, and Metal dispatch overhead

No model semantics, attention selection behavior, or approximation settings were changed.

Performance

Tested on an Apple M3 Ultra with 512 GB unified memory using the Q4 GGUF model, promessi_sposi.txt, warmed model residency, and 128 generated tokens.

Baseline: 80ebbc3
Optimized branch: 52e77a7

Context Metric Baseline Optimized Improvement
2K Prefill 522.29 tok/s 578.22 tok/s +10.71%
2K Decode 33.38 tok/s 36.34 tok/s +8.88%
4K Prefill 524.72 tok/s 581.04 tok/s +10.73%
4K Decode 26.53 tok/s 27.52 tok/s +3.73%

The measurements used order-balanced runs:

  • three runs per version at 2K
  • two runs per version at 4K
  • model residency time excluded

Correctness

A deterministic 64-token trace was compared directly with the original baseline. The complete logprob files were byte-identical:

6657ac9df2d53f9b41f329db323af89506894449277a3c8805cc06624c44975e

The focused Metal kernel suite also passes:

./ds4_test --metal-kernels

@ivanfioravanti
ivanfioravanti force-pushed the codex/m3-metal-prefill-decode branch from 52e77a7 to de0858c Compare July 12, 2026 17:27
@ivanfioravanti

Copy link
Copy Markdown
Contributor Author

Waiting for ds4-eval to complete before marking this ready for review. So far, so good.
image

@ivanfioravanti
ivanfioravanti marked this pull request as ready for review July 12, 2026 21:36
@ivanfioravanti

Copy link
Copy Markdown
Contributor Author

New: ds4-eval: 77/92 passed, 15 failed, runtime 04h:51m
Old: ds4-eval: 77/92 passed, 15 failed, runtime 05h:15m

100% correct.

- Stage argsort top-k scores in threadgroup memory instead of gathered
  device loads (same comparison network, bit-identical permutation).
- Cache the graph dump-prefix getenv and memoize flash-attn vec/reduce
  pipeline lookups on the per-token hot path.
- Default prefill routed MoE to a fused Q4_K gate+up+SwiGLU grouped
  matmul sharing one staged B tile; generalizes the IQ2 fused kernel
  into a quant-generic template. Removes one GEMM dispatch and the F32
  gate/up intermediate round-trip.

All paths are bit-identical to previous logits, verified with frontier
logit dumps (2k-32k ctx), local golden vectors, IQ2 fused-vs-unfused,
and make test (tensor equivalence max_abs=0).
@antirez

antirez commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Great! Let me check :)

@antirez

antirez commented Jul 17, 2026

Copy link
Copy Markdown
Owner

Also thanks for submitting a PR that contains a human-readable description ;D

@Flor1an-B

Copy link
Copy Markdown

M5 Max results (community datapoint)

Thanks for this! Since the PR is titled "Pre-M5" and was benchmarked on an M3 Ultra, I ran it on an M5 Max (128 GB unified) to check whether the gains carry over to the newer chip — they largely do.

Setup

  • Model: DeepSeek-V4-Flash-IQ2XXS-w2Q2K-AProjQ8-SExpQ8-OutQ8-chat-v2-imatrix.gguf
  • ds4-server --metal -c 262144 -n 8192, full Metal residency, KV disk cache
  • Back-to-back in the same session (thermal state matched), 3 reps, median
  • Baseline 80ebbc3 vs PR head 0a413c3
  • decode = 256 generated tokens; prefill = TTFT on a 42,683-token prompt

Results (M5 Max)

Metric Baseline 80ebbc3 PR 0a413c3 Δ
Decode @ ~1K ctx 37.34 tok/s 37.59 tok/s +0.7% (noise)
Decode @ ~43K ctx 25.97 tok/s 27.81 tok/s +7.1%
Prefill (42.7K tok) 131 s / 327 tok/s 114 s / 376 tok/s +15%

So on M5 the low-context decode is essentially flat, but high-context decode (+7%) and prefill (+15%) both improve meaningfully — the prefill gain is even slightly larger than the M3 numbers reported here. Nice work.

(Note: I measured throughput only and did not re-verify byte-identical logits; I'm trusting the deterministic-trace check in the PR description for correctness.)

@ivanfioravanti

Copy link
Copy Markdown
Contributor Author

WOW @Flor1an-B this is a side effect! A very positive one! I’m working aside on M5 but was struggling a bit there, I’ll let agent point to this PR to get inspiration then!

@gilbert-barajas

Copy link
Copy Markdown

@ivanfioravanti re: "working aside on M5 but struggling a bit" — here's what we've measured on M5, in case it saves you time. M5 Max 128GB, macOS 27.0 (26A5378n), running ds4 with GLM-5.2 IQ2_XXS (197GB, SSD-streamed) and V4-Flash.

Apple already ships a routed-MoE quantized GEMM, and it's MLX's gather_qmm on TensorOps.

MPSNDArrayQuantizedGatherMatrixMultiplication is live and instantiable in MPS right now. It's exported in the 26.5 SDK as well as 27.0 — so it isn't a 27 thing — but there's no public header in either: private ABI, tbd export only. Its init is the whole MoE pipeline spelled out:

initWithDevice:leftQuantizationDescriptor:rightQuantizationDescriptor:
  isSorted:batchDims:hasLHSIndices:hasUnsortOrder:sourceCount:

The metallib reflection gives away the design — shader family gather_qmm_rhs / gather_qmm_impl_cooperative_tile, params struct MPSNDArrayQuantizedGatherMultiplyParams carrying experts_per_token and w_experts_stride, kernel args x(half), w(uchar), scales, biases, indices(uint) — and it runs on __tensorops_impl_matmul2d_op_run_cooperative_b16_b16_f32_v2.

So it's a fused affine expert-loader on the same TensorOps matmul2d engine you'd target yourself on M5 — not new silicon. Its ceiling is TensorOps. That was the useful part for us: it told us where the roof is.

It's affine-only, by hard assert. Hand it a LUT/codebook descriptor:

MPSNDArrayQuantizedGatherMatrixMultiplication.mm:592: failed assertion `Only RHS affine quantization supported'

So it can't take ds4's IQ2_XXS (codebook magnitudes + separate sign channel + per-K-block scales). MLX-style affine (q·scale + bias) only. Its affine scale blocks also run along N, not K — K-grouped layouts get invalid block size.

Measured at ds4's GLM gate shape (6144→2048, top-8-of-256), affine-u8:

S=  1 (rows    8):   0.295 ms   eff 385 GB/s
S=  8 (rows   64):   2.195 ms   eff 369 GB/s
S= 64 (rows  512):  24.062 ms   eff 132 GB/s
S=256 (rows 2048): 110.430 ms   eff  33 GB/s

Note the efficiency collapse as fan-out saturates all 256 experts. Caveat: the one gather config that encodes cleanly mis-computes on this build (the same descriptor is exact to 8.8e-8 on the non-gather QMM), so treat those as timings only. We killed it for our format.

One more for your M5 work: Metal 27 adds MTLTensorDataTypeInt2/UInt2, MetalFloat4E2M1, MetalFloat8E4M3/E5M2, Float8UE8M0 — none in 26.5. And the OS upgrade brings the command-line tools to SDK 27.0, so you can compile against them without beta Xcode.

Happy to share the probe sources if any of that's worth reproducing.

@unsaltedbutter-ai

Copy link
Copy Markdown
Contributor

M3 Ultra (Q4): +5 to 6% prefill, +4% decode, no regressions

I reproduced this on a second M3 Ultra and it holds up. Every measured context frontier improved, prefill and decode, with byte-identical KV cache sizes.

Setup

  • Apple M3 Ultra, 256 GB, 60C
  • DeepSeek-V4-Flash Q4
  • ds4-bench with speed-bench/promessi_sposi.txt, sweep 2048 to 32768 by 2048, 128 decode tokens per frontier
  • Each config: make clean then full rebuild, fresh model load, single run

Base vs base + this PR

ctx prefill base prefill PR prefill Δ decode base decode PR decode Δ
2048 410.1 425.7 +3.8% 31.53 32.90 +4.3%
4096 381.4 405.3 +6.3% 25.42 26.41 +3.9%
6144 376.7 400.2 +6.2% 24.99 26.14 +4.6%
8192 373.1 396.3 +6.2% 24.96 26.00 +4.2%
10240 366.2 388.4 +6.1% 24.71 25.86 +4.7%
12288 363.0 384.9 +6.0% 25.22 25.79 +2.3%
14336 358.4 380.7 +6.2% 24.80 25.68 +3.6%
16384 355.5 376.3 +5.9% 24.57 25.53 +3.9%
18432 349.9 370.1 +5.8% 24.25 25.41 +4.8%
20480 346.7 366.6 +5.8% 24.38 25.34 +3.9%
22528 342.6 362.0 +5.7% 24.11 25.24 +4.7%
24576 339.7 359.0 +5.7% 24.26 25.19 +3.8%
26624 334.3 352.8 +5.6% 23.99 25.11 +4.7%
28672 331.4 349.7 +5.5% 24.08 24.98 +3.7%
30720 327.6 345.3 +5.4% 23.74 24.83 +4.6%
32768 325.0 342.7 +5.4% 23.68 24.61 +3.9%

Average across the sweep: prefill +5.8%, decode +4.1%. No frontier regressed.

Output is unchanged. kvcache_bytes is identical between base and PR at every frontier, consistent with the byte-identical claim.

It composes cleanly. My daily driver is main plus #371, #448, and #550, and I also cherry-picked this PR on top with no conflicts. The deltas matched what I measured on plain main, so the speedup is independent of those other patches. That combined branch is now my daily driver. If you're looking for KV cache improvements and MTP boosts, those three have been a solid win for me for a while and might be worth a look alongside this one.

One honest note on magnitude. My sustained prefill gain (~+5 to 6%) lands a little below the +10.7% headline, though decode matches (+9.3% at 2K on my prod build). Likely a different base GGUF and single-run variance rather than a real disagreement.

Nice work @ivanfioravanti . I would ❤️ to see this merged.

@ivanfioravanti

Copy link
Copy Markdown
Contributor Author

@gilbert-barajas thanks for the hint! I'm throwing all possible context and hints to the agent, I'm finally starting to see positive results! I'll add this info too. Keep you posted.

@gilbert-barajas

Copy link
Copy Markdown

@ivanfioravanti glad it helped. If it's useful to your agent, I can gist the probe sources — a self-contained Obj-C tool (17 subcommands: the support matrix, numeric gates, weight-map extraction, and a perf ladder at real MoE shapes) that dumps the MPS quant runtime surface and drives the gather kernel directly on-box. Just say the word. Rooting for the M5 push.

@ivanfioravanti

Copy link
Copy Markdown
Contributor Author

@antirez please wait on this one, I'm finalizing last tests on M5 speed-up, they should finish by tomorrow morning, if everything ok I'll merge changes in this branch so that we can land optimizations for the whole Apple Ecosystem 💪

Dense direct-RHS matmul (Q8_0/F16), attention-output low-rank, and
indexer scores NAX kernels on Metal 4 / M5:

- dense + attn-out: double-buffered weight tile (2->1 barrier per
  k-step), flattened staging with vector half4 stores, dead bounds
  checks removed under the host M%64/K%32 dispatch guarantee, and
  dequantize_q8_0_pairs producing identical halves from eight aligned
  16-bit loads instead of sixteen byte loads.
- indexer scores: double-buffered q tile (10->5 barriers per head),
  vectorized float4/half4 q/k staging, and head-packing (2 heads per
  matmul with a TQ=32 descriptor, halving mm.run count).

Kernel-level: dense NAX sweep ~+3.6%, attn-out op ~+3%, indexer ~2.7x.
End-to-end prefill ~+5% (7189-token chunked prompt) and up to ~+10%
in cool same-binary A/B; decode unchanged (NAX is prefill-only).

Logits verified bit-identical to the pre-change tree: full 129280-float
logits after prefill and after each of 64 greedy decode steps, on both
an unchunked 3840-token prompt (65 frames) and a chunked 7189-token
prompt (33 frames, exercising the pos0>0 indexer path). Project tests:
--metal-kernels, --local-golden-vectors, --logprob-vectors all OK.
@ivanfioravanti ivanfioravanti changed the title Optimize Metal prefill and decode on Apple Silicon Pre-M5 Optimize Metal prefill and decode on all Apple Silicon chips Jul 17, 2026
@ivanfioravanti

Copy link
Copy Markdown
Contributor Author

Ready to go @antirez a very small bump ~1.5% on M5 using NAX, but it gets benefits from previous changes too. I updated the name of the PR.

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.

5 participants