metal: fused Sdpa via ported MLX attention kernels (native GQA, decode kernel) - #2545
Merged
Merged
Conversation
czoli1976
force-pushed
the
feature/metal-mlx-sdpa-port
branch
from
August 1, 2026 12:48
21bee75 to
9d296eb
Compare
Contributor
Author
|
The pre-existing failure mentioned above is now split out as #2546 — that one is independent of this PR and applies to main on its own. |
czoli1976
force-pushed
the
feature/metal-mlx-sdpa-port
branch
from
August 1, 2026 16:17
9d296eb to
8e950c6
Compare
The vendored MetalFlashAttention metallib predates GQA and is a prefill kernel, so grouped-query attention never fuses and a decode-shaped Sdpa fuses to something slower than the explode path it replaces. Port MLX's attention kernels as owned .metal source - the sdpa_vector decode family (single-pass and split-KV two-pass) and the steel tiled prefill kernel - and route Sdpa through a single chooser that prefers them, keeps the metallib for shapes they do not cover, and explodes the rest. The mask input is wired through as an additive float mask, since an exported causal LLM passes its mask as a fourth input rather than is_causal, and cast to the query dtype so the constant folds and the node stays fusable. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
czoli1976
force-pushed
the
feature/metal-mlx-sdpa-port
branch
from
August 1, 2026 16:52
8e950c6 to
d062a33
Compare
Contributor
Author
|
The M5 tensor-op variant mentioned above is now filed separately as #2548, with the branch rebased on top of this PR — it needs one run on M5 hardware, which I don't have. |
Collaborator
|
/ci llm |
|
Reference: 2026-08-02 morning nightly run (1d old) · full report → run Speed — evaltime · prefill · decode no inference-speed regressions Improvements
|
| Δ | metric | device | main → PR |
|---|---|---|---|
| llama_3_2_3B_instruct_q40ef16_541 load+optimize · cuda |
jetson-orin-nx |
4.14 s → 4.41 s |
|
Reference: 2026-08-03 morning nightly run (0d old) · full report → run Speed — evaltime · prefill · decode no inference-speed regressions Improvements
|
| Δ | metric | device | main → PR |
|---|---|---|---|
| arm_ml_kws_cnn_m load · pass |
cortex-a9 |
84 ms → 103 ms | |
| arm_ml_kws_cnn_m load+optimize · pass |
cortex-a9 |
131 ms → 155 ms | |
| hey_snips_v1 load · 400ms |
cortex-a7 |
67 ms → 75 ms | |
| arm_ml_kws_cnn_m load · pass |
cortex-a7 |
78 ms → 87 ms | |
| hey_snips_v1 load+optimize · 400ms |
cortex-a7 |
112 ms → 124 ms | |
| llama_3_2_3B_instruct_q40ef16_541 load+optimize · cuda |
jetson-orin-nx |
4.14 s → 4.41 s |
✅ CI / large-models: success
|
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.
Follow-up to #2320, same fused-
Sdpaslot: MLX's attention kernels ported asowned
.metalsource — the decode-specialisedsdpa_vectorfamily (single-passand split-KV two-pass) and the tiled
steel_attentionprefill kernel — behind asingle chooser translator that prefers them, keeps the vendored MFA metallib for
shapes they don't cover, and explodes the rest.
Two things the metallib can't do. It predates GQA, so llama-class attention
falls out of fusion entirely; and it is a prefill kernel, so at decode shapes
(
qL=1) it is slower than not fusing at all — which is what main does today.The mask input is wired through as an additive float mask. That turned out to be
the difference between a synthetic win and a real one: an exported causal LLM
passes its mask as a fourth
Sdpainput rather than settingis_causal, sowithout it nothing in a real model fuses. It arrives as f32 next to f16
activations, and the kernels template the mask on the activation type, so a
rewrite casts it to the query dtype — the mask is a constant, so it folds.
Same vendoring idiom as
MlxGemm/MlxGemv:mlx_sdpa.metalis a mechanicalflattening of the MLX include closure at a pinned commit, each section verbatim,
f32/f16 entry points instantiated at the end; resync by re-flattening.
End to end
Qwen2.5-7B-Instruct q40ef16 (
--metal) on the M1 Pro. All 28 attention layersfuse to
MetalMlxSdpaat both shapes; on main the same graphs show 28MetalScaledMaskedSoftmax, i.e. nothing fused.Best of five interleaved runs each. This box is noisy under a 4 GB model, so the
per-pair spread is wide — decode 1.00–1.49× (ahead in 4/5), prefill 0.90–1.40×
(ahead in 4/5, one pair against).
Same model on the M4, which is a quieter box — three interleaved runs, best of:
Per-pair there was 1.22–1.27× decode and 1.34–2.91× prefill, ahead in every pair.
Model output stays finite — logits and all 28 KV cache pairs — on a model that
historically produced NaNs on this backend.
Second device (M4, 8-core GPU, macOS 26.6)
8-layer stacks, ms/layer. The metallib gap widens on this GPU, and prefill
improves over the M1 Pro's 1.34–1.46×:
One case needs a caveat rather than a claim.
bench_sdpa_decodefirst showedf32 with a short cache at 0.67× against exploding on this GPU, so I went looking
for a gate rule and swept 32 decode shapes (f16/f32 × D 64/128 × MHA and GQA 32/8
× kvL 256..4096) on both machines — 64 measurements,
bench_sdpa_gate_sweep.No rule emerged: the fused path wins 28/32 on the M1 Pro and 26/32 on the M4, and
the handful of losses are 0.82–0.99×, scattered, and do not replicate across the
two devices.
Re-running the original case several times explains it — the spread is warm-up,
not shape. The MLX kernels are compiled at pipeline-load time, and the fused
model is benched first, so the first measurement in a process pays that:
0.57× then 0.94× then 1.09× across three consecutive runs on the M4. So: no
measured regression I can reproduce, and no gate narrowing proposed. Flagging it
because the first number was real and someone else will hit it.
bench_vector_passesalso confirms the split-KV gate on a second device: the M4reports
applegpu_g16g, mlx does not treat it as large, and single-pass is indeedfaster there at 4096 (0.167 vs 0.186) and 16384 (0.634 vs 0.655).
Kernel and op numbers
M1 Pro (14-core GPU), macOS 26.5.2, 8-layer
Sdpastacks, ms/layer, best of5×N, KV resident on the device. Range over three runs.
Prefill, f32, H=8 S=512 D=64:
→ 1.34–1.46× over the metallib, ~2.4× over explode.
Decode, qL=1, H=8 D=64:
→ 3.2–10.2× over the metallib, 1.0–5.0× over explode. Note the metallib
column is 2.6–3.1× slower than explode here: today a decode-shaped
Sdpafuses to it, so this also removes that regression.
bench_sdpa_multilayer_mlx_vs_mfaswaps the op in the transformed graph, soboth kernels are measured in one process against the same graph, allocator and
sync pattern.
One deviation from the parked branch: the two-pass split-KV gate mirrors mlx's
own rule (large GPUs at ≥1024 keys, otherwise GQA at ≥4096) instead of splitting
unconditionally at 1024, so devices mlx does not treat as large keep the
single-pass kernel. This M1 Pro reports
applegpu_g13s, which mlx does treatas large, and the kernel-level numbers say that is the right call here
(
bench_vector_passes, ms/dispatch):So the split pays from ~4096 and is marginally wrong at exactly 1024; mlx's
threshold is kept rather than retuned on one device.
Validation
cargo test -p tract-metal --release: 93 passed, 8 ignored (benches), plus 16new
mlx_sdpacorrectness cases (f32/f16 × 1-pass/2-pass/steel, GQA, causal,unaligned, batched) checked against a CPU reference.
cargo build --workspaceclean,
cargo fmt --allclean,cargo clippy -p tract-metalclean.One failure,
mfa::tests::test_mfa_attention_causal_const_is_noop, ispre-existing: it fails identically on main at 405dd8d on macOS 26.5.2 (the
triangularfunction constant does mask now, so the probe's assertion no longerholds). Untouched here; happy to send a separate fix.
Not included: the M5 tensor-op (NAX) variant of the steel kernel. It needs M5
silicon to validate and I have none — parked until someone can run it.
MLX (ml-explore/mlx) is MIT, Copyright (c) 2023-2025 Apple Inc.; attributed in
the source header. Dispatch decisions follow MLX's own
scaled_dot_product_attention.cpp.🍍