Skip to content

perf: gate and up in one decode pass for GGUF formats on Apple7/8 - #27

Merged
paperniuk merged 2 commits into
paperniuk:apple7-m1-1.3.2from
ghisguth:apple7-m1-gate-up-pass-1.3.2
Oct 24, 2026
Merged

paperniuk merged 2 commits into
paperniuk:apple7-m1-1.3.2from
ghisguth:apple7-m1-gate-up-pass-1.3.2

Conversation

@ghisguth

Copy link
Copy Markdown

Base: paperniuk/splash apple7-m1-1.3.2 (branch apple7-m1-gate-up-pass-1.3.2: the change, then the test). Independent of the GDN verify and norm loads PRs (#25, #26). The same two commits also apply cleanly to apple7-m1-1.4.


On the M1/M2 register tile each MLP layer runs gate and up as two decode passes, and the up pass applies SwiGLU in its epilogue. This change runs both as one dispatch for GGUF formats: in each 32-column threadgroup every simdgroup computes 8 gate columns and the same 8 up columns, so SwiGLU happens in registers. On my M2 Max the 27B's gate/up takes 2–8% less time on every GGUF format, and the Q4_0 decode cycle is 1–2% faster. Outputs are bitwise the same as the two passes. As before, I used Claude Code to author it and tested it by hand.

What changed

  • New gguf_decode_sgf_<fmt>_l<lanes>_gu instances in linear_gguf_sgmatrix.metal (the F32 table instances, so Apple7/8 only). Grid {N/32, S}. With one split a threadgroup writes up · silu(gate) directly. With S splits it writes both tensors' partials, and the last of the S arrivals on a 32-column tile sums them and applies the gate.
  • Linear::ggufBaseline sets oneGateUpPass on the Apple7/8 register plan when gate and up are single tensors of one format and that format is not MLX affine (apple7GateUpFormat). The scratch bound covers the one-pass plan: partials of both tensors and one counter per 32 columns.
  • MLX 4-bit (af4g64) keeps two passes. With one pass its 17408×5120 gate/up was −7.6 / −5.3 / +2.3 / +4.4% at 1 / 2 / 3 / 4 lanes, so it would be slower at three and four lanes. The choice is by format, not by lane count, so a model runs the same plan at every lane count.
  • In the UD files only layers whose gate and up have the same format take one pass: 36 of 64 in UD-Q4_K_M, 40 of 64 in UD-Q5_K_M. The others run two passes as before.
  • gguf-projection-benchmark gets register_gu rows. Its staged rows took oneGateUpPass from the policy, which is now set on the register plan, so on Apple7/8 they would have run the staged one-pass kernel. Now they take it only from a staged policy, and they run what they ran before on every family.

Results (M2 Max, 38-core GPU)

Measured on apple7-m1-1.3.2 with #25 and #26 under it, and on apple7-m1-1.4.

Kernel time, gguf-projection-benchmark, 17408×5120 gate/up, the policy's config (S4), ms at 1 / 2 / 3 / 4 lanes, ABBA. Δ: − is faster. In the same runs the two-pass rows of the new build match the base within 0.3%.

Format Before (ms) After (ms) Δ (%)
q4k 0.428 / 0.756 / 1.070 / 1.411 0.400 / 0.732 / 1.036 / 1.366 −6.4 / −3.3 / −3.2 / −3.1
q5k 0.513 / 0.896 / 1.184 / 1.589 0.477 / 0.847 / 1.115 / 1.555 −7.1 / −5.4 / −5.8 / −2.2
q6k 0.530 / 0.859 / 1.155 / 1.537 0.496 / 0.855 / 1.117 / 1.482 −6.4 / −0.5 / −3.3 / −3.6
q3k 0.574 / 0.960 / 1.219 / 1.597 0.535 / 0.921 / 1.170 / 1.536 −6.8 / −4.0 / −4.0 / −3.8
q40 0.422 / 0.759 / 1.081 / 1.420 0.396 / 0.732 / 1.038 / 1.374 −6.1 / −3.6 / −4.0 / −3.2
q41 0.426 / 0.769 / 1.095 / 1.434 0.396 / 0.737 / 1.042 / 1.377 −7.2 / −4.1 / −4.8 / −4.0
q50 0.520 / 0.906 / 1.180 / 1.612 0.477 / 0.853 / 1.129 / 1.532 −8.3 / −5.9 / −4.3 / −4.9
q51 0.527 / 0.923 / 1.199 / 1.637 0.485 / 0.866 / 1.134 / 1.561 −8.0 / −6.2 / −5.4 / −4.6
q80 0.552 / 0.781 / 1.088 / 1.397 0.532 / 0.756 / 1.037 / 1.342 −3.6 / −3.3 / −4.7 / −4.0
iq4xs 0.400 / 0.719 / 0.985 / 1.315 0.370 / 0.676 / 0.971 / 1.283 −7.5 / −5.9 / −1.4 / −2.5
iq4nl 0.405 / 0.730 / 0.993 / 1.337 0.382 / 0.679 / 0.969 / 1.281 −5.9 / −7.0 / −2.4 / −4.2
af4g64 (two passes) 0.404 / 0.724 / 1.009 / 1.368 0.404 / 0.724 / 1.006 / 1.369 +0.2 / −0.1 / −0.3 / +0.0
af4g64 6144×2048, draft (two passes) 0.079 / 0.147 / 0.201 / 0.265 0.079 / 0.147 / 0.201 / 0.265 +0.2 / −0.0 / +0.0 / +0.0

decode-profile, 2048-token prompt, ms per cycle at 1 / 2 / 3 / 4 lanes, mean of two ABBA runs, on apple7-m1-1.3.2:

Q4_0 MLX 4-bit
Before 82.19 / 145.41 / 199.63 / 263.67 77.73 / 136.63 / 187.55 / 251.58
After 80.53 / 143.50 / 196.59 / 257.44 77.17 / 136.53 / 187.64 / 252.52
Δ (%) −2.0 / −1.3 / −1.5 / −2.4 −0.7 / −0.1 / +0.0 / +0.4

The Q4_0 4-lane number is flattered by one slow base run (266.8 against 260.6 ms). Against the other one it is −1.2%. MLX doesn't run the new code, and its two base runs differ by up to 0.8%, so I read it as flat. On apple7-m1-1.4: Q4_0 −1.9 / −1.7 / −1.2 / −1.0%, UD-Q4_K_M −1.0 / −0.6 / −1.1 / −0.0%, MLX 4-bit +0.3 / −0.2 / 0.0 / −0.1%.

make test-performance-real (ANE share 0.35, MLX first and last), before and after back to back, decode in tok/s at 1 / 2 / 4 lanes, on apple7-m1-1.3.2. Δ is from the decode times of the two runs. Prefill doesn't change (GGUFs within 1.1%).

Model After (tok/s) Δ (%)
MLX 4-bit, first run 93.8 / 107.2 / 116.2 +0.2 / −0.1 / −0.0
Q4_0 89.6 / 101.2 / 112.4 +2.2 / +1.1 / +0.8
Q8_0 71.9 / 101.3 / 115.2 +0.9 / +1.4 / +1.0
UD-Q4_K_M 84.0 / 98.9 / 112.4 +1.1 / +0.8 / +0.7
UD-Q5_K_M 77.0 / 92.9 / 105.3 +1.2 / +1.0 / −0.3
MLX 4-bit, last run 93.8 / 107.2 / 116.1 +0.3 / +0.2 / −0.0

The UD-Q5_K_M 4-lane number is one run, and I think it is noise, but I didn't rerun it.

Testing

  • gguf-projection: a new check runs the one pass against the two passes, bitwise, for all 42 register formats at 768×2048 (1–4 lanes, S 1–8) and for Q4_K and MLX 4-bit at 17408×5120 (1 and 4 lanes). Passes with and without MTL_SHADER_VALIDATION.
  • linear-plan: Apple7/8 plans one pass for a Q4_K pair and two for a Q4_K/Q6_K pair and an af4g64 pair, and the scratch bound covers the one-pass plan. Passes with and without validation.
  • make check passes on the M2 Max on apple7-m1-gate-up-pass-1.3.2.
  • The kernel A/B, decode-profile and make test-performance-real runs above.

On the Apple7/8 register tile a gate/up pair of one GGUF format now runs as
one dispatch instead of a gate pass and an up pass: grid {N/32, S}, each
simdgroup's fragment nf=0 is 8 gate columns and nf=1 the same 8 up columns,
so SwiGLU is applied in registers. With S splits the last of the S arrivals
on a 32-column tile sums both tensors' partials and applies the gate. The
outputs are the two passes' bit for bit (every register format at 768x2048,
1-4 lanes, S 1-8; Q4_K and MLX 4-bit at 17408x5120).

MLX affine formats (af4g64) keep two passes: with one pass their 17408x5120
gate/up was -7.6 / -5.3 / +2.3 / +4.4% at 1 / 2 / 3 / 4 lanes. The choice is
by format, not by lane count, so a model runs the same plan at every lane
count. In UD files only layers whose gate and up share a format take one
pass (36 of 64 in UD-Q4_K_M). gguf-projection-benchmark gets register_gu
rows for the one-pass plan.

M2 Max, 17408x5120 gate/up at 1 / 2 / 3 / 4 lanes (S4, ABBA): q4k -6.4 /
-3.3 / -3.2 / -3.1%, q5k -7.1 / -5.4 / -5.8 / -2.2%, q6k -6.4 / -0.5 / -3.3 /
-3.6%, q3k -6.8 / -4.0 / -4.0 / -3.8%, q40 -6.1 / -3.6 / -4.0 / -3.2%, q80
-3.6 / -3.3 / -4.7 / -4.0%, iq4xs -7.5 / -5.9 / -1.4 / -2.5%; af4g64 and the
draft's 6144x2048 af4g64 unchanged (within 0.3%).

decode-profile, 27B Q4_0, 2048-token prompt, mean of two ABBA runs, with the
GDN verify and norm loads changes under it: the cycle 82.19 -> 80.53 ms
(-2.0%), 145.41 -> 143.50 (-1.3%), 199.63 -> 196.59 (-1.5%), 263.67 ->
257.44 (-2.4%; one slow base run, -1.2% against the other). MLX 4-bit, which
keeps two passes, is flat within its runs' noise.
Check bit-exactness of one-pass gate/up against two passes across all 42
register formats at 768x2048 (1-4 lanes, S 1-8) and Q4_K and MLX 4-bit at
17408x5120 (1 and 4 lanes). Add linear-plan verification for Apple7/8
oneGateUpPass policy (GGUF formats one-pass, MLX af4g64 two-pass, mixed
formats two-pass) and scratch sizing bounds.
@ghisguth ghisguth changed the title perf: Apple7/8 register-tile (simdgroup MMA) draft attention (-0.7..-1.0% decode cycle) perf: gate and up in one decode pass for GGUF formats on Apple7/8 Oct 24, 2026
@ghisguth

Copy link
Copy Markdown
Author

@paperniuk I finished going through the ideas I had for decode on M1/M2. #25, #26 and #27 are the ones that paid off. Together with the merged #18–#21 this is what changed since 1.3.2-m1.2 on my M2 Max (without #24).

Decode t/s at 1 / 2 / 4 lanes, make test-performance-real, ANE 0.35 (A = 1.3.2-m1.2, B = apple7-m1-1.3.2 now, C = B + #25 + #26 + #27):

Model A B C Δ C/A (%)
MLX 4-bit 86.9 / 103.2 / 111.9 89.2 / 104.2 / 113.2 93.8 / 107.2 / 116.2 +7.9 / +3.9 / +3.8
Q4_0 83.0 / 95.9 / 108.5 84.0 / 97.5 / 108.9 89.6 / 101.2 / 112.4 +8.0 / +5.5 / +3.6
Q8_0 68.7 / 97.4 / 111.6 68.7 / 97.6 / 111.5 71.9 / 101.3 / 115.2 +4.7 / +4.0 / +3.2
UD-Q4_K_M 77.8 / 93.7 / 106.8 79.7 / 95.4 / 108.4 84.0 / 98.9 / 112.4 +8.0 / +5.5 / +5.2
UD-Q5_K_M 70.3 / 85.0 / 97.7 73.2 / 89.6 / 103.7 77.0 / 92.9 / 105.3 +9.5 / +9.3 / +7.8

Prefill is the same in all three. These are from runs on three different days, so take each number as ±0.5%. I am re-running all of it stacked in one session now (A, B, C, then A again) and will post the numbers here when done.

I also tried a few more things that didn't pay off on M2 Max: wider column tile for the register tile, two groups per barrier in the prefill tile, splitting the ANE prefill tail past 32 rows, clamped loads in prefill GDN and norm. Also register-tile draft attention: the kernel is 36–67% faster, but it is only ~1% of the cycle and nothing shows end to end, so I didn't open a PR. If you want it anyway let me know.

@ghisguth

Copy link
Copy Markdown
Author

@paperniuk Ok, the stacked re-run is done. All builds in one session on my M2 Max, display off, the release tag measured at the start and at the end. The two tag runs agree within 0.5%, so the day didn't drift much.

Decode t/s at 1 / 2 / 4 lanes, make test-performance-real, ANE 0.35 (A = 1.3.2-m1.2, mean of both runs, B = apple7-m1-1.3.2, C = B + #25 + #26 + #27):

Model A B C Δ B/A (%) Δ C/B (%) Δ C/A (%)
MLX 4-bit 87.1 / 103.2 / 112.0 89.0 / 104.1 / 113.0 93.2 / 107.0 / 116.1 +2.1 / +0.9 / +0.9 +4.8 / +2.8 / +2.7 +7.0 / +3.7 / +3.6
Q4_0 83.1 / 95.8 / 108.2 84.0 / 97.4 / 109.3 89.3 / 101.2 / 112.8 +1.1 / +1.6 / +1.0 +6.3 / +3.9 / +3.2 +7.4 / +5.6 / +4.2
Q8_0 68.7 / 97.6 / 111.7 68.8 / 97.7 / 111.9 72.0 / 101.2 / 115.8 +0.1 / +0.1 / +0.2 +4.6 / +3.6 / +3.5 +4.8 / +3.7 / +3.7
UD-Q4_K_M 77.9 / 93.8 / 106.6 79.5 / 95.2 / 108.8 83.7 / 99.0 / 112.5 +1.9 / +1.5 / +2.1 +5.3 / +4.0 / +3.4 +7.4 / +5.5 / +5.5
UD-Q5_K_M 70.1 / 84.6 / 97.6 73.1 / 89.8 / 103.7 76.7 / 92.3 / 106.3 +4.2 / +6.1 / +6.2 +4.9 / +2.8 / +2.5 +9.4 / +9.1 / +8.9

Prefill is the same in all of them (60.6 / 18.9 / 8.1 s on MLX). It is close to the numbers from three different days I posted above, MLX at one lane came out a bit lower (+7.0 instead of +7.9).

decode-profile on MLX 4-bit, cycle at one lane, A → C: 82.9 → 77.8 ms (−6.2%) at 2K context, 101.2 → 95.9 ms (−5.2%) at 32K.

I also replayed my agent chat on MLX 4-bit (157 requests, up to 125K context).

Aggregated Metrics (B = apple7-m1-1.3.2 replay from Oct 21, C = the stacked branch). Cycles/s is decode t/s divided by tok/cycle from the server log, so it doesn't depend on how many draft tokens were accepted. The 32k row has only 3–5 requests.

Context Gen T/s: B → C Cycles/s: B → C
32k 44.1 → 48.1 (+9.0%) 10.25 → 11.08 (+8.1%)
64k 39.1 → 39.5 (+1.0%) 9.12 → 9.35 (+2.6%)
96k 36.0 → 37.3 (+3.8%) 8.18 → 8.44 (+3.1%)
128k 36.0 → 36.6 (+1.7%) 7.02 → 7.18 (+2.3%)

All requests was successfull. At long context the gain is smaller since attention takes more of the cycle and these PRs don't touch it.

make check passes on the stacked branch, and the GDN, norm, projection and plan tests pass with and without MTL_SHADER_VALIDATION.

@paperniuk
paperniuk merged commit df5c111 into paperniuk:apple7-m1-1.3.2 Oct 24, 2026
@paperniuk

Copy link
Copy Markdown
Owner

Thanks! Confirmed on M1 Max (32-core GPU). linear-plan and gguf-projection pass.

backend-benchmark --scenario decode on unsloth/Qwen3.8-27B-GGUF:UD-Q5_K_M (40 of 64 layers take one pass), ANE share 0.35, ABBA, 3 samples each, on apple7-m1-1.3.2. Decode in tok/s at 1 / 2 / 3 / 4 lanes; output tokens identical.

Before After Δ (%)
UD-Q5_K_M 60.6 / 72.5 / 83.2 / 85.7 61.1 / 73.5 / 83.8 / 85.7 +0.9 / +1.3 / +0.7 / +0.1

Small on this file, as expected with only part of the layers in one pass, but consistent. Merged.

@ghisguth
ghisguth deleted the apple7-m1-gate-up-pass-1.3.2 branch October 24, 2026 16:09
paperniuk added a commit that referenced this pull request Oct 24, 2026
…port) into the 1.4 port

Conflicts:
- paged_attention.metal, rope.metal: 1.4's own versions, which already
  hold #24 and the precise RoPE tables.
- LinearGguf.cpp, linear_plan_test.mm: #27's one-pass check for the F32
  register tile next to 1.4's 16-row tile check; #27's one-pass scratch
  with LinearPlan::table().
- gguf_projection_test.mm: #27's register one-pass test where the device
  is not Apple9, before 1.4's prefill.
- gguf_projection_benchmark.mm: register_gu rows next to 1.4's staged16
  rows; the staged rows take one pass only from a staged policy.
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.

2 participants