Skip to content

perf: Q6_K's zero point in the operands of the Apple7/8 register decode tile - #19

Merged
paperniuk merged 2 commits into
paperniuk:apple7-m1-1.3.2from
ghisguth:apple7-m1-q6k-operand
Oct 21, 2026
Merged

paperniuk merged 2 commits into
paperniuk:apple7-m1-1.3.2from
ghisguth:apple7-m1-q6k-operand

Conversation

@ghisguth

@ghisguth ghisguth commented Oct 21, 2026 •

Copy link
Copy Markdown

This follows the 5-bit nibble operand PR (#18). Q6_K is the other large format in the Unsloth UD files: 43–60% of
UD-Q5_K_M's, UD-Q5_K_XL's and UD-Q6_K_M's bytes. It's also the output layer of every 4- to 6-bit Qwen3.8-27B GGUF,
1.04 GB read for every generated token. 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.

  • Q6_K's 6-bit codes enter the MMA as the exact half (code − 32)/64. The nibble goes to mantissa bits 4–7 and the two
    high bits to bits 8–9, which gives 1 + code/64; subtracting 1.5 then leaves (code − 32)/64.
  • The zero point is therefore inside the operand, so Q6_K's per-16 chains start from zero instead of the table's seed
    (−160 × the sum of 16 inputs). That saves a seed load per chain, and the epilogue is a single fma(dot, 64 s).
  • Before, Q6_K went through operand_f32 (128 + code), with each chain seeded from the table.
  • 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. Before is the 5-bit
nibble operand branch.

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

Before this change, the staged tile beat the register tile, which the policy picks, for Q6_K at two and four lanes.
On the 17408×5120 gate/up, the best staged split took 0.881 ms at two lanes and 1.599 ms at four, against the register
tile's 0.965 and 1.717. Now the register tile takes 0.862 and 1.530 ms, so it's the fastest at every lane count and no
policy change is needed.

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).

Model (size) 14K cold 10K hit + 4K 2K Decode 1 Decode 2 Decode 4
mlx-community 4-bit (16.1 GB) 60.64 18.91 8.07 86.9 103.0 112.0
Q4_0 (16.1 GB) 60.85 (+0.4%) 18.98 (+0.3%) 8.10 (+0.4%) 83.2 (−4.3%) 95.8 (−7.0%) 109.9 (−1.8%)
Q4_1 (17.5 GB) 61.19 (+0.9%) 19.07 (+0.8%) 8.15 (+1.0%) 82.7 (−4.9%) 95.7 (−7.1%) 108.3 (−3.2%)
Q8_0 (29.0 GB) 60.39 (−0.4%) 18.84 (−0.4%) 8.03 (−0.5%) 68.8 (−20.9%) 97.6 (−5.2%) 111.7 (−0.3%)
UD-IQ4_XS (14.2 GB) 61.84 (+2.0%) 19.27 (+1.9%) 8.25 (+2.2%) 77.7 (−10.6%) 95.2 (−7.6%) 109.3 (−2.4%)
UD-Q4_K_M (16.5 GB) 62.91 (+3.7%) 19.58 (+3.5%) 8.41 (+4.1%) 79.2 (−8.9%) 94.4 (−8.4%) 109.0 (−2.7%)
UD-Q4_K_XL (17.6 GB) 64.10 (+5.7%) 19.93 (+5.4%) 8.58 (+6.3%) 76.5 (−12.0%) 92.0 (−10.7%) 106.0 (−5.4%)
UD-Q5_K_M (19.8 GB) 65.86 (+8.6%) 20.44 (+8.1%) 8.83 (+9.4%) 72.5 (−16.6%) 89.6 (−13.1%) 103.0 (−8.0%)
UD-Q5_K_XL (20.9 GB) 66.04 (+8.9%) 20.49 (+8.3%) 8.86 (+9.8%) 71.4 (−17.9%) 89.5 (−13.1%) 102.7 (−8.2%)
UD-Q6_K_M (23.1 GB) 65.33 (+7.7%) 20.28 (+7.2%) 8.76 (+8.5%) 70.3 (−19.2%) 90.8 (−11.8%) 104.1 (−7.0%)
UD-Q6_K_XL (25.3 GB) 63.57 (+4.8%) 19.77 (+4.5%) 8.50 (+5.3%) 69.6 (−20.0%) 93.1 (−9.6%) 106.9 (−4.6%)
UD-Q8_K_XL (31.5 GB) 60.65 (+0.0%) 18.92 (+0.0%) 8.08 (+0.1%) 63.1 (−27.4%) 94.0 (−8.8%) 109.0 (−2.6%)

Q8_0 was rerun on its own: the first all-model run caught a system stall in two of its three 14K cold samples.

Decode before and after

The same benchmark on the 5-bit nibble operand branch, run earlier the same evening, in tok/s at 1 / 2 / 4 lanes;
Δ: + is faster. The MLX 4-bit runs of the two builds agree within 0.1%. The Q6_K share is of each file's tensor bytes.

Model Q6_K share Before (tok/s) After (tok/s) Δ (%)
UD-Q6_K_M 59.9% 69.3 / 86.3 / 98.5 70.3 / 90.8 / 104.1 +1.4 / +5.3 / +5.7
UD-Q5_K_XL 48.1% 70.5 / 86.3 / 99.2 71.4 / 89.5 / 102.7 +1.2 / +3.7 / +3.6
UD-Q5_K_M 43.2% 71.5 / 85.7 / 98.7 72.5 / 89.6 / 103.0 +1.4 / +4.4 / +4.4
UD-Q6_K_XL 42.2% 68.5 / 89.7 / 102.4 69.6 / 93.1 / 106.9 +1.5 / +3.8 / +4.4
UD-Q4_K_XL 16.3% 76.0 / 90.8 / 104.5 76.5 / 92.0 / 106.0 +0.6 / +1.3 / +1.4
UD-Q4_K_M 11.0% 78.9 / 94.2 / 106.6 79.2 / 94.4 / 109.0 +0.4 / +0.2 / +2.2
Q4_0 6.5% (output) 83.0 / 95.8 / 108.9 83.2 / 95.8 / 109.9 +0.2 / −0.0 / +0.9
Q4_1 5.9% (output) 82.4 / 95.1 / 107.8 82.7 / 95.7 / 108.3 +0.3 / +0.6 / +0.5
UD-IQ4_XS 2.4% 77.5 / 95.6 / 108.9 77.7 / 95.2 / 109.3 +0.2 / −0.4 / +0.4
Q8_0 0% 68.6 / 97.6 / 111.7 68.5 / 97.5 / 111.5 −0.2 / −0.1 / −0.2
UD-Q8_K_XL 0% 63.0 / 94.0 / 108.5 63.1 / 94.0 / 109.0 +0.2 / −0.0 / +0.5
MLX 4-bit — 86.9 / 103.1 / 111.9 86.9 / 103.0 / 112.0 +0.1 / −0.1 / +0.1
  • The gain follows the Q6_K share, mostly at two and four lanes. That's where the Q6_K kernel was most ALU-bound:
    10–18% faster against 3–4% at one lane. The files with 42–60% Q6_K decode 1.2–1.5% faster at one lane and 3.6–5.7%
    at two and four.
  • The output layer helps everything else a little. It's Q6_K in all the 4- to 6-bit files: Q4_0, Q4_1 and
    UD-Q4_K_M gain up to 0.9–2.2% at four lanes.
  • Prefill doesn't change, because the change is decode-only.
  • The controls don't change. MLX 4-bit, Q8_0 and UD-Q8_K_XL have no Q6_K, and their decode stays within 0.5%.
  • Both PRs together, against apple7-m1-1.3.2: UD-Q6_K_M decodes at 70.3 / 90.8 / 104.1 tok/s, against 68.6 /
    86.0 / 98.4 (+2.5 / +5.6 / +5.8%). UD-Q5_K_M decodes at 72.5 / 89.6 / 103.0, against 70.3 / 85.0 / 97.7 (+3.1 / +5.4
    / +5.4%).

Testing

  • gguf-projection passes on the M2 Max (all 43 formats, F32 instances natively).
  • make check passes on the M2 Max.
  • make test-performance-real on every model above, after; before is the 5-bit nibble operand branch.

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%.
…de tile

The F32 register decode tile ran Q6_K through operand_f32 (128 + code), each
16-input chain seeded from the table with -160 times the sum of its inputs: a
seed load per chain and the operand's half subtraction. Q6_K is 43-60% of the
Unsloth UD-Q5 and UD-Q6 files' bytes and the output layer of every 4- to
6-bit Qwen3.8-27B GGUF.

Its F32 chains now take nibble6_operand: the nibble at bits 4-7 and the high
bits at 8-9 of a half, 1 + code / 64, less 1.5, the exact (code - 32) / 64.
The zero point is in the operand, so the chains start from zero, read no
seed and close with fma(dot, 64 s). The Apple9 instances are unchanged.

M2 Max, ms at one to four lanes:
  q6k 17408x5120 gate/up   0.549 0.965 1.258  1.717 -> 0.533 0.862 1.159 1.530
  q6k 5120x17408 down      0.289 0.513 0.659  0.894 -> 0.280 0.456 0.609 0.810
  q6k 5120x6144 out        0.112 0.211 0.271  0.370 -> 0.109 0.172 0.255 0.335
  q6k 248320x5120 output   3.471 6.066 8.170 10.965 -> 3.335 5.621 7.518 9.572
  q5k, q3k, af4g64 (unchanged code) within 0.2%
The staged tile no longer beats the register tile on Q6_K at two and four
lanes (17408x5120: 0.881 and 1.599 ms).
27B decode at one, two and four lanes, tok/s:
  UD-Q6_K_M   69.3 86.3  98.5 -> 70.3 90.8 104.1
  UD-Q6_K_XL  68.5 89.7 102.4 -> 69.6 93.1 106.9
  UD-Q5_K_M   71.5 85.7  98.7 -> 72.5 89.6 103.0
  UD-Q5_K_XL  70.5 86.3  99.2 -> 71.4 89.5 102.7
  UD-Q4_K_XL  76.0 90.8 104.5 -> 76.5 92.0 106.0
Q4_0, Q4_1 and UD-Q4_K_M (their output is Q6_K) gain up to 0.9-2.2% at four
lanes; prefill and the MLX 4-bit, Q8_0, UD-IQ4_XS and UD-Q8_K_XL decodes are
within 0.5%.
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