Files
clawhdf5/crates/clawhdf5-accel/src/neon.rs
T
osobhandClaude Opus 5 56a8c2f3d0 feat(accel): aarch64 int8 dot product — SDOT and plain NEON
`dot_i8` had an AVX2 kernel and a scalar fallback, so on aarch64 the
quantised HNSW index ran the scalar loop. It now dispatches to one of
two NEON kernels:

- `dot_i8_dotprod`: the ARMv8.2 dot-product instruction, `SDOT`, which
  multiplies and accumulates sixteen i8 pairs into four i32 lanes per
  instruction. Present on Cortex-A76 and later (Raspberry Pi 5, current
  Android phones), Neoverse-N1 (Graviton2, Ampere Altra) and every Apple
  Silicon generation. Issued as inline assembly because the `vdotq_s32`
  intrinsic is still behind the unstable `stdarch_neon_dotprod` feature;
  inline asm is stable on aarch64.
- `dot_i8`: plain NEON for cores without the extension — `vmull_s8`
  widens to i16 (even -128 * -128 fits) and `vpadalq_s16` folds adjacent
  pairs into i32 accumulators, so nothing overflows.

Selected at runtime with `is_aarch64_feature_detected!("dotprod")`.

Verified on a Raspberry Pi 5 (Cortex-A76, `asimddp` present), not just
compiled — the aarch64 code is cfg'd out on x86, so x86 CI never builds
or lints it:

- both kernels bit-exact against scalar at every length, tails and
  extremes included. Each is tested directly rather than through
  dispatch, because dispatch only takes one path on a given CPU: on the
  Pi, testing through it alone would never have run the plain-NEON
  fallback at all.
- mutation-checked: dropping the SDOT kernel's second accumulator fails
  at length 32, and using the low half twice in the NEON kernel fails at
  length 16 — the first lengths that exercise each.
- the ANN suite passes, including int8 recall against ground truth.
- clippy clean with -D warnings on aarch64.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
2026-09-21 17:34:51 -07:00

310 lines
9.4 KiB
Rust

//! ARM NEON SIMD implementations.
//! NEON is always available on aarch64.
#![cfg(target_arch = "aarch64")]
use std::arch::aarch64::*;
/// NEON dot product for f32 slices.
///
/// # Safety
/// Caller must ensure aarch64 target (NEON always available).
// SAFETY: NEON is always available on aarch64 targets; caller guarantees aarch64.
#[target_feature(enable = "neon")]
pub unsafe fn dot_product(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
let len = a.len();
let mut i = 0;
let mut acc0 = vdupq_n_f32(0.0);
let mut acc1 = vdupq_n_f32(0.0);
// Process 8 elements per iteration (2x4 unrolled)
while i + 8 <= len {
// SAFETY: Caller guarantees NEON/FP16 available per the # Safety contract on this fn.
unsafe {
let va0 = vld1q_f32(a.as_ptr().add(i));
let vb0 = vld1q_f32(b.as_ptr().add(i));
acc0 = vfmaq_f32(acc0, va0, vb0);
let va1 = vld1q_f32(a.as_ptr().add(i + 4));
let vb1 = vld1q_f32(b.as_ptr().add(i + 4));
acc1 = vfmaq_f32(acc1, va1, vb1);
}
i += 8;
}
// Process remaining 4-element chunk
if i + 4 <= len {
// SAFETY: Caller guarantees NEON/FP16 available per the # Safety contract on this fn.
unsafe {
let va = vld1q_f32(a.as_ptr().add(i));
let vb = vld1q_f32(b.as_ptr().add(i));
acc0 = vfmaq_f32(acc0, va, vb);
}
i += 4;
}
let mut sum = vaddvq_f32(vaddq_f32(acc0, acc1));
// Scalar tail
while i < len {
sum += a[i] * b[i];
i += 1;
}
sum
}
/// NEON cosine similarity — fused single pass with 3 accumulators.
///
/// # Safety
/// Caller must ensure aarch64 target.
// SAFETY: NEON is always available on aarch64 targets; caller guarantees aarch64.
#[target_feature(enable = "neon")]
pub unsafe fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
let len = a.len();
let mut i = 0;
let mut dot_acc = vdupq_n_f32(0.0);
let mut norm_a_acc = vdupq_n_f32(0.0);
let mut norm_b_acc = vdupq_n_f32(0.0);
while i + 4 <= len {
// SAFETY: Caller guarantees NEON/FP16 available per the # Safety contract on this fn.
unsafe {
let va = vld1q_f32(a.as_ptr().add(i));
let vb = vld1q_f32(b.as_ptr().add(i));
dot_acc = vfmaq_f32(dot_acc, va, vb);
norm_a_acc = vfmaq_f32(norm_a_acc, va, va);
norm_b_acc = vfmaq_f32(norm_b_acc, vb, vb);
}
i += 4;
}
let mut dot = vaddvq_f32(dot_acc);
let mut norm_a = vaddvq_f32(norm_a_acc);
let mut norm_b = vaddvq_f32(norm_b_acc);
while i < len {
dot += a[i] * b[i];
norm_a += a[i] * a[i];
norm_b += b[i] * b[i];
i += 1;
}
let denom = (norm_a * norm_b).sqrt();
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
/// NEON L2 distance.
///
/// # Safety
/// Caller must ensure aarch64 target.
// SAFETY: NEON is always available on aarch64 targets; caller guarantees aarch64.
#[target_feature(enable = "neon")]
pub unsafe fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len());
let len = a.len();
let mut i = 0;
let mut acc = vdupq_n_f32(0.0);
while i + 4 <= len {
// SAFETY: Caller guarantees NEON/FP16 available per the # Safety contract on this fn.
unsafe {
let va = vld1q_f32(a.as_ptr().add(i));
let vb = vld1q_f32(b.as_ptr().add(i));
let diff = vsubq_f32(va, vb);
acc = vfmaq_f32(acc, diff, diff);
}
i += 4;
}
let mut sum = vaddvq_f32(acc);
while i < len {
let d = a[i] - b[i];
sum += d * d;
i += 1;
}
sum.sqrt()
}
/// NEON f16 to f32 batch conversion.
///
/// Note: Hardware vcvt_f32_f16 requires nightly (stdarch_neon_f16).
/// On stable Rust, we delegate to the scalar implementation.
/// The NEON module still provides the function for API uniformity.
pub fn f16_to_f32_batch(input: &[u16], output: &mut [f32]) {
// Delegate to scalar — hardware f16 intrinsics are unstable on aarch64.
crate::scalar::f16_to_f32_batch(input, output);
}
/// Fletcher32 checksum (scalar implementation, no NEON intrinsics used).
///
/// This function uses no NEON intrinsics despite living in the neon module.
/// It is safe to call without feature detection.
pub fn checksum_fletcher32(data: &[u8]) -> u32 {
let mut sum1: u32 = 0xFFFF;
let mut sum2: u32 = 0xFFFF;
let mut i = 0;
// Process in blocks of 360 words (720 bytes) to avoid overflow before modulo
// 360 * 65535 fits in u32
while i + 1 < data.len() {
let remaining_words = (data.len() - i) / 2;
let block_words = remaining_words.min(360);
for _ in 0..block_words {
let word = ((data[i] as u32) << 8) | (data[i + 1] as u32);
sum1 += word;
sum2 += sum1;
i += 2;
}
sum1 %= 65535;
sum2 %= 65535;
}
// Handle trailing byte
if i < data.len() {
let word = (data[i] as u32) << 8;
sum1 = (sum1 + word) % 65535;
sum2 = (sum2 + sum1) % 65535;
}
(sum2 << 16) | sum1
}
/// NEON dot product of two `i8` slices, widened to `i32`, for any aarch64 CPU.
///
/// `vmull_s8` multiplies eight lanes into `i16` — even `-128 * -128` is 16 384,
/// inside `i16` — and `vpadalq_s16` adds adjacent pairs of those into `i32`
/// accumulators, so nothing can overflow before the final horizontal sum.
///
/// CPUs with the ARMv8.2 dot-product extension should use
/// [`dot_i8_dotprod`], which does the multiply and the accumulate in one
/// instruction.
///
/// # Safety
/// Caller must ensure aarch64 target (NEON always available).
// SAFETY: NEON is always available on aarch64 targets; caller guarantees aarch64.
#[target_feature(enable = "neon")]
pub unsafe fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
assert_eq!(a.len(), b.len());
let len = a.len();
let mut i = 0;
let mut acc0 = vdupq_n_s32(0);
let mut acc1 = vdupq_n_s32(0);
while i + 16 <= len {
// SAFETY: NEON is available per the # Safety contract, and both
// 16-byte loads start at an index checked against `len` above.
unsafe {
let va = vld1q_s8(a.as_ptr().add(i));
let vb = vld1q_s8(b.as_ptr().add(i));
acc0 = vpadalq_s16(acc0, vmull_s8(vget_low_s8(va), vget_low_s8(vb)));
acc1 = vpadalq_s16(acc1, vmull_high_s8(va, vb));
}
i += 16;
}
let mut sum = vaddvq_s32(vaddq_s32(acc0, acc1));
while i < len {
sum += i32::from(a[i]) * i32::from(b[i]);
i += 1;
}
sum
}
/// One `SDOT`: for each of the four `i32` lanes of `acc`, add the dot
/// product of the corresponding four `i8` pairs from `a` and `b`.
///
/// Written as inline assembly because the `vdotq_s32` intrinsic is still
/// behind the unstable `stdarch_neon_dotprod` feature; inline assembly is
/// stable on aarch64.
///
/// # Safety
/// Caller must ensure the CPU supports the `dotprod` extension.
#[inline]
#[target_feature(enable = "neon,dotprod")]
unsafe fn sdot(acc: int32x4_t, a: int8x16_t, b: int8x16_t) -> int32x4_t {
let mut acc = acc;
// SAFETY: `dotprod` is enabled for this function and the caller
// guarantees the CPU supports it. The instruction reads only its three
// vector registers and touches no memory.
unsafe {
std::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc,
a = in(vreg) a,
b = in(vreg) b,
options(pure, nomem, nostack),
);
}
acc
}
/// NEON dot product of two `i8` slices using the ARMv8.2 dot-product
/// extension (`SDOT`): sixteen multiply-accumulates per instruction, straight
/// into `i32` lanes.
///
/// Present on the cores this crate actually runs on — Cortex-A76 and later
/// (Raspberry Pi 5, current Android phones), Neoverse-N1 (Graviton2, Ampere
/// Altra), and every Apple Silicon generation.
///
/// # Safety
/// Caller must verify `is_aarch64_feature_detected!("dotprod")`.
// SAFETY: caller has verified the dotprod extension at runtime.
#[target_feature(enable = "neon,dotprod")]
pub unsafe fn dot_i8_dotprod(a: &[i8], b: &[i8]) -> i32 {
assert_eq!(a.len(), b.len());
let len = a.len();
let mut i = 0;
let mut acc0 = vdupq_n_s32(0);
let mut acc1 = vdupq_n_s32(0);
// Two independent accumulators so consecutive SDOTs are not serialised on
// one register.
while i + 32 <= len {
// SAFETY: dotprod is available per the # Safety contract, and every
// 16-byte load starts at an index checked against `len` above.
unsafe {
acc0 = sdot(
acc0,
vld1q_s8(a.as_ptr().add(i)),
vld1q_s8(b.as_ptr().add(i)),
);
acc1 = sdot(
acc1,
vld1q_s8(a.as_ptr().add(i + 16)),
vld1q_s8(b.as_ptr().add(i + 16)),
);
}
i += 32;
}
if i + 16 <= len {
// SAFETY: as above; the load is bounds-checked by this condition.
unsafe {
acc0 = sdot(
acc0,
vld1q_s8(a.as_ptr().add(i)),
vld1q_s8(b.as_ptr().add(i)),
);
}
i += 16;
}
let mut sum = vaddvq_s32(vaddq_s32(acc0, acc1));
while i < len {
sum += i32::from(a[i]) * i32::from(b[i]);
i += 1;
}
sum
}