Skip to content

perf: Q3_K's zero point in its operands, and epilogue factors once per coefficient, in the Apple7/8 register decode tile - #21

Merged
paperniuk merged 2 commits into
paperniuk:apple7-m1-1.3.2from
ghisguth:apple7-m1-q3k-epilogue
Oct 22, 2026
Merged

paperniuk merged 2 commits into
paperniuk:apple7-m1-1.3.2from
ghisguth:apple7-m1-q3k-epilogue

Conversation

@ghisguth

@ghisguth ghisguth commented Oct 21, 2026 •

Copy link
Copy Markdown

Two small follow-ups to the 5-bit (#18) and Q6_K PRs (#19), in the same Apple7/8 F32 register decode tile, as two commits. As
before, I used Claude Code to author them and tested them by hand.

What changed

Only the Apple7/8 F32 instances of the register decode tile (linear_gguf_sgmatrix.metal) change. Apple9 and host
code are untouched.

  1. Q3_K's zero point in its operands. This is the Q6_K scheme. The 3-bit code enters the MMA as the exact half
    (code − 4)/8: the two low bits go to bits 7–8 and the hmask bit to bit 9, which gives 1 + code/8, less 1.5. The
    per-16 chains start from zero, read no seed and close with one fma. CenteredChunk now holds each centered
    format's operand and scale.
  2. Epilogue factors once per coefficient. The per-32 nibble chains (Q4_K, Q4_1, Q4_0, Q5_K, Q5_1, Q5_0, MLX affine
    4-bit in groups of 32) closed every chain with fma(dot, 64 s) and fma(sum, 64 s + (m − 128 s)), recomputing the
    two factors per chain. They're now computed once when the coefficient is decoded, with the same rounding. The
    centered chains (Q6_K, Q3_K) likewise hold Scale·s. MLX 4-bit's span chains keep their epilogue: precomputing there
    was 0.3–0.4% slower at two and four lanes.
  • No test changes. gguf-projection already runs every format through the F32 instances.

Results (M2 Max, 38-core GPU)

Kernel time, gguf-projection-benchmark, at 1 / 2 / 3 / 4 lanes, ABBA order; Δ: − is faster. Each commit is measured
against the one before it: the (1) rows compare #19 without and with the Q3_K commit, and the (2) rows compare the
Q3_K commit without and with the epilogue commit.

Change, format, shape Before (ms) After (ms) Δ (%)
(1) q3k 17408×5120 gate/up 0.586 / 1.005 / 1.300 / 1.767 0.575 / 0.975 / 1.218 / 1.595 −2.0 / −3.0 / −6.3 / −9.7
(1) q3k 5120×17408 down +res 0.308 / 0.529 / 0.677 / 0.920 0.300 / 0.515 / 0.630 / 0.842 −2.4 / −2.5 / −6.9 / −8.5
(2) q4k 17408×5120 gate/up 0.434 / 0.771 / 1.089 / 1.404 0.429 / 0.758 / 1.067 / 1.408 −1.1 / −1.8 / −2.0 / +0.3
(2) q4k 5120×17408 down +res 0.228 / 0.411 / 0.577 / 0.729 0.225 / 0.401 / 0.564 / 0.732 −1.3 / −2.4 / −2.2 / +0.3
(2) q5k 17408×5120 gate/up 0.520 / 0.907 / 1.193 / 1.606 0.517 / 0.897 / 1.180 / 1.581 −0.7 / −1.1 / −1.1 / −1.6
(2) q5k 5120×17408 down +res 0.275 / 0.478 / 0.622 / 0.835 0.273 / 0.471 / 0.615 / 0.831 −0.4 / −1.5 / −1.3 / −0.5
(2) q40 17408×5120 gate/up 0.423 / 0.767 / 1.103 / 1.416 0.420 / 0.756 / 1.079 / 1.421 −0.8 / −1.5 / −2.2 / +0.3
(2) q6k 17408×5120 gate/up 0.533 / 0.863 / 1.164 / 1.531 0.531 / 0.862 / 1.151 / 1.530 −0.4 / −0.2 / −1.1 / −0.1
(2) af4g64 (unchanged code) 0.404 / 0.725 / 1.005 / 1.361 0.404 / 0.725 / 1.005 / 1.362 ±0.1

Q4_K and Q4_0 take 0.3% more at four lanes, consistently across two ABBA passes. One to three lanes gain 1–2%, so I
kept it for them.

End to end (M2 Max), before and after

make test-performance-real, ANE share 0.35, decode in tok/s at 1 / 2 / 4 lanes; Δ: + is faster. Before is the Q6_K
branch, or for UD-Q4_K_M the Q6_K branch plus the MLX PR and the Q3_K commit. After is this branch. Prefill is
unchanged (14K cold within 0.02 s on every model). MLX 4-bit runs before and after the GGUF runs agree within 0.3%.

Model Before After Δ (%)
UD-Q4_K_XL 76.5 / 92.0 / 106.0 77.1 / 92.9 / 106.7 +0.8 / +1.0 / +0.7
Q4_0 83.2 / 95.8 / 109.9 83.9 / 97.3 / 109.3 +0.8 / +1.6 / −0.5
UD-Q5_K_M 72.5 / 89.6 / 103.0 72.7 / 90.0 / 103.5 +0.3 / +0.4 / +0.5
UD-Q4_K_M 79.6 / 95.3 / 109.4 79.5 / 95.6 / 108.7 −0.1 / +0.3 / −0.6
MLX 4-bit (has the MLX PR either way) 89.2 / 104.1 / 113.1 88.9 / 104.0 / 113.0 −0.3 / −0.1 / −0.1

These are small, and UD-Q4_K_M's are within run-to-run noise. The gains show mostly where Q4_0 or Q5_K dominate. Q3_K
alone, for UD-IQ4_XS (5.6% Q3_K), measured +0.1 / +0.1 / +0.4%.

All four PRs together, against apple7-m1-1.3.2

The same benchmark on every model, decode in tok/s at 1 / 2 / 4 lanes (Δ: + is faster); prefill is unchanged:

Model Before (tok/s) After (tok/s) Δ (%) 14K cold before → after (s)
MLX 4-bit 86.9 / 103.2 / 111.9 88.9 / 103.9 / 113.0 +2.3 / +0.7 / +1.0 60.64 → 60.64
Q4_0 83.0 / 95.9 / 108.5 83.8 / 97.2 / 108.8 +1.1 / +1.4 / +0.3 60.85 → 60.85
Q4_1 82.0 / 94.9 / 106.7 83.5 / 96.9 / 108.3 +1.9 / +2.1 / +1.4 61.19 → 61.19
Q8_0 68.7 / 97.4 / 111.6 68.7 / 97.6 / 111.5 +0.0 / +0.2 / −0.1 60.41 → 60.40
UD-IQ4_XS 77.0 / 94.9 / 108.5 77.6 / 96.1 / 109.8 +0.8 / +1.2 / +1.2 61.85 → 61.85
UD-Q4_K_M 77.8 / 93.7 / 106.8 79.6 / 96.0 / 108.4 +2.3 / +2.5 / +1.6 62.94 → 62.92
UD-Q4_K_XL 74.4 / 90.2 / 102.5 76.8 / 93.1 / 106.5 +3.2 / +3.2 / +3.9 64.10 → 64.10
UD-Q5_K_M 70.3 / 85.0 / 97.7 72.8 / 89.8 / 103.1 +3.5 / +5.7 / +5.6 65.86 → 65.86
UD-Q5_K_XL 69.3 / 85.6 / 98.2 71.9 / 90.2 / 103.6 +3.8 / +5.4 / +5.5 66.04 → 66.04
UD-Q6_K_M 68.6 / 86.0 / 98.4 70.4 / 91.2 / 104.8 +2.7 / +6.1 / +6.5 65.32 → 65.33
UD-Q6_K_XL 68.4 / 89.4 / 102.4 69.6 / 92.8 / 106.8 +1.7 / +3.9 / +4.3 63.58 → 63.58
UD-Q8_K_XL 63.2 / 93.9 / 109.2 63.2 / 94.1 / 109.1 +0.0 / +0.1 / −0.2 60.68 → 60.66

Testing

  • gguf-projection passes on the M2 Max after each commit (all 43 formats, F32 instances natively).
  • make check passes on the M2 Max.

@paperniuk

Copy link
Copy Markdown
Owner

#18 to #20 are merged, thanks! #21 now conflicts because it carries its own copy of the #20 commit. Could you rebase it onto apple7-m1-1.3.2? Only the Q3_K and epilogue commits should be left.

@ghisguth

Copy link
Copy Markdown
Author

#18 to #20 are merged, thanks! #21 now conflicts because it carries its own copy of the #20 commit. Could you rebase it onto apple7-m1-1.3.2? Only the Q3_K and epilogue commits should be left.

Will do in an hour or so. Thank you!

…de tile

The F32 register decode tile ran Q3_K through operand_f32, each 16-input
chain seeded from the table, as Q6_K was. Its 3-bit codes now enter the MMA
as the exact half (code - 4) / 8: the two low bits at bits 7-8 and the hmask
bit at 9, 1 + code / 8, less 1.5. The chains start from zero, read no seed
and close with fma(dot, 8 s). CenteredChunk now holds each centered format's
operand and scale. The Apple9 instances are unchanged.

M2 Max, ms at one to four lanes:
  q3k 17408x5120 gate/up  0.586 1.005 1.300 1.767 -> 0.575 0.975 1.218 1.595
  q3k 5120x17408 down     0.308 0.529 0.677 0.920 -> 0.300 0.515 0.630 0.842
  q6k (unchanged)         within 0.2%
Q3_K is 5.6% of UD-IQ4_XS's bytes and 0.7-1.6% of the UD-Q4 files'.
…nt on Apple7/8

The F32 per-32 nibble chains (Q4_K, Q4_1, Q4_0 and Q5_0 as ZeroAsMin, Q5_K,
Q5_1, MLX affine 4-bit in groups of 32) closed each chain with
fma(dot, 64 s) and fma(sum, 64 s + (m - 128 s)), two operations per chain
and column fragment that every chain of a coefficient repeated. The
coefficients of those formats now hold 64 s and m - 64 s, computed once when
they are decoded, with the same rounding, and the centered chains' (Q6_K,
Q3_K) hold Scale s rather than multiplying each chain's s. The span chains (MLX affine in
groups of 64 or 128) and the Apple9 instances are unchanged.

M2 Max, ms at one to four lanes, two ABBA passes:
  q4k 17408x5120 gate/up  0.434 0.771 1.089 1.404 -> 0.429 0.758 1.067 1.408
  q4k 5120x17408 down     0.228 0.411 0.577 0.729 -> 0.225 0.401 0.564 0.732
  q5k 17408x5120 gate/up  0.520 0.907 1.193 1.606 -> 0.517 0.897 1.180 1.581
  q5k 5120x17408 down     0.275 0.478 0.622 0.835 -> 0.273 0.471 0.615 0.831
  q40 17408x5120 gate/up  0.423 0.767 1.103 1.416 -> 0.420 0.756 1.079 1.421
  q6k 17408x5120 gate/up  0.533 0.863 1.164 1.531 -> 0.531 0.862 1.151 1.530
  q6k 5120x17408 down     0.280 0.457 0.608 0.808 -> 0.278 0.457 0.605 0.803
  q3k 17408x5120 gate/up  0.574 0.975 1.220 1.593 -> 0.571 0.963 1.223 1.588
  af4g64 (unchanged)      within 0.1%
@ghisguth
ghisguth force-pushed the apple7-m1-q3k-epilogue branch from d81a227 to 86a2c7f Compare October 21, 2026 19:30
@ghisguth

Copy link
Copy Markdown
Author

@paperniuk Done, rebased onto apple7-m1-1.3.2. Only the Q3_K (463b6ee) and epilogue (86a2c7f) commits are left. The resulting code is identical to what I tested before (make check, gguf-projection and the all-model runs in the description), so those numbers still apply. Thanks for merging #18–#20!

@paperniuk
paperniuk merged commit 58147d9 into paperniuk:apple7-m1-1.3.2 Oct 22, 2026
5 checks passed
@paperniuk

Copy link
Copy Markdown
Owner

Merged, thanks for the rebase! Here are the M1 Max numbers I promised in #18.

End to end on unsloth/Qwen3.8-27B-GGUF:UD-Q5_K_M, backend-benchmark --scenario decode, ANE share 0.35, ABBA order, 3 samples each. Before is apple7-m1-1.3.2 without #18 to #21, after is all four together. Decode in tok/s at 1 / 2 / 3 / 4 lanes; Δ: + is faster. Output tokens are identical, and 2K prefill is unchanged (11.5 s).

Model Before (tok/s) After (tok/s) Δ (%)
UD-Q5_K_M 58.7 / 69.6 / 79.9 / 80.8 61.3 / 73.4 / 83.8 / 85.9 +4.5 / +5.4 / +4.8 / +6.3

Kernel time on M1 Max (32-core GPU), gguf-projection-benchmark, median of 20 rounds at 1 / 2 / 3 / 4 lanes, one pass each; Δ: − is faster.

Format, shape Before (ms) After (ms) Δ (%)
q3k 10240×2560 0.119 / 0.203 / 0.260 / 0.346 0.117 / 0.197 / 0.246 / 0.313 −1.8 / −3.2 / −5.3 / −9.4
q6k 2560×6144 0.073 / 0.149 / 0.184 / 0.224 0.071 / 0.141 / 0.166 / 0.205 −2.6 / −5.5 / −9.4 / −8.8
q5k 2560×6144 0.071 / 0.140 / 0.165 / 0.210 0.070 / 0.135 / 0.165 / 0.207 −1.7 / −3.5 / −0.2 / −1.6
q5k 248320×2560 2.201 / 3.747 / 4.996 / 6.566 2.090 / 3.633 / 4.847 / 6.280 −5.0 / −3.0 / −3.0 / −4.4
q4k 6144×2560 0.057 / 0.108 / 0.148 / 0.182 0.057 / 0.107 / 0.142 / 0.183 −0.2 / −0.5 / −4.4 / +0.5
af4g64 10240×2560 0.085 / 0.151 / 0.206 / 0.275 0.083 / 0.144 / 0.203 / 0.271 −1.9 / −5.0 / −1.3 / −1.2
iq4xs 10240×2560 (control) 0.085 / 0.147 / 0.195 / 0.263 0.085 / 0.146 / 0.195 / 0.262 +0.2 / −0.8 / −0.1 / −0.4

gguf-projection passes natively on Apple7, with and without MTL_SHADER_VALIDATION.

paperniuk added a commit that referenced this pull request Oct 22, 2026
… 1.4 port

# Conflicts:
#	dev/benchmarks/attention_sweep.mm
@ghisguth
ghisguth deleted the apple7-m1-q3k-epilogue branch October 22, 2026 11:43
@ghisguth

Copy link
Copy Markdown
Author

@paperniuk Thank you for merging and for running it on M1 Max! It's nice to see it holds there too, even a bit better than on my M2 Max at 1 lane.

I have two more PRs for decode on M1/M2 coming, both bit exact. One is for GDN verify kernel (−35% kernel time), another one for the decode norm (−60%). Here are preliminary numbers from my M2 Max, decode tok/s at 1 / 2 / 4 lanes, on apple7-m1-1.4 (A = apple7-m1-1.4, B = A + GDN verify, C = A + decode norm):

Model Dec (A) Dec (B) Dec (C) Δ B/A Δ C/A
mlx-community 4-bit 88.7 / 104.1 / 113.1 90.8 / 105.8 / 115.1 91.2 / 105.8 / 114.0 +2.4 / +1.6 / +1.8% +2.8 / +1.6 / +0.8%
UD-Q4_K_M 79.4 / 95.4 / 108.9 81.0 / 97.2 / 111.1 81.0 / 96.8 / 109.3 +2.0 / +1.9 / +2.0% +2.0 / +1.5 / +0.4%

Prefill is the same. I didn't measure both together yet. Will send PRs to 1.3.2 once I finish end to end runs there with more quants.

Also, while testing on apple7-m1-1.4, I saw some tests failing on base too: q8_chunked_prefill_metal_test.mm does not build (#24 has the fix), and on my M2 Max gguf-moe, qwen4-kernels, paged-attention-plan and metal-backend fail with base metallib. Not sure if it's M2 specific, just letting you know.

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