Skip to content

perf: 5-bit nibble operands in the Apple7/8 register decode tile - #18

Merged
paperniuk merged 1 commit into
paperniuk:apple7-m1-1.3.2from
ghisguth:apple7-m1-q5-nibble
Oct 21, 2026
Merged

paperniuk merged 1 commit into
paperniuk:apple7-m1-1.3.2from
ghisguth:apple7-m1-q5-nibble

Conversation

@ghisguth

@ghisguth ghisguth commented Oct 21, 2026 •

Copy link
Copy Markdown

After PR #15, Q4_0 decodes within 3–7% of MLX 4-bit on my M2 Max, but the Unsloth UD quants were still 10–20% behind.
I ran explored most of the quants in Qwen3.8-27B GGUF. Q5_K is the largest format in the UD-Q4/Q5 files: 29% of
UD-Q4_K_M's bytes and 45% of UD-Q4_K_XL's and UD-Q5_K_M's. It was also the costliest per byte in the Apple7/8
register decode tile. This change gives the 5-bit formats the exact nibble operand that PR #15 gave the 4-bit ones. As
before, I used Claude Code to author it and tested it 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.

  • 5-bit codes in Q5_K's planes enter the MMA as the exact half 1 + code/64. That covers Q5_K, Q5_1, and Q5_0 viewed
    with its zero point as a min. The nibble goes to mantissa bits 4–7 and the fifth bit to bit 8, so their per-32 chains
    close with the same epilogue as the 4-bit nibble formats.
  • Before, they went through operand_f32: the fifth bit was shifted below the nibble, | 0x6400, then a half2
    subtract.
  • MLX affine 5-bit in groups of 32 was tried as well and left out: it was 1.3% slower at four lanes in two runs.
  • No test changes. gguf-projection already runs every format through the F32 instances: natively on Apple7/8, and on
    Apple9+ through PR perf: zero points as mins and nibble operands in the Apple7/8 register decode tile #15's pass.

Results (M2 Max, 38-core GPU)

Kernel time, gguf-projection-benchmark, at 1 / 2 / 3 / 4 lanes, ABBA order; Δ: − is faster. Two runs of the same build agree within
0.4%.

Format, shape Before (ms) After (ms) Δ (%)
q5k 17408×5120 gate/up 0.544 / 0.924 / 1.208 / 1.640 0.520 / 0.905 / 1.196 / 1.611 −4.3 / −2.1 / −1.0 / −1.7
q5k 5120×17408 down +res 0.288 / 0.488 / 0.628 / 0.853 0.273 / 0.480 / 0.625 / 0.836 −5.1 / −1.7 / −0.6 / −2.0
q5k 5120×6144 out +res 0.109 / 0.200 / 0.259 / 0.350 0.105 / 0.198 / 0.257 / 0.342 −4.1 / −1.0 / −0.8 / −2.5
q50 17408×5120 gate/up 0.550 / 0.931 / 1.206 / 1.646 0.523 / 0.918 / 1.205 / 1.645 −5.0 / −1.5 / −0.1 / −0.1
q51 17408×5120 gate/up 0.556 / 0.955 / 1.228 / 1.690 0.534 / 0.945 / 1.229 / 1.694 −3.9 / −1.1 / +0.1 / +0.2
q4k (unchanged code) 0.433 / 0.773 / 1.092 / 1.416 0.433 / 0.773 / 1.091 / 1.413 +0.0 / −0.0 / −0.1 / −0.2
af4g64 (unchanged code) 0.414 / 0.732 / 1.015 / 1.392 0.414 / 0.733 / 1.015 / 1.387 −0.1 / +0.1 / −0.0 / −0.4

All models on the M2 Max, after the change

make test-performance-real, Qwen3.8-27B, ANE share 0.35. Times are in seconds; decode is in tok/s at 1 / 2 / 4
lanes. The % against MLX 4-bit is in brackets (time: + is slower; decode: − is slower). The two MLX 4-bit runs, before
and after the GGUF runs, agree within 0.1% on each build.

Model (size) 14K cold 10K hit + 4K 2K Decode 1 Decode 2 Decode 4
mlx-community 4-bit (16.1 GB) 60.73 18.91 8.07 86.9 103.1 111.9
Q4_0 (16.1 GB) 60.85 (+0.2%) 18.98 (+0.3%) 8.10 (+0.4%) 83.0 (−4.4%) 95.8 (−7.1%) 108.9 (−2.6%)
Q4_1 (17.5 GB) 61.19 (+0.8%) 19.07 (+0.8%) 8.15 (+1.0%) 82.4 (−5.1%) 95.1 (−7.7%) 107.8 (−3.7%)
Q8_0 (29.0 GB) 60.40 (−0.5%) 18.84 (−0.4%) 8.03 (−0.4%) 68.6 (−21.0%) 97.6 (−5.4%) 111.7 (−0.1%)
UD-IQ4_XS (14.2 GB) 61.85 (+1.8%) 19.28 (+1.9%) 8.25 (+2.2%) 77.5 (−10.7%) 95.6 (−7.3%) 108.9 (−2.7%)
UD-Q4_K_M (16.5 GB) 62.92 (+3.6%) 19.58 (+3.5%) 8.41 (+4.2%) 78.9 (−9.2%) 94.2 (−8.7%) 106.6 (−4.7%)
UD-Q4_K_XL (17.6 GB) 64.10 (+5.5%) 19.93 (+5.4%) 8.58 (+6.3%) 76.0 (−12.5%) 90.8 (−11.9%) 104.5 (−6.6%)
UD-Q5_K_M (19.8 GB) 65.86 (+8.4%) 20.44 (+8.1%) 8.83 (+9.5%) 71.5 (−17.6%) 85.7 (−16.8%) 98.7 (−11.8%)
UD-Q5_K_XL (20.9 GB) 66.04 (+8.7%) 20.49 (+8.3%) 8.86 (+9.8%) 70.5 (−18.8%) 86.3 (−16.3%) 99.2 (−11.3%)
UD-Q6_K_M (23.1 GB) 65.33 (+7.6%) 20.29 (+7.3%) 8.76 (+8.5%) 69.3 (−20.2%) 86.3 (−16.3%) 98.5 (−11.9%)
UD-Q6_K_XL (25.3 GB) 63.59 (+4.7%) 19.78 (+4.6%) 8.50 (+5.4%) 68.5 (−21.1%) 89.7 (−13.0%) 102.4 (−8.5%)
UD-Q8_K_XL (31.5 GB) 60.67 (−0.1%) 18.93 (+0.1%) 8.08 (+0.1%) 63.0 (−27.5%) 94.0 (−8.9%) 108.5 (−3.0%)

Decode before and after

The same benchmark on apple7-m1-1.3.2, run right after the branch on the same machine, in tok/s at 1 / 2 / 4 lanes.
The Q5_K share is of each file's tensor bytes.

Model Q5_K share Before (tok/s) After (tok/s) Δ (%)
UD-Q4_K_XL 45.2% 74.4 / 90.2 / 102.5 76.0 / 90.8 / 104.5 +2.2 / +0.7 / +1.9
UD-Q5_K_M 44.8% 70.3 / 85.0 / 97.7 71.5 / 85.7 / 98.7 +1.8 / +0.8 / +1.0
UD-Q5_K_XL 39.3% 69.3 / 85.6 / 98.2 70.5 / 86.3 / 99.2 +1.8 / +0.9 / +1.0
UD-Q4_K_M 29.4% 77.8 / 93.7 / 106.8 78.9 / 94.2 / 106.6 +1.4 / +0.5 / −0.1
UD-IQ4_XS 14.7% 77.0 / 94.9 / 108.5 77.5 / 95.6 / 108.9 +0.7 / +0.7 / +0.4
UD-Q6_K_M 14.2% 68.6 / 86.0 / 98.4 69.3 / 86.3 / 98.5 +1.1 / +0.3 / +0.1
Q4_0 6.5% 83.0 / 95.9 / 108.5 83.0 / 95.8 / 108.9 +0.1 / −0.0 / +0.4
Q4_1 5.9% 82.0 / 94.9 / 106.7 82.4 / 95.1 / 107.8 +0.6 / +0.2 / +1.0
UD-Q6_K_XL 4.2% 68.4 / 89.4 / 102.4 68.5 / 89.7 / 102.4 +0.2 / +0.3 / +0.0
Q8_0 0% 68.7 / 97.4 / 111.6 68.6 / 97.6 / 111.7 −0.1 / +0.2 / +0.1
UD-Q8_K_XL 0% 63.2 / 93.9 / 109.2 63.0 / 94.0 / 108.5 −0.3 / +0.0 / −0.6
MLX 4-bit — 86.9 / 103.2 / 111.9 86.9 / 103.1 / 111.9 −0.0 / −0.1 / −0.0
  • The gain follows the Q5_K share. The files with 39–45% Q5_K decode 1.8–2.2% faster at one lane and 0.7–1.9% at
    two and four lanes. UD-Q4_K_M, at 29%, is 1.4% faster at one lane. A first two-model run gave it +1.5 / +1.7 /
    −0.1%, so the two-lane gain is between +0.5% and +1.7%.
  • The one-lane gain is about what the kernel predicts. Decode at one lane is mostly bound by the weights: the q5k
    kernel is 4–5% faster at one lane on all three of its shapes, and Q5_K is 30–45% of those files' bytes.
  • Q4_0 and Q4_1 are not pure 4-bit files. Unsloth keeps two tensors at more bits: output in Q6_K and ssm_out
    (the out projection of the 48 GDN layers) in Q5_K, 1.04 GB each. Q5_K is 6–6.5% of their bytes, so they pick up a
    small share of the gain: up to +0.6% at one lane and +1.0% (Q4_1) at four.
  • Prefill doesn't change (14K cold within ±0.2% on every model). The change is decode-only, and prefill runs the
    staged MMA tile.
  • The controls don't change. MLX 4-bit, Q8_0 and UD-Q8_K_XL have no Q5_K. Their decode stays within 0.3%, except
    UD-Q8_K_XL at four lanes (−0.6%). Its code path didn't change, and earlier runs of the same build put it at
    108.8–109.1 tok/s, so that's noise.
  • What's left: the UD-Q5/Q6 files are still 16–21% behind MLX 4-bit. They are 42–60% Q6_K, which this change
    doesn't touch. Q6_K is also every file's output layer, except the 8-bit ones.

Next

Follow-up PRs, opened at the same time so they can be tested and merged in order:

Testing

  • make check passes on the M2 Max.
  • gguf-projection passes on the M2 Max (all 43 formats, F32 instances natively).
  • make test-performance-real on every model above, before and after.

The F32 register decode tile ran Q5_K, Q5_1 and Q5_0 (as ZeroAsMin) through
operand_f32: the fifth bit shifted below the nibble, or-ed into half 1024 and
the offset subtracted, about two more operations per pair than the 4-bit
formats' nibble_operand. Q5_K is the largest format of the Unsloth UD-Q4 and
UD-Q5 files (29% of UD-Q4_K_M's bytes, 45% of UD-Q4_K_XL's and UD-Q5_K_M's)
and was the costliest per byte in the tile.

Their F32 chains per 32 inputs now take nibble_operand with the fifth bit at
bit 8 of each halfword, 1 + code / 64, exact for codes to 31, and close with
NibbleChunk's epilogue as the 4-bit formats do. MLX affine 5-bit in groups of
32 stays on operand_f32: it took 1.3% more at four lanes. The Apple9 instances
are unchanged.

M2 Max, ms at one to four lanes:
  q5k 17408x5120 gate/up   0.544 0.924 1.208 1.640 -> 0.520 0.905 1.196 1.611
  q5k 5120x17408 down      0.288 0.488 0.628 0.853 -> 0.273 0.480 0.625 0.836
  q5k 5120x6144 out        0.109 0.200 0.259 0.350 -> 0.105 0.198 0.257 0.342
  q50 17408x5120 gate/up   0.550 0.931 1.206 1.646 -> 0.523 0.918 1.205 1.645
  q51 17408x5120 gate/up   0.556 0.955 1.228 1.690 -> 0.534 0.945 1.229 1.694
  q4k, af4g64 (unchanged code) within 0.4%
27B decode at one, two and four lanes, tok/s:
  UD-Q4_K_XL  74.4 90.2 102.5 -> 76.0 90.8 104.5
  UD-Q5_K_M   70.3 85.0  97.7 -> 71.5 85.7  98.7
  UD-Q5_K_XL  69.3 85.6  98.2 -> 70.5 86.3  99.2
  UD-Q4_K_M   77.8 93.7 106.8 -> 78.9 94.2 106.6
Q4_0, Q4_1 (their ssm_out is Q5_K), UD-IQ4_XS and UD-Q6_K_M gain up to 1.1%;
prefill and the MLX 4-bit, Q8_0, UD-Q6_K_XL and UD-Q8_K_XL decodes are within
0.6%.
@ghisguth

Copy link
Copy Markdown
Author

@paperniuk Heads-up: I have a follow-up for Q6_K, the same idea applied to the 6-bit format. The code goes into the half as (code − 32)/64, so Q6_K’s per-16 chains no longer need the table’s seeds. Q6_K is 42–60% of the UD-Q5/Q6 files and the output layer of every 4- to 6-bit GGUF. Kernel results so far on the M2 Max:

Format, shape Before (ms) After (ms) Δ (%)
q6k 17408×5120 gate/up 0.549 / 0.965 / 1.258 / 1.717 0.533 / 0.862 / 1.159 / 1.530 −2.9 / −10.6 / −7.8 / −10.9
q6k 5120×17408 down +res 0.289 / 0.513 / 0.659 / 0.894 0.280 / 0.456 / 0.609 / 0.810 −3.2 / −11.1 / −7.6 / −9.4
q6k 5120×6144 out +res 0.112 / 0.211 / 0.271 / 0.370 0.109 / 0.172 / 0.255 / 0.335 −2.6 / −18.5 / −6.0 / −9.4
q6k 248320×5120 output 3.471 / 6.066 / 8.170 / 10.965 3.335 / 5.621 / 7.518 / 9.572 −3.9 / −7.3 / −8.0 / −12.7
q5k (unchanged code) 0.519 / 0.905 / 1.194 / 1.604 0.520 / 0.906 / 1.192 / 1.604 +0.1 / +0.1 / −0.2 / −0.0
q3k (unchanged code) 0.586 / 1.005 / 1.299 / 1.766 0.586 / 1.006 / 1.299 / 1.768 −0.0 / +0.1 / +0.0 / +0.1
af4g64 (unchanged code) 0.416 / 0.733 / 1.011 / 1.381 0.416 / 0.733 / 1.011 / 1.382 −0.1 / +0.1 / +0.1 / +0.1

It’s stacked on this PR. I’ll open it once the end-to-end runs and make check are done. Most likely tomorrow morning.

@paperniuk

Copy link
Copy Markdown
Owner

Merged, thanks! I ran the whole stack (#18 to #21) on M1 Max (Apple7, 32 cores). gguf-projection passes natively, with and without MTL_SHADER_VALIDATION. Q5_K got faster here too: 2560x6144 by 1.7/3.5/0.2/1.6% at 1/2/3/4 lanes, and the 248320x2560 output by 5.0/3.0/3.0/4.4%. Numbers for the rest are coming in #21.

@ghisguth
ghisguth deleted the apple7-m1-q5-nibble branch October 21, 2026 17:16
airiclenz pushed a commit to airiclenz/splash that referenced this pull request Oct 29, 2026
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