Skip to content

perf(cpu_neon): share the activation prep and the parallel region for PQ2_0 pairs - #462

Merged
geisten merged 3 commits into
mainfrom
perf/pq2-linear-pair
Sep 26, 2026
Merged

geisten merged 3 commits into
mainfrom
perf/pq2-linear-pair

Conversation

@geisten

@geisten geisten commented Sep 26, 2026

Copy link
Copy Markdown
Owner

Summary

The dispatcher already fuses two projections over one x —
forward/linear.c calls linear_pair_m1 when both weights carry the same
one — and feeds it FFN gate/up and attention q/k/v. cpu_neon implements it
for Q4_K; PQ2_0 left the slot null, so pq2_0_prep re-derived the
identical int8 quantization of the same x for each weight at decode,
pq2_0_permute_x re-derived the identical permutation at prefill, and each
projection opened its own parallel region.

internal.h already asserted the invariant this restores: "the pair kernels
quantize their shared x once and use it for both weights."
PQ2_0 just did
not honour it. Found in the main review (#434's campaign).

Changes

  • q8a_pair_m1 / x8_pair_m1: one pq2_0_prep, then both output ranges
    walked as one range
    through pq2_0_parallel_for, so the thread dispatch
    is shared too — which is what geist_weight.h says the slot is for.
  • x8_pair_mN: one pq2_0_permute_x, then two tile loops. They stay
    separate parallel regions on purpose: merging them would have both
    projections contend for the same per-thread dequant tile, and at m=128 the
    SGEMM dwarfs the dispatch anyway.
  • The tile loop is extracted as pq2_0_x8_gemm_permuted and the permute as
    pq2_0_permuted_x, so the single and pair paths share one implementation
    rather than a copy.
  • Installed on the x8 path from the post hooks, on the row path from the
    table match — keyed on the kernel actually installed, so a host without
    dotprod keeps the trampoline and gets no pair.

Testing

  • make test — unit suite 36 passed / 23 skipped / 0 failed.
    test_bonsai_e2e_int passes on cpu_neon and metal with the real
    7.2 GB fixture.
  • make MODE=asan test — same, 0 failed.
  • make format-check — clean (369 files).

test_pq2_0_unit gains a pair check: bit-exact equality with the two
separate calls, on two different n_out so an offset error cannot cancel.
A tolerance would have hidden exactly the failure mode that matters here.
Five mutations confirm it bites:

mutation caught
off-by-one in the index split yes
pair_m1 reuses w0 for both yes
pair_mN feeds one arm the unpermuted x yes
perturbed shared activation scale yes
wrong split point (a_rows = n1) yes

For perf/kernel changes, before/after numbers and host:

Isolated kernel A/B, min-of-300, arm order alternated between reps,
Bonsai-27B shapes:

shape separate pair four rounds
attention q/k, 5120 → 6144/1024, m=1 0.078 ms 0.073 ms +6.8 / +6.8 / +5.5 / +6.8 %
FFN gate/up, 5120 → 17408×2, m=1 0.402 ms 0.387 ms +3.9 / +1.0 / +2.0 / +1.3 %
FFN gate/up, 5120 → 17408×2, m=128 45.7 ms 44.8 ms +2.0 / +0.8 / +4.4 / +3.8 %

The gain is largest where the projections are small next to n_in, i.e.
where the fixed per-call cost is the biggest share. That is the expected
shape of the effect, which is part of why I believe these numbers.

Host: M1 Max, macOS 27.0, TARGET=mac-omp, Accelerate + libomp.

Two things I am explicitly not claiming

  1. No end-to-end number. Scaling the per-layer saving gives roughly 1 %
    of a decode token, and this host cannot resolve 1 % end-to-end — the
    between-run scatter of bench_perf_sweep on it reaches 18 %, which is how
    perf: match Accelerate's threading to the caller instead of oversubscribing #457 died. An isolated A/B that can resolve the effect is the honest
    measurement here; a whole-model figure would be noise dressed as a result.
  2. My first version of this measurement was wrong by 15×. It ran
    separate then pair in a fixed order inside each rep, so the pair always
    read weights the other arm had just pulled into cache. It reported +60.8 %
    on gate/up where the corrected harness reports +1–4 %. The fix — alternate
    which arm goes first — is in the harness and the reason the table above
    has four rounds per row instead of one.

API impact

  • No change to include/geist.h

🤖 Generated with Claude Code

geisten and others added 3 commits September 26, 2026 09:53
… PQ2_0 pairs

The dispatcher already fuses two projections over one x -- forward/linear.c
calls linear_pair_m1 when both weights carry the same one -- and feeds it FFN
gate/up and attention q/k/v. cpu_neon implements it for Q4_K; PQ2_0 left the
slot null, so pq2_0_prep re-derived the identical int8 quantization of the
same x for each weight at decode, pq2_0_permute_x re-derived the identical
permutation at prefill, and each projection opened its own parallel region.

The workspace comment in internal.h already asserted the invariant the pair
forms restore: "the pair kernels quantize their shared x once and use it for
both weights."

Four kernels, and the shared work pulled out so both paths use it:

  - q8a_pair_m1 / x8_pair_m1: one pq2_0_prep, then both output ranges as a
    single range through pq2_0_parallel_for, so the thread dispatch is shared
    too -- which is what geist_weight.h says the slot is for.
  - x8_pair_mN: one pq2_0_permute_x, then two tile loops. They stay separate
    parallel regions; merging them would have both projections contend for
    the same per-thread dequant tile, and at m=128 the SGEMM dwarfs the
    dispatch anyway.

Installed on the x8 path from the post hooks and on the row path from the
table match, keyed on the kernel actually installed so a host without
dotprod keeps the trampoline.

Isolated A/B against two separate calls, min-of-300 with the arm order
alternated, Bonsai-27B shapes on an M1 Max:

  attention q/k  (5120 -> 6144/1024)   +6.8 / +6.8 / +5.5 / +6.8 %
  FFN gate/up    (5120 -> 17408 x2)    +3.9 / +1.0 / +2.0 / +1.3 %
  prefill m=128  (5120 -> 17408 x2)    +2.0 / +0.8 / +4.4 / +3.8 %

The gain is largest where the projections are small next to n_in, which is
where the fixed per-call cost is the biggest share -- as expected.

test_pq2_0_unit gains a pair check: bit-exact equality with the two separate
calls, on two different n_out so an offset error cannot cancel. Five
mutations confirm it bites -- an off-by-one in the index split, reusing w0
for both, feeding one arm the unpermuted x, a perturbed shared scale, and a
wrong split point. test_bonsai_e2e_int passes on cpu_neon and metal.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@geisten
geisten merged commit 311d897 into main Sep 26, 2026
24 checks passed
@geisten
geisten deleted the perf/pq2-linear-pair branch October 10, 2026 07:50
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.

1 participant