Skip to content

arm: NEON vec_dot for PQ2_0 - #265

Merged
bri-prism merged 4 commits into
PrismML-Eng:prismfrom
kiljoy001:arm-neon-ternary
Oct 5, 2026
Merged

bri-prism merged 4 commits into
PrismML-Eng:prismfrom
kiljoy001:arm-neon-ternary

Conversation

@kiljoy001

@kiljoy001 kiljoy001 commented Sep 24, 2026 •

Copy link
Copy Markdown

Overview

Adds NEON vec_dot for PQ2_0, closing the same gap on ARM that #248 closed on x86.

PQ2_0 had a NEON kernel, but it targeted Q8_0 activations. PQ2_0 now dispatches through Q8_K, so that kernel was dead code for normal inference (the same issue bri-prism found and I fixed on x86 in #248).

ggml_vec_dot_pq2_0_q8_K reuses the existing Q8_0 kernel's unpack, but accumulates all four sub-block dots in int32 and applies the block scale once, matching Q8_K's one-scale-per-256 layout instead of one-scale-per-32. Uses vqtbl1q_u8 (via the ggml_vqtbl1q_u8 wrapper) for the code/trit unpack and ggml_vdotq_s32 (sdot) for the reduction.

PTQ1_0 is no longer part of this PR

This PR originally also added a NEON kernel for PTQ1_0. Two independent real-hardware reports found it slower than the plain generic loop it was meant to replace:

Root cause: the kernel decoded trits by widening into uint16 lanes, which costs more than it saves. karusrus posted a working alternative that stays in 8-bit lanes (the same trick upstream's TQ1_0 NEON kernel uses) and adds an i8mm/SMMLA 2x2-tile path for hardware that has it, measuring ~1.9x pp / ~1.2x tg over the generic loop on their device: https://github.com/karusrus/llama.cpp/tree/arm-neon-ptq1_0

I don't have i8mm-capable ARM hardware here (my Orange Pi 5 Plus is Cortex-A76/A55, noi8mm) to independently verify that kernel's fast path, so rather than merge a replacement I can't fully test myself, this PR now drops the regressive PTQ1_0 kernel entirely and aliases it back to the generic scalar loop on ARM (matching the same fallback pattern arch-fallback.h already used for PTQ1_0 on x86 before #248 added a real SIMD kernel there). A correctly-verified PTQ1_0 NEON kernel - likely karusrus's, once I or someone else can test the i8mm path on real hardware - should land as a follow-up PR.

Results (PQ2_0 only)

Orange Pi 5 Plus (RK3588, 4x Cortex-A76 + 4x Cortex-A55, asimddp). Isolated A/B: same binary, only libggml-cpu.so swapped, alternated, two pairs.

Ternary Bonsai 1.7B PQ2_0

build pp128 tg32
scalar 4.50 / 4.53 3.63 / 3.66
NEON 23.19 / 23.21 17.54 / 15.89

~5.1x pp, ~4.6x tg.

For reference, PTQ1_0 on this same hardware now runs the generic loop (no NEON path): 27B model, pp64 0.33 t/s / tg16 0.27 t/s at 5 threads.

Correctness

test-quantize-fns gains the same exact check as #248 (unchanged - the property has nothing architecture-specific about it): packed vec_dot vs. dequantize-then-scalar-dot, power-of-two scales, bit-exact. 0 failures for PQ2_0 (NEON) and PTQ1_0 (generic fallback) on real Cortex-A76/A55 hardware.

Also validated with a private property-based harness (theft, ~124k random inputs per format across all three x86 ISA tiers when I built it for #248) and an exhaustive edge sweep (every weight byte 0-255 x activation extremes {-128,-1,0,1,127}, including out-of-range trit bytes) - not part of either PR, kept as a private correctness check before opening either.

End-to-end verification

Greedy (temp=0) completion on the 8B PQ2_0 model is byte-identical between this NEON build and an x86 SSE build (#248), same prompt, same seed - exercising the full inference pipeline, not just the kernel in isolation.

ARMv7

Fixed in a follow-up commit (22e70a2d0): the PQ2_0 kernels used raw vqtbl1q_u8, which is AArch64-only, instead of the codebase's own ggml_vqtbl1q_u8 wrapper (which has a 32-bit fallback). I don't have ARMv7 hardware or a cross-toolchain to independently verify the fix compiles there - this is a pattern-match against bri-prism's diagnosis and against how other kernels in the same file already do it correctly (ggml_vec_dot_q2_0_q8_0). What I did verify: ggml_vqtbl1q_u8 is a plain #define to the raw intrinsic on __aarch64__, so the change is a no-op by construction on the hardware I can test, and test-quantize-fns still passes 0 failures on the Orange Pi after the change.

Claude-Session: https://claude.ai/code/session_016pBjsH3XSFLbnzoDyzkcUU

Same gap on ARM as x86 had before the SSE work: PTQ1_0 had no NEON
implementation at all (arch-fallback.h aliased it straight to the generic
C loop), and the existing NEON PQ2_0 kernel targets Q8_0 activations,
which is no longer what the traits table dispatches now that PQ2_0 uses
Q8_K - so it was dead code for normal inference, same issue bri-prism
found on x86 in PR ggml-org#248.

Add:

  ggml_vec_dot_pq2_0_q8_K  - same 2-bit codec and vqtbl1q_u8 replication
                             trick as the existing Q8_0 kernel, but four
                             sub-block dots accumulate in int32 and the
                             block scale is applied once, matching the
                             Q8_K activation layout (one float scale per
                             256 elements instead of one fp16 scale per 32).

  ggml_vec_dot_ptq1_0_q8_0 - base-3 trit decode: for packed byte v the
                             next trit is floor(3*v/256) and v carries
                             forward as (3*v) & 0xFF, done 16 lanes at a
                             time in uint16 so 3*v (max 765) never
                             overflows and needs no division. Same three
                             stages as the scalar reference (16/16/8 qs
                             groups, then qh), reduced with
                             ggml_vdotq_s32 (sdot) on the 8-lane groups
                             and vmull_s8 + vpadalq_s32 on the tail
                             8-lane groups.

Correctness: exact vec_dot check (ported from PR ggml-org#248, same test) passes
0 failures for both formats on a real Cortex-A76/A55 board (Orange Pi 5
Plus, RK3588). Also validated with a private property-based harness
(theft, ~124k random inputs per format) and an exhaustive edge sweep
(every weight byte 0-255 x activation extremes) before this PR - not
included here, same reasoning as the x86 PR.

Found one real bug before it ever ran: an earlier version fed the wrong
vector width into vpadalq_s16 (vpaddl_s8+vmovl_s16 produces int32x4_t,
not the int16x8_t the intrinsic expects), which failed to compile on
ARM. Fixed to vmull_s8 (real int16x8_t product, no overflow since
|trit| <= 1 and |activation| <= 128) feeding vpadalq_s16 directly.

Performance, Orange Pi 5 Plus (RK3588, 4x Cortex-A76 + 4x Cortex-A55,
asimddp), isolated A/B (same binary, only libggml-cpu.so swapped,
alternated, two pairs):

  Ternary Bonsai 1.7B PQ2_0    pp128          tg32
    scalar                     4.50/4.53      3.63/3.66
    NEON                      23.19/23.21    17.54/15.89

~5.1x pp, ~4.6x tg - the largest speedup measured across any backend for
this format family so far.

End-to-end verification: greedy (temp=0) completion on the 8B PQ2_0 model
is byte-identical between this NEON build and an x86 SSE build (PR ggml-org#248),
same prompt, same seed. This exercises the full inference pipeline, not
just the kernel in isolation.

The 27B PTQ1_0 model does NOT match byte-for-byte between x86 and ARM at
temp=0, diverging after ~28 tokens on an otherwise-identical prefix. This
is not a bug in this kernel: the same divergence reproduces with the
generic scalar C path on ARM (i.e. with no NEON code in the execution
path at all), so it predates and is independent of this PR. It is the
well-documented cross-architecture floating-point non-associativity
issue in SIMD reduction (different horizontal-sum order between AVX2 and
NEON, plus FMA/reassociation differences) - the same class of divergence
llama-cpp-et (github.com/anomly-labs/llama-cpp-et) exists to eliminate,
at a stated ~3x throughput cost for their exact/deterministic profile.
Not worth chasing here: it would erase most of the margin ternary
quantization exists to create, and same-architecture runs are already
reproducible (confirmed: re-running the x86 build gives identical
output; the ARM scalar and ARM NEON builds agree with each other, just
not with x86).
Same test as PR ggml-org#248 (x86), applicable here unchanged since the property
being checked - packed SIMD vec_dot bit-for-bit equal to
dequantize-then-scalar-dot, with power-of-two scales so the comparison is
exact - has nothing architecture-specific about it. Kept as a separate
commit for the same reason it was split there: the test and the kernel
are independently reviewable, and this one is the correctness gate that
would have caught the earlier NEON compile bug in a debug build if it had
been silently wrong instead of a hard compile error.

Passes on ARM (Cortex-A76/A55) with 0 failures for both formats.
@bri-prism

Copy link
Copy Markdown
Collaborator

Evaluated 308f4b648 from an x86 machine (no ARM hardware here), so this covers compile checks and review only. The NEON performance and on-device correctness claims are not re-verified by me.

Cross-compile checks (clang 22.1.8, -fsyntax-only on ggml/src/ggml-cpu/arch/arm/quants.c, aarch64/armv7a-w64-mingw32):

target prism @ 078192590 this PR
AArch64, -march=armv8.2-a+dotprod (sdot path) ✅ ✅
AArch64, -march=armv8-a (ggml_vdotq_s32 fallback, no dotprod) ✅ ✅
32-bit ARMv7, -mfpu=neon -mfloat-abi=hard ❌ 1 site: ggml_vec_dot_pq2_0_q8_0 L339/344 ❌ 2 sites: the same plus the new ggml_vec_dot_pq2_0_q8_K L409/411

The cause is the same in both: raw vqtbl1q_u8 is AArch64-only, but the kernels are guarded only by __ARM_NEON, which ARMv7 NEON also defines. ggml-cpu-impl.h already has ggml_vqtbl1q_u8 with a 32-bit fallback. Other kernels in this file use it, e.g. arm/quants.c:266. Switching the two new call sites and the two pre-existing ones to ggml_vqtbl1q_u8 fixes all of them. Low severity: no fork release target builds 32-bit ARM. But it's a four-identifier change, and the new kernel shouldn't copy the break. The new PTQ1_0 kernel compiles on ARMv7 as is.

Review of ggml_vec_dot_pq2_0_q8_K: scale handling looks right. Each 128-weight PQ2_0 block i reads its half of Q8_K block i >> 1 (offset 128 * (i & 1)) and applies d_x * d_y once to an exact int32 dot. Codes map to {−1, 0, +1} (code 3 → +2 is unreachable given d = amax). The int32 accumulator can't overflow (≤128 products of |·| ≤ 256).

Consistency with x86: the "PQ2_0 Q8_0 NEON kernel is dead code" premise is correct. PQ2_0's vec_dot_type is Q8_K in the arch-independent CPU traits, which is the same finding as on x86 (#248).

Shared files:

On the cross-architecture PTQ1_0 greedy divergence (x86 vs ARM after ~28 tokens, reproducing with the generic path on ARM): that matches what I measured on x86. A same-kernel rebuild with different ISA flags already moves KLD to around 5e-4 on a sensitive model (see #255), so I agree it isn't attributable to this PR.

Evaluated with Claude Code.

@bri-prism bri-prism left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks, this is a big improvement for PQ2_0. I tested on an Apple M5 Pro (NEON + dotprod): test-quantize-fns passes with 0 failures for both formats. PQ2_0 CPU throughput is about 6.7x for prompt processing and 5.7x for generation on a 2B model.

For PTQ1_0 I saw the opposite on this chip. On the 27B, CPU-only, the NEON path is about 9% slower than the generic C loop (pp32 5.3 vs 5.8 t/s, tg8 4.7 vs 5.1 t/s, two alternating runs). Clang seems to vectorize the generic loop well here. Could you share PTQ1_0 numbers from the RK3588? If it isn't faster there either, one option is to land the PQ2_0 kernel now and keep PTQ1_0 on the generic path until it wins.

Two smaller things: this PR and #248 both touch arch-fallback.h and tests/test-quantize-fns.cpp (including the same test function), so whichever merges second will need a rebase. And for 32-bit ARM builds, ggml_vqtbl1q_u8 rather than vqtbl1q_u8 would match the rest of the file.

@bri-prism

Copy link
Copy Markdown
Collaborator

For context: the review above is the on-device follow-up to the earlier x86-only comment, run on real ARM hardware (Apple M5 Pro, NEON + dotprod).

@kiljoy001

kiljoy001 commented Sep 24, 2026 via email

Copy link
Copy Markdown
Author

Raw vqtbl1q_u8 is AArch64-only, but both new kernels are guarded by
__ARM_NEON, which 32-bit ARMv7 NEON also defines. ggml-cpu-impl.h
already has ggml_vqtbl1q_u8 with a 32-bit fallback for exactly this,
and it's what other kernels in this same file use (e.g. the unrelated
ggml_vec_dot_q2_0_q8_0). Switch the two new call sites, plus the two
pre-existing ones in the (currently dead, per this PR's own commit
message) ggml_vec_dot_pq2_0_q8_0 that had the same mistake already.

Found by bri-prism cross-compiling with clang -fsyntax-only for
armv7a-w64-mingw32; I don't have ARMv7 hardware or a cross-toolchain
here to independently re-verify the fix compiles there (no sudo to
install one), so this is the same pattern-match the reviewer already
identified, not an independent confirmation.

What IS re-verified, on the real Cortex-A76/A55 board this PR was
tested on: ggml_vqtbl1q_u8 is a plain #define to vqtbl1q_u8 on
__aarch64__ (ggml-cpu-impl.h), so this is a no-op there by construction.
test-quantize-fns still passes 0 failures for both formats after the
change, confirming nothing broke on the platform I can actually test.
@kiljoy001

Copy link
Copy Markdown
Author

Fixed in 22e70a2d0: all four sites (the two new ones plus the two pre-existing ones you also caught) now use ggml_vqtbl1q_u8.

One honest caveat: I don't have ARMv7 hardware or a cross-toolchain here (no sudo to install gcc-arm-linux-gnueabihf), so I couldn't independently re-verify this compiles there the way you did with clang. This is a pattern-match against your diagnosis and against how the codebase already does it correctly elsewhere in the same file (ggml_vec_dot_q2_0_q8_0), not an independent confirmation on real ARMv7. What I did re-verify: ggml_vqtbl1q_u8 is a plain #define to the raw intrinsic on __aarch64__, so the change is a no-op by construction on the hardware I can test, and test-quantize-fns still passes 0 failures on the Orange Pi after the change.

If you get a chance to re-run your clang -fsyntax-only check against 22e70a2d0, that would be the actual confirmation - happy to iterate again if it's still not clean.

Thanks for reviewing on real ARM hardware (M5 Pro) as a follow-up to the x86-only pass - that's exactly the kind of thing I can't do myself for this PR, and the scale-handling review of ggml_vec_dot_pq2_0_q8_K is a more careful check than I ran on my own code.

Still looking into the PTQ1_0 divergence question - will follow up separately.

@karusrus

Copy link
Copy Markdown

Some PTQ1_0 data from a phone, since the open question here is whether a NEON PTQ1_0 kernel can beat the generic loop.

Device: Motorola Edge 70, Snapdragon 7 Gen 4 (SM7750: 1× + 4× Cortex-A720, 3× Cortex-A520), NEON + dotprod + i8mm. Android 16, llama.cpp built on-device in Termux with clang 21, -DGGML_CPU_ARM_ARCH=armv8.6-a+dotprod+i8mm+bf16. Ternary Bonsai 2 27B PTQ1_0, CPU only, -t 5. Same model and flags for all three builds, runs alternated, two passes:

build pp64 t/s tg16 t/s
prism @ adfffbe (generic PTQ1_0) 1.22 / 1.22 1.09 / 1.09
this PR @ 22e70a2d0 1.19 / 1.07 1.07 / 0.96
alternative kernel below 2.33 / 2.33 1.31 / 1.31

So on this chip I see the same thing bri-prism saw on the M5 Pro: the PTQ1_0 kernel in this PR is slightly slower than the generic loop. The kernel below is ~1.9× on prompt and ~1.2× on generation against generic.

What it does differently (branch: https://github.com/karusrus/llama.cpp/tree/arm-neon-ptq1_0, commit f9078a625, 157 lines on top of adfffbe):

  1. Trit decode stays in 8-bit lanes. ((q + (q >> 1)) >> 1) >> 6 is exactly (3q) >> 8 for every byte value 0..255 (checked exhaustively), the same trick the upstream TQ1_0 NEON kernel uses. No widening to u16. The whole 128-trit block is decoded into eight int8x16_t in element order (vhadd/vshr/vmul on 16 + 8 lanes, the 2-byte qh tail via one vmul_u8 against {1,1,3,3,9,9,27,27}).
  2. One horizontal add per row, not four per block. Each 32-wide Q8_0 sub-block's sdot result is converted and accumulated with vmlaq_n_f32 into a float32x4; vaddvq_f32 only at the end.
  3. i8mm 2×2 tile for prompt processing. With __ARM_FEATURE_MATMUL_INT8, PTQ1_0 gets .nrows = 2 (like Q8_0), and the nrc == 2 path decodes two weight rows once and multiplies them against two activation columns with vmmlaq_s32, so every decoded trit vector is used twice. This is where most of the prompt gain comes from; tg (nrc = 1) only benefits from 1 and 2.

Correctness: test-quantize-fns 0 failures (including the packed ternary check). Greedy 24-token completion on the 27B is byte-identical between this kernel and the generic path on the same device, with the prompt going through the nrc == 2 path. I have not run perplexity on it yet.

Micro-benchmark from test-backend-ops perf -b CPU -o MUL_MAT on the same phone, m=4096, k=14336: n=1 PTQ1_0 76 GFLOPS vs Q4_0 86; n=512 PTQ1_0 157 vs Q4_0 214.

How would you like to take it? I'm happy for you to fold it into this PR (replacing the PTQ1_0 kernel here), or to open it as a follow-up once the PQ2_0 part lands, as bri-prism suggested. I can run tests on this device on request.

Disclosure, per CONTRIBUTING.md: the kernel was written with Claude Code (AI). I built, tested and measured it on the device myself and I'm responsible for it.

@kiljoy001

kiljoy001 commented Sep 27, 2026 via email

Copy link
Copy Markdown
Author

Two independent real-hardware reports (bri-prism on Apple M5 Pro,
karusrus on a Snapdragon 7 Gen 4 phone) found this kernel slower than
the plain scalar loop it was meant to replace - the u16-lane widening
used to decode trits costs more than it saves. karusrus also posted a
working replacement (8-bit lane decode, i8mm/SMMLA 2x2 tiling) that
measures ~1.9x/1.2x over generic on their hardware:
https://github.com/karusrus/llama.cpp/tree/arm-neon-ptq1_0

I don't have i8mm-capable ARM hardware to independently verify that
kernel's fast path, so rather than merge an alternative I can't test,
this drops the regressive kernel here and aliases PTQ1_0 back to
generic on ARM (matching the existing x86 fallback pattern in
arch-fallback.h before ggml-org#248 added a real SIMD kernel there). PQ2_0's
NEON kernels are unaffected and keep their validated ~5.1x/4.6x
speedup.

Verified on Orange Pi 5 Plus (RK3588, Cortex-A76/A55, no i8mm):
test-quantize-fns passes 0 failures for both formats. Real 27B PTQ1_0
throughput on this fallback: pp64 0.33 t/s, tg16 0.27 t/s (generic
loop, 5 threads) - down from this PR's earlier NEON numbers, which
were never a real improvement to begin with.

Claude-Session: https://claude.ai/code/session_016pBjsH3XSFLbnzoDyzkcUU
@kiljoy001 kiljoy001 changed the title arm: NEON vec_dot for PTQ1_0 and PQ2_0 arm: NEON vec_dot for PQ2_0 Sep 28, 2026
@bri-prism
bri-prism merged commit 6bfcd79 into PrismML-Eng:prism Oct 5, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants