Skip to content

Decode zero-point formats with fewer chains on Apple9's register tile - #569

Closed
ghisguth wants to merge 3 commits into
incoai:mainfrom
ghisguth:apple9-zero-points
Closed

ghisguth wants to merge 3 commits into
incoai:mainfrom
ghisguth:apple9-zero-points

Conversation

@ghisguth

@ghisguth ghisguth commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Base: incoai/splash main (3135ab4). Measured on 1.4.0 (1482f6b) before rebasing. None of upstream's changes since then touch the files this PR changes.

Branch: ghisguth/apple9-zero-points (2c12b35)


I have been working on the Apple7/8 decode tile in paperniuk's M1/M2 fork for a while. Most of what we changed there is for the F32 tile, and the Apple9 bf16 tile doesn't have those problems. But two ideas looked like they could carry over (paperniuk#15, paperniuk#19 and paperniuk#21 there, tested on an M1 Max too), so I tried them on my MacBook Air M4 (10-core GPU, 32 GB), which runs upstream's Apple9 register tile for decode. Both help. The first one helps at every lane count, the second mostly with three and four lanes. I used Claude Code to author it and tested it by hand.

What changed

Three commits. They change only what Apple9 runs. Apple10 runs this tile on narrow grids, and it decodes these formats as before. So do the MoE expert kernels.

  • Q4_0, Q5_0 and PQ2_0 take their zero point as a min. Apple9 decodes them through ZeroAsMin<F>, a view with m = −zero·d. This is exact, the zero points are powers of two. A group of 32 inputs then enters the MMA as 128 + code in one chain, closed against the table's sum with b = −(128 + zero)·d, the way Q4_1 and Q5_1 already work. Before, each half of the group was a chain of its own, seeded from the table, with a seed load per lane and a coefficient per half. The operand is upstream's existing one-add 128 + code, so there is no new operand code.
  • Q6_K and Q3_K take their zero point in the operand. Apple9 decodes them through Centered<F>, whose operand is code − zero (−32..31 and −4..3): upstream's 128 + code, then one bf16 subtraction, exact. The 16-input chains start from zero and read no seeds. The subtraction is per weight pair and shared by the lanes, the saved seed load is per chain and lane, so the gain grows with lanes.
  • How it is wired: Apple9View<F> names the type Apple9 decodes F as, and gguf_apple9_view() in metal/abi/Gguf.h names the same formats for the host. A static_assert keeps the two in sync. Apple9 gets gguf_decode_sg9_<fmt>_* instances for these five formats. A fused segment in such a format carries GGUF_DECODE_APPLE9_VIEW in its fmt. Linear::addGgufRegister picks both when family_ == Apple9.
  • gguf-projection also runs the register decode suite through a Linear of the other family for these formats. So an Apple9 GPU still tests the seeded chains Apple10 runs, and an Apple10 GPU tests Apple9's views.
  • Outputs are not bit-exact, because the terms of a group are summed in another order. They stay within the fp64 bound gguf-projection checks. Apple10's outputs don't change.

I kept it to Apple9 on purpose, since that is what I can measure. M5 runs the same tile on narrow grids and I have no M5, so this PR doesn't touch what it runs. The first idea may help there too, and then it would be simpler: a few lines in Shape<F> instead of the views, covering the fused and MoE kernels as well. If someone with an M5 can measure it, I am happy to do that version as a follow-up.

Results (MacBook Air M4, 10-core GPU)

Kernel time, gguf-projection-benchmark, at 1 / 2 / 3 / 4 lanes, the policy's config, ABBA order. The Air is fanless and throttles, so each run waits for a cooldown. Δ: − is faster. An A/A run of the same build is within 0.2%, 0.5% at one lane.

Format, shape Before (ms) After (ms) Δ (%)
q40 17408×5120 gate/up 1.154 / 2.068 / 3.040 / 3.945 1.112 / 1.979 / 2.812 / 3.818 −3.6 / −4.3 / −7.5 / −3.2
q40 5120×17408 down +res 0.580 / 1.032 / 1.521 / 1.973 0.560 / 0.983 / 1.398 / 1.908 −3.4 / −4.8 / −8.1 / −3.3
q50 17408×5120 gate/up 1.354 / 2.240 / 3.201 / 4.214 1.306 / 2.155 / 3.078 / 4.111 −3.6 / −3.8 / −3.9 / −2.4
q50 5120×17408 down +res 0.684 / 1.121 / 1.602 / 2.120 0.656 / 1.080 / 1.540 / 2.054 −4.1 / −3.6 / −3.9 / −3.1
pq20 17408×5120 gate/up 1.139 / 2.028 / 2.987 / 3.843 1.102 / 1.941 / 2.790 / 3.753 −3.3 / −4.3 / −6.6 / −2.3
pq20 5120×17408 down +res 0.571 / 1.025 / 1.491 / 1.926 0.552 / 0.991 / 1.420 / 1.915 −3.3 / −3.3 / −4.7 / −0.6
q6k 17408×5120 gate/up 1.495 / 2.186 / 3.178 / 4.210 1.496 / 2.143 / 3.085 / 3.988 +0.0 / −2.0 / −2.9 / −5.3
q6k 5120×6144 out +res 0.284 / 0.399 / 0.579 / 0.760 0.285 / 0.392 / 0.557 / 0.722 +0.3 / −1.9 / −3.8 / −5.0
q6k 248320×5120 output 10.21 / 17.08 / 24.86 / 32.59 10.34 / 17.05 / 23.81 / 30.83 +1.3 / −0.2 / −4.2 / −5.4
q3k 17408×5120 gate/up 1.329 / 2.229 / 3.220 / 4.241 1.328 / 2.228 / 3.147 / 4.107 −0.1 / −0.0 / −2.3 / −3.2
q4k, q41, af4g64 (unchanged code) within ±0.3

The q6k output layer is 1 GB and drifts 2–5% within a session on the Air, so its one-lane +1.3% is noise.

End to end, decode only: backend-benchmark --scenario decode --samples 3 --ane-ffn-share 0, main against the branch, each build's own binary, ABBA. GPU time per decode step in ms at 1 / 2 / 3 / 4 lanes. Output tokens and acceptance are the same on both builds.

Model (ABBA passes) main (ms/step) branch (ms/step) Δ (%)
unsloth Qwen3.8-27B Q4_0 (3) 272.7 / 457.1 / 665.5 / 884.8 263.0 / 449.4 / 643.7 / 858.7 −3.6 / −1.7 / −3.3 / −3.0
prism-ml Ternary-Bonsai-2-27B PQ2_0 (2) 225.9 / 404.0 / 579.6 / 784.3 220.8 / 394.2 / 566.1 / 766.7 −2.3 / −2.4 / −2.3 / −2.2
  • The end-to-end gain is about what the kernels predict. Q4_0 is 83% of the Q4_0 file's bytes and PQ2_0 99% of Bonsai's. The rest of a decode step (attention, GDN, norms, the draft) doesn't change.
  • The Q6_K/Q3_K part shows mostly at three and four lanes. In the Q4_0 file Q6_K is just the output layer (6.5%), so nearly all of that file's gain is the first commit. UD-Q5/Q6 files, 43–60% Q6_K, should gain more at four lanes. They don't fit on a 32 GB Air with the draft model, so I couldn't measure them.
  • Prefill doesn't change. The register tile runs only in decode.

Testing

  • make check passes on the M4 Air on the rebased branch.
  • gguf-projection passes on the M4 Air after each commit, and with and without MTL_SHADER_VALIDATION on the rebased branch: all 43 formats on Apple9, and the five formats through Apple10's Linear too.
  • make test-performance-real on main and the branch with MLX 4-bit, Q4_0, PQ2_0 (Bonsai) and UD-IQ3_XXS: the same output tokens and draft acceptance on every model.
  • Kernel A/B and end-to-end runs above. Not tested on an Apple10 GPU.

View with [code]smith Autofix with [code]smith
Need help on this PR? Tag @codesmith-bot with what you need.

…ter tile

Apple9's register decode tile ran Q4_0 and Q5_0 as zero-point formats:
each 16-input half of a group was a chain of its own, seeded from the
table with -160 times the sum of its inputs, with a seed load per lane and
a coefficient per half.

Apple9 now decodes them in a view of their own (Apple9View, ZeroAsMin)
that takes the zero point as a min, m = -zero d: a group enters the MMA as
128 + code in one chain, closed against the table's sum of its 32 inputs
with b = -(128 + zero) d, exact, as Q4_1's and Q5_1's groups are, with
half the coefficients. Its kernels are the gguf_decode_sg9_ instances, and
a fused segment in the view carries GGUF_DECODE_APPLE9_VIEW in its format;
gguf_apple9_view names the formats on both sides. Apple10, whose narrow
grids run the register tile, and the MoE expert kernels decode the formats
as before. The outputs move within the fp64 bound gguf-projection checks:
the chain's sums round in another order.

10-core M4, ms at one to four lanes (kernel-ab, three ABBA passes; an A/A
pass is within 0.2%, 0.5% at one lane):
  q40 17408x5120 gate/up  1.154 2.068 3.040 3.945 -> 1.112 1.979 2.812 3.818
  q40 5120x17408 down     0.580 1.032 1.521 1.973 -> 0.560 0.983 1.398 1.908
  q50 17408x5120 gate/up  1.354 2.240 3.201 4.214 -> 1.306 2.155 3.078 4.111
  q50 5120x17408 down     0.684 1.121 1.602 2.120 -> 0.656 1.080 1.540 2.054
  q4k, q41, af4g64 (unchanged code) within 0.3%

gguf-projection also runs the register decode suite in the other family's
view of these formats, so that an Apple9 GPU tests Apple10's seeded
chains and an Apple10 GPU Apple9's.
…s register tile

Apple9's register decode tile entered Q6_K's and Q3_K's codes into the MMA
as 160 + code - zero, each 16-input chain seeded from the table with -160
times the sum of its inputs: a seed load per chain and lane.

Apple9 now decodes them in a view (Centered) whose operand is code - zero,
-32..31 and -4..3: 128 + code in one integer add, less 128 + zero in bf16,
exact. The chains start from zero and read no seed. The operand costs one
subtraction a pair of weights, which every lane of a threadgroup shares,
and saves a seed load per chain for each lane, so the gain grows with the
lanes. Apple10 and the MoE expert kernels decode the formats as before.
The outputs move within gguf-projection's fp64 bound.

10-core M4, ms at one to four lanes (kernel-ab, ABBA passes free of other
GPU work; an A/A pass is within 0.2%, 0.5% at one lane):
  q6k 17408x5120 gate/up    1.495 2.186 3.178  4.210 -> 1.496 2.143 3.085  3.988
  q6k 5120x6144 out         0.284 0.399 0.579  0.760 -> 0.285 0.392 0.557  0.722
  q6k 248320x5120 output   10.21 17.08 24.86  32.59  -> 10.34 17.05 23.81  30.83
  q3k 17408x5120 gate/up    1.329 2.229 3.220  4.241 -> 1.328 2.228 3.147  4.107
  q4k (unchanged code) within 0.1%
The output layer's one-lane difference (+1.2%) is within that case's
spread from run to run.

With both commits the 27B in Q4_0 (its output layer Q6_K) takes 3.6, 1.7,
3.3 and 3.0% less GPU time per decode step at one to four lanes on the
10-core M4 than main (backend-benchmark's decode scenario, Neural Engine
off, ABBA x 3); its output tokens are the same.
PQ2_0, Prism ML's ternary format (zero point 1, one scale per 128 inputs),
ran on Apple9's register decode tile as Q4_0 did before ZeroAsMin: two
16-input chains per group seeded from the table. Ternary-Bonsai-2-27B is
99% PQ2_0.

Apple9 now decodes it in ZeroAsMin's view too: one 32-input chain closed
against the table's sum with b = -129 d, exact. Its coefficient unit is
unchanged, so the host's K splits are too. Apple10 and the MoE expert
kernels decode it as before. The outputs move within gguf-projection's
fp64 bound.

10-core M4, ms at one to four lanes (kernel-ab, ABBA):
  pq20 17408x5120 gate/up  1.139 2.028 2.987 3.843 -> 1.102 1.941 2.790 3.753
  pq20 5120x17408 down     0.571 1.025 1.491 1.926 -> 0.552 0.991 1.420 1.915
Ternary-Bonsai-2-27B takes 2.3, 2.4, 2.3 and 2.2% less GPU time per decode
step at one to four lanes than main (backend-benchmark's decode scenario,
Neural Engine off, ABBA x 2); its output tokens are the same.
@jianc99

jianc99 commented Oct 27, 2026

Copy link
Copy Markdown
Collaborator

Thanks @ghisguth, both ideas were worth measuring. I couldn't push to your branch, so #602 carries your three commits with your authorship, stacked on #600 (the register tile decodes each coefficient once per coefficient group and adds a min's term once per group; it merges first), plus one commit that replaces the Apple9-only views with a rule every family's register tile shares. I'm leaving this PR open for now.

What your two rules measured as shared rules, on a 10-core M4 and a 32-core M5 Max:

  • Zero point in the operand (Q6_K, Q3_K, IQ1_M): slower than main at one and two lanes on both. M4: Q3_K +3.5% at one lane, Q6_K +1.4% at two. M5 Max: IQ1_M +3.8%, Q6_K +2.2%, Q3_K +1.7% at one lane. These formats keep main's chains.
  • Zero point as a min: faster than main, but slower than 32-input seeded chains on both, for example Q4_0 at two lanes on the M4, −2.9% against −5.6%.

The rule that won keeps your idea of taking the zero point like a min: where one scale covers 32 inputs or more (Q4_0, Q5_0, IQ1_S, PQ2_0, Q2_0), chains cover 32 inputs, and the zero point's term, −160 times the table's 32-input sums, starts the last chain of each coefficient group, at most once per 64-input span, the way a min's term is added. The views, the sg9 kernels, the ABI bit and the family branch are gone, and the table is unchanged.

Over #600 on the M4 (one to four lanes): Q4_0 −0.8/−3.5/−3.9/−4.4%, Q5_0 −0.4/−3.1/−6.5/−4.5%, Q2_0 −2.8/−2.8/−2.3/−4.6%, PQ2_0 −2.6/−3.1/−2.4/−4.7%, IQ1_S −2.1/−3.1/−4.9/−4.0%, with no shape slower. On the M5 Max's narrow grids no row is slower than main.

Tests on the M5 Max: gguf-dequant, gguf-projection, linear-plan, gguf-moe, gguf-moe-e512 and gguf-moe-sigmoid under shader validation, make test-engine, and the Qwen3.6-35B-A3B MLX runtime oracle.

@ghisguth

Copy link
Copy Markdown
Contributor Author

@jianc99 thank you for carrying it. Glad you made it much cleaner and faster than my Apple9 views. Now that #602 is merged, closing this one.

@ghisguth ghisguth closed this Oct 27, 2026
@ghisguth
ghisguth deleted the apple9-zero-points branch October 27, 2026 03:39
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