Repository navigation
perf(cpu_neon): share the activation prep and the parallel region for PQ2_0 pairs - #462
Merged
Merged
Conversation
… 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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
The dispatcher already fuses two projections over one
x—forward/linear.ccallslinear_pair_m1when both weights carry the sameone — and feeds it FFN gate/up and attention q/k/v.
cpu_neonimplements itfor Q4_K; PQ2_0 left the slot null, so
pq2_0_prepre-derived theidentical int8 quantization of the same
xfor each weight at decode,pq2_0_permute_xre-derived the identical permutation at prefill, and eachprojection opened its own parallel region.
internal.halready asserted the invariant this restores: "the pair kernelsquantize their shared x once and use it for both weights." PQ2_0 just did
not honour it. Found in the
mainreview (#434's campaign).Changes
q8a_pair_m1/x8_pair_m1: onepq2_0_prep, then both output rangeswalked as one range through
pq2_0_parallel_for, so the thread dispatchis shared too — which is what
geist_weight.hsays the slot is for.x8_pair_mN: onepq2_0_permute_x, then two tile loops. They stayseparate 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.
pq2_0_x8_gemm_permutedand the permute aspq2_0_permuted_x, so the single and pair paths share one implementationrather than a copy.
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_intpasses on cpu_neon and metal with the real7.2 GB fixture.
make MODE=asan test— same, 0 failed.make format-check— clean (369 files).test_pq2_0_unitgains a pair check: bit-exact equality with the twoseparate calls, on two different
n_outso an offset error cannot cancel.A tolerance would have hidden exactly the failure mode that matters here.
Five mutations confirm it bites:
pair_m1reusesw0for bothpair_mNfeeds one arm the unpermutedxa_rows = n1)For perf/kernel changes, before/after numbers and host:
Isolated kernel A/B, min-of-300, arm order alternated between reps,
Bonsai-27B shapes:
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
of a decode token, and this host cannot resolve 1 % end-to-end — the
between-run scatter of
bench_perf_sweepon it reaches 18 %, which is howperf: 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.
separatethenpairin a fixed order inside each rep, so the pair alwaysread 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
include/geist.h🤖 Generated with Claude Code