Turbo4 query simd (#10391)

* TQ SIMD: one backend ladder, resolved once per query

The 2- and 4-bit kernels share the same preference order (AVX-512 VNNI
→ AVX2 → SSE → NEON + SDOT → NEON → scalar), spelled out six times as
chains of `is_x86_feature_detected!` — and `Query{2,4}bitSimd::dotprod`
re-ran its chain for every vector scored.

Move the ladder into one `simd::SimdBackend` enum with a single `detect()`.
The query types resolve it in `new()` and dispatch on the stored value;
the symmetric `score_{2,4}bit_internal*` entry points dispatch on
`SimdBackend::detect()`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: one query layout and scalar reference for every packing width

`Query{1,2,4}bitSimd` are three copies of the same idea — quantize the
query into i8 halves, multiply them against an integer codebook — each
with its own query layout and its own set of SIMD kernels.  Introduce
`simd::query::QuerySimd<PLANES>`, generic over the number of codes per
packed byte (2, 4 or 8), which the three widths will share.

The query halves are stored as planes, one per code position within a
byte: plane `k` entry `j` is the half of query dim `PLANES · j + k`.
That is the order the codes come out of raw data bytes with a shift and
a mask, so a kernel never has to unpack them into dim order.  Planes are
zero-padded to the widest SIMD block, so a partial last block on the
data side multiplies against zeros.

The widths contribute only their integer encoding (`Encoding`: codebook
table, offset, scale and query range); the 1-bit width gets one here —
`{0, 128}` with offset 64 on x86_64, `∓127` on aarch64 — chosen so the
query keeps full i8 halves.  Only the scalar reference exists yet; the
SIMD kernels follow, and the width types switch over once they're in.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: AVX-512 VNNI kernel on the query planes

One ZMM of packed codes per block; for each plane the codes are shifted
down by the code width, masked and looked up in the codebook with one
`vpshufb`, then `VPDPBUSD` folds them into the plane's low and high
accumulators.  Two accumulator pairs per vector keep the VNNI latency
off the critical path at every width.  The last partial block is a
masked load whose dead lanes multiply against the planes' zero padding.

The shift count is an immediate, so the shift-by-width helper spells
out the three widths in a `match` — the only place the kernel is not
literally generic over `PLANES`.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: NEON SDOT kernel on the query planes

The AVX-512 kernel's shape on 128-bit registers: one `TBL` codebook
lookup per plane, `SDOT` (inline asm — `vdotq_s32` is still unstable)
into two accumulator pairs.  The last partial block runs on a
zero-padded copy of the remaining bytes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: AVX2 kernel on the query planes

One YMM of packed codes per block, one `vpshufb` lookup per plane and
`maddubs → madd` against ones into the same two accumulator pairs as
the VNNI kernel.  The `maddubs` pair sums stay inside i16 by the
per-width query bounds.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: SSE and plain NEON kernels on the query planes

The 128-bit forms of the AVX2 and SDOT kernels: `maddubs → madd` on
XMM, `vmull_s8 → vpadalq_s16` on NEON without `dotprod`.  Every backend
of the shared query type now has its kernel.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Query4bitSimd: score through QuerySimd<2>

`Query4bitSimd` becomes an alias of the shared query type; its own
chunk-and-tail query layout and the per-backend kernels built on it go
away, along with the accuracy tests the shared module now runs for
every width.  What stays in `query4bit` is the 4-bit encoding and the
symmetric `score_4bit_internal*` paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Query2bitSimd: score through QuerySimd<4>

The 2-bit asymmetric kernels unpacked every 4 packed bytes into 16
centroid bytes through two `pshufb` / `TBL` pair-table lookups and a
zip before a single multiply-accumulate step — on AVX-512 that was four
128-bit unpacks and six lane inserts per pair of `VPDPBUSD`.  On the
query planes the same 16 codes cost one shift, one mask and one lookup
per plane, straight from a full-width load.

`Query2bitSimd` becomes an alias of the shared query type; its chunk
layout and per-backend kernels go away.  The pair-table unpack stays
for the symmetric `score_2bit_internal*` paths.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Query1bitSimd: score through QuerySimd<8>

The 1-bit asymmetric kernels bit-plane-transposed the query and scored
`Σ_b 2^b · popcount(data AND plane_b)` per 16-byte block — eight
AND + popcount + add steps on XMM (even with AVX-512, through the VL
forms) and a `BITS`-deep accumulator array.  On the query planes a
sign bit is just a one-bit code: shift, mask, a two-entry codebook
lookup and the same multiply-accumulate as the wider widths, on full
256-/512-bit registers.

`Query1bitSimd` becomes an alias of the shared query type.  Its query
width was a const parameter (8 bits by default, 16 for TQ+ through the
`Bits1Wide` variant); the shared encoding always carries 16-bit halves,
so the variant and the TQ+ special case go away.  The popcount kernels
stay for the symmetric `score_1bit_internal`, where XOR + popcount is
the right tool.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* Bench: cold per-vector rows for every width

`query{4,2,1}bit_dotprod_cold` from one generic body — scalar reference,
public `dotprod` and each backend — so the widths can be compared on one
host.  `TURBO_SIMD_DIMS` narrows or widens the dims of a run and
`TURBO_SIMD_POOL_KB` shrinks the pool to L1 for hot-kernel numbers.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* QuerySimd: query bytes as a parameter; 8-bit queries for the 1-bit width

The shared kernels always carried two query bytes (a ~16-bit query),
which cost the 1-bit width its 8-bit-query speed: the old bit-plane
kernel scored a 16-byte vector in 4.9 ns hot (34.7 ns cold) against
9.1 ns (54.7 ns) through the planes, the difference being the second
byte's multiply-accumulates on a vector that fills a quarter of one
block.  Above 512 dims the planes win either way.

Make the number of query bytes a parameter: `QuerySimd<PLANES,
QUERY_BYTES>` with one plane per query byte and code position, and one
accumulator pair per query byte.  A one-byte query is scaled to the
range of a single byte, `RADIX / 2 − 1`.  `Query1bitSimd` is the
one-byte instance — at parity with the old kernel at small dims (cold
36.9 / 38.3 / 38.4 ns at d = 128 / 256 / 512) and 1.8× faster at 1536
(68 vs 121 ns) — and `Query1bitWideSimd` the two-byte one, which TQ+
selects through the `Bits1Wide` variant as before.  The 2- and 4-bit
widths keep two bytes.

Bench: `query1bit_wide_dotprod_cold` and a `query1bit_wide` row next
to BQ.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Ivan Pleshkov
2026-09-03 12:45:56 +02:00
committed by timvisee
co-authored by Claude Fable 5
parent d931e0c2b8
commit 82b9ed8d32
16 changed files with 1821 additions and 2570 deletions
+57 -125
View File
@@ -3,7 +3,7 @@ use std::hint::black_box;
use criterion::{BenchmarkId, Criterion, Throughput, criterion_group, criterion_main};
use quantization::encoded_vectors_binary::BitsStoreType;
use quantization::turboquant::simd::{
Query1bitSimd, Query2bitSimd, Query4bitSimd, score_1bit_internal, score_1bit_internal_scalar,
Query1bitSimd, Query1bitWideSimd, QuerySimd, score_1bit_internal, score_1bit_internal_scalar,
score_2bit_internal, score_2bit_internal_scalar, score_4bit_internal,
score_4bit_internal_scalar,
};
@@ -32,9 +32,22 @@ const DIMS_2BIT: &[usize] = &[128, 1532, 1536]; // 1532 = 95 chunks (odd) + 12-d
const DIMS_1BIT: &[usize] = &[128, 1528, 1536]; // 1528 = 11 blocks (odd) + 120-dim tail
/// Pool size ≫ L2. Indices are shuffled so the hardware prefetcher can't stream
/// vectors into cache — each iteration pays a real DRAM fetch.
/// vectors into cache — each iteration pays a real DRAM fetch. Override with
/// `TURBO_SIMD_POOL_KB` to measure hot kernels (a pool that fits L1).
const POOL_BYTES: usize = 64 * 1024 * 1024;
fn pool_bytes() -> usize {
match std::env::var("TURBO_SIMD_POOL_KB") {
Ok(kb) => {
kb.trim()
.parse::<usize>()
.expect("TURBO_SIMD_POOL_KB: not a size")
* 1024
}
Err(_) => POOL_BYTES,
}
}
struct VectorPool {
buf: Vec<u8>,
indices: Vec<u32>,
@@ -44,7 +57,7 @@ struct VectorPool {
impl VectorPool {
fn with_packed_bytes(packed_bytes: usize, seed: u64) -> Self {
let count = (POOL_BYTES / packed_bytes).max(1024);
let count = (pool_bytes() / packed_bytes).max(64);
let mut rng = SmallRng::seed_from_u64(seed);
let buf: Vec<u8> = (0..count * packed_bytes)
.map(|_| rng.random_range(0..=u8::MAX))
@@ -88,12 +101,33 @@ fn make_query(dim: usize) -> Vec<f32> {
(0..dim).map(|_| rng.random_range(-1.0_f32..1.0)).collect()
}
fn bench_dotprod_cold(c: &mut Criterion) {
let mut group = c.benchmark_group("query4bit_dotprod_cold");
for &dim in DIMS_4BIT {
/// Dims for a bench group: the built-in list, or `TURBO_SIMD_DIMS` (comma
/// separated) to narrow or widen a run.
fn dims(default: &[usize]) -> Vec<usize> {
match std::env::var("TURBO_SIMD_DIMS") {
Ok(list) => list
.split(',')
.map(|dim| dim.trim().parse().expect("TURBO_SIMD_DIMS: not a dim"))
.collect(),
Err(_) => default.to_vec(),
}
}
/// Cold-cache query-vs-vector dotprod at the width packing `PLANES` codes
/// per byte: a hot encoded query against data vectors drawn from a shuffled
/// pool ≫ cache, so every call pays a real DRAM fetch — the HNSW scoring
/// pattern. `scalar` is the reference kernel, `dotprod` the public path
/// (best backend + float reconstruction), the rest the individual backends.
fn dotprod_cold<const PLANES: usize, const QUERY_BYTES: usize>(
c: &mut Criterion,
group: &str,
default_dims: &[usize],
) {
let mut group = c.benchmark_group(group);
for dim in dims(default_dims) {
let q = make_query(dim);
let query = Query4bitSimd::new(&q);
let pool = VectorPool::new_4bit(dim, 7);
let query = QuerySimd::<PLANES, QUERY_BYTES>::new(&q);
let pool = VectorPool::with_packed_bytes(dim / PLANES, 7);
group.throughput(Throughput::Elements(dim as u64));
@@ -106,10 +140,6 @@ fn bench_dotprod_cold(c: &mut Criterion) {
});
});
// Full public path: `dotprod_raw_best` (best SIMD backend) + the
// `sum_codebook_over_vector` bias correction + `as f32` reconstruction.
// This is what a real caller pays. Comparing against the best raw
// backend for the current CPU shows the bias-correction overhead.
group.bench_with_input(BenchmarkId::new("dotprod", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
@@ -184,6 +214,13 @@ fn bench_dotprod_cold(c: &mut Criterion) {
group.finish();
}
fn bench_dotprod_cold(c: &mut Criterion) {
dotprod_cold::<2, 2>(c, "query4bit_dotprod_cold", DIMS_4BIT);
dotprod_cold::<4, 2>(c, "query2bit_dotprod_cold", DIMS_2BIT);
dotprod_cold::<8, 1>(c, "query1bit_dotprod_cold", DIMS_1BIT);
dotprod_cold::<8, 2>(c, "query1bit_wide_dotprod_cold", DIMS_1BIT);
}
/// Benchmarks [`score_4bit_internal`] (both vectors already PQ-encoded, both
/// cold from DRAM). Every iteration picks two *different* pool indices so
/// each call pays two independent random cache misses — this models the
@@ -379,11 +416,10 @@ fn bench_score_1bit_cold(c: &mut Criterion) {
/// Query-against-data benchmarks: a single hot query is scored against cold
/// 1-bit PQ data vectors. Mirrors the HNSW scoring pattern.
///
/// Compares our `Query1bitSimd<{8,12,16}>` (signed bit-plane transpose +
/// AND-popcount) against the existing BQ `Scalar8bits` path
/// Compares `Query1bitSimd` (8-bit query) and `Query1bitWideSimd` (16-bit)
/// against the existing BQ `Scalar8bits` path
/// (`BitsStoreType::xor_popcnt_scalar` with `bits_count=8`) — BQ stays at
/// 8 bits (its only supported scalar width) and serves as the baseline.
/// 12/16 rows show the linear cost of widening the query.
fn bench_query1bit_vs_bq_hot(c: &mut Criterion) {
let mut group = c.benchmark_group("query1bit_vs_bq_scalar8bits");
let mut rng_seed = SmallRng::seed_from_u64(42);
@@ -393,37 +429,27 @@ fn bench_query1bit_vs_bq_hot(c: &mut Criterion) {
.map(|_| rng_seed.random_range(-1.0_f32..1.0))
.collect();
let q_our_8 = Query1bitSimd::<8>::new(&query_floats);
let q_our_12 = Query1bitSimd::<12>::new(&query_floats);
let q_our_16 = Query1bitSimd::<16>::new(&query_floats);
let query = Query1bitSimd::new(&query_floats);
let query_wide = Query1bitWideSimd::new(&query_floats);
let q_bq = encode_bq_scalar8bits(&query_floats);
group.throughput(Throughput::Elements(dim as u64));
group.bench_with_input(BenchmarkId::new("query1bit_8bit", dim), &dim, |b, _| {
group.bench_with_input(BenchmarkId::new("query1bit", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
q_our_8.dotprod(black_box(v))
query.dotprod(black_box(v))
});
});
group.bench_with_input(BenchmarkId::new("query1bit_12bit", dim), &dim, |b, _| {
group.bench_with_input(BenchmarkId::new("query1bit_wide", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
q_our_12.dotprod(black_box(v))
});
});
group.bench_with_input(BenchmarkId::new("query1bit_16bit", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
q_our_16.dotprod(black_box(v))
query_wide.dotprod(black_box(v))
});
});
@@ -467,99 +493,6 @@ fn encode_bq_scalar8bits(query: &[f32]) -> Vec<u8> {
encoded
}
/// Cold-cache query-vs-vector dotprod for 2-bit PQ. Mirrors
/// [`bench_dotprod_cold`] for 4-bit — a hot `Query2bitSimd` against cold
/// data vectors drawn from a shuffled 64 MB pool.
fn bench_dotprod_2bit_cold(c: &mut Criterion) {
let mut group = c.benchmark_group("query2bit_dotprod_cold");
for &dim in DIMS_2BIT {
let q = make_query(dim);
let query = Query2bitSimd::new(&q);
let pool = VectorPool::new_2bit(dim, 7);
group.throughput(Throughput::Elements(dim as u64));
group.bench_with_input(BenchmarkId::new("scalar", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
black_box(&query).dotprod_raw(black_box(v))
});
});
group.bench_with_input(BenchmarkId::new("dotprod", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
black_box(&query).dotprod(black_box(v))
});
});
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
{
group.bench_with_input(BenchmarkId::new("neon", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
unsafe { black_box(&query).dotprod_raw_neon(black_box(v)) }
});
});
if std::arch::is_aarch64_feature_detected!("dotprod") && dim.is_multiple_of(32) {
group.bench_with_input(BenchmarkId::new("neon_sdot", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
unsafe { black_box(&query).dotprod_raw_neon_sdot(black_box(v)) }
});
});
}
}
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("sse4.1") && std::is_x86_feature_detected!("ssse3") {
group.bench_with_input(BenchmarkId::new("sse", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
unsafe { black_box(&query).dotprod_raw_sse(black_box(v)) }
});
});
}
if std::is_x86_feature_detected!("avx2") {
group.bench_with_input(BenchmarkId::new("avx2", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
unsafe { black_box(&query).dotprod_raw_avx2(black_box(v)) }
});
});
}
if std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni")
{
group.bench_with_input(BenchmarkId::new("avx512_vnni", dim), &dim, |b, _| {
let mut cursor = 0usize;
b.iter(|| {
let v = pool.vector(cursor);
cursor = cursor.wrapping_add(1);
unsafe { black_box(&query).dotprod_raw_avx512_vnni(black_box(v)) }
});
});
}
}
}
group.finish();
}
/// Cold-cache vector-vs-vector score for 2-bit PQ. Mirrors
/// [`bench_score_cold`] for 4-bit.
fn bench_score_2bit_cold(c: &mut Criterion) {
@@ -661,7 +594,6 @@ criterion_group!(
benches,
bench_dotprod_cold,
bench_score_cold,
bench_dotprod_2bit_cold,
bench_score_2bit_cold,
bench_score_1bit_cold,
bench_query1bit_vs_bq_hot,
+4 -6
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use crate::turboquant::simd::{Query1bitSimd, Query2bitSimd, Query4bitSimd};
use crate::turboquant::simd::{Query1bitSimd, Query1bitWideSimd, Query2bitSimd, Query4bitSimd};
pub mod encoding;
pub mod lloyd_max;
@@ -123,15 +123,13 @@ pub struct EncodedQueryTQ {
/// on architectures without a matching SIMD instruction set the scalar
/// reference kernel inside each type takes over automatically.
///
/// `Bits1Wide` is the same kernel as `Bits1` but with 16-bit query
/// quantization instead of the default 8-bit (the kernel's max). Used in
/// `Bits1Wide` is `Bits1` with a 16-bit query instead of 8-bit. Used in
/// TQ+ for 1-bit storage: the per-coord `D'` pre-scaling pushes some query
/// coords into the bottom of the 8-bit integer range, where rounding noise
/// is large relative to the signal — 16 bits gives the most headroom the
/// existing kernel supports.
/// is large relative to the signal.
pub enum EncodedQueryTQData {
Bits1(Query1bitSimd),
Bits1Wide(Query1bitSimd<16>),
Bits1Wide(Query1bitWideSimd),
Bits2(Query2bitSimd),
Bits4(Query4bitSimd),
}
+10 -10
View File
@@ -4,9 +4,9 @@ use crate::DistanceType;
use crate::turboquant::encoding::TqVectorExtras;
use crate::turboquant::rotation::HadamardRotation;
use crate::turboquant::simd::{
CODEBOOK_SCALE_SQ_2BIT, CODEBOOK_SCALE_SQ_4BIT, Query1bitSimd, Query2bitSimd, Query4bitSimd,
score_1bit_internal, score_2bit_internal, score_2bit_internal_weighted, score_4bit_internal,
score_4bit_internal_weighted,
CODEBOOK_SCALE_SQ_2BIT, CODEBOOK_SCALE_SQ_4BIT, Query1bitSimd, Query1bitWideSimd,
Query2bitSimd, Query4bitSimd, score_1bit_internal, score_2bit_internal,
score_2bit_internal_weighted, score_4bit_internal, score_4bit_internal_weighted,
};
use crate::turboquant::{EncodedQueryTQ, EncodedQueryTQData, TQBits, TQMode, TQRotation};
@@ -540,18 +540,18 @@ impl TurboQuantizer {
// has no downstream benefit here).
let rotated_f32: Vec<f32> = rotated.iter().map(|&x| x as f32).collect();
// For TQ+ + Bits1 storage, widen query quantization from the default
// 8 bits to the kernel's max of 16. The per-coord `D'` pre-scaling
// can push some coords toward the small end of the integer range;
// 8 bits loses too much there.
// For TQ+ + Bits1 storage, widen the query from 8 to 16 bits: the
// per-coord `D'` pre-scaling can push some coords toward the small
// end of the integer range, where 8 bits lose too much.
let use_wide_query =
self.error_correction.is_some() && matches!(self.bits, TQBits::Bits1 | TQBits::Bits1_5);
let data = match self.bits {
TQBits::Bits1 | TQBits::Bits1_5 if use_wide_query => {
EncodedQueryTQData::Bits1Wide(Query1bitSimd::<16>::new(&rotated_f32))
EncodedQueryTQData::Bits1Wide(Query1bitWideSimd::new(&rotated_f32))
}
TQBits::Bits1 | TQBits::Bits1_5 => {
EncodedQueryTQData::Bits1(Query1bitSimd::new(&rotated_f32))
}
TQBits::Bits1 => EncodedQueryTQData::Bits1(Query1bitSimd::new(&rotated_f32)),
TQBits::Bits1_5 => EncodedQueryTQData::Bits1(Query1bitSimd::new(&rotated_f32)),
TQBits::Bits2 => EncodedQueryTQData::Bits2(Query2bitSimd::new(&rotated_f32)),
TQBits::Bits4 => EncodedQueryTQData::Bits4(Query4bitSimd::new(&rotated_f32)),
};
+65 -11
View File
@@ -3,36 +3,90 @@
//! Every `query{N}bit` submodule exposes two public entry points:
//!
//! * [`Query{N}bitSimd`](query4bit::Query4bitSimd) — a rotation-applied query
//! precomputed for fast asymmetric scoring (original-query × PQ-vector).
//! `dotprod(vector)` dispatches at runtime to the best SIMD backend available
//! on the host CPU.
//! precomputed for fast asymmetric scoring (original-query × PQ-vector):
//! the shared [`query::QuerySimd`] kernels instantiated at the width, which
//! dispatch to the best SIMD backend available on the host CPU.
//! * [`score_{N}bit_internal`](query4bit::score_4bit_internal) — dot product of
//! two already-encoded PQ vectors (symmetric scoring), same runtime dispatch.
//!
//! Available SIMD backends per bit-width:
//! Available SIMD backends:
//!
//! | Bits | x86_64 | aarch64 |
//! |------|-----------------------------------------------|----------------------|
//! | 1 | AVX-512 VPOPCNTDQ, AVX2, SSE4.1+SSSE3 | NEON |
//! | 2 | AVX-512 VNNI, AVX2, SSE4.1+SSSE3 | NEON + SDOT, NEON |
//! | 4 | AVX-512 VNNI, AVX2, SSE4.1+SSSE3 | NEON + SDOT, NEON |
//! | Path | x86_64 | aarch64 |
//! |-------------------|---------------------------------------|-------------------|
//! | asymmetric, 1/2/4 | AVX-512 VNNI, AVX2, SSE4.1+SSSE3 | NEON + SDOT, NEON |
//! | symmetric, 1 | AVX-512 VPOPCNTDQ, AVX2, SSE4.1+SSSE3 | NEON |
//! | symmetric, 2/4 | AVX-512 VNNI, AVX2, SSE4.1+SSSE3 | NEON + SDOT, NEON |
//!
//! On any other target the scalar reference kernels in each module take over.
//! On any other target the scalar reference kernels take over.
pub mod hadamard;
pub mod query;
pub mod query1bit;
pub mod query2bit;
pub mod query4bit;
/// Best multiply-accumulate backend the host CPU supports, in preference
/// order AVX-512 VNNI → AVX2 → SSE → NEON + SDOT → NEON → scalar. Shared by
/// every kernel built on `u8 × i8` / `i8 × i8` products (the asymmetric
/// paths of every width and the symmetric 2- and 4-bit paths); resolve it
/// once with [`SimdBackend::detect`] and dispatch on the value, so a scoring
/// loop doesn't re-run CPU feature detection per vector.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) enum SimdBackend {
#[cfg(target_arch = "x86_64")]
Avx512Vnni,
#[cfg(target_arch = "x86_64")]
Avx2,
#[cfg(target_arch = "x86_64")]
Sse,
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
NeonSdot,
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
Neon,
Scalar,
}
impl SimdBackend {
pub(crate) fn detect() -> Self {
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni")
{
return SimdBackend::Avx512Vnni;
}
if std::is_x86_feature_detected!("avx2") {
return SimdBackend::Avx2;
}
if std::is_x86_feature_detected!("sse4.1") && std::is_x86_feature_detected!("ssse3") {
return SimdBackend::Sse;
}
}
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
{
if std::arch::is_aarch64_feature_detected!("dotprod") {
return SimdBackend::NeonSdot;
}
return SimdBackend::Neon;
}
#[allow(unreachable_code)]
SimdBackend::Scalar
}
}
// Re-exports below include the runtime-dispatching entry points used by the
// crate's scoring paths (`Query{N}bitSimd`, `score_{N}bit_internal`) plus
// scalar-reference and arch-specific kernels the benchmarks at
// `benches/turbo_simd.rs` target directly. Every symbol here is consumed
// either by `turboquant::quantization` inside the crate or by benches/
// outside — narrowing them to `pub(crate)` would break the bench build.
pub use query::QuerySimd;
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
pub use query1bit::score_1bit_internal_neon;
pub use query1bit::{Query1bitSimd, score_1bit_internal, score_1bit_internal_scalar};
pub use query1bit::{
Query1bitSimd, Query1bitWideSimd, score_1bit_internal, score_1bit_internal_scalar,
};
#[cfg(target_arch = "x86_64")]
pub use query1bit::{
score_1bit_internal_avx2, score_1bit_internal_avx512_vpopcntdq, score_1bit_internal_sse,
@@ -0,0 +1,362 @@
//! NEON kernels for [`QuerySimd`] on aarch64.
//!
//! The codebook is stored signed (`Encoding::codebook` as `i8`), so
//! `vmull_s8` and `SDOT` operate on true `i8 × i8` products — no bias
//! correction — and the query bytes use the full i8 range (see the module
//! docs of [`super`]).
use core::arch::aarch64::*;
use super::{Code, PLANE_BLOCK, QueryPlanes, QuerySimd, encoding};
/// Packed data bytes per NEON block: one 128-bit register of codes.
const BLOCK_128: usize = 16;
const _: () = assert!(PLANE_BLOCK.is_multiple_of(BLOCK_128));
/// The codebook as a `TBL` table for the width packing `PLANES` codes per
/// byte.
const fn codebook<const PLANES: usize>() -> [Code; 16] {
encoding(PLANES).codebook
}
/// Mask of one code in the low bits of a byte.
const fn code_mask<const PLANES: usize>() -> u8 {
((1u16 << (8 / PLANES)) - 1) as u8
}
/// One [`BLOCK_128`]-byte block of every query plane. Loaded once per
/// block and shared by all vectors scored against it.
#[derive(Clone, Copy)]
struct QueryBlock128<const PLANES: usize, const QUERY_BYTES: usize> {
bytes: [[int8x16_t; PLANES]; QUERY_BYTES],
}
impl<const PLANES: usize, const QUERY_BYTES: usize> QueryBlock128<PLANES, QUERY_BYTES> {
/// # Safety
/// `offset + BLOCK_128` must not exceed the plane length.
#[inline]
#[target_feature(enable = "neon")]
unsafe fn load(planes: &QueryPlanes<PLANES, QUERY_BYTES>, offset: usize) -> Self {
let mut block = Self {
bytes: [[vdupq_n_s8(0); PLANES]; QUERY_BYTES],
};
for (b, byte_planes) in block.bytes.iter_mut().enumerate() {
for (k, plane) in byte_planes.iter_mut().enumerate() {
*plane = unsafe { load_plane_128(&planes.bytes[b][k], offset) };
}
}
block
}
}
/// # Safety
/// `offset + BLOCK_128 <= plane.len()`.
#[inline]
#[target_feature(enable = "neon")]
unsafe fn load_plane_128(plane: &[i8], offset: usize) -> int8x16_t {
debug_assert!(offset + BLOCK_128 <= plane.len());
unsafe { vld1q_s8(plane.as_ptr().add(offset)) }
}
/// The next plane's codes moved into the low bits of every byte. The shift
/// count is an immediate, hence the match over the widths.
#[inline]
#[target_feature(enable = "neon")]
fn next_plane_128<const PLANES: usize>(codes: uint8x16_t) -> uint8x16_t {
match PLANES {
2 => vshrq_n_u8(codes, 4),
4 => vshrq_n_u8(codes, 2),
_ => vshrq_n_u8(codes, 1),
}
}
/// Codebook values addressed by the low code of every byte of `codes`.
#[inline]
#[target_feature(enable = "neon")]
fn lookup_codes<const PLANES: usize>(codes: uint8x16_t) -> int8x16_t {
let table = const { codebook::<PLANES>() };
let codebook = unsafe { vld1q_s8(table.as_ptr()) };
vqtbl1q_s8(
codebook,
vandq_u8(codes, vdupq_n_u8(const { code_mask::<PLANES>() })),
)
}
/// `acc[lane] += Σ₄ a · b` over each lane's four i8 pairs (`SDOT`). Inline
/// asm because `vdotq_s32` is still unstable (rust-lang/rust#117224).
///
/// # Safety
/// CPU must support `dotprod`.
#[inline]
#[target_feature(enable = "neon,dotprod")]
unsafe fn sdot(mut acc: int32x4_t, a: int8x16_t, b: int8x16_t) -> int32x4_t {
unsafe {
core::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, preserves_flags),
);
}
acc
}
/// `acc[lane] += Σ₄ a · b` without `dotprod`: widening i8 multiplies
/// (`vmull_s8`, exact in i16) pairwise-added into the i32 lanes
/// (`vpadalq_s16`). Same per-lane bound as [`sdot`].
#[inline]
#[target_feature(enable = "neon")]
fn mul_add(acc: int32x4_t, a: int8x16_t, b: int8x16_t) -> int32x4_t {
let acc = vpadalq_s16(acc, vmull_s8(vget_low_s8(a), vget_low_s8(b)));
vpadalq_s16(acc, vmull_high_s8(a, b))
}
/// i32 accumulators of one vector: per query byte two independent chains,
/// plane `k` feeding chain `k & 1`, which keeps the multiply-accumulate
/// latency off the critical path at every width.
///
/// i32 lane bound: each block adds at most `4 · 127 · 128 = 65 024` per
/// plane to a lane, so overflow needs ~33 K blocks ≈ 500 K packed bytes.
#[derive(Clone, Copy)]
struct Acc128<const QUERY_BYTES: usize> {
bytes: [[int32x4_t; 2]; QUERY_BYTES],
}
impl<const QUERY_BYTES: usize> Acc128<QUERY_BYTES> {
#[inline]
#[target_feature(enable = "neon")]
fn zero() -> Self {
Self {
bytes: [[vdupq_n_s32(0); 2]; QUERY_BYTES],
}
}
/// Fold one block of packed `codes` (16 bytes) into the accumulators
/// with `SDOT`: plane by plane, the codes are shifted down, masked to
/// one code per byte and looked up in the codebook.
///
/// # Safety
/// CPU must support `dotprod`.
#[inline]
#[target_feature(enable = "neon,dotprod")]
unsafe fn accumulate_sdot<const PLANES: usize>(
&mut self,
codes: uint8x16_t,
query: QueryBlock128<PLANES, QUERY_BYTES>,
) {
let mut shifted = codes;
for k in 0..PLANES {
let values = lookup_codes::<PLANES>(shifted);
for (acc, planes) in self.bytes.iter_mut().zip(&query.bytes) {
acc[k & 1] = unsafe { sdot(acc[k & 1], values, planes[k]) };
}
shifted = next_plane_128::<PLANES>(shifted);
}
}
/// [`Self::accumulate_sdot`] for CPUs without `dotprod`.
#[inline]
#[target_feature(enable = "neon")]
fn accumulate_mull<const PLANES: usize>(
&mut self,
codes: uint8x16_t,
query: QueryBlock128<PLANES, QUERY_BYTES>,
) {
let mut shifted = codes;
for k in 0..PLANES {
let values = lookup_codes::<PLANES>(shifted);
for (acc, planes) in self.bytes.iter_mut().zip(&query.bytes) {
acc[k & 1] = mul_add(acc[k & 1], values, planes[k]);
}
shifted = next_plane_128::<PLANES>(shifted);
}
}
/// Per-lane totals of every query byte.
#[inline]
#[target_feature(enable = "neon")]
fn fold(self) -> [int32x4_t; QUERY_BYTES] {
self.bytes.map(|[a, b]| vaddq_s32(a, b))
}
}
impl<const PLANES: usize, const QUERY_BYTES: usize> QuerySimd<PLANES, QUERY_BYTES> {
/// ARM NEON over the query planes for CPUs without `dotprod`: the
/// [`Self::dotprod_raw_neon_sdot`] block loop with `vmull_s8 →
/// vpadalq_s16` in place of `SDOT`.
///
/// # Safety
/// CPU must support the `neon` feature (always true on aarch64).
#[target_feature(enable = "neon")]
pub unsafe fn dotprod_raw_neon(&self, vector: &[u8]) -> i64 {
assert_eq!(
vector.len(),
self.vector_bytes,
"QuerySimd<{PLANES}, {QUERY_BYTES}>::dotprod_raw_neon: vector length mismatch ({} \
vs expected {})",
vector.len(),
self.vector_bytes,
);
unsafe { Self::reduce_neon(self.accumulate_neon(vector.as_ptr())) }
}
/// [`Self::accumulate_neon_sdot`] for CPUs without `dotprod`.
///
/// # Safety
/// `data` must be readable for `self.vector_bytes` bytes.
#[inline]
#[target_feature(enable = "neon")]
unsafe fn accumulate_neon(&self, data: *const u8) -> Acc128<QUERY_BYTES> {
unsafe {
let mut acc = Acc128::zero();
let full_blocks = self.vector_bytes / BLOCK_128;
for block in 0..full_blocks {
let offset = block * BLOCK_128;
let query = QueryBlock128::load(&self.planes, offset);
acc.accumulate_mull(vld1q_u8(data.add(offset)), query);
}
let tail = self.vector_bytes % BLOCK_128;
if tail > 0 {
let offset = full_blocks * BLOCK_128;
let query = QueryBlock128::load(&self.planes, offset);
let mut block = [0u8; BLOCK_128];
std::ptr::copy_nonoverlapping(data.add(offset), block.as_mut_ptr(), tail);
acc.accumulate_mull(vld1q_u8(block.as_ptr()), query);
}
acc
}
}
/// ARMv8.2-A Dot Product variant over the query planes: one 128-bit
/// register of packed codes per block, one `TBL` codebook lookup per
/// plane and `SDOT` into independent accumulators (see [`Acc128`]).
/// The last partial block runs on a zero-padded copy of the remaining
/// bytes.
///
/// # Safety
/// CPU must support `neon` and `dotprod`.
#[target_feature(enable = "neon,dotprod")]
pub unsafe fn dotprod_raw_neon_sdot(&self, vector: &[u8]) -> i64 {
assert_eq!(
vector.len(),
self.vector_bytes,
"QuerySimd<{PLANES}, {QUERY_BYTES}>::dotprod_raw_neon_sdot: vector length mismatch \
({} vs expected {})",
vector.len(),
self.vector_bytes,
);
unsafe { Self::reduce_neon(self.accumulate_neon_sdot(vector.as_ptr())) }
}
/// Block loop of the SDOT kernels over the vector at `data`.
///
/// # Safety
/// CPU must support `dotprod`; `data` must be readable for
/// `self.vector_bytes` bytes.
#[inline]
#[target_feature(enable = "neon,dotprod")]
unsafe fn accumulate_neon_sdot(&self, data: *const u8) -> Acc128<QUERY_BYTES> {
unsafe {
let mut acc = Acc128::zero();
let full_blocks = self.vector_bytes / BLOCK_128;
for block in 0..full_blocks {
let offset = block * BLOCK_128;
let query = QueryBlock128::load(&self.planes, offset);
acc.accumulate_sdot(vld1q_u8(data.add(offset)), query);
}
let tail = self.vector_bytes % BLOCK_128;
if tail > 0 {
let offset = full_blocks * BLOCK_128;
let query = QueryBlock128::load(&self.planes, offset);
let mut block = [0u8; BLOCK_128];
std::ptr::copy_nonoverlapping(data.add(offset), block.as_mut_ptr(), tail);
acc.accumulate_sdot(vld1q_u8(block.as_ptr()), query);
}
acc
}
}
/// Raw dot product from one vector's accumulators. Horizontal adds are
/// single instructions here, so nothing is gained by fusing them.
#[inline]
#[target_feature(enable = "neon")]
fn reduce_neon(acc: Acc128<QUERY_BYTES>) -> i64 {
Self::combine_bytes(acc.fold().map(|total| i64::from(vaddvq_s32(total))))
}
}
#[cfg(test)]
mod tests {
use rand::SeedableRng as _;
use rand::prelude::StdRng;
use super::super::QuerySimd;
use super::super::shared::{parity_dims, random_inputs};
/// Every kernel the host supports must reproduce the scalar reference
/// bit-exactly at every parity dim.
fn kernels_match_scalar<const PLANES: usize, const QUERY_BYTES: usize>() {
let has_dotprod = std::arch::is_aarch64_feature_detected!("dotprod");
let mut rng = StdRng::seed_from_u64(7);
for dim in parity_dims::<PLANES>() {
let (query, vector) = random_inputs::<PLANES, QUERY_BYTES>(&mut rng, dim);
let scalar = query.dotprod_raw(&vector);
let tag = format!("PLANES={PLANES} QUERY_BYTES={QUERY_BYTES} dim={dim}");
unsafe {
let neon = query.dotprod_raw_neon(&vector);
assert_eq!(scalar, neon, "{tag}: scalar {scalar} != neon {neon}");
if has_dotprod {
let sdot = query.dotprod_raw_neon_sdot(&vector);
assert_eq!(scalar, sdot, "{tag}: scalar {scalar} != sdot {sdot}");
}
}
}
}
#[test]
fn test_kernels_match_scalar() {
kernels_match_scalar::<2, 2>();
kernels_match_scalar::<4, 2>();
kernels_match_scalar::<8, 1>();
kernels_match_scalar::<8, 2>();
}
/// Saturation safety at an extreme dim (64K) under the worst-case load:
/// the query maxed out and every code at the max-magnitude codebook
/// slot (all-ones bytes at every width). The scalar reference is i64
/// throughout; a SIMD mismatch proves some intermediate saturated or
/// overflowed.
fn saturation_safety_64k<const PLANES: usize, const QUERY_BYTES: usize>() {
let dim = 65_536;
let query = QuerySimd::<PLANES, QUERY_BYTES>::new(&vec![1.0_f32; dim]);
let vector = vec![0xFF_u8; dim / PLANES];
let scalar = query.dotprod_raw(&vector);
let tag = format!("PLANES={PLANES} QUERY_BYTES={QUERY_BYTES}");
unsafe {
let neon = query.dotprod_raw_neon(&vector);
assert_eq!(scalar, neon, "{tag}: neon disagrees");
if std::arch::is_aarch64_feature_detected!("dotprod") {
let sdot = query.dotprod_raw_neon_sdot(&vector);
assert_eq!(scalar, sdot, "{tag}: sdot disagrees");
}
}
}
#[test]
fn test_saturation_safety_64k() {
saturation_safety_64k::<2, 2>();
saturation_safety_64k::<4, 2>();
saturation_safety_64k::<8, 1>();
saturation_safety_64k::<8, 2>();
}
}
@@ -0,0 +1,488 @@
//! Asymmetric scoring — an f32 query against vectors of packed codebook
//! indices — generic over the packing width and the query precision.
//!
//! [`QuerySimd<PLANES, QUERY_BYTES>`] scores against vectors whose codes are
//! packed `PLANES` per byte: two 4-bit codes, four 2-bit codes or eight
//! 1-bit codes (`PLANES = 8 / bits`). The widths differ only in their
//! integer codebook (see [`Encoding`]); the query layout, the scalar
//! reference and every SIMD kernel are shared.
//!
//! # Query layout
//!
//! The query is quantized to signed integers of `QUERY_BYTES` i8 bytes,
//! `q_signed = Σ_b K^b · byte_b` (one byte for an 8-bit query, two for a
//! ~16-bit one), which the kernels multiply against the codebook values
//! with `u8 × i8` (x86_64) or `i8 × i8` (aarch64) instructions. The bytes
//! are stored as [`QueryPlanes`]: one plane per query byte and code
//! position within a packed data byte, so a wide load of raw data bytes
//! lines up with the query without unpacking into dim order.
//!
//! # Integer encoding
//!
//! Both architectures share the reconstruction
//! `postprocess_scale · (dot_raw bias_correction)`, but encode the
//! codebook and the query differently to get the most precision out of
//! their instruction sets:
//!
//! * **aarch64** — `vmull_s8` and `SDOT` are true `i8 × i8 → i16/i32`
//! signed multiplies, so the codebook is stored as signed `i8 ∈ [127,
//! 127]` with no offset. Query bytes are full `i8` combined with
//! `K = 256` (~15.9-bit precision for a two-byte query), and there is no
//! bias.
//!
//! * **x86_64** — `maddubs` / `VPDPBUSD` consume one `u8` and one `i8`
//! operand. The codebook is stored unsigned (`c_u = c_signed + offset`),
//! and the query bytes are kept narrow enough that the `maddubs` pair
//! sum stays inside i16: with the full `u8` codebooks of the 2- and 4-bit
//! widths (`c_u ≤ 255`) the bytes are 7-bit (`K = 128`, `|pair| ≤
//! 2·255·64 = 32 640`); the 1-bit width only needs `c_u ∈ {0, 128}` and
//! keeps full i8 bytes. The offset contributes a per-query bias
//! `offset · Σ q_signed`, subtracted once per vector.
use super::{SimdBackend, query1bit, query2bit, query4bit};
/// Codebook entry in the storage form the kernels multiply: signed on
/// aarch64 (`vmull_s8` / `SDOT`), unsigned everywhere else (`maddubs` /
/// `VPDPBUSD` take a `u8` operand; the scalar fallback mirrors x86_64).
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
pub(crate) type Code = i8;
#[cfg(not(all(target_arch = "aarch64", target_feature = "neon")))]
pub(crate) type Code = u8;
/// Integer encoding of one packing width: the codebook the kernels multiply
/// and the query quantization that keeps every intermediate inside its lane.
#[derive(Clone, Copy)]
pub(crate) struct Encoding {
/// Codebook value per code in the arch-native storage form, padded to
/// a full 16-entry shuffle table; entries past the width's code range
/// are never addressed.
pub codebook: [Code; 16],
/// `c_signed = codebook[k] offset`; `0` where the codebook is stored
/// signed.
pub offset: i64,
/// Integer units per centroid unit: `c_signed ≈ c_float · scale`.
pub scale: f32,
/// Radix of the query bytes: `q_signed = query_high_coef · high + low`
/// for a two-byte query, each byte in `[K/2, K/2)`.
pub query_high_coef: i64,
/// Largest `|q_signed|` a two-byte query is scaled to — the symmetric
/// range both bytes cover.
pub query_abs_max: f32,
}
/// Pad a width's codebook to the 16-entry shuffle table [`Encoding`] holds.
pub(crate) const fn pad_codebook<const N: usize>(codebook: [Code; N]) -> [Code; 16] {
let mut padded = [0; 16];
let mut k = 0;
while k < N {
padded[k] = codebook[k];
k += 1;
}
padded
}
/// The encoding of the width packing `planes` codes per byte.
const fn encoding(planes: usize) -> Encoding {
match planes {
2 => query4bit::ENCODING,
4 => query2bit::ENCODING,
8 => query1bit::ENCODING,
_ => panic!("QuerySimd: PLANES must be 2, 4 or 8"),
}
}
/// Widest block of packed data bytes any kernel consumes at once (AVX-512:
/// 64 bytes). Every query plane is padded to a multiple of it.
const PLANE_BLOCK: usize = 64;
/// Query bytes regrouped by code position — the layout the SIMD kernels
/// consume.
///
/// A packed data byte `j` holds the codes of dims `PLANES · j + k` for
/// `k ∈ 0..PLANES`, code `k` in bits `[k · bits, (k + 1) · bits)`. Plane
/// `bytes[b][k]` lines query byte `b` up with exactly that order: its entry
/// `j` is byte `b` of query dim `PLANES · j + k`. So a kernel shifts the
/// raw data bytes by `k · bits`, masks off one code per byte and multiplies
/// against the planes of `k` — no unpacking into dim order.
///
/// Each plane is zero-padded to a multiple of [`PLANE_BLOCK`] bytes, so a
/// kernel can always load a whole block on the query side; whatever the
/// data lanes past the vector's end hold, they multiply against zeros.
pub(crate) struct QueryPlanes<const PLANES: usize, const QUERY_BYTES: usize> {
bytes: [[Vec<i8>; PLANES]; QUERY_BYTES],
}
impl<const PLANES: usize, const QUERY_BYTES: usize> QueryPlanes<PLANES, QUERY_BYTES> {
/// Build the planes from the encoded query, one `[i8; QUERY_BYTES]` per
/// dim; the number of dims must be a multiple of `PLANES`.
fn new(encoded: impl ExactSizeIterator<Item = [i8; QUERY_BYTES]>) -> Self {
let dim = encoded.len();
debug_assert!(dim.is_multiple_of(PLANES));
let len = (dim / PLANES).next_multiple_of(PLANE_BLOCK);
let mut bytes: [[Vec<i8>; PLANES]; QUERY_BYTES] =
std::array::from_fn(|_| std::array::from_fn(|_| vec![0; len]));
for (i, dim_bytes) in encoded.enumerate() {
let (j, k) = (i / PLANES, i % PLANES);
for (b, &byte) in dim_bytes.iter().enumerate() {
bytes[b][k][j] = byte;
}
}
Self { bytes }
}
}
/// Encoded query for asymmetric scoring against vectors packing `PLANES`
/// codes per byte, quantized to `QUERY_BYTES` (1 or 2) i8 bytes per dim.
///
/// The f32 query is scaled to the widest range its bytes can hold and split
/// into i8 bytes stored as [`QueryPlanes`] (see the module docs for the
/// per-arch encoding). Any dim that is a multiple of `PLANES` works, so a
/// matryoshka-trimmed model fits without re-encoding.
///
/// `dotprod` computes `dot_raw = Σ_j q_signed[j] · codebook[v[j]]` and
/// returns `postprocess_scale · (dot_raw bias_correction)`.
pub struct QuerySimd<const PLANES: usize, const QUERY_BYTES: usize> {
/// Query bytes, regrouped by code position.
planes: QueryPlanes<PLANES, QUERY_BYTES>,
/// Packed bytes per encoded vector: `dim / PLANES`.
vector_bytes: usize,
/// `1 / (q_scale · c_scale)` — prefactor from integer to float dot
/// product.
postprocess_scale: f32,
/// `offset · Σ q_signed[j]` over all dims, subtracted from `dot_raw` to
/// recover the signed dot product; `0` where the codebook is signed.
bias_correction: i64,
/// SIMD backend resolved once at construction, so scoring doesn't re-run
/// CPU feature detection for every vector.
backend: SimdBackend,
}
impl<const PLANES: usize, const QUERY_BYTES: usize> QuerySimd<PLANES, QUERY_BYTES> {
const ENCODING: Encoding = encoding(PLANES);
/// Bits per code.
const BITS: usize = 8 / PLANES;
/// Radix of the query bytes.
const RADIX: i64 = Self::ENCODING.query_high_coef;
/// Largest `|q_signed|` the query is scaled to: the encoding's two-byte
/// range, or for a one-byte query the range of a single byte.
const QUERY_ABS_MAX: f32 = match QUERY_BYTES {
1 => (Self::RADIX / 2 - 1) as f32,
2 => Self::ENCODING.query_abs_max,
_ => panic!("QuerySimd: QUERY_BYTES must be 1 or 2"),
};
/// Encode `data`; its length must be a multiple of `PLANES`.
pub fn new(data: &[f32]) -> Self {
assert!(
data.len().is_multiple_of(PLANES),
"QuerySimd<{PLANES}, {QUERY_BYTES}> requires query dim to be a multiple of {PLANES} \
(got {})",
data.len(),
);
let encoding = Self::ENCODING;
let q_abs_max = data
.iter()
.copied()
.map(f32::abs)
.fold(0.0_f32, f32::max)
.max(f32::EPSILON);
let q_scale = Self::QUERY_ABS_MAX / q_abs_max;
let k = Self::RADIX as i32;
let half_k = k / 2;
let clamp_hi = Self::QUERY_ABS_MAX;
let clamp_lo = -Self::QUERY_ABS_MAX;
// Balanced signed split: every byte but the last takes the remainder
// in `[k/2, k/2)`, the last takes what is left.
let mut sum_q_signed: i64 = 0;
let planes = QueryPlanes::new(data.iter().map(|&value| {
let q_signed = (value * q_scale).round().clamp(clamp_lo, clamp_hi) as i32;
sum_q_signed += i64::from(q_signed);
let mut rest = q_signed;
std::array::from_fn(|b| {
if b + 1 == QUERY_BYTES {
return rest as i8;
}
let r = rest.rem_euclid(k);
let byte = if r >= half_k { r - k } else { r };
rest = (rest - byte) / k;
byte as i8
})
}));
Self {
planes,
vector_bytes: data.len() / PLANES,
postprocess_scale: 1.0 / (q_scale * encoding.scale),
bias_correction: encoding.offset * sum_q_signed,
backend: SimdBackend::detect(),
}
}
/// Packed bytes per encoded vector: `dim / PLANES`.
#[inline]
pub fn vector_bytes(&self) -> usize {
self.vector_bytes
}
/// Score the encoded query against a `vector` of packed codes (`PLANES`
/// codes per byte, code `k` of byte `j` in bits `[k · bits, (k + 1) ·
/// bits)`). `vector.len()` must equal [`Self::vector_bytes`].
///
/// Dispatches to the SIMD backend resolved at construction (see
/// [`SimdBackend`]).
pub fn dotprod(&self, vector: &[u8]) -> f32 {
self.postprocess(self.dotprod_raw_best(vector))
}
/// Float reconstruction of a raw integer dot product.
#[inline]
fn postprocess(&self, dot_raw: i64) -> f32 {
self.postprocess_scale * (dot_raw - self.bias_correction) as f32
}
#[inline]
fn dotprod_raw_best(&self, vector: &[u8]) -> i64 {
match self.backend {
#[cfg(target_arch = "x86_64")]
SimdBackend::Avx512Vnni => unsafe { self.dotprod_raw_avx512_vnni(vector) },
#[cfg(target_arch = "x86_64")]
SimdBackend::Avx2 => unsafe { self.dotprod_raw_avx2(vector) },
#[cfg(target_arch = "x86_64")]
SimdBackend::Sse => unsafe { self.dotprod_raw_sse(vector) },
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
SimdBackend::NeonSdot => unsafe { self.dotprod_raw_neon_sdot(vector) },
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
SimdBackend::Neon => unsafe { self.dotprod_raw_neon(vector) },
SimdBackend::Scalar => self.dotprod_raw(vector),
}
}
/// `Σ_b K^b · sums[b]` — the raw dot product from per-byte totals.
#[inline]
fn combine_bytes(sums: [i64; QUERY_BYTES]) -> i64 {
sums.iter()
.rev()
.fold(0, |total, &sum| total * Self::RADIX + sum)
}
/// Scalar reference: `Σ_j q_signed[j] · codebook[v[j]]` over all dims,
/// computed per query byte with i64 accumulators and combined at the
/// end — saturation-free by construction, which is what the SIMD parity
/// tests check against.
pub fn dotprod_raw(&self, vector: &[u8]) -> i64 {
assert_eq!(
vector.len(),
self.vector_bytes,
"QuerySimd<{PLANES}, {QUERY_BYTES}>::dotprod_raw: vector length mismatch ({} vs \
expected {})",
vector.len(),
self.vector_bytes,
);
let encoding = Self::ENCODING;
let mask = (1u8 << Self::BITS) - 1;
let mut sums = [0i64; QUERY_BYTES];
for (j, &byte) in vector.iter().enumerate() {
for k in 0..PLANES {
let code = (byte >> (k * Self::BITS)) & mask;
let c = i64::from(encoding.codebook[code as usize]);
for (sum, planes) in sums.iter_mut().zip(&self.planes.bytes) {
*sum += i64::from(planes[k][j]) * c;
}
}
}
Self::combine_bytes(sums)
}
}
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
mod arm;
#[cfg(target_arch = "x86_64")]
mod x64;
/// Test helpers shared by the kernel parity tests of every backend.
#[cfg(test)]
pub(crate) mod shared {
use rand::prelude::StdRng;
use super::super::shared::{random_bytes, sample_normal_vec};
use super::QuerySimd;
/// Corner-case vector lengths (packed bytes) for the block loops of
/// every backend — blocks of 16 (SSE, NEON), 32 (AVX2) and 64 (AVX-512)
/// bytes, with the last partial block padded or masked:
/// • `16, 64, 128, 512, 1024` — whole blocks only, at every width.
/// • `1` — a single data byte.
/// • `15, 31, 63` — one byte short of a 16-, 32- and 64-byte block;
/// `65` — one byte past a 64-byte block.
/// • `8, 9, 23, 24, 135, 513, 1023` — assorted partial blocks,
/// including a realistic matryoshka slice.
pub const PARITY_BYTES: &[usize] = &[
1, 8, 9, 15, 16, 23, 24, 31, 63, 64, 65, 128, 135, 512, 513, 1023, 1024,
];
/// The dims of [`PARITY_BYTES`] at a width packing `PLANES` codes per
/// byte.
pub fn parity_dims<const PLANES: usize>() -> impl Iterator<Item = usize> {
PARITY_BYTES.iter().map(move |&bytes| bytes * PLANES)
}
/// Parity-test inputs: a query ~ N(0, 1) and a vector of uniformly
/// random codes (random bytes are random packed codes at every width).
pub fn random_inputs<const PLANES: usize, const QUERY_BYTES: usize>(
rng: &mut StdRng,
dim: usize,
) -> (QuerySimd<PLANES, QUERY_BYTES>, Vec<u8>) {
let query = sample_normal_vec(rng, dim);
(QuerySimd::new(&query), random_bytes(rng, dim / PLANES))
}
}
/// Accuracy tests of the public `QuerySimd` API against float ground truth,
/// at every width and query precision. Per-backend parity tests (SIMD
/// kernel vs the scalar reference `dotprod_raw`) live in the `arm` / `x64`
/// submodules.
#[cfg(test)]
mod tests {
use rand::SeedableRng as _;
use rand::prelude::StdRng;
use super::super::shared::{encode_to_nearest_centroid, pack_codes, sample_normal_vec};
use super::QuerySimd;
use crate::turboquant::TQBits;
/// The width packing `PLANES` codes per byte.
fn bits<const PLANES: usize>() -> TQBits {
match PLANES {
2 => TQBits::Bits4,
4 => TQBits::Bits2,
8 => TQBits::Bits1,
_ => unreachable!(),
}
}
/// Reconstruction accuracy on realistic PQ inputs: query N(0,1), vector
/// drawn from N(0,1) then mapped to its nearest centroid. `dotprod` is
/// compared against the ideal PQ dot (`Σ q[j] · c[v[j]]` with
/// float-precision centroid lookup) — the error the integer encoding
/// adds over a hypothetical perfect-precision PQ should be tiny.
///
/// Parameterized over matryoshka-style corner-case dims to exercise the
/// tail handling end-to-end (not just bit-exact parity).
fn dotprod_matches_float<const PLANES: usize, const QUERY_BYTES: usize>(dim: usize) {
let mut rng = StdRng::seed_from_u64(42);
let n_trials = 64;
let bits = bits::<PLANES>();
let centroids = bits.get_centroids();
for _ in 0..n_trials {
let query = sample_normal_vec(&mut rng, dim);
let v_raw = sample_normal_vec(&mut rng, dim);
let indices = encode_to_nearest_centroid(centroids, &v_raw);
let v_pq: Vec<f32> = indices.iter().map(|&k| centroids[k as usize]).collect();
let pq_dot: f32 = query.iter().zip(v_pq.iter()).map(|(a, b)| a * b).sum();
let simd_dot = QuerySimd::<PLANES, QUERY_BYTES>::new(&query)
.dotprod(&pack_codes(&indices, bits.bit_size()));
// Error scales roughly like √dim · σ_q · ε_c. Allow a tolerance
// that is comfortably above the 3σ tail for dim up to ~2K; an
// 8-bit query quantizes ~64× coarser than a two-byte one.
let base = if QUERY_BYTES == 1 { 0.06 } else { 0.03 };
let tol = (0.5_f32).max(base * (dim as f32).sqrt());
assert!(
(pq_dot - simd_dot).abs() < tol,
"PLANES={PLANES} QUERY_BYTES={QUERY_BYTES} dim={dim}: simd_dot {simd_dot} too \
far from ideal PQ dot {pq_dot} (tol={tol})",
);
}
}
/// Corner-case packed lengths: whole blocks, small and maximal tails,
/// odd block counts and a realistic matryoshka slice.
#[rstest::rstest]
#[case::full_blocks(128)]
#[case::small_tail(9)]
#[case::max_tail(15)]
#[case::odd_blocks_only(24)]
#[case::odd_blocks_plus_tail(31)]
#[case::matryoshka(135)]
#[case::large_with_tail(1023)]
fn test_dotprod_matches_float(#[case] bytes: usize) {
dotprod_matches_float::<2, 2>(bytes * 2);
dotprod_matches_float::<4, 2>(bytes * 4);
dotprod_matches_float::<8, 1>(bytes * 8);
dotprod_matches_float::<8, 2>(bytes * 8);
}
/// Quantitative proof that the integer encoding is negligible next to
/// PQ centroid snapping: RMS error added by the encoding is at least 5×
/// smaller than the RMS error PQ itself introduces. If this invariant
/// ever flips, something in the quantization pipeline lost precision.
fn simd_noise_below_pq_noise<const PLANES: usize, const QUERY_BYTES: usize>() {
let mut rng = StdRng::seed_from_u64(123);
let dim = 256;
let n_trials = 256;
let bits = bits::<PLANES>();
let centroids = bits.get_centroids();
let mut sq_pq_noise = 0.0_f64;
let mut sq_simd_noise = 0.0_f64;
for _ in 0..n_trials {
let query = sample_normal_vec(&mut rng, dim);
let v_raw = sample_normal_vec(&mut rng, dim);
let indices = encode_to_nearest_centroid(centroids, &v_raw);
let v_pq: Vec<f32> = indices.iter().map(|&k| centroids[k as usize]).collect();
let true_dot: f64 = query
.iter()
.zip(v_raw.iter())
.map(|(a, b)| f64::from(*a) * f64::from(*b))
.sum();
let pq_dot: f64 = query
.iter()
.zip(v_pq.iter())
.map(|(a, b)| f64::from(*a) * f64::from(*b))
.sum();
let simd_dot = f64::from(
QuerySimd::<PLANES, QUERY_BYTES>::new(&query)
.dotprod(&pack_codes(&indices, bits.bit_size())),
);
sq_pq_noise += (pq_dot - true_dot).powi(2);
sq_simd_noise += (simd_dot - pq_dot).powi(2);
}
let rms_pq_noise = (sq_pq_noise / f64::from(n_trials)).sqrt();
let rms_simd_noise = (sq_simd_noise / f64::from(n_trials)).sqrt();
// Print for easy comparison across encoding variants.
eprintln!(
"NOISE at PLANES={PLANES} QUERY_BYTES={QUERY_BYTES} dim={dim}: \
pq_rms={rms_pq_noise:.4} simd_rms={rms_simd_noise:.4} ratio={:.2}×",
rms_pq_noise / rms_simd_noise,
);
assert!(
rms_simd_noise * 5.0 < rms_pq_noise,
"PLANES={PLANES} QUERY_BYTES={QUERY_BYTES}: SIMD noise RMS {rms_simd_noise:.4} \
should be << PQ noise RMS {rms_pq_noise:.4} (ratio {:.2}×)",
rms_pq_noise / rms_simd_noise,
);
}
#[test]
fn test_simd_noise_below_pq_noise() {
simd_noise_below_pq_noise::<2, 2>();
simd_noise_below_pq_noise::<4, 2>();
simd_noise_below_pq_noise::<8, 1>();
simd_noise_below_pq_noise::<8, 2>();
}
}
@@ -0,0 +1,655 @@
//! x86_64 kernels for [`QuerySimd`].
//!
//! The codebook is stored unsigned (`Encoding::codebook` as `u8`), which
//! is what `maddubs` / `VPDPBUSD` expect in their `u8` operand slot; the
//! signed shift `c_signed = c_u offset` is undone by the per-query
//! `bias_correction`. See the module docs of [`super`] for the query
//! ranges that keep the `maddubs` pair sums inside i16.
use core::arch::x86_64::*;
use super::{Code, PLANE_BLOCK, QueryPlanes, QuerySimd, encoding};
/// Packed data bytes per SSE block: one XMM of codes.
const BLOCK_128: usize = 16;
const _: () = assert!(PLANE_BLOCK.is_multiple_of(BLOCK_128));
/// Packed data bytes per AVX2 block: one YMM of codes.
const BLOCK_256: usize = 32;
const _: () = assert!(PLANE_BLOCK.is_multiple_of(BLOCK_256));
/// Packed data bytes per AVX-512 block: one ZMM of codes.
const BLOCK_512: usize = 64;
const _: () = assert!(PLANE_BLOCK.is_multiple_of(BLOCK_512));
/// The codebook as a shuffle table for the width packing `PLANES` codes per
/// byte.
const fn codebook<const PLANES: usize>() -> [Code; 16] {
encoding(PLANES).codebook
}
/// Mask of one code in the low bits of a byte.
const fn code_mask<const PLANES: usize>() -> i8 {
((1u16 << (8 / PLANES)) - 1) as i8
}
/// One [`BLOCK_128`]-byte block of every query plane.
#[derive(Clone, Copy)]
struct QueryBlock128<const PLANES: usize, const QUERY_BYTES: usize> {
bytes: [[__m128i; PLANES]; QUERY_BYTES],
}
impl<const PLANES: usize, const QUERY_BYTES: usize> QueryBlock128<PLANES, QUERY_BYTES> {
/// # Safety
/// CPU must support `sse2`; `offset + BLOCK_128` must not exceed the
/// plane length.
#[inline]
#[target_feature(enable = "sse2")]
unsafe fn load(planes: &QueryPlanes<PLANES, QUERY_BYTES>, offset: usize) -> Self {
let mut block = Self {
bytes: [[_mm_setzero_si128(); PLANES]; QUERY_BYTES],
};
for (b, byte_planes) in block.bytes.iter_mut().enumerate() {
for (k, plane) in byte_planes.iter_mut().enumerate() {
*plane = unsafe { load_plane_128(&planes.bytes[b][k], offset) };
}
}
block
}
}
/// # Safety
/// CPU must support `sse2`; `offset + BLOCK_128 <= plane.len()`.
#[inline]
#[target_feature(enable = "sse2")]
unsafe fn load_plane_128(plane: &[i8], offset: usize) -> __m128i {
debug_assert!(offset + BLOCK_128 <= plane.len());
unsafe { _mm_loadu_si128(plane.as_ptr().add(offset).cast::<__m128i>()) }
}
/// [`next_plane_512`] on XMM.
#[inline]
#[target_feature(enable = "sse2")]
unsafe fn next_plane_128<const PLANES: usize>(codes: __m128i) -> __m128i {
match PLANES {
2 => _mm_srli_epi16(codes, 4),
4 => _mm_srli_epi16(codes, 2),
_ => _mm_srli_epi16(codes, 1),
}
}
/// `maddubs → madd` accumulators of one vector; the 128-bit form of
/// [`Acc256`], with the same integer bounds.
#[derive(Clone, Copy)]
struct Acc128<const QUERY_BYTES: usize> {
bytes: [[__m128i; 2]; QUERY_BYTES],
}
impl<const QUERY_BYTES: usize> Acc128<QUERY_BYTES> {
/// # Safety
/// CPU must support `sse2`.
#[inline]
#[target_feature(enable = "sse2")]
unsafe fn zero() -> Self {
Self {
bytes: [[_mm_setzero_si128(); 2]; QUERY_BYTES],
}
}
/// Fold one block of packed `codes` (16 bytes) into the accumulators.
///
/// # Safety
/// CPU must support `ssse3` and `sse4.1`.
#[inline]
#[target_feature(enable = "sse4.1,ssse3")]
unsafe fn accumulate<const PLANES: usize>(
&mut self,
codes: __m128i,
query: QueryBlock128<PLANES, QUERY_BYTES>,
) {
let table = const { codebook::<PLANES>() };
let codebook = unsafe { _mm_loadu_si128(table.as_ptr().cast::<__m128i>()) };
let mask = _mm_set1_epi8(const { code_mask::<PLANES>() });
let ones = _mm_set1_epi16(1);
let mut shifted = codes;
for k in 0..PLANES {
let values = _mm_shuffle_epi8(codebook, _mm_and_si128(shifted, mask));
for (acc, planes) in self.bytes.iter_mut().zip(&query.bytes) {
acc[k & 1] = _mm_add_epi32(
acc[k & 1],
_mm_madd_epi16(_mm_maddubs_epi16(values, planes[k]), ones),
);
}
shifted = unsafe { next_plane_128::<PLANES>(shifted) };
}
}
/// Per-lane totals of every query byte.
///
/// # Safety
/// CPU must support `sse2`.
#[inline]
#[target_feature(enable = "sse2")]
unsafe fn fold(self) -> [__m128i; QUERY_BYTES] {
self.bytes.map(|[a, b]| _mm_add_epi32(a, b))
}
}
/// One [`BLOCK_256`]-byte block of every query plane.
#[derive(Clone, Copy)]
struct QueryBlock256<const PLANES: usize, const QUERY_BYTES: usize> {
bytes: [[__m256i; PLANES]; QUERY_BYTES],
}
impl<const PLANES: usize, const QUERY_BYTES: usize> QueryBlock256<PLANES, QUERY_BYTES> {
/// # Safety
/// CPU must support `avx2`; `offset + BLOCK_256` must not exceed the
/// plane length.
#[inline]
#[target_feature(enable = "avx2")]
unsafe fn load(planes: &QueryPlanes<PLANES, QUERY_BYTES>, offset: usize) -> Self {
let mut block = Self {
bytes: [[_mm256_setzero_si256(); PLANES]; QUERY_BYTES],
};
for (b, byte_planes) in block.bytes.iter_mut().enumerate() {
for (k, plane) in byte_planes.iter_mut().enumerate() {
*plane = unsafe { load_plane_256(&planes.bytes[b][k], offset) };
}
}
block
}
}
/// # Safety
/// CPU must support `avx2`; `offset + BLOCK_256 <= plane.len()`.
#[inline]
#[target_feature(enable = "avx2")]
unsafe fn load_plane_256(plane: &[i8], offset: usize) -> __m256i {
debug_assert!(offset + BLOCK_256 <= plane.len());
unsafe { _mm256_loadu_si256(plane.as_ptr().add(offset).cast::<__m256i>()) }
}
/// [`next_plane_512`] on YMM.
#[inline]
#[target_feature(enable = "avx2")]
unsafe fn next_plane_256<const PLANES: usize>(codes: __m256i) -> __m256i {
match PLANES {
2 => _mm256_srli_epi16(codes, 4),
4 => _mm256_srli_epi16(codes, 2),
_ => _mm256_srli_epi16(codes, 1),
}
}
/// `maddubs → madd` accumulators of one vector; the same two-pair shape as
/// [`Acc512`].
///
/// The `maddubs` pair sums stay inside i16 by the module-level query bound
/// (`|pair| ≤ 2 · 255 · 64 = 32 640`, or exactly `2 · 128 · 128 = 32 768`
/// at the negative end for the 1-bit encoding); `madd` against ones then
/// adds at most 65 280 (65 536) per i32 lane per plane, the same bound as
/// VNNI.
#[derive(Clone, Copy)]
struct Acc256<const QUERY_BYTES: usize> {
bytes: [[__m256i; 2]; QUERY_BYTES],
}
impl<const QUERY_BYTES: usize> Acc256<QUERY_BYTES> {
/// # Safety
/// CPU must support `avx2`.
#[inline]
#[target_feature(enable = "avx2")]
unsafe fn zero() -> Self {
Self {
bytes: [[_mm256_setzero_si256(); 2]; QUERY_BYTES],
}
}
/// Fold one block of packed `codes` (32 bytes) into the accumulators.
///
/// # Safety
/// CPU must support `avx2`.
#[inline]
#[target_feature(enable = "avx2")]
unsafe fn accumulate<const PLANES: usize>(
&mut self,
codes: __m256i,
query: QueryBlock256<PLANES, QUERY_BYTES>,
) {
let table = const { codebook::<PLANES>() };
let codebook = _mm256_broadcastsi128_si256(unsafe {
_mm_loadu_si128(table.as_ptr().cast::<__m128i>())
});
let mask = _mm256_set1_epi8(const { code_mask::<PLANES>() });
let ones = _mm256_set1_epi16(1);
let mut shifted = codes;
for k in 0..PLANES {
let values = _mm256_shuffle_epi8(codebook, _mm256_and_si256(shifted, mask));
for (acc, planes) in self.bytes.iter_mut().zip(&query.bytes) {
acc[k & 1] = _mm256_add_epi32(
acc[k & 1],
_mm256_madd_epi16(_mm256_maddubs_epi16(values, planes[k]), ones),
);
}
shifted = unsafe { next_plane_256::<PLANES>(shifted) };
}
}
/// Per-lane totals of every query byte.
///
/// # Safety
/// CPU must support `avx2`.
#[inline]
#[target_feature(enable = "avx2")]
unsafe fn fold(self) -> [__m256i; QUERY_BYTES] {
self.bytes.map(|[a, b]| _mm256_add_epi32(a, b))
}
}
/// One [`BLOCK_512`]-byte block of every query plane.
#[derive(Clone, Copy)]
struct QueryBlock512<const PLANES: usize, const QUERY_BYTES: usize> {
bytes: [[__m512i; PLANES]; QUERY_BYTES],
}
impl<const PLANES: usize, const QUERY_BYTES: usize> QueryBlock512<PLANES, QUERY_BYTES> {
/// # Safety
/// CPU must support `avx512f`; `offset + BLOCK_512` must not exceed the
/// plane length.
#[inline]
#[target_feature(enable = "avx512f")]
unsafe fn load(planes: &QueryPlanes<PLANES, QUERY_BYTES>, offset: usize) -> Self {
let mut block = Self {
bytes: [[_mm512_setzero_si512(); PLANES]; QUERY_BYTES],
};
for (b, byte_planes) in block.bytes.iter_mut().enumerate() {
for (k, plane) in byte_planes.iter_mut().enumerate() {
*plane = unsafe { load_plane_512(&planes.bytes[b][k], offset) };
}
}
block
}
}
/// # Safety
/// CPU must support `avx512f`; `offset + BLOCK_512 <= plane.len()`.
#[inline]
#[target_feature(enable = "avx512f")]
unsafe fn load_plane_512(plane: &[i8], offset: usize) -> __m512i {
debug_assert!(offset + BLOCK_512 <= plane.len());
unsafe { _mm512_loadu_si512(plane.as_ptr().add(offset).cast::<__m512i>()) }
}
/// The next plane's codes moved into the low bits of every byte: `codes >>
/// bits` within 16-bit lanes, so the caller masks off what crosses over
/// from the neighboring byte. The shift count is an immediate, hence the
/// match over the widths.
#[inline]
#[target_feature(enable = "avx512bw")]
unsafe fn next_plane_512<const PLANES: usize>(codes: __m512i) -> __m512i {
match PLANES {
2 => _mm512_srli_epi16(codes, 4),
4 => _mm512_srli_epi16(codes, 2),
_ => _mm512_srli_epi16(codes, 1),
}
}
/// `VPDPBUSD` accumulators of one vector: per query byte two independent
/// chains, plane `k` feeding chain `k & 1`. Two chains per byte are
/// enough to keep the multiply-accumulate latency off the critical path at
/// every width (`PLANES / 2` dependent steps per block against `PLANES`
/// issued per byte).
///
/// i32 lane bound: each `VPDPBUSD` adds at most `4 · 255 · 64 = 65 280`
/// (`4 · 128 · 128 = 65 536` for the 1-bit encoding) to a lane, so overflow
/// needs ~32 K blocks ≈ 2 M packed bytes — far beyond any real input.
#[derive(Clone, Copy)]
struct Acc512<const QUERY_BYTES: usize> {
bytes: [[__m512i; 2]; QUERY_BYTES],
}
impl<const QUERY_BYTES: usize> Acc512<QUERY_BYTES> {
/// # Safety
/// CPU must support `avx512f`.
#[inline]
#[target_feature(enable = "avx512f")]
unsafe fn zero() -> Self {
Self {
bytes: [[_mm512_setzero_si512(); 2]; QUERY_BYTES],
}
}
/// Fold one block of packed `codes` (64 bytes) into the accumulators:
/// plane by plane, the codes are shifted down, masked to one code per
/// byte and looked up in the codebook.
///
/// # Safety
/// CPU must support `avx512f`, `avx512bw`, and `avx512vnni`.
#[inline]
#[target_feature(enable = "avx512f,avx512bw,avx512vnni")]
unsafe fn accumulate<const PLANES: usize>(
&mut self,
codes: __m512i,
query: QueryBlock512<PLANES, QUERY_BYTES>,
) {
let table = const { codebook::<PLANES>() };
let codebook =
_mm512_broadcast_i32x4(unsafe { _mm_loadu_si128(table.as_ptr().cast::<__m128i>()) });
let mask = _mm512_set1_epi8(const { code_mask::<PLANES>() });
let mut shifted = codes;
for k in 0..PLANES {
let values = _mm512_shuffle_epi8(codebook, _mm512_and_si512(shifted, mask));
for (acc, planes) in self.bytes.iter_mut().zip(&query.bytes) {
acc[k & 1] = _mm512_dpbusd_epi32(acc[k & 1], values, planes[k]);
}
shifted = unsafe { next_plane_512::<PLANES>(shifted) };
}
}
/// Per-lane totals of every query byte.
///
/// # Safety
/// CPU must support `avx512f`.
#[inline]
#[target_feature(enable = "avx512f")]
unsafe fn fold(self) -> [__m512i; QUERY_BYTES] {
self.bytes.map(|[a, b]| _mm512_add_epi32(a, b))
}
}
impl<const PLANES: usize, const QUERY_BYTES: usize> QuerySimd<PLANES, QUERY_BYTES> {
/// x86_64 SSE4.1 + SSSE3 over the query planes: one XMM of packed codes
/// per block, the 128-bit form of the AVX2 kernel.
///
/// # Safety
/// CPU must support `ssse3` and `sse4.1`.
#[target_feature(enable = "sse4.1,ssse3")]
pub unsafe fn dotprod_raw_sse(&self, vector: &[u8]) -> i64 {
assert_eq!(
vector.len(),
self.vector_bytes,
"QuerySimd<{PLANES}, {QUERY_BYTES}>::dotprod_raw_sse: vector length mismatch ({} vs \
expected {})",
vector.len(),
self.vector_bytes,
);
unsafe {
let totals = self.accumulate_sse(vector.as_ptr()).fold();
Self::combine_bytes(totals.map(|total| i64::from(hsum_i32_sse(total))))
}
}
/// Block loop of the SSE kernels over the vector at `data`.
///
/// # Safety
/// CPU must support `ssse3` and `sse4.1`; `data` must be readable for
/// `self.vector_bytes` bytes.
#[inline]
#[target_feature(enable = "sse4.1,ssse3")]
unsafe fn accumulate_sse(&self, data: *const u8) -> Acc128<QUERY_BYTES> {
unsafe {
let mut acc = Acc128::zero();
let full_blocks = self.vector_bytes / BLOCK_128;
for block in 0..full_blocks {
let offset = block * BLOCK_128;
let query = QueryBlock128::load(&self.planes, offset);
let codes = _mm_loadu_si128(data.add(offset).cast::<__m128i>());
acc.accumulate(codes, query);
}
let tail = self.vector_bytes % BLOCK_128;
if tail > 0 {
let offset = full_blocks * BLOCK_128;
let query = QueryBlock128::load(&self.planes, offset);
let mut block = [0u8; BLOCK_128];
std::ptr::copy_nonoverlapping(data.add(offset), block.as_mut_ptr(), tail);
let codes = _mm_loadu_si128(block.as_ptr().cast::<__m128i>());
acc.accumulate(codes, query);
}
acc
}
}
/// x86_64 AVX2 over the query planes: one YMM of packed codes per
/// block, one `vpshufb` codebook lookup per plane and `maddubs → madd`
/// products into independent accumulators (see [`Acc256`]). The last
/// partial block runs on a zero-padded copy of the remaining bytes.
///
/// # Safety
/// CPU must support `avx2`.
#[target_feature(enable = "avx2")]
pub unsafe fn dotprod_raw_avx2(&self, vector: &[u8]) -> i64 {
assert_eq!(
vector.len(),
self.vector_bytes,
"QuerySimd<{PLANES}, {QUERY_BYTES}>::dotprod_raw_avx2: vector length mismatch ({} \
vs expected {})",
vector.len(),
self.vector_bytes,
);
unsafe { Self::reduce_avx2(self.accumulate_avx2(vector.as_ptr())) }
}
/// Block loop of the AVX2 kernels over the vector at `data`.
///
/// # Safety
/// CPU must support `avx2`; `data` must be readable for
/// `self.vector_bytes` bytes.
#[inline]
#[target_feature(enable = "avx2")]
unsafe fn accumulate_avx2(&self, data: *const u8) -> Acc256<QUERY_BYTES> {
unsafe {
let mut acc = Acc256::zero();
let full_blocks = self.vector_bytes / BLOCK_256;
for block in 0..full_blocks {
let offset = block * BLOCK_256;
let query = QueryBlock256::load(&self.planes, offset);
let codes = _mm256_loadu_si256(data.add(offset).cast::<__m256i>());
acc.accumulate(codes, query);
}
let tail = self.vector_bytes % BLOCK_256;
if tail > 0 {
let offset = full_blocks * BLOCK_256;
let query = QueryBlock256::load(&self.planes, offset);
let mut block = [0u8; BLOCK_256];
std::ptr::copy_nonoverlapping(data.add(offset), block.as_mut_ptr(), tail);
let codes = _mm256_loadu_si256(block.as_ptr().cast::<__m256i>());
acc.accumulate(codes, query);
}
acc
}
}
/// Raw dot product from one vector's accumulators.
///
/// # Safety
/// CPU must support `avx2`.
#[inline]
#[target_feature(enable = "avx2")]
unsafe fn reduce_avx2(acc: Acc256<QUERY_BYTES>) -> i64 {
unsafe { Self::combine_bytes(acc.fold().map(|total| i64::from(hsum_i32_avx2(total)))) }
}
/// AVX-512 VNNI (Ice Lake Xeon+, Zen 4+) over the query planes: one ZMM
/// of packed codes per block, one `vpshufb` codebook lookup per plane
/// and `VPDPBUSD` into independent accumulators (see [`Acc512`]). The
/// last partial block is a masked load whose dead lanes multiply
/// against the planes' zero padding.
///
/// # Safety
/// CPU must support `avx512f`, `avx512bw`, and `avx512vnni`.
#[target_feature(enable = "avx512f,avx512bw,avx512vnni")]
pub unsafe fn dotprod_raw_avx512_vnni(&self, vector: &[u8]) -> i64 {
assert_eq!(
vector.len(),
self.vector_bytes,
"QuerySimd<{PLANES}, {QUERY_BYTES}>::dotprod_raw_avx512_vnni: vector length \
mismatch ({} vs expected {})",
vector.len(),
self.vector_bytes,
);
unsafe { Self::reduce_avx512(self.accumulate_avx512(vector.as_ptr())) }
}
/// Block loop of the AVX-512 kernels over the vector at `data`.
///
/// # Safety
/// CPU must support `avx512f`, `avx512bw`, and `avx512vnni`; `data` must
/// be readable for `self.vector_bytes` bytes.
#[inline]
#[target_feature(enable = "avx512f,avx512bw,avx512vnni")]
unsafe fn accumulate_avx512(&self, data: *const u8) -> Acc512<QUERY_BYTES> {
unsafe {
let mut acc = Acc512::zero();
let full_blocks = self.vector_bytes / BLOCK_512;
for block in 0..full_blocks {
let offset = block * BLOCK_512;
let query = QueryBlock512::load(&self.planes, offset);
let codes = _mm512_loadu_si512(data.add(offset).cast::<__m512i>());
acc.accumulate(codes, query);
}
let tail = self.vector_bytes % BLOCK_512;
if tail > 0 {
let offset = full_blocks * BLOCK_512;
let query = QueryBlock512::load(&self.planes, offset);
let mask: __mmask64 = (1 << tail) - 1;
let codes = _mm512_maskz_loadu_epi8(mask, data.add(offset).cast::<i8>());
acc.accumulate(codes, query);
}
acc
}
}
/// Raw dot product from one vector's accumulators.
///
/// # Safety
/// CPU must support `avx512f`.
#[inline]
#[target_feature(enable = "avx512f")]
unsafe fn reduce_avx512(acc: Acc512<QUERY_BYTES>) -> i64 {
unsafe {
Self::combine_bytes(
acc.fold()
.map(|total| i64::from(_mm512_reduce_add_epi32(total))),
)
}
}
}
#[target_feature(enable = "sse2")]
unsafe fn hsum_i32_sse(v: __m128i) -> i32 {
let v = _mm_add_epi32(v, _mm_shuffle_epi32(v, 0x4E));
let v = _mm_add_epi32(v, _mm_shuffle_epi32(v, 0xB1));
_mm_cvtsi128_si32(v)
}
#[inline]
#[target_feature(enable = "avx2")]
unsafe fn hsum_i32_avx2(v: __m256i) -> i32 {
unsafe {
hsum_i32_sse(_mm_add_epi32(
_mm256_castsi256_si128(v),
_mm256_extracti128_si256(v, 1),
))
}
}
#[cfg(test)]
mod tests {
use rand::SeedableRng as _;
use rand::prelude::StdRng;
use super::super::QuerySimd;
use super::super::shared::{parity_dims, random_inputs};
fn has_sse() -> bool {
std::is_x86_feature_detected!("ssse3") && std::is_x86_feature_detected!("sse4.1")
}
fn has_avx512_vnni() -> bool {
std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni")
}
/// Every kernel the host supports must reproduce the scalar reference
/// bit-exactly at every parity dim.
fn kernels_match_scalar<const PLANES: usize, const QUERY_BYTES: usize>() {
let mut rng = StdRng::seed_from_u64(7);
for dim in parity_dims::<PLANES>() {
let (query, vector) = random_inputs::<PLANES, QUERY_BYTES>(&mut rng, dim);
let scalar = query.dotprod_raw(&vector);
let tag = format!("PLANES={PLANES} QUERY_BYTES={QUERY_BYTES} dim={dim}");
unsafe {
if has_sse() {
let sse = query.dotprod_raw_sse(&vector);
assert_eq!(scalar, sse, "{tag}: scalar {scalar} != sse {sse}");
}
if std::is_x86_feature_detected!("avx2") {
let avx2 = query.dotprod_raw_avx2(&vector);
assert_eq!(scalar, avx2, "{tag}: scalar {scalar} != avx2 {avx2}");
}
if has_avx512_vnni() {
let vnni512 = query.dotprod_raw_avx512_vnni(&vector);
assert_eq!(
scalar, vnni512,
"{tag}: scalar {scalar} != avx512_vnni {vnni512}"
);
}
}
}
}
#[test]
fn test_kernels_match_scalar() {
kernels_match_scalar::<2, 2>();
kernels_match_scalar::<4, 2>();
kernels_match_scalar::<8, 1>();
kernels_match_scalar::<8, 2>();
}
/// Saturation safety at an extreme dim (64K) under the worst-case load:
/// the query maxed out and every code at the max-magnitude codebook
/// slot (all-ones bytes at every width). The scalar reference is i64
/// throughout; a SIMD mismatch proves some intermediate saturated or
/// overflowed.
fn saturation_safety_64k<const PLANES: usize, const QUERY_BYTES: usize>() {
let dim = 65_536;
let query = QuerySimd::<PLANES, QUERY_BYTES>::new(&vec![1.0_f32; dim]);
let vector = vec![0xFF_u8; dim / PLANES];
let scalar = query.dotprod_raw(&vector);
let tag = format!("PLANES={PLANES} QUERY_BYTES={QUERY_BYTES}");
unsafe {
if has_sse() {
let sse = query.dotprod_raw_sse(&vector);
assert_eq!(scalar, sse, "{tag}: sse disagrees");
}
if std::is_x86_feature_detected!("avx2") {
let avx2 = query.dotprod_raw_avx2(&vector);
assert_eq!(scalar, avx2, "{tag}: avx2 disagrees");
}
if has_avx512_vnni() {
let vnni512 = query.dotprod_raw_avx512_vnni(&vector);
assert_eq!(scalar, vnni512, "{tag}: avx512_vnni disagrees");
}
}
}
#[test]
fn test_saturation_safety_64k() {
saturation_safety_64k::<2, 2>();
saturation_safety_64k::<4, 2>();
saturation_safety_64k::<8, 1>();
saturation_safety_64k::<8, 2>();
}
}
@@ -45,73 +45,6 @@ pub unsafe fn score_1bit_internal_neon(a: &[u8], b: &[u8]) -> f32 {
}
}
impl<const BITS: usize> super::Query1bitSimd<BITS> {
/// NEON implementation of [`super::Query1bitSimd::dotprod_raw`].
///
/// Per block: one 16-byte data load + `BITS` plane loads; each plane's
/// `vandq_u8 · vcntq_u8` pair is pair-added through `vpaddlq_u8` /
/// `vpadalq_u16` into a dedicated `uint32x4_t` accumulator. `BITS`
/// accumulators live in registers (≤ 16 of 32 vregs available), so the
/// inner loop is purely ALU.
///
/// # Safety
/// CPU must support the `neon` feature (always true on aarch64).
#[target_feature(enable = "neon")]
pub unsafe fn dotprod_raw_neon(&self, vector: &[u8]) -> i64 {
use core::arch::aarch64::*;
unsafe {
let mut acc: [uint32x4_t; BITS] = core::array::from_fn(|_| vdupq_n_u32(0));
// Main loop: full 128-dim blocks read directly from `vector`.
for block_idx in 0..self.num_full_blocks() {
let data = vld1q_u8(vector.as_ptr().add(block_idx * super::BLOCK_BYTES));
let block_base = block_idx * BITS * super::BLOCK_BYTES;
for (b, acc_b) in acc.iter_mut().enumerate() {
let plane = vld1q_u8(
self.planes
.as_ptr()
.add(block_base + b * super::BLOCK_BYTES),
);
let cnt = vcntq_u8(vandq_u8(data, plane));
let cnt16 = vpaddlq_u8(cnt);
*acc_b = vpadalq_u16(*acc_b, cnt16);
}
}
// Partial tail block via zero-padded stack buffer — same SIMD
// kernel, data bytes beyond `tail_bytes` are zero so they can't
// contribute to any plane's AND-popcount.
if let Some((buf, block_idx)) = self.tail_block_scratch(vector) {
let data = vld1q_u8(buf.as_ptr());
let block_base = block_idx * BITS * super::BLOCK_BYTES;
for (b, acc_b) in acc.iter_mut().enumerate() {
let plane = vld1q_u8(
self.planes
.as_ptr()
.add(block_base + b * super::BLOCK_BYTES),
);
let cnt = vcntq_u8(vandq_u8(data, plane));
let cnt16 = vpaddlq_u8(cnt);
*acc_b = vpadalq_u16(*acc_b, cnt16);
}
}
let mut v_dot_q: i64 = 0;
for (b, acc_b) in acc.iter().enumerate() {
let popcnt = u64::from(vaddvq_u32(*acc_b));
let w_b: i64 = if b == BITS - 1 {
-(1i64 << (BITS - 1))
} else {
1i64 << b
};
v_dot_q += w_b * popcnt as i64;
}
v_dot_q
}
}
}
#[cfg(test)]
mod tests {
use rand::SeedableRng as _;
@@ -161,68 +94,4 @@ mod tests {
let expected = -super::super::CENTROID_SQ * (byte_len * 8) as f32;
assert!((scalar - expected).abs() / expected.abs() < 1e-6);
}
/// Parity for `Query1bitSimd::dotprod_raw_neon` vs the scalar kernel,
/// at a few `BITS` values and dims. Integer result must match bit-exactly.
#[test]
fn test_query_dotprod_neon_matches_scalar() {
use rand_distr::{Distribution, StandardNormal};
use super::super::Query1bitSimd;
fn check<const BITS: usize>(dim: usize, seed: u64) {
let mut rng = StdRng::seed_from_u64(seed);
let query: Vec<f32> = (0..dim).map(|_| StandardNormal.sample(&mut rng)).collect();
let data = random_bytes(&mut rng, dim / 8);
let q = Query1bitSimd::<BITS>::new(&query);
let scalar = q.dotprod_raw(&data);
let neon = unsafe { q.dotprod_raw_neon(&data) };
assert_eq!(
scalar, neon,
"BITS={BITS} dim={dim}: scalar={scalar} neon={neon}"
);
}
// Corner-case dims exercising every tail size the 1-bit pipeline
// can produce (tail ∈ {0, 8, 16, …, 120}, always a multiple of 8):
// • 128, 256, 512, 1024, 2048 — full blocks, no tail.
// • 8, 64, 120 — zero blocks + tail-only scoring paths.
// • 136, 1032, 2040 — realistic matryoshka slices with tails.
// • 640, 768, 896 — Gemma/BGE-style matryoshka dims.
for &dim in &[
8usize, 64, 120, 128, 136, 256, 512, 640, 768, 896, 1024, 1032, 2040, 2048,
] {
check::<8>(dim, 0xCAFE);
check::<10>(dim, 0xBEEF);
check::<12>(dim, 0xDEAD);
}
}
/// Overflow safety for `dotprod_raw_neon` at dim=64K and max BITS
/// (quantization constants stressed to the extreme): all-1 data vs a
/// query scaled to saturate the signed range. Scalar is u64-accumulator
/// reference; NEON u32 per-plane accumulators must match exactly.
#[test]
fn test_query_dotprod_neon_overflow_safety_64k() {
use super::super::Query1bitSimd;
let dim = 65_536;
// Query = all +1.0 float → maps to +max signed int in every lane.
let query = vec![1.0_f32; dim];
let data = vec![0xFFu8; dim / 8];
let q8 = Query1bitSimd::<8>::new(&query);
assert_eq!(
q8.dotprod_raw(&data),
unsafe { q8.dotprod_raw_neon(&data) },
"BITS=8 dim={dim}",
);
let q16 = Query1bitSimd::<16>::new(&query);
assert_eq!(
q16.dotprod_raw(&data),
unsafe { q16.dotprod_raw_neon(&data) },
"BITS=16 dim={dim}",
);
}
}
@@ -10,11 +10,12 @@
//! work is just picking the fastest popcount primitive (AVX-512 VPOPCNTDQ,
//! AVX2 / SSE `pshufb`-nibble lookup, NEON `vcntq_u8`).
//!
//! * [`Query1bitSimd<BITS>`] — asymmetric scoring of an original query against
//! packed data. The query is quantized to `BITS`-bit signed integers
//! (default 8), bit-plane-transposed into a block-interleaved layout, and
//! scored per block as `Σ_b w_b · popcount(data AND plane_b)` where
//! `w_b = 2^b` for b < BITS1 and `2^(BITS1)` for the sign plane.
//! * [`Query1bitSimd`] / [`Query1bitWideSimd`] — asymmetric scoring of an
//! original query (8- or 16-bit) against packed data, through the shared
//! [`QuerySimd`] kernels with the 1-bit [`Encoding`] defined here (a sign
//! bit indexes a two-entry codebook).
use super::query::{Code, Encoding, QuerySimd, pad_codebook};
/// `|c|` for the 1-bit codebook — Lloyd-Max on N(0, 1) gives `sqrt(2/π)`.
/// Kept in sync with `CENTROIDS_1BIT` in `lloyd_max.rs` by a test below.
@@ -24,224 +25,47 @@ const CENTROID_ABS: f32 = 0.797_884_6;
/// each lane contributes `(±c)·(±c) = ±c²`, summing to `c² · sign_sum`.
const CENTROID_SQ: f32 = CENTROID_ABS * CENTROID_ABS;
/// Block size for the bit-plane interleave: 16 packed bytes = 128 data dims.
/// Matches an XMM register; AVX2 processes 2 blocks per iter, AVX-512 takes 4.
const BLOCK_BYTES: usize = 16;
/// Integer encoding of the 1-bit width for the shared asymmetric kernels
/// ([`super::query::QuerySimd`]). Both codebook entries are `∓c`, so any
/// symmetric integer pair represents them exactly; the pair is chosen per
/// arch for the widest query the multiply keeps inside its lane.
pub(super) const ENCODING: Encoding = Encoding {
codebook: pad_codebook(CODEBOOK),
offset: CODEBOOK_OFFSET,
scale: CODEBOOK_SCALE,
query_high_coef: 256,
query_abs_max: 32639.0,
};
/// Encoded query for 1-bit PQ scoring with `BITS`-bit signed query
/// quantization (default 8).
///
/// # Encoding
/// Query is quantized to signed `BITS`-bit integers `q ∈ [2^(BITS1),
/// 2^(BITS1) 1]` in two's complement, then bit-plane-transposed and
/// block-interleaved: for each group of 128 dims (16 packed data bytes),
/// `BITS` × 16 consecutive bytes hold plane 0's 16 bytes, then plane 1's
/// 16 bytes, ..., then plane `BITS1`'s 16 bytes. This lets scoring read
/// data once per block (16-byte SIMD load) and consume `BITS` contiguous
/// plane chunks in a tight inner loop.
///
/// # Scoring
/// The signed dot product is
/// ```text
/// signed_dot = 2 · v_dot_q Σ q_signed,
/// v_dot_q = Σ_b w_b · popcount(v_block AND plane_b),
/// w_b = 2^b for b ∈ 0..BITS1, w_{BITS1} = 2^(BITS1).
/// ```
/// The `Σ q_signed` correction is query-side (stored in `sum_q_signed`),
/// so no per-data-vector precomputation is needed. The final float output
/// is `(c / q_scale) · signed_dot`, folded into `postprocess_scale`.
pub struct Query1bitSimd<const BITS: usize = 8> {
/// Block-interleaved bit-planes. Length is `total_blocks · BITS · 16`
/// bytes, where `total_blocks = num_full_blocks + (tail_bytes > 0)`.
/// When a partial tail block is present it sits at the end, with bit-plane
/// bytes beyond `tail_bytes` zero-padded so `data AND plane = 0` for the
/// padding lanes. Same SIMD kernel handles full and partial blocks once
/// the data side is zero-padded into a 16-byte stack buffer.
planes: Vec<u8>,
/// Number of **full** 128-dim blocks (excludes the partial tail).
num_full_blocks: usize,
/// Bytes in the trailing partial block — `0` if dim is block-aligned,
/// `1..=15` otherwise (`tail_bytes · 8 = tail_dims`).
tail_bytes: u8,
/// `c / q_scale` — single scalar reconstruction factor.
postprocess_scale: f32,
/// `Σ q_signed` over **all** dims (full blocks + tail).
sum_q_signed: i64,
}
/// aarch64: signed `∓127`, no offset.
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
const CODEBOOK: [Code; 2] = [-127, 127];
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
const CODEBOOK_OFFSET: i64 = 0;
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
const CODEBOOK_SCALE: f32 = 127.0 / CENTROID_ABS;
impl<const BITS: usize> Query1bitSimd<BITS> {
/// Query dim must be a multiple of 8 (the 1-bit packing width).
/// Matryoshka-friendly — any such dim is accepted, with a tail of up to
/// 15 packed bytes (120 dims) handled by the same SIMD kernel as full
/// blocks via a zero-padded 16-byte scratch buffer.
pub fn new(data: &[f32]) -> Self {
assert!(
(2..=16).contains(&BITS),
"Query1bitSimd: BITS must be in [2, 16], got {BITS}",
);
assert!(
data.len().is_multiple_of(8),
"Query1bitSimd: dim must be a multiple of 8 (got {})",
data.len(),
);
/// x86_64: `{0, 128}` with offset 64 — a codebook magnitude of 128 lets
/// the query halves span the full i8 range without the `maddubs` pair sum
/// leaving i16 (`|pair| ≤ 2 · 128 · 128 = 32 768`, reached only at the
/// negative end, which i16 holds).
#[cfg(not(all(target_arch = "aarch64", target_feature = "neon")))]
const CODEBOOK: [Code; 2] = [0, 128];
#[cfg(not(all(target_arch = "aarch64", target_feature = "neon")))]
const CODEBOOK_OFFSET: i64 = 64;
#[cfg(not(all(target_arch = "aarch64", target_feature = "neon")))]
const CODEBOOK_SCALE: f32 = 64.0 / CENTROID_ABS;
let q_abs_max_int = (1i64 << (BITS - 1)) - 1;
let q_abs_max = data
.iter()
.copied()
.map(f32::abs)
.fold(0.0_f32, f32::max)
.max(f32::EPSILON);
let q_scale = q_abs_max_int as f32 / q_abs_max;
let clamp_hi = q_abs_max_int as f32;
let clamp_lo = -clamp_hi;
/// Encoded query for asymmetric 1-bit scoring: [`QuerySimd`] over eight
/// codes per byte (the sign bit of dim `8j + k` is bit `k` of byte `j`),
/// with an 8-bit query — plenty next to the ±c codes, and half the
/// multiply-accumulate work of [`Query1bitWideSimd`].
pub type Query1bitSimd = QuerySimd<8, 1>;
let encode =
|value: f32| -> i64 { (value * q_scale).round().clamp(clamp_lo, clamp_hi) as i64 };
let num_full_blocks = data.len() / (8 * BLOCK_BYTES);
let full_dims = num_full_blocks * 8 * BLOCK_BYTES;
let tail_dims = data.len() - full_dims;
debug_assert!(tail_dims < 8 * BLOCK_BYTES && tail_dims.is_multiple_of(8));
let tail_bytes = tail_dims / 8;
let has_tail = tail_bytes > 0;
let total_blocks = num_full_blocks + usize::from(has_tail);
let mut planes = vec![0u8; total_blocks * BITS * BLOCK_BYTES];
let mut sum_q_signed: i64 = 0;
let bits_mask = (1u64 << BITS) - 1;
// Helper to deposit a single dim's bits into the bit-plane layout.
let mut deposit = |q: i64, block_idx: usize, byte_in_block: usize, bit_in_byte: usize| {
let q_bits = (q as u64) & bits_mask;
let block_base = block_idx * BITS * BLOCK_BYTES;
for b in 0..BITS {
let bit = ((q_bits >> b) & 1) as u8;
planes[block_base + b * BLOCK_BYTES + byte_in_block] |= bit << bit_in_byte;
}
};
for block_idx in 0..num_full_blocks {
for byte_in_block in 0..BLOCK_BYTES {
for bit_in_byte in 0..8 {
let dim = block_idx * 8 * BLOCK_BYTES + byte_in_block * 8 + bit_in_byte;
let q = encode(data[dim]);
sum_q_signed += q;
deposit(q, block_idx, byte_in_block, bit_in_byte);
}
}
}
// Partial tail block: same bit-plane encoding, padding bytes stay zero.
if has_tail {
for i in 0..tail_dims {
let q = encode(data[full_dims + i]);
sum_q_signed += q;
deposit(q, num_full_blocks, i / 8, i % 8);
}
}
Self {
planes,
num_full_blocks,
tail_bytes: tail_bytes as u8,
postprocess_scale: CENTROID_ABS / q_scale,
sum_q_signed,
}
}
/// Score the encoded query against a PQ-encoded `vector` (8 lanes / byte).
/// `vector.len()` must equal the original query `dim / 8` — full blocks
/// first, then up to 15 tail bytes.
pub fn dotprod(&self, vector: &[u8]) -> f32 {
let v_dot_q = self.dotprod_raw_best(vector);
let signed_dot = 2 * v_dot_q - self.sum_q_signed;
self.postprocess_scale * signed_dot as f32
}
#[inline]
fn dotprod_raw_best(&self, vector: &[u8]) -> i64 {
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx512vl")
&& std::is_x86_feature_detected!("avx512vpopcntdq")
{
return unsafe { self.dotprod_raw_avx512_vpopcntdq(vector) };
}
if std::is_x86_feature_detected!("sse4.1") && std::is_x86_feature_detected!("ssse3") {
return unsafe { self.dotprod_raw_sse(vector) };
}
}
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
{
return unsafe { self.dotprod_raw_neon(vector) };
}
#[allow(unreachable_code)]
self.dotprod_raw(vector)
}
/// Integer kernel: `Σ v_j · q_signed[j]` across all dims — full blocks
/// decoded from the bit-plane AND-popcount form, plus scalar tail.
/// Reference implementation — SIMD variants in [`arm`] / [`x64`] must
/// match this bit-exactly.
pub fn dotprod_raw(&self, vector: &[u8]) -> i64 {
let mut v_dot_q: i64 = 0;
for block_idx in 0..self.num_full_blocks {
let data_block = &vector[block_idx * BLOCK_BYTES..(block_idx + 1) * BLOCK_BYTES];
v_dot_q += self.score_block_scalar(data_block, block_idx);
}
if let Some((buf, block_idx)) = self.tail_block_scratch(vector) {
v_dot_q += self.score_block_scalar(&buf, block_idx);
}
v_dot_q
}
/// Score a single 16-byte data block against the plane entries for
/// `block_idx`. Scalar reference used by [`Self::dotprod_raw`] and by
/// the shared tail-copy helpers in arch modules.
#[inline]
fn score_block_scalar(&self, data_block: &[u8], block_idx: usize) -> i64 {
let plane_base = block_idx * BITS * BLOCK_BYTES;
let mut v_dot_q: i64 = 0;
for b in 0..BITS {
let plane = &self.planes[plane_base + b * BLOCK_BYTES..][..BLOCK_BYTES];
let mut c: u32 = 0;
for i in 0..BLOCK_BYTES {
c += (data_block[i] & plane[i]).count_ones();
}
let w_b: i64 = if b == BITS - 1 {
-(1i64 << (BITS - 1))
} else {
1i64 << b
};
v_dot_q += w_b * i64::from(c);
}
v_dot_q
}
/// Copy `tail_bytes` bytes from `vector`'s trailing partial block into a
/// zero-padded 16-byte scratch buffer. Returns `None` if the query has
/// no tail; otherwise returns `Some((buf, block_idx))` — the block index
/// is where the SIMD backend should read plane bytes from.
#[inline]
pub(crate) fn tail_block_scratch(&self, vector: &[u8]) -> Option<([u8; BLOCK_BYTES], usize)> {
if self.tail_bytes == 0 {
return None;
}
let mut buf = [0u8; BLOCK_BYTES];
let tail_start = self.num_full_blocks * BLOCK_BYTES;
let tail_len = self.tail_bytes as usize;
buf[..tail_len].copy_from_slice(&vector[tail_start..tail_start + tail_len]);
Some((buf, self.num_full_blocks))
}
/// Number of full 128-dim blocks the SIMD main-loop should iterate over.
/// Exposed to arch backends so they don't depend on the internal field name.
#[inline]
pub(crate) fn num_full_blocks(&self) -> usize {
self.num_full_blocks
}
}
/// [`Query1bitSimd`] with a 16-bit query, for TQ+: the per-coord `D'`
/// pre-scaling pushes some query coords toward the small end of the
/// integer range, where 8 bits lose too much.
pub type Query1bitWideSimd = QuerySimd<8, 2>;
/// Dot product between two already-encoded 1-bit PQ vectors.
///
@@ -326,8 +150,9 @@ pub mod shared {
pub const PARITY_BYTE_LENS: &[usize] = &[1, 7, 8, 15, 16, 31, 32, 63, 64, 127, 128, 257, 513];
}
/// Accuracy / precision tests for `score_1bit_internal` and `Query1bitSimd`.
/// Per-arch SIMD parity tests live in the `arm` / `x64` submodules.
/// Codebook and symmetric-scoring accuracy tests. Per-arch parity tests of
/// the popcount kernels live in the `arm` / `x64` submodules; the
/// asymmetric path is covered by `super::query`.
#[cfg(test)]
mod tests {
use rand::SeedableRng as _;
@@ -405,68 +230,4 @@ mod tests {
assert!((score_1bit_internal(&a, &a) - max).abs() < 1e-3);
assert!((score_1bit_internal(&a, &not_a) + max).abs() < 1e-3);
}
/// Round-trip test: `Query1bitSimd::dotprod(data)` should approximate
/// the exact centroid-dot-product `Σ query_i · sign(v_i) · c`, modulo
/// query quantization noise. Uses BITS=8 (default) and BITS=12 to
/// sanity-check that widening actually reduces error.
///
/// Parameterized over matryoshka-style corner-case dims — every case
/// exercises the tail path for BITS=8 and BITS=12 independently.
#[rstest::rstest]
#[case::full_blocks(1024)]
#[case::tail_only(120)]
#[case::block_plus_small_tail(136)]
#[case::block_plus_max_tail(1144)] // 8 blocks + 120 tail dims
#[case::matryoshka_640(640)]
#[case::matryoshka_768(768)]
#[case::matryoshka_896(896)]
fn test_query_dotprod_matches_reference(#[case] dim: usize) {
use rand_distr::{Distribution, StandardNormal};
let byte_len = dim / 8;
let mut rng = StdRng::seed_from_u64(1234);
let query: Vec<f32> = (0..dim).map(|_| StandardNormal.sample(&mut rng)).collect();
let data: Vec<u8> = random_bytes(&mut rng, byte_len);
// Reference: decode data bits to ±c and compute plain float dot.
let mut expected = 0.0_f32;
for (i, &q_i) in query.iter().enumerate() {
let bit = (data[i / 8] >> (i % 8)) & 1;
let sign = if bit == 1 {
CENTROID_ABS
} else {
-CENTROID_ABS
};
expected += q_i * sign;
}
let q8 = Query1bitSimd::<8>::new(&query);
let got8 = q8.dotprod(&data);
let q12 = Query1bitSimd::<12>::new(&query);
let got12 = q12.dotprod(&data);
// Compare against `|expected| + sqrt(dim)` so small dims (where
// `expected` may be near zero by chance) don't artificially blow up
// the relative error.
let scale = expected.abs().max((dim as f32).sqrt());
let rel_err_8 = (got8 - expected).abs() / scale;
let rel_err_12 = (got12 - expected).abs() / scale;
assert!(
rel_err_8 < 2e-2,
"dim={dim} BITS=8 rel_err={rel_err_8} (got {got8} vs {expected})"
);
assert!(
rel_err_12 < 2e-3,
"dim={dim} BITS=12 rel_err={rel_err_12} (got {got12} vs {expected})"
);
// Widening must shrink error (strictly, with overwhelming probability
// — tightens to `<=` here to be robust against tiny-dim rng noise).
assert!(
rel_err_12 <= rel_err_8,
"dim={dim} BITS=12 err {rel_err_12} should be ≤ BITS=8 err {rel_err_8}",
);
}
}
@@ -182,160 +182,6 @@ pub unsafe fn score_1bit_internal_avx512_vpopcntdq(a: &[u8], b: &[u8]) -> f32 {
}
}
impl<const BITS: usize> super::Query1bitSimd<BITS> {
/// SSE4.1 + SSSE3 implementation of
/// [`super::Query1bitSimd::dotprod_raw`].
///
/// Per block: load 16-byte data chunk, then for each of `BITS` planes
/// `pshufb`-nibble-lookup popcount of `AND` reduced via `psadbw` into
/// u64 pair; accumulate in `[__m128i; BITS]` regs (one per plane).
///
/// # Safety
/// CPU must support `ssse3` and `sse4.1`.
#[target_feature(enable = "sse4.1,ssse3")]
pub unsafe fn dotprod_raw_sse(&self, vector: &[u8]) -> i64 {
use core::arch::x86_64::*;
unsafe {
let lookup = _mm_loadu_si128(NIBBLE_POPCNT.as_ptr().cast::<__m128i>());
let low_mask = _mm_set1_epi8(0x0F);
let zero = _mm_setzero_si128();
let mut acc: [__m128i; BITS] = core::array::from_fn(|_| _mm_setzero_si128());
// Main loop: full blocks from the vector directly.
for block_idx in 0..self.num_full_blocks() {
let data = _mm_loadu_si128(
vector
.as_ptr()
.add(block_idx * super::BLOCK_BYTES)
.cast::<__m128i>(),
);
let block_base = block_idx * BITS * super::BLOCK_BYTES;
for (b, acc_b) in acc.iter_mut().enumerate() {
let plane = _mm_loadu_si128(
self.planes
.as_ptr()
.add(block_base + b * super::BLOCK_BYTES)
.cast::<__m128i>(),
);
let x = _mm_and_si128(data, plane);
let lo = _mm_and_si128(x, low_mask);
let hi = _mm_and_si128(_mm_srli_epi16(x, 4), low_mask);
let cnt_lo = _mm_shuffle_epi8(lookup, lo);
let cnt_hi = _mm_shuffle_epi8(lookup, hi);
let cnt = _mm_add_epi8(cnt_lo, cnt_hi);
*acc_b = _mm_add_epi64(*acc_b, _mm_sad_epu8(cnt, zero));
}
}
// Partial tail block via zero-padded stack buffer.
if let Some((buf, block_idx)) = self.tail_block_scratch(vector) {
let data = _mm_loadu_si128(buf.as_ptr().cast::<__m128i>());
let block_base = block_idx * BITS * super::BLOCK_BYTES;
for (b, acc_b) in acc.iter_mut().enumerate() {
let plane = _mm_loadu_si128(
self.planes
.as_ptr()
.add(block_base + b * super::BLOCK_BYTES)
.cast::<__m128i>(),
);
let x = _mm_and_si128(data, plane);
let lo = _mm_and_si128(x, low_mask);
let hi = _mm_and_si128(_mm_srli_epi16(x, 4), low_mask);
let cnt_lo = _mm_shuffle_epi8(lookup, lo);
let cnt_hi = _mm_shuffle_epi8(lookup, hi);
let cnt = _mm_add_epi8(cnt_lo, cnt_hi);
*acc_b = _mm_add_epi64(*acc_b, _mm_sad_epu8(cnt, zero));
}
}
reduce_planes::<BITS>(&acc)
}
}
/// AVX-512 VPOPCNTDQ (on XMM via AVX-512VL) implementation of
/// [`super::Query1bitSimd::dotprod_raw`].
///
/// Replaces the Muła nibble-lookup with hardware `_mm_popcnt_epi64`
/// (one instruction per block per plane). Block stays at 16 bytes
/// since the interleave layout keeps plane chunks at 16-byte granularity.
///
/// # Safety
/// CPU must support `avx512vl` and `avx512vpopcntdq` (and thus SSE2 for
/// the XMM load/store pairs).
#[target_feature(enable = "avx512vl,avx512vpopcntdq")]
pub unsafe fn dotprod_raw_avx512_vpopcntdq(&self, vector: &[u8]) -> i64 {
use core::arch::x86_64::*;
unsafe {
let mut acc: [__m128i; BITS] = core::array::from_fn(|_| _mm_setzero_si128());
for block_idx in 0..self.num_full_blocks() {
let data = _mm_loadu_si128(
vector
.as_ptr()
.add(block_idx * super::BLOCK_BYTES)
.cast::<__m128i>(),
);
let block_base = block_idx * BITS * super::BLOCK_BYTES;
for (b, acc_b) in acc.iter_mut().enumerate() {
let plane = _mm_loadu_si128(
self.planes
.as_ptr()
.add(block_base + b * super::BLOCK_BYTES)
.cast::<__m128i>(),
);
let cnt = _mm_popcnt_epi64(_mm_and_si128(data, plane));
*acc_b = _mm_add_epi64(*acc_b, cnt);
}
}
// Partial tail block via zero-padded stack buffer.
if let Some((buf, block_idx)) = self.tail_block_scratch(vector) {
let data = _mm_loadu_si128(buf.as_ptr().cast::<__m128i>());
let block_base = block_idx * BITS * super::BLOCK_BYTES;
for (b, acc_b) in acc.iter_mut().enumerate() {
let plane = _mm_loadu_si128(
self.planes
.as_ptr()
.add(block_base + b * super::BLOCK_BYTES)
.cast::<__m128i>(),
);
let cnt = _mm_popcnt_epi64(_mm_and_si128(data, plane));
*acc_b = _mm_add_epi64(*acc_b, cnt);
}
}
reduce_planes::<BITS>(&acc)
}
}
}
/// Reduce `[__m128i; BITS]` plane accumulators (each holding 2 × u64
/// popcount lanes) into the weighted `v_dot_q` integer sum.
///
/// # Safety
/// Caller must have enabled at least SSE2 (true of every caller here).
#[inline]
#[target_feature(enable = "sse2")]
unsafe fn reduce_planes<const BITS: usize>(acc: &[core::arch::x86_64::__m128i; BITS]) -> i64 {
use core::arch::x86_64::*;
let mut v_dot_q: i64 = 0;
for (b, acc_b) in acc.iter().enumerate() {
let lo = _mm_cvtsi128_si64(*acc_b) as u64;
let hi = _mm_cvtsi128_si64(_mm_unpackhi_epi64(*acc_b, *acc_b)) as u64;
let popcnt = lo + hi;
let w_b: i64 = if b == BITS - 1 {
-(1i64 << (BITS - 1))
} else {
1i64 << b
};
v_dot_q += w_b * popcnt as i64;
}
v_dot_q
}
#[cfg(test)]
mod tests {
use rand::SeedableRng as _;
@@ -433,70 +279,4 @@ mod tests {
}
}
}
/// Parity of `Query1bitSimd::dotprod_raw_{sse, avx512_vpopcntdq}` vs
/// the scalar kernel across several BITS values and dims.
#[test]
fn test_query_dotprod_x86_matches_scalar() {
use rand_distr::{Distribution, StandardNormal};
use super::super::Query1bitSimd;
fn check<const BITS: usize>(dim: usize, seed: u64) {
let mut rng = StdRng::seed_from_u64(seed);
let query: Vec<f32> = (0..dim).map(|_| StandardNormal.sample(&mut rng)).collect();
let data = random_bytes(&mut rng, dim / 8);
let q = Query1bitSimd::<BITS>::new(&query);
let scalar = q.dotprod_raw(&data);
if std::is_x86_feature_detected!("ssse3") && std::is_x86_feature_detected!("sse4.1") {
let sse = unsafe { q.dotprod_raw_sse(&data) };
assert_eq!(scalar, sse, "BITS={BITS} dim={dim}: sse mismatch");
}
if std::is_x86_feature_detected!("avx512vl")
&& std::is_x86_feature_detected!("avx512vpopcntdq")
{
let avx512 = unsafe { q.dotprod_raw_avx512_vpopcntdq(&data) };
assert_eq!(scalar, avx512, "BITS={BITS} dim={dim}: avx512 mismatch");
}
}
for &dim in &[128usize, 256, 384, 512, 1024, 2048] {
check::<8>(dim, 0xCAFE);
check::<10>(dim, 0xBEEF);
check::<12>(dim, 0xDEAD);
}
}
/// Overflow safety at dim=64K with max-magnitude query against all-1
/// data. Each SIMD path (when available on the CPU) must match scalar
/// exactly; a mismatch would mean an intermediate `u32` per-plane
/// accumulator (or the u64 lane in the VPOPCNTDQ variant) saturated.
#[test]
fn test_query_dotprod_x86_overflow_safety_64k() {
use super::super::Query1bitSimd;
let dim = 65_536;
let query = vec![1.0_f32; dim];
let data = vec![0xFFu8; dim / 8];
fn check<const BITS: usize>(query: &[f32], data: &[u8]) {
let q = Query1bitSimd::<BITS>::new(query);
let scalar = q.dotprod_raw(data);
if std::is_x86_feature_detected!("ssse3") && std::is_x86_feature_detected!("sse4.1") {
let sse = unsafe { q.dotprod_raw_sse(data) };
assert_eq!(scalar, sse, "BITS={BITS} sse overflow at 64k");
}
if std::is_x86_feature_detected!("avx512vl")
&& std::is_x86_feature_detected!("avx512vpopcntdq")
{
let avx512 = unsafe { q.dotprod_raw_avx512_vpopcntdq(data) };
assert_eq!(scalar, avx512, "BITS={BITS} avx512 overflow at 64k");
}
}
check::<8>(&query, &data);
check::<16>(&query, &data);
}
}
@@ -1,7 +1,5 @@
//! NEON SIMD paths for [`Query2bitSimd`] on aarch64.
//!
//! Codebook storage mirrors [`super::query4bit::arm`] — full signed `i8` with
//! no offset — so `vmull_s8` / `sdot` run as true signed-signed multiplies.
//! NEON kernels for the symmetric 2-bit paths (`score_2bit_internal*`) on
//! aarch64. The codebook is the signed `CODEBOOK_I8`.
//!
//! # Unpack trick
//! Each packed data byte holds 4 × 2-bit codes; consecutive pairs (`c0c1`,
@@ -21,7 +19,7 @@
//!
//! Same pipeline as 4-bit from that point on (i8 × i8 → i16 + `vpadalq_s16`).
use super::{CODEBOOK_I8, CODEBOOK_SCALE, QUERY_HIGH_COEF, Query2bitSimd};
use super::{CODEBOOK_I8, CODEBOOK_SCALE};
/// `PAIR_TABLE_EVEN[nibble]` = `CODEBOOK_I8[nibble & 0b11]`.
const PAIR_TABLE_EVEN: [i8; 16] = {
@@ -76,152 +74,6 @@ unsafe fn unpack_16_codes(bytes4: *const u8) -> core::arch::aarch64::int8x16_t {
}
}
impl Query2bitSimd {
/// NEON implementation of [`Query2bitSimd::dotprod_raw`].
///
/// # Safety
/// CPU must support the `neon` feature (always true on aarch64).
#[target_feature(enable = "neon")]
pub unsafe fn dotprod_raw_neon(&self, vector: &[u8]) -> i64 {
use core::arch::aarch64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query2bitSimd::dotprod_raw_neon: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let mut acc_low = vdupq_n_s32(0);
let mut acc_high = vdupq_n_s32(0);
for (chunk_idx, [low, high]) in self.query_data.iter().enumerate() {
let c = unpack_16_codes(vector.as_ptr().add(chunk_idx * 4));
let q_low = vld1q_s8(low.as_ptr());
let q_high = vld1q_s8(high.as_ptr());
let prod_low_lo = vmull_s8(vget_low_s8(q_low), vget_low_s8(c));
let prod_low_hi = vmull_high_s8(q_low, c);
let prod_high_lo = vmull_s8(vget_low_s8(q_high), vget_low_s8(c));
let prod_high_hi = vmull_high_s8(q_high, c);
acc_low = vpadalq_s16(acc_low, prod_low_lo);
acc_low = vpadalq_s16(acc_low, prod_low_hi);
acc_high = vpadalq_s16(acc_high, prod_high_lo);
acc_high = vpadalq_s16(acc_high, prod_high_hi);
}
let full =
i64::from(vaddvq_s32(acc_low)) + QUERY_HIGH_COEF * i64::from(vaddvq_s32(acc_high));
full + self.dotprod_raw_tail(vector)
}
}
/// ARMv8.2-A Dot Product variant. Uses SDOT to sum 4 × i8 × i8 products
/// per i32 lane per instruction, with a 2× unroll (2 chunks per iter) +
/// a 1-chunk tail for odd chunk counts (allows `dim % 16 == 0`, matching
/// the scalar / plain-NEON contract).
///
/// # Safety
/// CPU must support `neon` and `dotprod`.
#[target_feature(enable = "neon,dotprod")]
pub unsafe fn dotprod_raw_neon_sdot(&self, vector: &[u8]) -> i64 {
use core::arch::aarch64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query2bitSimd::dotprod_raw_neon_sdot: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let mut acc_low_0 = vdupq_n_s32(0);
let mut acc_low_1 = vdupq_n_s32(0);
let mut acc_high_0 = vdupq_n_s32(0);
let mut acc_high_1 = vdupq_n_s32(0);
let n_pairs = self.query_data.len() / 2;
let data_ptr = vector.as_ptr();
for i in 0..n_pairs {
let [low_0, high_0] = &self.query_data[2 * i];
let [low_1, high_1] = &self.query_data[2 * i + 1];
let c_0 = unpack_16_codes(data_ptr.add(8 * i));
let c_1 = unpack_16_codes(data_ptr.add(8 * i + 4));
let q_low_0 = vld1q_s8(low_0.as_ptr());
let q_high_0 = vld1q_s8(high_0.as_ptr());
let q_low_1 = vld1q_s8(low_1.as_ptr());
let q_high_1 = vld1q_s8(high_1.as_ptr());
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_low_0,
a = in(vreg) q_low_0,
b = in(vreg) c_0,
options(pure, nomem, nostack, preserves_flags),
);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_high_0,
a = in(vreg) q_high_0,
b = in(vreg) c_0,
options(pure, nomem, nostack, preserves_flags),
);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_low_1,
a = in(vreg) q_low_1,
b = in(vreg) c_1,
options(pure, nomem, nostack, preserves_flags),
);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_high_1,
a = in(vreg) q_high_1,
b = in(vreg) c_1,
options(pure, nomem, nostack, preserves_flags),
);
}
// Tail: odd chunk count → one extra chunk via single SDOT per half.
if self.query_data.len() % 2 == 1 {
let tail = 2 * n_pairs;
let [low_t, high_t] = &self.query_data[tail];
let c_t = unpack_16_codes(data_ptr.add(4 * tail));
let q_low_t = vld1q_s8(low_t.as_ptr());
let q_high_t = vld1q_s8(high_t.as_ptr());
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_low_0,
a = in(vreg) q_low_t,
b = in(vreg) c_t,
options(pure, nomem, nostack, preserves_flags),
);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_high_0,
a = in(vreg) q_high_t,
b = in(vreg) c_t,
options(pure, nomem, nostack, preserves_flags),
);
}
let acc_low = vaddq_s32(acc_low_0, acc_low_1);
let acc_high = vaddq_s32(acc_high_0, acc_high_1);
let full =
i64::from(vaddvq_s32(acc_low)) + QUERY_HIGH_COEF * i64::from(vaddvq_s32(acc_high));
full + self.dotprod_raw_tail(vector)
}
}
}
/// NEON implementation of [`super::score_2bit_internal`] — vector × vector
/// centroid dot, both sides unpacked via the pair-table trick.
///
@@ -260,8 +112,7 @@ pub unsafe fn score_2bit_internal_neon(a: &[u8], b: &[u8]) -> f32 {
}
}
/// SDOT variant of [`score_2bit_internal_neon`]. 2× chunk unroll mirrors
/// [`Query2bitSimd::dotprod_raw_neon_sdot`].
/// SDOT variant of [`score_2bit_internal_neon`], 2× chunk-unrolled.
///
/// # Safety
/// CPU must support `neon` and `dotprod`.
@@ -398,9 +249,7 @@ mod tests {
use super::super::super::shared::pack_codes;
use super::super::shared::{PARITY_DIMS, random_inputs};
use super::super::{
Query2bitSimd, score_2bit_internal_scalar, score_2bit_internal_weighted_scalar,
};
use super::super::{score_2bit_internal_scalar, score_2bit_internal_weighted_scalar};
use super::{
score_2bit_internal_neon, score_2bit_internal_neon_sdot, score_2bit_internal_weighted_neon,
};
@@ -414,54 +263,6 @@ mod tests {
.collect()
}
#[test]
fn test_neon_matches_scalar() {
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let neon = unsafe { simd_query.dotprod_raw_neon(&vector) };
assert_eq!(scalar, neon, "scalar {scalar} != neon {neon} at dim {dim}");
}
}
#[test]
fn test_neon_sdot_matches_scalar() {
if !std::arch::is_aarch64_feature_detected!("dotprod") {
return;
}
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let sdot = unsafe { simd_query.dotprod_raw_neon_sdot(&vector) };
assert_eq!(scalar, sdot, "scalar {scalar} != sdot {sdot} at dim {dim}");
}
}
/// Saturation safety for query-vs-vector at dim=64K: query maxed out,
/// every lane pointing at CODEBOOK_I8[3] = +127.
#[test]
fn test_saturation_safety_64k() {
let dim = 65_536;
let query = vec![1.0_f32; dim];
let indices: Vec<u8> = vec![3; dim]; // +127 centroid
let vector = pack_codes(&indices, 2);
let q = Query2bitSimd::new(&query);
let scalar = q.dotprod_raw(&vector);
unsafe {
let neon = q.dotprod_raw_neon(&vector);
assert_eq!(scalar, neon, "neon disagrees at dim={dim}");
if std::arch::is_aarch64_feature_detected!("dotprod") {
let sdot = q.dotprod_raw_neon_sdot(&vector);
assert_eq!(scalar, sdot, "sdot disagrees at dim={dim}");
}
}
}
#[test]
fn test_score_neon_matches_scalar() {
let mut rng = StdRng::seed_from_u64(7);
@@ -1,22 +1,38 @@
//! 2-bit product-quantization scoring.
//!
//! Mirrors [`super::query4bit`]: query is quantized to two signed 7-bit
//! halves (combined via `q_signed = QUERY_HIGH_COEF · high + low`), the
//! codebook is the arch-native storage form (`CODEBOOK_I8` on aarch64,
//! `CODEBOOK_U8` on x86_64), and scoring uses bias-corrected integer
//! accumulation. The only real differences from 4-bit are:
//! Asymmetric scoring (query against packed codes) goes through the shared
//! [`QuerySimd`] kernels with the 2-bit [`Encoding`] defined here. The
//! symmetric paths (`score_2bit_internal*`, two packed vectors) have their
//! own kernels in the `arm` / `x64` submodules, built on a **pair-table**
//! unpack: a nibble of a packed byte holds a pair of 2-bit codes (16
//! combinations), which maps one-to-one to a 16-entry `vqtbl1q_s8` /
//! `pshufb` table. Two such lookups (even / odd code of each pair),
//! zipped, give 16 centroid bytes in natural dim order per 4 packed bytes.
//!
//! 1. 4 centroids instead of 16 — `CENTROIDS_2BIT` from `lloyd_max`.
//! 2. 4 codes packed per byte (2 bits each) instead of 2 nibbles per byte.
//! 3. The SIMD unpack uses a **pair-table** trick: a nibble of the packed
//! data byte encodes a pair of 2-bit codes (16 possible combinations),
//! which maps one-to-one to a 16-entry `vqtbl1q_s8` / `pshufb` table.
//! Two such lookups (even / odd centroid of each pair), zipped, give a
//! natural-order `int8x16` of 16 centroid bytes per 4 packed data bytes.
//! Both codebooks derive from `CENTROIDS_2BIT` (Lloyd-Max on N(0,1)); see
//! `test_codebook_matches_lloyd_max` for the consistency check.
use super::SimdBackend;
use super::query::{Code, Encoding, QuerySimd, pad_codebook};
/// `max|c|` over `CENTROIDS_2BIT` — the extreme centroid magnitude.
const CODEBOOK_ABS_MAX: f32 = 1.510;
/// Integer encoding of the 2-bit width for the shared asymmetric kernels
/// ([`super::query::QuerySimd`]).
pub(super) const ENCODING: Encoding = Encoding {
codebook: pad_codebook(CODEBOOK),
offset: CODEBOOK_OFFSET,
scale: CODEBOOK_SCALE,
query_high_coef: QUERY_HIGH_COEF,
query_abs_max: QUERY_ABS_MAX,
};
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
const CODEBOOK: [Code; 4] = CODEBOOK_I8;
#[cfg(not(all(target_arch = "aarch64", target_feature = "neon")))]
const CODEBOOK: [Code; 4] = CODEBOOK_U8;
/// Signed `i8` codebook for aarch64: `c_scale = 127 / max|c|`, no offset.
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
const CODEBOOK_I8: [i8; 4] = [-127, -38, 38, 127];
@@ -100,207 +116,10 @@ fn codebook_signed_i64(idx: u8) -> i64 {
codebook_value_i64(idx) - CODEBOOK_OFFSET
}
/// Encoded query for asymmetric 2-bit PQ scoring.
///
/// # Encoding
/// The f32 query is quantized to signed integers
/// `q_signed ∈ [QUERY_ABS_MAX, QUERY_ABS_MAX]` and split into two i8 halves
/// combined as `q_signed = QUERY_HIGH_COEF · high + low` (`K = 256` on
/// aarch64 for full-range i8 halves, `K = 128` on x86_64 to keep the x86
/// `maddubs` pair sum inside i16). Storage is 16-dim chunks of `[low, high]`
/// plus a scalar-handled tail of up to 12 dims (`dim % 4 == 0`).
///
/// # Scoring
/// `dotprod_raw = Σ_j q_signed[j] · c_raw[v[j]]` accumulated from the SIMD
/// kernel's chunk pass plus the scalar tail. The float result is
/// `postprocess_scale · (dot_raw bias_correction)`, where `bias_correction`
/// absorbs the `+OFFSET` shift in the x86 unsigned-codebook layout and is 0
/// on aarch64 (signed codebook, no shift).
pub struct Query2bitSimd {
/// Full 16-dim chunks of the query — each covers 16 dims → 4 packed data bytes.
query_data: Vec<[[i8; 16]; 2]>,
/// Trailing dims that didn't fill a 16-dim chunk — up to 12 (since
/// `dim % 4 == 0`: tail is one of 0, 4, 8, 12 dims).
tail_low: [i8; 12],
tail_high: [i8; 12],
/// Number of meaningful entries in the tail arrays (`0..=12`, multiple of 4).
tail_dims: u8,
/// `1 / (q_scale · c_scale)` — prefactor from integer to float dot product.
postprocess_scale: f32,
/// `CODEBOOK_OFFSET · Σ q_signed[j]` — subtracted from `dot_raw` to
/// recover the true signed dot. `0` on aarch64 (signed codebook).
bias_correction: i64,
}
impl Query2bitSimd {
/// Query dim must be a multiple of 4 (the 2-bit packing width: four codes
/// per byte). Dims that don't fill a 16-dim chunk produce up to a 12-dim
/// tail handled scalar-wise in every SIMD path — Matryoshka-friendly.
pub fn new(data: &[f32]) -> Self {
assert!(
data.len().is_multiple_of(4),
"Query2bitSimd requires query dim to be a multiple of 4 (got {})",
data.len(),
);
let q_abs_max = data
.iter()
.copied()
.map(f32::abs)
.fold(0.0_f32, f32::max)
.max(f32::EPSILON);
let q_scale = QUERY_ABS_MAX / q_abs_max;
let k = QUERY_HIGH_COEF as i32;
let half_k = k / 2;
let clamp_hi = QUERY_ABS_MAX;
let clamp_lo = -QUERY_ABS_MAX;
let encode = |value: f32| -> (i8, i8, i64) {
let q_signed = (value * q_scale).round().clamp(clamp_lo, clamp_hi) as i32;
let l_mod = q_signed.rem_euclid(k);
let l = if l_mod >= half_k { l_mod - k } else { l_mod } as i8;
let h = ((q_signed - i32::from(l)) / k) as i8;
(l, h, i64::from(q_signed))
};
let num_chunks = data.len() / 16;
let full_dims = num_chunks * 16;
let tail_dims = data.len() - full_dims;
debug_assert!(tail_dims < 16 && tail_dims.is_multiple_of(4));
let mut query_data: Vec<[[i8; 16]; 2]> = Vec::with_capacity(num_chunks);
let mut sum_q_signed: i64 = 0;
for chunk_idx in 0..num_chunks {
let mut low = [0_i8; 16];
let mut high = [0_i8; 16];
for i in 0..16 {
let (l, h, q) = encode(data[chunk_idx * 16 + i]);
low[i] = l;
high[i] = h;
sum_q_signed += q;
}
query_data.push([low, high]);
}
let mut tail_low = [0_i8; 12];
let mut tail_high = [0_i8; 12];
for i in 0..tail_dims {
let (l, h, q) = encode(data[full_dims + i]);
tail_low[i] = l;
tail_high[i] = h;
sum_q_signed += q;
}
Self {
query_data,
tail_low,
tail_high,
tail_dims: tail_dims as u8,
postprocess_scale: 1.0 / (q_scale * CODEBOOK_SCALE),
bias_correction: CODEBOOK_OFFSET * sum_q_signed,
}
}
/// Number of `vector` bytes the encoded query expects: 4 bytes per full
/// 16-dim chunk plus the packed tail (four 2-bit codes per byte).
#[inline]
pub(crate) fn expected_vector_bytes(&self) -> usize {
self.query_data.len() * 4 + (self.tail_dims as usize).div_ceil(4)
}
/// Score the encoded query against a 2-bit PQ-encoded `vector`
/// (four centroid indices per byte; bits `[2k..2k+2]` hold the code for
/// lane `k ∈ 0..=3`). `vector.len()` must equal `ceil(dim * 2 / 8)` —
/// full chunks first, then the tail bytes covering `tail_dims`.
///
/// Dispatches at runtime to the best SIMD backend available on the host
/// CPU (AVX-512 VNNI → AVX2 → SSE → NEON + SDOT → NEON → scalar).
pub fn dotprod(&self, vector: &[u8]) -> f32 {
let dot_raw = self.dotprod_raw_best(vector);
self.postprocess_scale * (dot_raw - self.bias_correction) as f32
}
#[inline]
fn dotprod_raw_best(&self, vector: &[u8]) -> i64 {
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni")
{
return unsafe { self.dotprod_raw_avx512_vnni(vector) };
}
if std::is_x86_feature_detected!("avx2") {
return unsafe { self.dotprod_raw_avx2(vector) };
}
if std::is_x86_feature_detected!("sse4.1") && std::is_x86_feature_detected!("ssse3") {
return unsafe { self.dotprod_raw_sse(vector) };
}
}
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
{
if std::arch::is_aarch64_feature_detected!("dotprod") {
return unsafe { self.dotprod_raw_neon_sdot(vector) };
}
return unsafe { self.dotprod_raw_neon(vector) };
}
#[allow(unreachable_code)]
self.dotprod_raw(vector)
}
/// Compute `Σ q_signed[j] · c_raw[v[j]]` across all dims — full chunks
/// plus tail. Returns `acc_low + QUERY_HIGH_COEF · acc_high`.
///
/// `vector` is PQ-encoded with four 2-bit codes packed per byte:
/// bits `[2k..2k+2]` for `k ∈ 0..=3` hold codes `0..=3` of the byte,
/// in low-to-high bit order.
pub fn dotprod_raw(&self, vector: &[u8]) -> i64 {
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query2bitSimd::dotprod_raw: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
let mut acc_low: i64 = 0;
let mut acc_high: i64 = 0;
for (chunk_idx, [low, high]) in self.query_data.iter().enumerate() {
let v = &vector[chunk_idx * 4..(chunk_idx + 1) * 4];
for i in 0..16 {
let byte = v[i / 4];
let shift = 2 * (i % 4);
let idx = (byte >> shift) & 0x03;
let c = codebook_value_i64(idx);
acc_low += i64::from(low[i]) * c;
acc_high += i64::from(high[i]) * c;
}
}
acc_low + QUERY_HIGH_COEF * acc_high + self.dotprod_raw_tail(vector)
}
/// Scalar contribution from the trailing `tail_dims` query entries.
/// Shared by every SIMD backend so they only implement the full-chunk
/// loop and forward the tail here.
#[inline]
pub(crate) fn dotprod_raw_tail(&self, vector: &[u8]) -> i64 {
if self.tail_dims == 0 {
return 0;
}
let tail_byte_start = self.query_data.len() * 4;
let mut acc_low: i64 = 0;
let mut acc_high: i64 = 0;
for i in 0..self.tail_dims as usize {
let byte = vector[tail_byte_start + i / 4];
let shift = 2 * (i % 4);
let idx = (byte >> shift) & 0x03;
let c = codebook_value_i64(idx);
acc_low += i64::from(self.tail_low[i]) * c;
acc_high += i64::from(self.tail_high[i]) * c;
}
acc_low + QUERY_HIGH_COEF * acc_high
}
}
/// Encoded query for asymmetric 2-bit scoring: [`QuerySimd`] over four
/// codes per byte (code `k` of dim `4j + k` in bits `[2k, 2k + 2)` of byte
/// `j`).
pub type Query2bitSimd = QuerySimd<4, 2>;
/// Dot product between two already-encoded 2-bit PQ vectors. Any byte length
/// is accepted — bytes beyond the last SIMD chunk are folded in scalar-wise.
@@ -316,30 +135,19 @@ pub fn score_2bit_internal(a: &[u8], b: &[u8]) -> f32 {
b.len(),
);
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni")
{
return unsafe { x64::score_2bit_internal_avx512_vnni(a, b) };
}
if std::is_x86_feature_detected!("avx2") {
return unsafe { x64::score_2bit_internal_avx2(a, b) };
}
if std::is_x86_feature_detected!("sse4.1") && std::is_x86_feature_detected!("ssse3") {
return unsafe { x64::score_2bit_internal_sse(a, b) };
}
match SimdBackend::detect() {
#[cfg(target_arch = "x86_64")]
SimdBackend::Avx512Vnni => unsafe { x64::score_2bit_internal_avx512_vnni(a, b) },
#[cfg(target_arch = "x86_64")]
SimdBackend::Avx2 => unsafe { x64::score_2bit_internal_avx2(a, b) },
#[cfg(target_arch = "x86_64")]
SimdBackend::Sse => unsafe { x64::score_2bit_internal_sse(a, b) },
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
SimdBackend::NeonSdot => unsafe { arm::score_2bit_internal_neon_sdot(a, b) },
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
SimdBackend::Neon => unsafe { arm::score_2bit_internal_neon(a, b) },
SimdBackend::Scalar => score_2bit_internal_scalar(a, b),
}
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
{
if std::arch::is_aarch64_feature_detected!("dotprod") {
return unsafe { arm::score_2bit_internal_neon_sdot(a, b) };
}
return unsafe { arm::score_2bit_internal_neon(a, b) };
}
#[allow(unreachable_code)]
score_2bit_internal_scalar(a, b)
}
/// Scalar reference for [`score_2bit_internal`] — see the 4-bit counterpart
@@ -395,24 +203,19 @@ pub fn score_2bit_internal_weighted(a: &[u8], b: &[u8], weights: &[i16]) -> i64
4 * a.len(),
);
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx2")
&& std::is_x86_feature_detected!("ssse3")
&& std::is_x86_feature_detected!("sse4.1")
{
return unsafe { x64::score_2bit_internal_weighted_avx2(a, b, weights) };
}
if std::is_x86_feature_detected!("ssse3") && std::is_x86_feature_detected!("sse4.1") {
return unsafe { x64::score_2bit_internal_weighted_sse(a, b, weights) };
}
match SimdBackend::detect() {
#[cfg(target_arch = "x86_64")]
SimdBackend::Avx512Vnni | SimdBackend::Avx2 => unsafe {
x64::score_2bit_internal_weighted_avx2(a, b, weights)
},
#[cfg(target_arch = "x86_64")]
SimdBackend::Sse => unsafe { x64::score_2bit_internal_weighted_sse(a, b, weights) },
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
SimdBackend::NeonSdot | SimdBackend::Neon => unsafe {
arm::score_2bit_internal_weighted_neon(a, b, weights)
},
SimdBackend::Scalar => score_2bit_internal_weighted_scalar(a, b, weights),
}
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
{
return unsafe { arm::score_2bit_internal_weighted_neon(a, b, weights) };
}
#[allow(unreachable_code)]
score_2bit_internal_weighted_scalar(a, b, weights)
}
/// Scalar reference for [`score_2bit_internal_weighted`].
@@ -482,15 +285,16 @@ pub mod shared {
}
}
/// Accuracy / precision tests for `Query2bitSimd` and `score_2bit_internal`.
/// Per-arch SIMD parity tests live in the `arm` / `x64` submodules.
/// Codebook and symmetric-scoring accuracy tests. Per-arch parity tests of
/// the symmetric kernels live in the `arm` / `x64` submodules; the
/// asymmetric path is covered by `super::query`.
#[cfg(test)]
mod tests {
use rand::SeedableRng as _;
use rand::prelude::StdRng;
use super::super::shared::{encode_to_nearest_centroid, pack_codes, sample_normal_vec};
use super::{CODEBOOK_ABS_MAX, Query2bitSimd, score_2bit_internal_scalar};
use super::{CODEBOOK_ABS_MAX, score_2bit_internal_scalar};
use crate::turboquant::TQBits;
#[test]
@@ -528,39 +332,6 @@ mod tests {
}
}
#[rstest::rstest]
#[case::full_chunks(256)]
#[case::small_tail(20)]
#[case::max_tail(28)]
#[case::odd_chunks_only(48)]
#[case::odd_chunks_plus_tail(60)]
#[case::matryoshka(268)]
#[case::large_with_tail(2044)]
fn test_dotprod_matches_float(#[case] dim: usize) {
let mut rng = StdRng::seed_from_u64(42);
let n_trials = 64;
let centroids = TQBits::Bits2.get_centroids();
for _ in 0..n_trials {
let query = sample_normal_vec(&mut rng, dim);
let v_raw = sample_normal_vec(&mut rng, dim);
let indices = encode_to_nearest_centroid(centroids, &v_raw);
let v_pq: Vec<f32> = indices.iter().map(|&k| centroids[k as usize]).collect();
let pq_dot: f32 = query.iter().zip(v_pq.iter()).map(|(a, b)| a * b).sum();
let simd_dot = Query2bitSimd::new(&query).dotprod(&pack_codes(&indices, 2));
// SIMD-added error scales like √dim · σ_q · ε_c. Scale tolerance
// with √dim so large-dim trials don't falsely fail on 3σ tails.
let tol = (0.5_f32).max(0.03 * (dim as f32).sqrt());
assert!(
(pq_dot - simd_dot).abs() < tol,
"dim={dim}: simd_dot {simd_dot} too far from ideal PQ dot {pq_dot} (tol={tol})",
);
}
}
/// `score_2bit_internal_scalar(a, b)` ≈ `Σ centroid(a_k) · centroid(b_k)`.
#[test]
fn test_score_2bit_internal_matches_centroid_product() {
@@ -1,19 +1,16 @@
//! x86_64 SIMD paths for [`Query2bitSimd`] and [`super::score_2bit_internal`].
//! x86_64 kernels for the symmetric 2-bit paths (`score_2bit_internal*`).
//!
//! Storage / encoding mirror [`super::query4bit::x64`]: unsigned `CODEBOOK_U8`
//! consumed as the `u8` operand of `maddubs` / `VPDPBUSD`, with the query
//! quantized to 7-bit signed halves (K=128) so the `u8 × i8 → i16` pair-sum
//! never saturates. The only new machinery is the 2-bit → centroid unpack,
//! which uses a pair-table trick analogous to [`super::arm`]:
//! The codebook is the unsigned `CODEBOOK_U8`; the 2-bit → centroid unpack
//! uses a pair-table trick analogous to [`super::arm`]:
//!
//! * `PAIR_TABLE_EVEN_U8[nibble] = CODEBOOK_U8[nibble & 0b11]`
//! * `PAIR_TABLE_ODD_U8[nibble] = CODEBOOK_U8[(nibble >> 2) & 0b11]`
//!
//! Per 4 packed data bytes: split low/high nibbles, `pshufb` both pair tables
//! with the nibble indices, interleave via `punpcklbw` → 16 centroid bytes
//! in natural dim order. From there on the pipeline is identical to 4-bit.
//! in natural dim order.
use super::{CODEBOOK_SCALE, CODEBOOK_U8, QUERY_HIGH_COEF, Query2bitSimd};
use super::{CODEBOOK_SCALE, CODEBOOK_U8};
/// `PAIR_TABLE_EVEN_U8[nibble]` = `CODEBOOK_U8[nibble & 0b11]`.
const PAIR_TABLE_EVEN_U8: [u8; 16] = {
@@ -73,215 +70,6 @@ unsafe fn hsum_i32_sse(v: core::arch::x86_64::__m128i) -> i32 {
_mm_cvtsi128_si32(v)
}
impl Query2bitSimd {
/// SSE4.1 + SSSE3 implementation of [`Query2bitSimd::dotprod_raw`].
///
/// # Safety
/// CPU must support `ssse3` and `sse4.1`.
#[target_feature(enable = "sse4.1,ssse3")]
pub unsafe fn dotprod_raw_sse(&self, vector: &[u8]) -> i64 {
use core::arch::x86_64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query2bitSimd::dotprod_raw_sse: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let mut acc_low = _mm_setzero_si128();
let mut acc_high = _mm_setzero_si128();
let ones = _mm_set1_epi16(1);
for (chunk_idx, [low, high]) in self.query_data.iter().enumerate() {
let c = unpack_16_codes_sse(vector.as_ptr().add(chunk_idx * 4));
let q_low = _mm_loadu_si128(low.as_ptr().cast::<__m128i>());
let q_high = _mm_loadu_si128(high.as_ptr().cast::<__m128i>());
let prod_low = _mm_maddubs_epi16(c, q_low);
let prod_high = _mm_maddubs_epi16(c, q_high);
acc_low = _mm_add_epi32(acc_low, _mm_madd_epi16(prod_low, ones));
acc_high = _mm_add_epi32(acc_high, _mm_madd_epi16(prod_high, ones));
}
let sum_low = i64::from(hsum_i32_sse(acc_low));
let sum_high = i64::from(hsum_i32_sse(acc_high));
sum_low + QUERY_HIGH_COEF * sum_high + self.dotprod_raw_tail(vector)
}
}
/// AVX2 implementation. Built on top of the SSE unpack (`_mm_shuffle_epi8`
/// stays 128-bit-lane-scoped on AVX2, so doubling up to YMM for 8 bytes of
/// data at once requires extra lane-management that costs more than the
/// unroll saves). We call the SSE unpack twice per iteration and pair the
/// `maddubs` / `madd_epi16` paths on 256-bit vectors where they're cheap.
///
/// # Safety
/// CPU must support `avx2`, `ssse3` and `sse4.1`.
#[target_feature(enable = "avx2,sse4.1,ssse3")]
pub unsafe fn dotprod_raw_avx2(&self, vector: &[u8]) -> i64 {
use core::arch::x86_64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query2bitSimd::dotprod_raw_avx2: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let ones = _mm256_set1_epi16(1);
let mut acc_low = _mm256_setzero_si256();
let mut acc_high = _mm256_setzero_si256();
let n_chunks = self.query_data.len();
let n_pairs = n_chunks / 2;
// 2× unroll: fold two SSE chunks into one YMM accumulation per iter.
for p in 0..n_pairs {
let [qa_lo, qa_hi] = &self.query_data[2 * p];
let [qb_lo, qb_hi] = &self.query_data[2 * p + 1];
let c_a = unpack_16_codes_sse(vector.as_ptr().add(4 * (2 * p)));
let c_b = unpack_16_codes_sse(vector.as_ptr().add(4 * (2 * p + 1)));
let c = _mm256_set_m128i(c_b, c_a);
let q_lo_a = _mm_loadu_si128(qa_lo.as_ptr().cast::<__m128i>());
let q_lo_b = _mm_loadu_si128(qb_lo.as_ptr().cast::<__m128i>());
let q_hi_a = _mm_loadu_si128(qa_hi.as_ptr().cast::<__m128i>());
let q_hi_b = _mm_loadu_si128(qb_hi.as_ptr().cast::<__m128i>());
let q_low = _mm256_set_m128i(q_lo_b, q_lo_a);
let q_high = _mm256_set_m128i(q_hi_b, q_hi_a);
let prod_low = _mm256_maddubs_epi16(c, q_low);
let prod_high = _mm256_maddubs_epi16(c, q_high);
acc_low = _mm256_add_epi32(acc_low, _mm256_madd_epi16(prod_low, ones));
acc_high = _mm256_add_epi32(acc_high, _mm256_madd_epi16(prod_high, ones));
}
// Fold YMM accumulators into XMM.
let mut sum_low_sse = _mm_add_epi32(
_mm256_castsi256_si128(acc_low),
_mm256_extracti128_si256(acc_low, 1),
);
let mut sum_high_sse = _mm_add_epi32(
_mm256_castsi256_si128(acc_high),
_mm256_extracti128_si256(acc_high, 1),
);
// Tail: odd chunk count → one extra chunk via SSE.
if n_chunks % 2 == 1 {
let idx = 2 * n_pairs;
let [q_lo_t, q_hi_t] = &self.query_data[idx];
let c = unpack_16_codes_sse(vector.as_ptr().add(4 * idx));
let q_low = _mm_loadu_si128(q_lo_t.as_ptr().cast::<__m128i>());
let q_high = _mm_loadu_si128(q_hi_t.as_ptr().cast::<__m128i>());
let prod_low = _mm_maddubs_epi16(c, q_low);
let prod_high = _mm_maddubs_epi16(c, q_high);
let ones = _mm_set1_epi16(1);
sum_low_sse = _mm_add_epi32(sum_low_sse, _mm_madd_epi16(prod_low, ones));
sum_high_sse = _mm_add_epi32(sum_high_sse, _mm_madd_epi16(prod_high, ones));
}
let sum_low = i64::from(hsum_i32_sse(sum_low_sse));
let sum_high = i64::from(hsum_i32_sse(sum_high_sse));
sum_low + QUERY_HIGH_COEF * sum_high + self.dotprod_raw_tail(vector)
}
}
/// AVX-512 + VNNI implementation — uses `VPDPBUSD` on 512-bit ZMM for
/// fused `u8 × i8 → i32` MAC. Processes 4 chunks (64 codes) per iter.
///
/// Tail handling falls back to SSE `maddubs + madd_epi16` rather than the
/// narrower 128/256-bit VPDPBUSD variants, because those need `avx512vl`.
///
/// # Safety
/// CPU must support `avx512f`, `avx512bw`, `avx512vnni`, `ssse3`, `sse4.1`.
#[target_feature(enable = "avx512f,avx512bw,avx512vnni,sse4.1,ssse3")]
pub unsafe fn dotprod_raw_avx512_vnni(&self, vector: &[u8]) -> i64 {
use core::arch::x86_64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query2bitSimd::dotprod_raw_avx512_vnni: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let mut acc_low = _mm512_setzero_si512();
let mut acc_high = _mm512_setzero_si512();
let n_chunks = self.query_data.len();
let n_quads = n_chunks / 4;
for q in 0..n_quads {
let base = 4 * q;
// Unpack 4 × 16 centroids from 16 packed data bytes.
let c_0 = unpack_16_codes_sse(vector.as_ptr().add(4 * base));
let c_1 = unpack_16_codes_sse(vector.as_ptr().add(4 * (base + 1)));
let c_2 = unpack_16_codes_sse(vector.as_ptr().add(4 * (base + 2)));
let c_3 = unpack_16_codes_sse(vector.as_ptr().add(4 * (base + 3)));
let c_ab = _mm256_set_m128i(c_1, c_0);
let c_cd = _mm256_set_m128i(c_3, c_2);
let c = _mm512_inserti64x4(_mm512_castsi256_si512(c_ab), c_cd, 1);
// Load 4 × 16 query-low i8 and 4 × 16 query-high i8 into ZMMs.
let q_lo_0 = _mm_loadu_si128(self.query_data[base][0].as_ptr().cast::<__m128i>());
let q_lo_1 =
_mm_loadu_si128(self.query_data[base + 1][0].as_ptr().cast::<__m128i>());
let q_lo_2 =
_mm_loadu_si128(self.query_data[base + 2][0].as_ptr().cast::<__m128i>());
let q_lo_3 =
_mm_loadu_si128(self.query_data[base + 3][0].as_ptr().cast::<__m128i>());
let q_lo_ab = _mm256_set_m128i(q_lo_1, q_lo_0);
let q_lo_cd = _mm256_set_m128i(q_lo_3, q_lo_2);
let q_low = _mm512_inserti64x4(_mm512_castsi256_si512(q_lo_ab), q_lo_cd, 1);
let q_hi_0 = _mm_loadu_si128(self.query_data[base][1].as_ptr().cast::<__m128i>());
let q_hi_1 =
_mm_loadu_si128(self.query_data[base + 1][1].as_ptr().cast::<__m128i>());
let q_hi_2 =
_mm_loadu_si128(self.query_data[base + 2][1].as_ptr().cast::<__m128i>());
let q_hi_3 =
_mm_loadu_si128(self.query_data[base + 3][1].as_ptr().cast::<__m128i>());
let q_hi_ab = _mm256_set_m128i(q_hi_1, q_hi_0);
let q_hi_cd = _mm256_set_m128i(q_hi_3, q_hi_2);
let q_high = _mm512_inserti64x4(_mm512_castsi256_si512(q_hi_ab), q_hi_cd, 1);
acc_low = _mm512_dpbusd_epi32(acc_low, c, q_low);
acc_high = _mm512_dpbusd_epi32(acc_high, c, q_high);
}
let mut total_low = i64::from(_mm512_reduce_add_epi32(acc_low));
let mut total_high = i64::from(_mm512_reduce_add_epi32(acc_high));
// Tail (0..3 chunks) via plain SSE `maddubs + madd_epi16` —
// avoids pulling in avx512vl for the narrow VPDPBUSD variants.
let tail_start = n_quads * 4;
let ones = _mm_set1_epi16(1);
for p in tail_start..n_chunks {
let [q_lo_chunk, q_hi_chunk] = &self.query_data[p];
let c = unpack_16_codes_sse(vector.as_ptr().add(4 * p));
let q_low = _mm_loadu_si128(q_lo_chunk.as_ptr().cast::<__m128i>());
let q_high = _mm_loadu_si128(q_hi_chunk.as_ptr().cast::<__m128i>());
let prod_low = _mm_maddubs_epi16(c, q_low);
let prod_high = _mm_maddubs_epi16(c, q_high);
total_low += i64::from(hsum_i32_sse(_mm_madd_epi16(prod_low, ones)));
total_high += i64::from(hsum_i32_sse(_mm_madd_epi16(prod_high, ones)));
}
total_low + QUERY_HIGH_COEF * total_high + self.dotprod_raw_tail(vector)
}
}
}
// ------------------------------------------------------------------
// score_2bit_internal — both operands signed, same widen-to-i16
// pattern as query4bit's score_4bit_internal. XOR 0x80 converts the
@@ -661,9 +449,7 @@ mod tests {
use super::super::super::shared::pack_codes;
use super::super::shared::{PARITY_DIMS, random_inputs};
use super::super::{
Query2bitSimd, score_2bit_internal_scalar, score_2bit_internal_weighted_scalar,
};
use super::super::{score_2bit_internal_scalar, score_2bit_internal_weighted_scalar};
use super::*;
/// Build deterministic non-negative i16 weights of length `4 · vec_bytes`
@@ -675,77 +461,6 @@ mod tests {
.collect()
}
#[test]
fn test_sse_matches_scalar() {
if !std::is_x86_feature_detected!("ssse3") || !std::is_x86_feature_detected!("sse4.1") {
return;
}
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let got = unsafe { simd_query.dotprod_raw_sse(&vector) };
assert_eq!(scalar, got, "sse mismatch at dim {dim}");
}
}
#[test]
fn test_avx2_matches_scalar() {
if !std::is_x86_feature_detected!("avx2") {
return;
}
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let got = unsafe { simd_query.dotprod_raw_avx2(&vector) };
assert_eq!(scalar, got, "avx2 mismatch at dim {dim}");
}
}
#[test]
fn test_avx512_vnni_matches_scalar() {
if !(std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni"))
{
return;
}
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let got = unsafe { simd_query.dotprod_raw_avx512_vnni(&vector) };
assert_eq!(scalar, got, "avx512 mismatch at dim {dim}");
}
}
#[test]
fn test_saturation_safety_64k() {
let dim = 65_536;
let query = vec![1.0_f32; dim];
let indices: Vec<u8> = vec![3; dim]; // max-magnitude centroid
let vector = pack_codes(&indices, 2);
let q = Query2bitSimd::new(&query);
let scalar = q.dotprod_raw(&vector);
unsafe {
if std::is_x86_feature_detected!("ssse3") && std::is_x86_feature_detected!("sse4.1") {
assert_eq!(scalar, q.dotprod_raw_sse(&vector));
}
if std::is_x86_feature_detected!("avx2") {
assert_eq!(scalar, q.dotprod_raw_avx2(&vector));
}
if std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni")
{
assert_eq!(scalar, q.dotprod_raw_avx512_vnni(&vector));
}
}
}
#[test]
fn test_score_sse_matches_scalar() {
if !std::is_x86_feature_detected!("ssse3") || !std::is_x86_feature_detected!("sse4.1") {
@@ -1,233 +1,7 @@
//! NEON SIMD paths for [`Query4bitSimd`] on aarch64.
//!
//! The codebook is stored as signed i8 here (`CODEBOOK_I8`), so `vmull_s8` and
//! `sdot` operate on true i8×i8 products — no bias correction. The
//! combining coefficient `QUERY_HIGH_COEF = 256` pairs with full-range i8
//! query halves, yielding ~15.9-bit query precision.
//! NEON kernels for the symmetric 4-bit paths (`score_4bit_internal*`) on
//! aarch64. The codebook is stored as signed i8 (`CODEBOOK_I8`).
use super::{CODEBOOK_I8, CODEBOOK_SCALE, QUERY_HIGH_COEF, Query4bitSimd};
impl Query4bitSimd {
/// ARM NEON implementation of [`Query4bitSimd::dotprod_raw`].
///
/// # Safety
/// CPU must support the `neon` feature (always true on aarch64).
#[target_feature(enable = "neon")]
pub unsafe fn dotprod_raw_neon(&self, vector: &[u8]) -> i64 {
use core::arch::aarch64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query4bitSimd::dotprod_raw_neon: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let codebook = vld1q_s8(CODEBOOK_I8.as_ptr());
let mut acc_low = vdupq_n_s32(0);
let mut acc_high = vdupq_n_s32(0);
let nibble_mask = vdup_n_u8(0x0F);
for (chunk_idx, [low, high]) in self.query_data.iter().enumerate() {
let v_packed = vld1_u8(vector.as_ptr().add(chunk_idx * 8));
let v_lo = vand_u8(v_packed, nibble_mask);
let v_hi = vshr_n_u8(v_packed, 4);
let v = vcombine_u8(vzip1_u8(v_lo, v_hi), vzip2_u8(v_lo, v_hi));
let c = vqtbl1q_s8(codebook, v);
let q_low = vld1q_s8(low.as_ptr());
let q_high = vld1q_s8(high.as_ptr());
let prod_low_lo = vmull_s8(vget_low_s8(q_low), vget_low_s8(c));
let prod_low_hi = vmull_high_s8(q_low, c);
let prod_high_lo = vmull_s8(vget_low_s8(q_high), vget_low_s8(c));
let prod_high_hi = vmull_high_s8(q_high, c);
acc_low = vpadalq_s16(acc_low, prod_low_lo);
acc_low = vpadalq_s16(acc_low, prod_low_hi);
acc_high = vpadalq_s16(acc_high, prod_high_lo);
acc_high = vpadalq_s16(acc_high, prod_high_hi);
}
// Tail: one extra chunk via NEON on a zero-padded 8-byte scratch.
// Data bytes beyond `tail_dims / 2` are zero, and `tail_low /
// tail_high` slots beyond `tail_dims` are zero — their products
// contribute `0` to the final sum.
if let Some(buf) = self.tail_chunk_scratch(vector) {
let v_packed = vld1_u8(buf.as_ptr());
let v_lo = vand_u8(v_packed, nibble_mask);
let v_hi = vshr_n_u8(v_packed, 4);
let v = vcombine_u8(vzip1_u8(v_lo, v_hi), vzip2_u8(v_lo, v_hi));
let c = vqtbl1q_s8(codebook, v);
let q_low = vld1q_s8(self.tail_low.as_ptr());
let q_high = vld1q_s8(self.tail_high.as_ptr());
let prod_low_lo = vmull_s8(vget_low_s8(q_low), vget_low_s8(c));
let prod_low_hi = vmull_high_s8(q_low, c);
let prod_high_lo = vmull_s8(vget_low_s8(q_high), vget_low_s8(c));
let prod_high_hi = vmull_high_s8(q_high, c);
acc_low = vpadalq_s16(acc_low, prod_low_lo);
acc_low = vpadalq_s16(acc_low, prod_low_hi);
acc_high = vpadalq_s16(acc_high, prod_high_lo);
acc_high = vpadalq_s16(acc_high, prod_high_hi);
}
i64::from(vaddvq_s32(acc_low)) + QUERY_HIGH_COEF * i64::from(vaddvq_s32(acc_high))
}
}
/// ARMv8.2-A Dot Product variant. Uses `SDOT` to sum four i8×i8 products
/// per i32 lane per instruction, emitted via inline asm because
/// `vdotq_s32` is still unstable (rust-lang/rust#117224).
///
/// 2× unrolled: two chunks per iteration with four independent `i32×4`
/// accumulators (two for low, two for high) break the single dependency
/// chain of a naive implementation. On Apple M-series SDOT has ~3-cycle
/// latency at 4/cycle throughput, and four parallel chains lift
/// throughput ~720% over a 1× version (larger dims benefit more —
/// latency dominates there).
///
/// # Safety
/// CPU must support `neon` and `dotprod`.
#[target_feature(enable = "neon,dotprod")]
pub unsafe fn dotprod_raw_neon_sdot(&self, vector: &[u8]) -> i64 {
use core::arch::aarch64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query4bitSimd::dotprod_raw_neon_sdot: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let codebook = vld1q_s8(CODEBOOK_I8.as_ptr());
let mut acc_low_0 = vdupq_n_s32(0);
let mut acc_low_1 = vdupq_n_s32(0);
let mut acc_high_0 = vdupq_n_s32(0);
let mut acc_high_1 = vdupq_n_s32(0);
let nibble_mask_q = vdupq_n_u8(0x0F);
let chunks = self.query_data.as_slice();
let n_pairs = chunks.len() / 2;
for i in 0..n_pairs {
let [low_0, high_0] = chunks[2 * i];
let [low_1, high_1] = chunks[2 * i + 1];
// One 16-byte load covers both chunks' 8 packed bytes each.
let v_packed = vld1q_u8(vector.as_ptr().add(16 * i));
let v_lo = vandq_u8(v_packed, nibble_mask_q);
let v_hi = vshrq_n_u8(v_packed, 4);
let v_0 = vzip1q_u8(v_lo, v_hi);
let v_1 = vzip2q_u8(v_lo, v_hi);
let c_0 = vqtbl1q_s8(codebook, v_0);
let c_1 = vqtbl1q_s8(codebook, v_1);
let q_low_0 = vld1q_s8(low_0.as_ptr());
let q_high_0 = vld1q_s8(high_0.as_ptr());
let q_low_1 = vld1q_s8(low_1.as_ptr());
let q_high_1 = vld1q_s8(high_1.as_ptr());
// Four independent SDOT dependency chains — 2× unroll for ILP.
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_low_0,
a = in(vreg) q_low_0,
b = in(vreg) c_0,
options(pure, nomem, nostack, preserves_flags),
);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_low_1,
a = in(vreg) q_low_1,
b = in(vreg) c_1,
options(pure, nomem, nostack, preserves_flags),
);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_high_0,
a = in(vreg) q_high_0,
b = in(vreg) c_0,
options(pure, nomem, nostack, preserves_flags),
);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_high_1,
a = in(vreg) q_high_1,
b = in(vreg) c_1,
options(pure, nomem, nostack, preserves_flags),
);
}
// Odd leftover chunk (after the paired loop) — single-chunk SDOT
// reusing acc_*_0 accumulators. Needed when `query_data.len()` is
// odd, i.e. `dim ∈ (16·n, 16·n + 14]` rather than a multiple of 32.
if chunks.len() % 2 == 1 {
let tail_chunk = n_pairs * 2;
let [low_t, high_t] = chunks[tail_chunk];
let v_packed = vld1_u8(vector.as_ptr().add(8 * tail_chunk));
let v_lo = vand_u8(v_packed, vdup_n_u8(0x0F));
let v_hi = vshr_n_u8(v_packed, 4);
let v = vcombine_u8(vzip1_u8(v_lo, v_hi), vzip2_u8(v_lo, v_hi));
let c = vqtbl1q_s8(codebook, v);
let q_low_t = vld1q_s8(low_t.as_ptr());
let q_high_t = vld1q_s8(high_t.as_ptr());
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_low_0,
a = in(vreg) q_low_t,
b = in(vreg) c,
options(pure, nomem, nostack, preserves_flags),
);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_high_0,
a = in(vreg) q_high_t,
b = in(vreg) c,
options(pure, nomem, nostack, preserves_flags),
);
}
// Tail: one extra chunk via single-SDOT on a zero-padded scratch.
if let Some(buf) = self.tail_chunk_scratch(vector) {
let v_packed = vld1_u8(buf.as_ptr());
let v_lo = vand_u8(v_packed, vdup_n_u8(0x0F));
let v_hi = vshr_n_u8(v_packed, 4);
let v = vcombine_u8(vzip1_u8(v_lo, v_hi), vzip2_u8(v_lo, v_hi));
let c = vqtbl1q_s8(codebook, v);
let q_low_t = vld1q_s8(self.tail_low.as_ptr());
let q_high_t = vld1q_s8(self.tail_high.as_ptr());
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_low_0,
a = in(vreg) q_low_t,
b = in(vreg) c,
options(pure, nomem, nostack, preserves_flags),
);
core::arch::asm!(
"sdot {acc:v}.4s, {a:v}.16b, {b:v}.16b",
acc = inout(vreg) acc_high_0,
a = in(vreg) q_high_t,
b = in(vreg) c,
options(pure, nomem, nostack, preserves_flags),
);
}
let acc_low = vaddq_s32(acc_low_0, acc_low_1);
let acc_high = vaddq_s32(acc_high_0, acc_high_1);
i64::from(vaddvq_s32(acc_low)) + QUERY_HIGH_COEF * i64::from(vaddvq_s32(acc_high))
}
}
}
use super::{CODEBOOK_I8, CODEBOOK_SCALE};
/// NEON implementation of [`super::score_4bit_internal`]. Both PQ-encoded
/// vectors are unpacked into i8 codebook values via `vqtbl1q_s8`, multiplied
@@ -283,8 +57,7 @@ pub unsafe fn score_4bit_internal_neon(a: &[u8], b: &[u8]) -> f32 {
}
/// SDOT variant of [`score_4bit_internal_neon`]. Two independent `i32×4`
/// accumulators consume two chunks (32 elements) per iteration, following
/// the same 2× unroll pattern as [`Query4bitSimd::dotprod_raw_neon_sdot`].
/// accumulators consume two chunks (32 elements) per iteration.
///
/// # Safety
/// CPU must support `neon` and `dotprod`.
@@ -447,9 +220,7 @@ mod tests {
use super::super::super::shared::pack_codes;
use super::super::shared::{PARITY_DIMS, random_inputs};
use super::super::{
Query4bitSimd, score_4bit_internal_scalar, score_4bit_internal_weighted_scalar,
};
use super::super::{score_4bit_internal_scalar, score_4bit_internal_weighted_scalar};
use super::{
score_4bit_internal_neon, score_4bit_internal_neon_sdot, score_4bit_internal_weighted_neon,
};
@@ -463,58 +234,6 @@ mod tests {
.collect()
}
#[test]
fn test_neon_matches_scalar() {
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let neon = unsafe { simd_query.dotprod_raw_neon(&vector) };
assert_eq!(scalar, neon, "scalar {scalar} != neon {neon} at dim {dim}");
}
}
#[test]
fn test_neon_sdot_matches_scalar() {
if !std::arch::is_aarch64_feature_detected!("dotprod") {
return;
}
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let sdot = unsafe { simd_query.dotprod_raw_neon_sdot(&vector) };
assert_eq!(scalar, sdot, "scalar {scalar} != sdot {sdot} at dim {dim}");
}
}
/// Single saturation-safety check at an extreme dim (64K) with the
/// worst-case combination: query maxed out and every lane of the vector
/// pointing at the extreme-magnitude codebook slot. Scalar is the
/// reference (i64 throughout, saturation-free by construction); each
/// SIMD path must match it exactly. A mismatch proves that some
/// intermediate integer saturated or overflowed.
#[test]
fn test_saturation_safety_64k() {
let dim = 65_536;
let query = vec![1.0_f32; dim];
let indices: Vec<u8> = vec![15; dim]; // CODEBOOK_I8[15] = +127
let vector = pack_codes(&indices, 4);
let q = Query4bitSimd::new(&query);
let scalar = q.dotprod_raw(&vector);
unsafe {
let neon = q.dotprod_raw_neon(&vector);
assert_eq!(scalar, neon, "neon disagrees at dim={dim}");
if std::arch::is_aarch64_feature_detected!("dotprod") {
let sdot = q.dotprod_raw_neon_sdot(&vector);
assert_eq!(scalar, sdot, "sdot disagrees at dim={dim}");
}
}
}
/// Parity: NEON `score_4bit_internal` variants must reproduce the scalar
/// reference bit-exactly. Integer accumulators are identical across
/// paths (no saturating intermediates at parity-test dims), so the f32
@@ -1,34 +1,34 @@
//! 4-bit product-quantization scoring.
//!
//! # Encoding strategy (arch-specific)
//!
//! Both architectures share the same storage layout for `query_data`
//! (`Vec<[[i8; 16]; 2]>`) and the same `dot_raw` → float reconstruction formula
//! (`postprocess_scale · (dot_raw bias_correction)`), but the numerical
//! encoding of the codebook and query differs to squeeze the most precision out
//! of each SIMD instruction set:
//!
//! * **aarch64** — `vmull_s8` and `sdot` are true `i8 × i8 → i16/i32` signed
//! multiplies, so we can store the full `i8 ∈ [127, 127]` codebook directly
//! with no offset. Query halves are full `i8 ∈ [128, 127]` combined as
//! `q_signed = 256 · high + low`, giving ~15.9-bit query precision. The
//! reconstruction needs no bias correction.
//!
//! * **x86_64** — `_mm_maddubs_epi16` and `VPDPBUSD` consume one `u8` and one
//! `i8` operand. To carry full 8-bit codebook magnitude we feed it unsigned
//! `c_u ∈ [0, 255]` (shifted from signed by `+128`) and keep the query halves
//! narrower to stay under i16 pair-sum saturation
//! (`c_u ≤ 255, q ∈ [64, 63]` → `|pair| ≤ 2·255·64 = 32 640 < 32 767` ✓).
//! Query halves are 7-bit signed combined as `q_signed = 128 · high + low`,
//! giving ~13.9-bit query precision. The shift contributes a per-query
//! bias `128 · Σ q_signed` that we subtract once in `dotprod`.
//! Asymmetric scoring (query against packed codes) goes through the shared
//! [`QuerySimd`] kernels with the 4-bit [`Encoding`] defined here; the
//! symmetric paths (`score_4bit_internal*`, two packed vectors) have their
//! own kernels in the `arm` / `x64` submodules.
//!
//! Both codebooks derive from `CENTROIDS_4BIT` (Lloyd-Max on N(0,1)); see
//! `test_codebook_matches_lloyd_max` for the consistency check.
use super::SimdBackend;
use super::query::{Code, Encoding, QuerySimd};
/// `max|c|` over `CENTROIDS_4BIT` — the extreme centroid. Shared by both archs.
const CODEBOOK_ABS_MAX: f32 = 2.733;
/// Integer encoding of the 4-bit width for the shared asymmetric kernels
/// ([`super::query::QuerySimd`]).
pub(super) const ENCODING: Encoding = Encoding {
codebook: CODEBOOK,
offset: CODEBOOK_OFFSET,
scale: CODEBOOK_SCALE,
query_high_coef: QUERY_HIGH_COEF,
query_abs_max: QUERY_ABS_MAX,
};
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
const CODEBOOK: [Code; 16] = CODEBOOK_I8;
#[cfg(not(all(target_arch = "aarch64", target_feature = "neon")))]
const CODEBOOK: [Code; 16] = CODEBOOK_U8;
/// Full `i8` signed codebook for aarch64. `c_scale = 127 / max|c|` so the
/// extremes hit ±127. `c_signed[k] = CODEBOOK_I8[k]` directly — no offset.
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
@@ -136,234 +136,9 @@ fn codebook_signed_i64(idx: u8) -> i64 {
codebook_value_i64(idx) - CODEBOOK_OFFSET
}
/// Encoded query for asymmetric 4-bit PQ scoring.
///
/// # Encoding
/// The f32 query is quantized to signed integers
/// `q_signed ∈ [QUERY_ABS_MAX, QUERY_ABS_MAX]` and split into two i8 halves
/// combined as `q_signed = QUERY_HIGH_COEF · high + low` — see the module-level
/// docs for the per-arch values of `QUERY_HIGH_COEF` (256 on aarch64, 128 on
/// x86_64) and the reasoning behind the split. Storage is 16-dim chunks of
/// `[low, high]` plus a scalar-handled tail of up to 14 dims (`dim % 2 == 0`).
/// A matryoshka-trimmed model at any even dim fits without re-encoding.
///
/// # Scoring
/// `dotprod_raw = Σ_j q_signed[j] · c_raw[v[j]]`. The float result is
/// `postprocess_scale · (dot_raw bias_correction)`, where `bias_correction`
/// absorbs the `+OFFSET` shift in the x86 unsigned-codebook layout and is 0
/// on aarch64 (signed codebook, no shift).
pub struct Query4bitSimd {
/// Full 16-dim chunks of the query, each stored as `[low, high]` i8 halves.
query_data: Vec<[[i8; 16]; 2]>,
/// Trailing dims that don't fill a 16-dim chunk — up to 14 (since
/// `dim % 2 == 0`). Arrays are sized to a full 16-lane SIMD register
/// (zero-padded beyond `tail_dims`) so arch backends can feed them
/// straight into one extra `maddubs` / `vmull_s8` iteration on top of a
/// zero-padded data chunk, replacing the scalar 14-iteration loop.
tail_low: [i8; 16],
tail_high: [i8; 16],
/// Number of meaningful entries in the tail arrays (`0..=14`, always even).
tail_dims: u8,
/// `1 / (q_scale · c_scale)` — prefactor from integer to float dot product.
postprocess_scale: f32,
/// `CODEBOOK_OFFSET · Σ q_signed[j]` — sums over **all** dims (full
/// chunks + tail). Subtracted from `dot_raw` to recover the true signed
/// dot product. `0` on aarch64, where the codebook is already signed.
bias_correction: i64,
}
impl Query4bitSimd {
/// Query dim must be a multiple of 2 (the 4-bit packing width: two codes
/// per byte). Any such dim is accepted — dims that don't fill a full
/// 16-dim chunk produce up to a 14-dim tail handled scalar-wise in every
/// SIMD path. This makes `Query4bitSimd` Matryoshka-friendly: a model
/// trimmed to 640 / 768 / 896 / 1024 dims all work with the same storage.
pub fn new(data: &[f32]) -> Self {
assert!(
data.len().is_multiple_of(2),
"Query4bitSimd requires query dim to be a multiple of 2 (got {})",
data.len(),
);
let q_abs_max = data
.iter()
.copied()
.map(f32::abs)
.fold(0.0_f32, f32::max)
.max(f32::EPSILON);
let q_scale = QUERY_ABS_MAX / q_abs_max;
let k = QUERY_HIGH_COEF as i32;
let half_k = k / 2;
let clamp_hi = QUERY_ABS_MAX;
let clamp_lo = -QUERY_ABS_MAX;
// Balanced signed split, same math for full chunks and tail.
let encode = |value: f32| -> (i8, i8, i64) {
let q_signed = (value * q_scale).round().clamp(clamp_lo, clamp_hi) as i32;
let l_mod = q_signed.rem_euclid(k);
let l = if l_mod >= half_k { l_mod - k } else { l_mod } as i8;
let h = ((q_signed - i32::from(l)) / k) as i8;
(l, h, i64::from(q_signed))
};
let num_chunks = data.len() / 16;
let full_dims = num_chunks * 16;
let tail_dims = data.len() - full_dims;
debug_assert!(tail_dims < 16 && tail_dims.is_multiple_of(2));
let mut query_data: Vec<[[i8; 16]; 2]> = Vec::with_capacity(num_chunks);
let mut sum_q_signed: i64 = 0;
for chunk_idx in 0..num_chunks {
let mut low = [0_i8; 16];
let mut high = [0_i8; 16];
for i in 0..16 {
let (l, h, q) = encode(data[chunk_idx * 16 + i]);
low[i] = l;
high[i] = h;
sum_q_signed += q;
}
query_data.push([low, high]);
}
let mut tail_low = [0_i8; 16];
let mut tail_high = [0_i8; 16];
for i in 0..tail_dims {
let (l, h, q) = encode(data[full_dims + i]);
tail_low[i] = l;
tail_high[i] = h;
sum_q_signed += q;
}
Self {
query_data,
tail_low,
tail_high,
tail_dims: tail_dims as u8,
postprocess_scale: 1.0 / (q_scale * CODEBOOK_SCALE),
bias_correction: CODEBOOK_OFFSET * sum_q_signed,
}
}
/// Number of `vector` bytes the encoded query expects: 8 bytes per full
/// 16-dim chunk plus the packed tail (two 4-bit codes per byte).
#[inline]
pub(crate) fn expected_vector_bytes(&self) -> usize {
self.query_data.len() * 8 + (self.tail_dims as usize).div_ceil(2)
}
/// Score the encoded query against a 4-bit PQ-encoded `vector`
/// (two centroid indices per byte; low nibble = even lane, high nibble
/// = odd lane). `vector.len()` must equal `ceil(dim / 2)` — full chunks
/// first, then the tail bytes covering `tail_dims`.
///
/// Dispatches at runtime to the best SIMD backend available on the host
/// CPU (AVX-512 VNNI → AVX2 → SSE → NEON + SDOT → NEON → scalar).
pub fn dotprod(&self, vector: &[u8]) -> f32 {
// No per-vector correction loop: `bias_correction` was baked in at `new()`.
let dot_raw = self.dotprod_raw_best(vector);
self.postprocess_scale * (dot_raw - self.bias_correction) as f32
}
#[inline]
fn dotprod_raw_best(&self, vector: &[u8]) -> i64 {
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni")
{
return unsafe { self.dotprod_raw_avx512_vnni(vector) };
}
if std::is_x86_feature_detected!("avx2") {
return unsafe { self.dotprod_raw_avx2(vector) };
}
if std::is_x86_feature_detected!("sse4.1") && std::is_x86_feature_detected!("ssse3") {
return unsafe { self.dotprod_raw_sse(vector) };
}
}
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
{
if std::arch::is_aarch64_feature_detected!("dotprod") {
return unsafe { self.dotprod_raw_neon_sdot(vector) };
}
return unsafe { self.dotprod_raw_neon(vector) };
}
#[allow(unreachable_code)]
self.dotprod_raw(vector)
}
/// Compute `Σ q_signed[j] · c_raw[v[j]]` over all dims — both the
/// full-chunk section (SIMD-friendly 16 lanes per chunk) and the tail.
/// Returns `acc_low + QUERY_HIGH_COEF · acc_high`.
///
/// `vector` is PQ-encoded with two 4-bit codebook indices packed per byte:
/// the low nibble is the index for the even lane (j = 2k), the high nibble
/// for the odd lane (j = 2k + 1).
pub fn dotprod_raw(&self, vector: &[u8]) -> i64 {
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query4bitSimd::dotprod_raw: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
let mut acc_low: i64 = 0;
let mut acc_high: i64 = 0;
for (chunk_idx, [low, high]) in self.query_data.iter().enumerate() {
let v = &vector[chunk_idx * 8..(chunk_idx + 1) * 8];
for i in 0..16 {
let byte = v[i / 2];
let idx = if i & 1 == 0 { byte & 0x0F } else { byte >> 4 };
let c = codebook_value_i64(idx);
acc_low += i64::from(low[i]) * c;
acc_high += i64::from(high[i]) * c;
}
}
acc_low + QUERY_HIGH_COEF * acc_high + self.dotprod_raw_tail(vector)
}
/// Scalar contribution from the `tail_dims` trailing query entries.
/// Used by the scalar [`Self::dotprod_raw`] reference. SIMD backends
/// have their own tail helpers that feed one zero-padded chunk into the
/// same kernel used for full chunks.
#[inline]
pub(crate) fn dotprod_raw_tail(&self, vector: &[u8]) -> i64 {
if self.tail_dims == 0 {
return 0;
}
let tail_byte_start = self.query_data.len() * 8;
let mut acc_low: i64 = 0;
let mut acc_high: i64 = 0;
for i in 0..self.tail_dims as usize {
let byte = vector[tail_byte_start + i / 2];
let idx = if i & 1 == 0 { byte & 0x0F } else { byte >> 4 };
let c = codebook_value_i64(idx);
acc_low += i64::from(self.tail_low[i]) * c;
acc_high += i64::from(self.tail_high[i]) * c;
}
acc_low + QUERY_HIGH_COEF * acc_high
}
/// Prepare an 8-byte zero-padded scratch buffer with the packed tail data,
/// suitable for feeding into a single SSE / NEON chunk kernel.
/// Returns `None` when there is no tail. The returned buffer has the
/// same nibble layout as any full chunk in `vector` — low nibble = even
/// lane, high nibble = odd lane. Unused lanes are zero: with
/// `tail_low[tail_dims..] = tail_high[tail_dims..] = 0` they contribute
/// nothing to `maddubs` / `vmull` products.
#[inline]
pub(crate) fn tail_chunk_scratch(&self, vector: &[u8]) -> Option<[u8; 8]> {
if self.tail_dims == 0 {
return None;
}
let tail_byte_start = self.query_data.len() * 8;
let tail_bytes = (self.tail_dims as usize).div_ceil(2);
let mut buf = [0u8; 8];
buf[..tail_bytes].copy_from_slice(&vector[tail_byte_start..tail_byte_start + tail_bytes]);
Some(buf)
}
}
/// Encoded query for asymmetric 4-bit scoring: [`QuerySimd`] over two codes
/// per byte (low nibble = even dim, high nibble = odd dim).
pub type Query4bitSimd = QuerySimd<2, 2>;
/// Dot product between two already-encoded 4-bit PQ vectors. Both `a` and
/// `b` are the packed-nibble format that [`Query4bitSimd::dotprod`] takes as
@@ -387,30 +162,19 @@ pub fn score_4bit_internal(a: &[u8], b: &[u8]) -> f32 {
b.len(),
);
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni")
{
return unsafe { x64::score_4bit_internal_avx512_vnni(a, b) };
}
if std::is_x86_feature_detected!("avx2") {
return unsafe { x64::score_4bit_internal_avx2(a, b) };
}
if std::is_x86_feature_detected!("sse4.1") && std::is_x86_feature_detected!("ssse3") {
return unsafe { x64::score_4bit_internal_sse(a, b) };
}
match SimdBackend::detect() {
#[cfg(target_arch = "x86_64")]
SimdBackend::Avx512Vnni => unsafe { x64::score_4bit_internal_avx512_vnni(a, b) },
#[cfg(target_arch = "x86_64")]
SimdBackend::Avx2 => unsafe { x64::score_4bit_internal_avx2(a, b) },
#[cfg(target_arch = "x86_64")]
SimdBackend::Sse => unsafe { x64::score_4bit_internal_sse(a, b) },
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
SimdBackend::NeonSdot => unsafe { arm::score_4bit_internal_neon_sdot(a, b) },
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
SimdBackend::Neon => unsafe { arm::score_4bit_internal_neon(a, b) },
SimdBackend::Scalar => score_4bit_internal_scalar(a, b),
}
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
{
if std::arch::is_aarch64_feature_detected!("dotprod") {
return unsafe { arm::score_4bit_internal_neon_sdot(a, b) };
}
return unsafe { arm::score_4bit_internal_neon(a, b) };
}
#[allow(unreachable_code)]
score_4bit_internal_scalar(a, b)
}
/// Scalar reference implementation of [`score_4bit_internal`]. Exposed as
@@ -471,21 +235,19 @@ pub fn score_4bit_internal_weighted(a: &[u8], b: &[u8], weights: &[i16]) -> i64
2 * a.len(),
);
#[cfg(target_arch = "x86_64")]
{
if std::is_x86_feature_detected!("avx2") {
return unsafe { x64::score_4bit_internal_weighted_avx2(a, b, weights) };
}
if std::is_x86_feature_detected!("sse4.1") && std::is_x86_feature_detected!("ssse3") {
return unsafe { x64::score_4bit_internal_weighted_sse(a, b, weights) };
}
match SimdBackend::detect() {
#[cfg(target_arch = "x86_64")]
SimdBackend::Avx512Vnni | SimdBackend::Avx2 => unsafe {
x64::score_4bit_internal_weighted_avx2(a, b, weights)
},
#[cfg(target_arch = "x86_64")]
SimdBackend::Sse => unsafe { x64::score_4bit_internal_weighted_sse(a, b, weights) },
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
SimdBackend::NeonSdot | SimdBackend::Neon => unsafe {
arm::score_4bit_internal_weighted_neon(a, b, weights)
},
SimdBackend::Scalar => score_4bit_internal_weighted_scalar(a, b, weights),
}
#[cfg(all(target_arch = "aarch64", target_feature = "neon"))]
{
return unsafe { arm::score_4bit_internal_weighted_neon(a, b, weights) };
}
#[allow(unreachable_code)]
score_4bit_internal_weighted_scalar(a, b, weights)
}
/// Scalar reference for [`score_4bit_internal_weighted`].
@@ -560,11 +322,9 @@ pub mod shared {
}
}
/// Accuracy / precision tests for the public `Query4bitSimd` API.
///
/// Per-arch SIMD parity tests and the saturation-safety test live in the
/// `arm` / `x64` submodules — they verify that each SIMD implementation
/// matches the scalar reference `dotprod_raw`, not the float ground truth.
/// Codebook and symmetric-scoring accuracy tests. Per-arch parity tests of
/// the symmetric kernels live in the `arm` / `x64` submodules; the
/// asymmetric path is covered by `super::query`.
#[cfg(test)]
mod tests {
// Anonymous `use _` brings the trait into scope for `StdRng::seed_from_u64`
@@ -573,7 +333,7 @@ mod tests {
use rand::prelude::StdRng;
use super::super::shared::{encode_to_nearest_centroid, pack_codes, sample_normal_vec};
use super::{CODEBOOK_ABS_MAX, Query4bitSimd};
use super::CODEBOOK_ABS_MAX;
use crate::turboquant::TQBits;
/// Whichever codebook representation the current arch uses (signed i8 on
@@ -622,102 +382,6 @@ mod tests {
}
}
/// Reconstruction accuracy on realistic PQ inputs: query N(0,1), vector
/// drawn from N(0,1) then mapped to its nearest centroid. We compare
/// `simd.dotprod()` against the "ideal" PQ dot (sum of `q[j] · c[v[j]]`
/// with float-precision centroid lookup) — the error our SIMD path adds
/// over a hypothetical perfect-precision PQ should be tiny.
///
/// Parameterized over matryoshka-style corner-case dims to exercise the
/// tail-handling logic end-to-end (not just bit-exact parity).
#[rstest::rstest]
#[case::full_chunks(256)]
#[case::small_tail(18)]
#[case::max_tail(30)]
#[case::odd_chunks_only(48)]
#[case::odd_chunks_plus_tail(62)]
#[case::matryoshka(270)]
#[case::large_with_tail(2046)]
fn test_dotprod_matches_float(#[case] dim: usize) {
let mut rng = StdRng::seed_from_u64(42);
let n_trials = 64;
let centroids = TQBits::Bits4.get_centroids();
for _ in 0..n_trials {
let query = sample_normal_vec(&mut rng, dim);
let v_raw = sample_normal_vec(&mut rng, dim);
let indices = encode_to_nearest_centroid(centroids, &v_raw);
let v_pq: Vec<f32> = indices.iter().map(|&k| centroids[k as usize]).collect();
let pq_dot: f32 = query.iter().zip(v_pq.iter()).map(|(a, b)| a * b).sum();
let simd_dot = Query4bitSimd::new(&query).dotprod(&pack_codes(&indices, 4));
// Error scales roughly like √dim · σ_q · ε_c. Allow a tolerance
// that is comfortably above the 3σ tail for dim up to ~2K.
let tol = (0.5_f32).max(0.03 * (dim as f32).sqrt());
assert!(
(pq_dot - simd_dot).abs() < tol,
"dim={dim}: simd_dot {simd_dot} too far from ideal PQ dot {pq_dot} (tol={tol})",
);
}
}
/// Quantitative proof that our SIMD quantization is negligible next to PQ
/// centroid snapping: RMS error added by our encoding is at least 5×
/// smaller than the RMS error PQ itself introduces. If this invariant
/// ever flips, something in the quantization pipeline lost precision.
#[test]
fn test_simd_noise_below_pq_noise() {
let mut rng = StdRng::seed_from_u64(123);
let dim = 256;
let n_trials = 256;
let centroids = TQBits::Bits4.get_centroids();
let mut sq_pq_noise = 0.0_f64;
let mut sq_simd_noise = 0.0_f64;
for _ in 0..n_trials {
let query = sample_normal_vec(&mut rng, dim);
let v_raw = sample_normal_vec(&mut rng, dim);
let indices = encode_to_nearest_centroid(centroids, &v_raw);
let v_pq: Vec<f32> = indices.iter().map(|&k| centroids[k as usize]).collect();
let true_dot: f64 = query
.iter()
.zip(v_raw.iter())
.map(|(a, b)| f64::from(*a) * f64::from(*b))
.sum();
let pq_dot: f64 = query
.iter()
.zip(v_pq.iter())
.map(|(a, b)| f64::from(*a) * f64::from(*b))
.sum();
let simd_dot = f64::from(Query4bitSimd::new(&query).dotprod(&pack_codes(&indices, 4)));
sq_pq_noise += (pq_dot - true_dot).powi(2);
sq_simd_noise += (simd_dot - pq_dot).powi(2);
}
let rms_pq_noise = (sq_pq_noise / f64::from(n_trials)).sqrt();
let rms_simd_noise = (sq_simd_noise / f64::from(n_trials)).sqrt();
// Print for easy comparison across encoding variants.
eprintln!(
"NOISE at dim={dim}: pq_rms={rms_pq_noise:.4} simd_rms={rms_simd_noise:.4} \
ratio={:.2}×",
rms_pq_noise / rms_simd_noise,
);
assert!(
rms_simd_noise * 5.0 < rms_pq_noise,
"SIMD noise RMS {rms_simd_noise:.4} should be << PQ noise RMS \
{rms_pq_noise:.4} (ratio {:.2}×)",
rms_pq_noise / rms_simd_noise,
);
}
/// `score_4bit_internal` should recover the pure centroid-space dot
/// product `Σ c[a[j]] · c[b[j]]` up to the i8 quantization step of the
/// codebook (≤ 1/c_scale ≈ 0.022 per centroid). For dim=256 the
@@ -1,242 +1,9 @@
//! x86_64 SIMD paths for [`Query4bitSimd`].
//! x86_64 kernels for the symmetric 4-bit paths (`score_4bit_internal*`).
//!
//! The codebook is stored as unsigned u8 ∈ [0, 255] (`CODEBOOK_U8`), which is
//! exactly what `_mm_maddubs_epi16` / `VPDPBUSD` expect in their u8 operand
//! slot; the signed shift `c_signed = c_u 128` is undone by
//! `Query4bitSimd`'s per-query `bias_correction`. Query halves are 7-bit
//! signed to keep the maddubs pair sum under i16 saturation:
//! c_u ≤ 255, q ∈ [64, 63] → |pair| ≤ 2·255·64 = 32 640 < 32 767.
//! `QUERY_HIGH_COEF = 128` here — half the aarch64 value because the query
//! halves cover a 7-bit range.
//! The codebook is stored as unsigned u8 ∈ [0, 255] (`CODEBOOK_U8`); the
//! kernels XOR it with 0x80 to recover the signed form in-register.
use super::{CODEBOOK_SCALE, CODEBOOK_U8, QUERY_HIGH_COEF, Query4bitSimd};
impl Query4bitSimd {
/// x86_64 SSE4.1 + SSSE3 implementation of [`Query4bitSimd::dotprod_raw`].
///
/// # Safety
/// CPU must support `ssse3` and `sse4.1`.
#[target_feature(enable = "sse4.1,ssse3")]
pub unsafe fn dotprod_raw_sse(&self, vector: &[u8]) -> i64 {
use core::arch::x86_64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query4bitSimd::dotprod_raw_sse: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let codebook = _mm_loadu_si128(CODEBOOK_U8.as_ptr().cast::<__m128i>());
let ones = _mm_set1_epi16(1);
let nibble_mask = _mm_set1_epi8(0x0F);
let mut acc_low = _mm_setzero_si128();
let mut acc_high = _mm_setzero_si128();
for (chunk_idx, [low, high]) in self.query_data.iter().enumerate() {
let v_packed =
_mm_loadl_epi64(vector.as_ptr().add(chunk_idx * 8).cast::<__m128i>());
let v_lo = _mm_and_si128(v_packed, nibble_mask);
let v_hi = _mm_and_si128(_mm_srli_epi16(v_packed, 4), nibble_mask);
let v = _mm_unpacklo_epi8(v_lo, v_hi);
let c_u = _mm_shuffle_epi8(codebook, v);
let q_low = _mm_loadu_si128(low.as_ptr().cast::<__m128i>());
let q_high = _mm_loadu_si128(high.as_ptr().cast::<__m128i>());
let prod_low = _mm_maddubs_epi16(c_u, q_low);
let prod_high = _mm_maddubs_epi16(c_u, q_high);
acc_low = _mm_add_epi32(acc_low, _mm_madd_epi16(prod_low, ones));
acc_high = _mm_add_epi32(acc_high, _mm_madd_epi16(prod_high, ones));
}
// Tail: one extra SSE chunk on a zero-padded 8-byte scratch.
if let Some(buf) = self.tail_chunk_scratch(vector) {
let v_packed = _mm_loadl_epi64(buf.as_ptr().cast::<__m128i>());
let v_lo = _mm_and_si128(v_packed, nibble_mask);
let v_hi = _mm_and_si128(_mm_srli_epi16(v_packed, 4), nibble_mask);
let v = _mm_unpacklo_epi8(v_lo, v_hi);
let c_u = _mm_shuffle_epi8(codebook, v);
let q_low = _mm_loadu_si128(self.tail_low.as_ptr().cast::<__m128i>());
let q_high = _mm_loadu_si128(self.tail_high.as_ptr().cast::<__m128i>());
let prod_low = _mm_maddubs_epi16(c_u, q_low);
let prod_high = _mm_maddubs_epi16(c_u, q_high);
acc_low = _mm_add_epi32(acc_low, _mm_madd_epi16(prod_low, ones));
acc_high = _mm_add_epi32(acc_high, _mm_madd_epi16(prod_high, ones));
}
i64::from(hsum_i32_sse(acc_low)) + QUERY_HIGH_COEF * i64::from(hsum_i32_sse(acc_high))
}
}
/// x86_64 AVX2 implementation. `query_data` stores `[low, high]` as 32
/// contiguous i8 bytes per chunk, so a single YMM load grabs both halves.
/// Codebook is broadcast to both 128-bit lanes; maddubs pairs the upper
/// lane with `high` and the lower lane with `low`, producing i32 sums
/// split cleanly by lane at the end.
///
/// # Safety
/// CPU must support `avx2`.
#[target_feature(enable = "avx2")]
pub unsafe fn dotprod_raw_avx2(&self, vector: &[u8]) -> i64 {
use core::arch::x86_64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query4bitSimd::dotprod_raw_avx2: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let codebook_128 = _mm_loadu_si128(CODEBOOK_U8.as_ptr().cast::<__m128i>());
let codebook = _mm256_broadcastsi128_si256(codebook_128);
let ones = _mm256_set1_epi16(1);
let ones_128 = _mm_set1_epi16(1);
let nibble_mask = _mm_set1_epi8(0x0F);
let mut acc = _mm256_setzero_si256();
for (chunk_idx, chunk) in self.query_data.iter().enumerate() {
let low_high = _mm256_loadu_si256(chunk.as_ptr().cast::<__m256i>());
let v_packed =
_mm_loadl_epi64(vector.as_ptr().add(chunk_idx * 8).cast::<__m128i>());
let v_lo = _mm_and_si128(v_packed, nibble_mask);
let v_hi = _mm_and_si128(_mm_srli_epi16(v_packed, 4), nibble_mask);
let v128 = _mm_unpacklo_epi8(v_lo, v_hi);
let v = _mm256_broadcastsi128_si256(v128);
let c = _mm256_shuffle_epi8(codebook, v);
let prods = _mm256_maddubs_epi16(c, low_high);
acc = _mm256_add_epi32(acc, _mm256_madd_epi16(prods, ones));
}
let mut acc_low = _mm256_castsi256_si128(acc);
let mut acc_high = _mm256_extracti128_si256(acc, 1);
// Tail: one extra SSE chunk on a zero-padded scratch — matches
// the SSE variant's post-loop kernel.
if let Some(buf) = self.tail_chunk_scratch(vector) {
let v_packed = _mm_loadl_epi64(buf.as_ptr().cast::<__m128i>());
let v_lo = _mm_and_si128(v_packed, nibble_mask);
let v_hi = _mm_and_si128(_mm_srli_epi16(v_packed, 4), nibble_mask);
let v = _mm_unpacklo_epi8(v_lo, v_hi);
let c_u = _mm_shuffle_epi8(codebook_128, v);
let q_low = _mm_loadu_si128(self.tail_low.as_ptr().cast::<__m128i>());
let q_high = _mm_loadu_si128(self.tail_high.as_ptr().cast::<__m128i>());
let prod_low = _mm_maddubs_epi16(c_u, q_low);
let prod_high = _mm_maddubs_epi16(c_u, q_high);
acc_low = _mm_add_epi32(acc_low, _mm_madd_epi16(prod_low, ones_128));
acc_high = _mm_add_epi32(acc_high, _mm_madd_epi16(prod_high, ones_128));
}
i64::from(hsum_i32_sse(acc_low)) + QUERY_HIGH_COEF * i64::from(hsum_i32_sse(acc_high))
}
}
/// AVX-512 VNNI (Ice Lake Xeon+, Zen 4+): 2 chunks per iteration. Two
/// consecutive `[low, high]` entries = 64 bytes = one ZMM load. `VPDPBUSD`
/// fuses the 4-wide u8×i8 dot with i32 accumulation; the ZMM layout puts
/// [low_a, high_a, low_b, high_b] into lanes 0..3.
///
/// # Safety
/// CPU must support `avx512f`, `avx512bw`, and `avx512vnni`.
#[target_feature(enable = "avx512f,avx512bw,avx512vnni,sse4.1,ssse3")]
pub unsafe fn dotprod_raw_avx512_vnni(&self, vector: &[u8]) -> i64 {
use core::arch::x86_64::*;
assert_eq!(
vector.len(),
self.expected_vector_bytes(),
"Query4bitSimd::dotprod_raw_avx512_vnni: vector length mismatch ({} vs expected {})",
vector.len(),
self.expected_vector_bytes(),
);
unsafe {
let codebook_128 = _mm_loadu_si128(CODEBOOK_U8.as_ptr().cast::<__m128i>());
let codebook_512 = _mm512_broadcast_i32x4(codebook_128);
let nibble_mask_128 = _mm_set1_epi8(0x0F);
let ones_128 = _mm_set1_epi16(1);
let mut acc = _mm512_setzero_si512();
let chunks = self.query_data.as_slice();
let n_pairs = chunks.len() / 2;
for i in 0..n_pairs {
let pair_ptr = chunks.as_ptr().add(2 * i).cast::<__m512i>();
let low_high_pair = _mm512_loadu_si512(pair_ptr);
let v_packed_16 = _mm_loadu_si128(vector.as_ptr().add(16 * i).cast::<__m128i>());
let v_lo = _mm_and_si128(v_packed_16, nibble_mask_128);
let v_hi = _mm_and_si128(_mm_srli_epi16(v_packed_16, 4), nibble_mask_128);
let v_chunk_a = _mm_unpacklo_epi8(v_lo, v_hi);
let v_chunk_b = _mm_unpackhi_epi8(v_lo, v_hi);
let v_dup_a = _mm256_broadcastsi128_si256(v_chunk_a);
let v_dup_b = _mm256_broadcastsi128_si256(v_chunk_b);
let v_512 = _mm512_inserti64x4(_mm512_castsi256_si512(v_dup_a), v_dup_b, 1);
let c_512 = _mm512_shuffle_epi8(codebook_512, v_512);
acc = _mm512_dpbusd_epi32(acc, c_512, low_high_pair);
}
let acc_256_lo = _mm512_castsi512_si256(acc);
let acc_256_hi = _mm512_extracti64x4_epi64(acc, 1);
let lane_a_low = _mm256_castsi256_si128(acc_256_lo);
let lane_a_high = _mm256_extracti128_si256(acc_256_lo, 1);
let lane_b_low = _mm256_castsi256_si128(acc_256_hi);
let lane_b_high = _mm256_extracti128_si256(acc_256_hi, 1);
let mut sum_low_xmm = _mm_add_epi32(lane_a_low, lane_b_low);
let mut sum_high_xmm = _mm_add_epi32(lane_a_high, lane_b_high);
// Odd leftover chunk via SSE-style `maddubs + madd_epi16` — 1 chunk
// is too narrow to benefit from VNNI here. Only reached when
// `query_data.len()` is odd (dim not a multiple of 32).
if chunks.len() % 2 == 1 {
let tail_chunk = 2 * n_pairs;
let [low, high] = chunks[tail_chunk];
let v_packed =
_mm_loadl_epi64(vector.as_ptr().add(tail_chunk * 8).cast::<__m128i>());
let v_lo = _mm_and_si128(v_packed, nibble_mask_128);
let v_hi = _mm_and_si128(_mm_srli_epi16(v_packed, 4), nibble_mask_128);
let v = _mm_unpacklo_epi8(v_lo, v_hi);
let c_u = _mm_shuffle_epi8(codebook_128, v);
let q_low = _mm_loadu_si128(low.as_ptr().cast::<__m128i>());
let q_high = _mm_loadu_si128(high.as_ptr().cast::<__m128i>());
let prod_low = _mm_maddubs_epi16(c_u, q_low);
let prod_high = _mm_maddubs_epi16(c_u, q_high);
sum_low_xmm = _mm_add_epi32(sum_low_xmm, _mm_madd_epi16(prod_low, ones_128));
sum_high_xmm = _mm_add_epi32(sum_high_xmm, _mm_madd_epi16(prod_high, ones_128));
}
// Tail dims (< 14 remaining after the optional leftover chunk):
// one SSE chunk on a zero-padded scratch, same as the SSE variant.
if let Some(buf) = self.tail_chunk_scratch(vector) {
let v_packed = _mm_loadl_epi64(buf.as_ptr().cast::<__m128i>());
let v_lo = _mm_and_si128(v_packed, nibble_mask_128);
let v_hi = _mm_and_si128(_mm_srli_epi16(v_packed, 4), nibble_mask_128);
let v = _mm_unpacklo_epi8(v_lo, v_hi);
let c_u = _mm_shuffle_epi8(codebook_128, v);
let q_low = _mm_loadu_si128(self.tail_low.as_ptr().cast::<__m128i>());
let q_high = _mm_loadu_si128(self.tail_high.as_ptr().cast::<__m128i>());
let prod_low = _mm_maddubs_epi16(c_u, q_low);
let prod_high = _mm_maddubs_epi16(c_u, q_high);
sum_low_xmm = _mm_add_epi32(sum_low_xmm, _mm_madd_epi16(prod_low, ones_128));
sum_high_xmm = _mm_add_epi32(sum_high_xmm, _mm_madd_epi16(prod_high, ones_128));
}
i64::from(hsum_i32_sse(sum_low_xmm))
+ QUERY_HIGH_COEF * i64::from(hsum_i32_sse(sum_high_xmm))
}
}
}
use super::{CODEBOOK_SCALE, CODEBOOK_U8};
#[target_feature(enable = "sse2")]
unsafe fn hsum_i32_sse(v: core::arch::x86_64::__m128i) -> i32 {
@@ -665,9 +432,7 @@ mod tests {
use super::super::super::shared::pack_codes;
use super::super::shared::{PARITY_DIMS, random_inputs};
use super::super::{
Query4bitSimd, score_4bit_internal_scalar, score_4bit_internal_weighted_scalar,
};
use super::super::{score_4bit_internal_scalar, score_4bit_internal_weighted_scalar};
use super::{
score_4bit_internal_avx2, score_4bit_internal_avx512_vnni, score_4bit_internal_sse,
score_4bit_internal_weighted_avx2, score_4bit_internal_weighted_sse,
@@ -682,89 +447,6 @@ mod tests {
.collect()
}
#[test]
fn test_sse_matches_scalar() {
if !std::is_x86_feature_detected!("ssse3") || !std::is_x86_feature_detected!("sse4.1") {
return;
}
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let sse = unsafe { simd_query.dotprod_raw_sse(&vector) };
assert_eq!(scalar, sse, "scalar {scalar} != sse {sse} at dim {dim}");
}
}
#[test]
fn test_avx2_matches_scalar() {
if !std::is_x86_feature_detected!("avx2") {
return;
}
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let avx2 = unsafe { simd_query.dotprod_raw_avx2(&vector) };
assert_eq!(scalar, avx2, "scalar {scalar} != avx2 {avx2} at dim {dim}");
}
}
#[test]
fn test_avx512_vnni_matches_scalar() {
if !(std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni"))
{
return;
}
let mut rng = StdRng::seed_from_u64(7);
for &dim in PARITY_DIMS {
let (simd_query, vector) = random_inputs(&mut rng, dim);
let scalar = simd_query.dotprod_raw(&vector);
let vnni512 = unsafe { simd_query.dotprod_raw_avx512_vnni(&vector) };
assert_eq!(
scalar, vnni512,
"scalar {scalar} != avx512_vnni {vnni512} at dim {dim}"
);
}
}
/// Single saturation-safety check at an extreme dim (64K) with the
/// worst-case combination: query maxed out and every lane of the vector
/// pointing at the extreme-magnitude codebook slot. Scalar is the
/// reference (i64 throughout, saturation-free by construction); each
/// SIMD path must match it exactly. A mismatch proves that some
/// intermediate integer saturated or overflowed.
#[test]
fn test_saturation_safety_64k() {
let dim = 65_536;
let query = vec![1.0_f32; dim];
let indices: Vec<u8> = vec![15; dim]; // CODEBOOK_U8[15] = 255 (max magnitude)
let vector = pack_codes(&indices, 4);
let q = Query4bitSimd::new(&query);
let scalar = q.dotprod_raw(&vector);
unsafe {
if std::is_x86_feature_detected!("ssse3") && std::is_x86_feature_detected!("sse4.1") {
let sse = q.dotprod_raw_sse(&vector);
assert_eq!(scalar, sse, "sse disagrees at dim={dim}");
}
if std::is_x86_feature_detected!("avx2") {
let avx2 = q.dotprod_raw_avx2(&vector);
assert_eq!(scalar, avx2, "avx2 disagrees at dim={dim}");
}
if std::is_x86_feature_detected!("avx512f")
&& std::is_x86_feature_detected!("avx512bw")
&& std::is_x86_feature_detected!("avx512vnni")
{
let v512 = q.dotprod_raw_avx512_vnni(&vector);
assert_eq!(scalar, v512, "avx512_vnni disagrees at dim={dim}");
}
}
}
/// Parity: each x86 `score_4bit_internal` variant must reproduce the
/// scalar reference bit-exactly. Both sides compute `Σ c_signed_a ·
/// c_signed_b / c_scale²` with deterministic ordering, so the f32