Skip to content

Use NEON on Linux arm64 - #21

Merged
vlasky merged 1 commit into
vlasky:mainfrom
MayCXC:neon-linux-arm64-vlasky
Oct 28, 2026
Merged

vlasky merged 1 commit into
vlasky:mainfrom
MayCXC:neon-linux-arm64-vlasky

Conversation

@MayCXC

@MayCXC MayCXC commented Oct 22, 2026 •

Copy link
Copy Markdown

The Makefile enables NEON only on macOS arm64, so a Linux arm64 build runs every distance's scalar loop. NEON is part of the arm64 baseline: GCC and Clang define __ARM_NEON for aarch64 with no -m flag, so the Linux build needs only the define, inside the guard that keeps host detection away from Android cross-compiles. OMIT_SIMD=1 still builds without it.

The define follows the compiler's target, $(CC) -dumpmachine, not uname, as mceachen's photostructure/sqlite-vec@350c67a has it: 32-bit Raspberry Pi OS runs a 64-bit kernel, so uname says aarch64 while gcc targets 32-bit ARM, which lacks the AArch64-only intrinsics the NEON kernels use. Under an aarch64 kernel, make loadable CC=arm-linux-gnueabihf-gcc fails when uname decides (implicit declaration of vaddvq_f32, unknown type name float64x2_t) and builds a 32-bit ARM extension when the compiler does; for gcc and clang targeting aarch64, make -n loadable static cli prints the same commands either way.

GCC could not build the NEON code: l1_int8_neon() passes the signed vectors vabdq_s8() returns to vpaddlq_u8(), and the unsigned sums to vaddq_s32(), conversions Clang makes implicitly and GCC refuses, so a GCC build with -DSQLITE_VEC_ENABLE_NEON fails on main. They are now explicit reinterprets, as photostructure/sqlite-vec@a5aec74 writes them; L1 distances between int8 vectors still match the scalar loop's.

Built with GCC 14 at -O3 on Linux arm64 (medians of 20 queries on a 100,000-row float[768] table with the default L2 metric):

k scalar NEON
10 77.1 ms 32.6 ms
100 84.3 ms 39.7 ms

What changes in the results, on 2,000 random pairs at each of six lengths from 8 to 1,536:

  • L1 and Hamming distances are bit-identical. The float32 L1 kernel adds in another order than the scalar loop, though, both in double, so where the sum rounds the two differ in the last digits: on values spread from 1e-8 to 1e8, for 176 of 500 random pairs at 8 dimensions and 467 of 500 at 768.
  • float32 L2 distances differ in the last digits, since the NEON kernel splits the sum across 16 partial sums where the scalar loop keeps one, and they are closer to the exact value: against a float64 reference, the mean relative error at 768 dimensions is 3.0e-8 instead of 1.7e-7. So a KNN query can order rows with nearly equal distances differently, as the macOS arm64 build already does; 4 of the 2,000 ranks of the k = 100 queries above swapped such rows.
  • int8 L2 distances: the kernel sums exactly and the scalar loop in float, which is exact only below 2^24, so they differ only past that, from about 1,536 random dimensions. The kernel keeps that sum in 32 bits, which vec_distance_l2() overflows past 33,026 elements at opposite extremes, as it already does on macOS arm64; photostructure/sqlite-vec@e395db1 keeps it in 64 bits. vec0 tables allow at most 8,192 dimensions, so KNN queries are not affected.

The Test workflow ran nothing on Linux arm64: build-linux-aarch64-extension cross-compiles for 32-bit ARM on an x86_64 runner, where the Makefile leaves NEON off, and runs no tests. test-linux-aarch64-extension runs the x86_64 job's build and tests on ubuntu-24.04-arm, without the artifact upload, whose name the cross-compile job already uses, and without the Rust harness, whose build script compiles without SIMD. Locally make test-loadable (143 passed, 4 skipped) passes on the NEON and the scalar GCC builds, and make test-unit, which compiles without the Makefile's CFLAGS, passes too. CLAUDE.md's SIMD line names Linux arm64.

#20 ports the NEON cosine kernels. The two merge cleanly with each other and with #9 through #14, #17 through #19 and #22 through #24, and together they build with GCC and Clang without warnings and pass the suite. The same change is on photostructure/sqlite-vec's main, from photostructure#6 with mceachen's 350c67a on top, where the NEON code already compiled with GCC, and it is open on asg017/sqlite-vec as asg017#339, on top of asg017#322's fix for the same conversions.

The Makefile enabled NEON only on macOS arm64, so a Linux arm64 build
ran every distance's scalar loop. NEON is part of the arm64 baseline:
GCC and Clang define __ARM_NEON for aarch64 with no -m flag, so the
Linux build needs only the define, inside the guard that keeps host
detection away from Android cross-compiles. OMIT_SIMD=1 still builds
without it.

The define follows the compiler's target ($(CC) -dumpmachine), not
uname, as mceachen's photostructure/sqlite-vec@350c67a has it: 32-bit
Raspberry Pi OS runs a 64-bit kernel, so uname says aarch64 while gcc
targets 32-bit ARM, which lacks the AArch64-only intrinsics the NEON
kernels use. Under an aarch64 kernel, make loadable
CC=arm-linux-gnueabihf-gcc fails when uname decides (implicit
declaration of vaddvq_f32, unknown type name float64x2_t) and builds a
32-bit ARM extension when the compiler does; for gcc and clang
targeting aarch64 the build commands are the same either way.

GCC rejected the NEON code: l1_int8_neon() passed the signed vectors
vabdq_s8() returns to vpaddlq_u8(), and the unsigned sums to
vaddq_s32(), conversions Clang makes implicitly and GCC refuses. They
are now explicit reinterprets, as photostructure/sqlite-vec a5aec74
writes them, which changes no result: L1 distances between int8
vectors match the scalar loop's.

Built with GCC 14 at -O3 on Linux arm64, an L2 KNN query on a
100,000-row float[768] table takes 32.6 ms instead of 77.1 ms for
k = 10 and 39.7 ms instead of 84.3 ms for k = 100 (medians of 20
queries). L1 and Hamming distances are bit-identical to the scalar
loops' on 2,000 random pairs at each of six lengths from 8 to 1,536.
The float32 L1 kernel adds in another order than the scalar loop,
though, both in double, so where the sum rounds the two differ in the
last digits: on values spread from 1e-8 to 1e8, for 176 of 500 random
pairs at 8 dimensions and 467 of 500 at 768. Float32 L2 distances
differ in the last digits, since the NEON kernel splits the sum across
16 partial sums where the scalar loop keeps one, and are closer to the
exact value: against a float64 reference the mean relative error at
768 dimensions is 3.0e-8 instead of 1.7e-7. So a KNN query can order
rows with nearly equal distances differently, as the macOS arm64 build
already does; 4 of the 2,000 ranks of the k = 100 queries swapped such
rows. The int8 L2 kernel sums exactly and the scalar loop in float,
exact only below 2^24, so the two differ only past that, from about
1,536 random dimensions.

The Test workflow ran nothing on Linux arm64:
build-linux-aarch64-extension cross-compiles for 32-bit ARM on an
x86_64 runner, where the Makefile leaves NEON off, and runs no tests.
test-linux-aarch64-extension runs the x86_64 job's build and tests on
ubuntu-24.04-arm. test-loadable.py passes on the NEON and the scalar
GCC build, and make test-unit, which compiles without the Makefile's
CFLAGS, passes too.
@vlasky

vlasky commented Oct 28, 2026

Copy link
Copy Markdown
Owner

Thanks, this reproduces exactly as described: gcc-16 -DSQLITE_VEC_ENABLE_NEON fails on main with the five vpaddlq_u8 errors and compiles cleanly here, L1 and Hamming are bit-identical to the scalar loops, and the -dumpmachine check in the updated head is the right answer to the cross-compile case (a 32-bit userland on a 64-bit kernel, or CC=x86_64-linux-gnu-gcc on an aarch64 host, now stays scalar).

I could not push to your branch: your repository is a fork of asg017/sqlite-vec rather than of this one, so GitHub never grants the maintainer-edit permission here even with the box ticked (the push is refused with 403). So I merged the PR with one commit of mine on top, 7ad5801: the int8 L2 accumulator your description mentions is now an i64, with the square root taken in double, as photostructure e395db1 does. Enabling NEON on Linux is the point at which that overflow stops being a macOS-only quirk and becomes a regression for a platform that had the scalar loop's finite answer, and the change is two lines with no cost in the hot loop:

select vec_distance_l2(vec_int8(?), vec_int8(?))   -- 33,026 bytes of 0x80 and 0x7f
scalar (Linux arm64 today):  46340.94
NEON before:                 NULL
NEON now:                    46341.297  (exact: 46341.295)

The test in test_vec_distance_l2 covers 33,025, 33,026 and 33,032 elements (the last a multiple of eight, so no tail) and fails on the previous kernel. The full suite and make test-unit pass on the NEON and the OMIT_SIMD=1 build. Merged.

One thing I noticed but did not change: make test-unit does not pass CFLAGS, so the new arm64 job's unit tests compile the scalar code (the Python tests do exercise NEON). That can wait for a follow-up.

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