Files
clawhdf5/crates/clawhdf5-accel/src/scalar.rs
T
osobhandClaude Opus 5 97e65f2adf feat(accel): runtime-dispatched int8 dot product
The int8-quantised HNSW index compared vectors with a scalar loop that
the compiler vectorised for the x86-64 baseline (SSE2), while the f32
path it was measured against goes through `clawhdf5-accel` and runs
AVX2. So the ~13% throughput cost recorded for `quantized_index` was a
missing kernel rather than a property of int8.

`clawhdf5_accel::dot_i8` adds a scalar fallback and an AVX2 path:
sign-extend each 16-byte half to i16, then `madd_epi16`, which
multiplies and sums adjacent pairs straight into i32 lanes. It is
dispatched through the same detected backend as the f32 kernels, and
the index now calls it.

Integer arithmetic, so the SIMD path must agree with scalar bit for
bit — tested at lengths that are and are not multiples of the block,
and at the -128 extreme for overflow.

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

173 lines
5.0 KiB
Rust

//! Portable scalar implementations of all operations.
//! These serve as fallbacks when SIMD is not available.
pub fn dot_product(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len(), "vectors must have equal length");
a.iter().zip(b.iter()).map(|(x, y)| x * y).sum()
}
pub fn vector_norm(v: &[f32]) -> f32 {
dot_product(v, v).sqrt()
}
pub fn cosine_similarity(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len(), "vectors must have equal length");
let mut dot = 0.0f32;
let mut norm_a = 0.0f32;
let mut norm_b = 0.0f32;
for (x, y) in a.iter().zip(b.iter()) {
dot += x * y;
norm_a += x * x;
norm_b += y * y;
}
let denom = (norm_a * norm_b).sqrt();
if denom < f32::EPSILON {
0.0
} else {
dot / denom
}
}
pub fn batch_cosine(query: &[f32], vectors: &[&[f32]], results: &mut [(usize, f32)]) {
for (i, v) in vectors.iter().enumerate() {
results[i] = (i, cosine_similarity(query, v));
}
}
pub fn batch_cosine_prenorm(
query_normed: &[f32],
vectors: &[&[f32]],
norms: &[f32],
results: &mut [(usize, f32)],
) {
for (i, v) in vectors.iter().enumerate() {
let dot: f32 = query_normed.iter().zip(v.iter()).map(|(x, y)| x * y).sum();
let sim = if norms[i] == 0.0 { 0.0 } else { dot / norms[i] };
results[i] = (i, sim);
}
}
pub fn l2_distance(a: &[f32], b: &[f32]) -> f32 {
assert_eq!(a.len(), b.len(), "vectors must have equal length");
a.iter()
.zip(b.iter())
.map(|(x, y)| {
let d = x - y;
d * d
})
.sum::<f32>()
.sqrt()
}
pub fn batch_norms(vectors: &[&[f32]], norms: &mut [f32]) {
for (i, v) in vectors.iter().enumerate() {
norms[i] = vector_norm(v);
}
}
pub fn checksum_fletcher32(data: &[u8]) -> u32 {
let mut sum1: u32 = 0xFFFF;
let mut sum2: u32 = 0xFFFF;
// Process data as 16-bit words (big-endian, per HDF5 spec)
let mut i = 0;
while i + 1 < data.len() {
let word = ((data[i] as u32) << 8) | (data[i + 1] as u32);
sum1 = (sum1 + word) % 65535;
sum2 = (sum2 + sum1) % 65535;
i += 2;
}
// 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
}
#[cfg(feature = "float16")]
pub fn f16_to_f32_batch(input: &[u16], output: &mut [f32]) {
assert_eq!(input.len(), output.len());
for (i, &bits) in input.iter().enumerate() {
output[i] = half::f16::from_bits(bits).to_f32();
}
}
#[cfg(not(feature = "float16"))]
pub fn f16_to_f32_batch(input: &[u16], output: &mut [f32]) {
assert_eq!(input.len(), output.len());
// Software f16 -> f32 conversion without external deps
for (i, &bits) in input.iter().enumerate() {
output[i] = f16_to_f32_soft(bits);
}
}
/// Software half-precision to single-precision conversion.
#[cfg(not(feature = "float16"))]
fn f16_to_f32_soft(h: u16) -> f32 {
let sign = ((h >> 15) & 1) as u32;
let exp = ((h >> 10) & 0x1F) as u32;
let mant = (h & 0x3FF) as u32;
let f32_bits = if exp == 0 {
if mant == 0 {
// Zero
sign << 31
} else {
// Subnormal: normalize
let mut m = mant;
let mut e = 0i32;
while (m & 0x400) == 0 {
m <<= 1;
e += 1;
}
let exp32 = (127 - 15 - e) as u32;
let mant32 = (m & 0x3FF) << 13;
(sign << 31) | (exp32 << 23) | mant32
}
} else if exp == 31 {
// Inf or NaN
let mant32 = mant << 13;
(sign << 31) | (0xFF << 23) | mant32
} else {
// Normal
let exp32 = (exp as i32 - 15 + 127) as u32;
let mant32 = mant << 13;
(sign << 31) | (exp32 << 23) | mant32
};
f32::from_bits(f32_bits)
}
/// Dot product of two `i8` slices, widened to `i32`.
///
/// `dim` terms of at most `127 * 127` fit an `i32` for any realistic
/// dimension (over 130 000 terms before overflow is possible).
pub fn dot_i8(a: &[i8], b: &[i8]) -> i32 {
assert_eq!(a.len(), b.len());
// Four independent accumulators over 32-lane blocks: the widening product
// has to sit in a fixed-length chunk for the vectoriser to see it, and the
// separate accumulators keep it off one dependency chain.
const LANE: usize = 8;
let (a_blocks, a_tail) = a.as_chunks::<{ LANE * 4 }>();
let (b_blocks, b_tail) = b.as_chunks::<{ LANE * 4 }>();
let mut acc = [0i32; 4];
for (x, y) in a_blocks.iter().zip(b_blocks) {
for (lane, slot) in acc.iter_mut().enumerate() {
let mut sum = 0i32;
for k in 0..LANE {
sum += i32::from(x[lane * LANE + k]) * i32::from(y[lane * LANE + k]);
}
*slot += sum;
}
}
let tail: i32 = a_tail
.iter()
.zip(b_tail)
.map(|(&x, &y)| i32::from(x) * i32::from(y))
.sum();
acc[0] + acc[1] + acc[2] + acc[3] + tail
}