mirror of
https://github.com/qdrant/qdrant.git
synced 2026-09-21 13:37:46 -05:00
* 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>
283 lines
10 KiB
Rust
283 lines
10 KiB
Rust
//! x86_64 popcount paths for [`super::score_1bit_internal`].
|
||
//!
|
||
//! Three backends, picked at runtime by the dispatcher in `mod.rs`:
|
||
//!
|
||
//! * **SSE4.1 / SSSE3** — `pshufb`-based nibble-lookup popcount (Muła): split
|
||
//! each byte into its two nibbles, `pshufb` a 16-entry popcount table,
|
||
//! sum. `psadbw` against zero horizontally sums 8 bytes into a u16 lane,
|
||
//! which we accumulate into a u64 pair.
|
||
//!
|
||
//! * **AVX2** — same Muła trick on 32-byte YMM registers.
|
||
//!
|
||
//! * **AVX-512 VPOPCNTDQ** — `vpopcntq` in hardware: 8 × u64 popcounts per
|
||
//! instruction, summed into a 512-bit u64 accumulator and reduced with
|
||
//! `_mm512_reduce_add_epi64`.
|
||
//!
|
||
//! All three use a u64 accumulator so no intermediate can saturate at any
|
||
//! reasonable vector size (see `test_score_*_overflow_safety_64k`).
|
||
|
||
/// 16-byte popcount-of-nibble lookup table. Index = nibble value,
|
||
/// value = number of 1-bits. Broadcast to YMM/ZMM as needed.
|
||
const NIBBLE_POPCNT: [i8; 16] = [0, 1, 1, 2, 1, 2, 2, 3, 1, 2, 2, 3, 2, 3, 3, 4];
|
||
|
||
/// Raw popcount of `a ⊕ b` using 16-byte SSE pshufb-nibble-lookup chunks
|
||
/// plus a scalar byte tail. Shared between [`score_1bit_internal_sse`] and
|
||
/// the tail path of [`score_1bit_internal_avx512_vpopcntdq`].
|
||
///
|
||
/// # Safety
|
||
/// `a.len() == b.len()`. CPU must support `ssse3` and `sse4.1`.
|
||
#[inline]
|
||
#[target_feature(enable = "sse4.1,ssse3")]
|
||
unsafe fn popcount_sse(a: &[u8], b: &[u8]) -> u64 {
|
||
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 = _mm_setzero_si128();
|
||
|
||
let chunks = a.len() / 16;
|
||
for i in 0..chunks {
|
||
let va = _mm_loadu_si128(a.as_ptr().add(i * 16).cast::<__m128i>());
|
||
let vb = _mm_loadu_si128(b.as_ptr().add(i * 16).cast::<__m128i>());
|
||
let x = _mm_xor_si128(va, vb);
|
||
|
||
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);
|
||
// Per-byte popcount ≤ 8; two halves summed ≤ 16 per u8 — fits u8.
|
||
let cnt = _mm_add_epi8(cnt_lo, cnt_hi);
|
||
|
||
// `psadbw(cnt, 0)` horizontally sums 8 bytes into each u64 lane
|
||
// (max 8 · 16 = 128 per lane per chunk — zero overflow risk).
|
||
acc = _mm_add_epi64(acc, _mm_sad_epu8(cnt, zero));
|
||
}
|
||
|
||
let lo = _mm_cvtsi128_si64(acc) as u64;
|
||
let hi = _mm_cvtsi128_si64(_mm_unpackhi_epi64(acc, acc)) as u64;
|
||
let mut popcnt = lo + hi;
|
||
|
||
let tail_start = chunks * 16;
|
||
for i in tail_start..a.len() {
|
||
popcnt += u64::from((a[i] ^ b[i]).count_ones());
|
||
}
|
||
popcnt
|
||
}
|
||
}
|
||
|
||
/// SSE4.1 + SSSE3 implementation of [`super::score_1bit_internal`].
|
||
///
|
||
/// # Safety
|
||
/// CPU must support `ssse3` and `sse4.1`.
|
||
#[target_feature(enable = "sse4.1,ssse3")]
|
||
pub unsafe fn score_1bit_internal_sse(a: &[u8], b: &[u8]) -> f32 {
|
||
assert_eq!(
|
||
a.len(),
|
||
b.len(),
|
||
"score_1bit_internal_sse: vector length mismatch ({} vs {})",
|
||
a.len(),
|
||
b.len(),
|
||
);
|
||
super::popcount_to_score(a.len(), unsafe { popcount_sse(a, b) })
|
||
}
|
||
|
||
/// AVX2 implementation of [`super::score_1bit_internal`].
|
||
///
|
||
/// Tail after the 32-byte bulk loop (up to 31 bytes) is routed through
|
||
/// [`popcount_sse`] — at most 1 SSE chunk + scalar bytes, still cheaper
|
||
/// than a 31-iteration scalar loop on short vectors.
|
||
///
|
||
/// # Safety
|
||
/// CPU must support `avx2`, `ssse3`, and `sse4.1`.
|
||
#[target_feature(enable = "avx2,sse4.1,ssse3")]
|
||
pub unsafe fn score_1bit_internal_avx2(a: &[u8], b: &[u8]) -> f32 {
|
||
use core::arch::x86_64::*;
|
||
|
||
assert_eq!(
|
||
a.len(),
|
||
b.len(),
|
||
"score_1bit_internal_avx2: vector length mismatch ({} vs {})",
|
||
a.len(),
|
||
b.len(),
|
||
);
|
||
|
||
unsafe {
|
||
// Broadcast the 16-byte lookup into both 128-bit halves — pshufb
|
||
// operates per-lane, so each half needs its own copy of the table.
|
||
let lookup_half = _mm_loadu_si128(NIBBLE_POPCNT.as_ptr().cast::<__m128i>());
|
||
let lookup = _mm256_set_m128i(lookup_half, lookup_half);
|
||
let low_mask = _mm256_set1_epi8(0x0F);
|
||
let zero = _mm256_setzero_si256();
|
||
let mut acc = _mm256_setzero_si256();
|
||
|
||
let chunks = a.len() / 32;
|
||
for i in 0..chunks {
|
||
let va = _mm256_loadu_si256(a.as_ptr().add(i * 32).cast::<__m256i>());
|
||
let vb = _mm256_loadu_si256(b.as_ptr().add(i * 32).cast::<__m256i>());
|
||
let x = _mm256_xor_si256(va, vb);
|
||
|
||
let lo = _mm256_and_si256(x, low_mask);
|
||
let hi = _mm256_and_si256(_mm256_srli_epi16(x, 4), low_mask);
|
||
let cnt_lo = _mm256_shuffle_epi8(lookup, lo);
|
||
let cnt_hi = _mm256_shuffle_epi8(lookup, hi);
|
||
let cnt = _mm256_add_epi8(cnt_lo, cnt_hi);
|
||
|
||
let sum64 = _mm256_sad_epu8(cnt, zero);
|
||
acc = _mm256_add_epi64(acc, sum64);
|
||
}
|
||
|
||
// Reduce 4 × u64 → scalar u64 via two 128-bit halves.
|
||
let lo128 = _mm256_castsi256_si128(acc);
|
||
let hi128 = _mm256_extracti128_si256(acc, 1);
|
||
let sum128 = _mm_add_epi64(lo128, hi128);
|
||
let lo = _mm_cvtsi128_si64(sum128) as u64;
|
||
let hi = _mm_cvtsi128_si64(_mm_unpackhi_epi64(sum128, sum128)) as u64;
|
||
let mut popcnt = lo + hi;
|
||
|
||
let tail_start = chunks * 32;
|
||
popcnt += popcount_sse(&a[tail_start..], &b[tail_start..]);
|
||
|
||
super::popcount_to_score(a.len(), popcnt)
|
||
}
|
||
}
|
||
|
||
/// AVX-512 VPOPCNTDQ implementation of [`super::score_1bit_internal`].
|
||
///
|
||
/// Tail after the 64-byte bulk loop (up to 63 bytes) is handled via the
|
||
/// [`popcount_sse`] helper — 3 SSE chunks + scalar bytes is ~10× cheaper
|
||
/// than a 63-iteration scalar loop when the tail is non-trivial.
|
||
///
|
||
/// # Safety
|
||
/// CPU must support `avx512f`, `avx512vpopcntdq`, `ssse3`, and `sse4.1`.
|
||
#[target_feature(enable = "avx512f,avx512vpopcntdq,sse4.1,ssse3")]
|
||
pub unsafe fn score_1bit_internal_avx512_vpopcntdq(a: &[u8], b: &[u8]) -> f32 {
|
||
use core::arch::x86_64::*;
|
||
|
||
assert_eq!(
|
||
a.len(),
|
||
b.len(),
|
||
"score_1bit_internal_avx512_vpopcntdq: vector length mismatch ({} vs {})",
|
||
a.len(),
|
||
b.len(),
|
||
);
|
||
|
||
unsafe {
|
||
let mut acc = _mm512_setzero_si512();
|
||
let chunks = a.len() / 64;
|
||
for i in 0..chunks {
|
||
let va = _mm512_loadu_si512(a.as_ptr().add(i * 64).cast::<__m512i>());
|
||
let vb = _mm512_loadu_si512(b.as_ptr().add(i * 64).cast::<__m512i>());
|
||
let x = _mm512_xor_si512(va, vb);
|
||
let cnt = _mm512_popcnt_epi64(x);
|
||
acc = _mm512_add_epi64(acc, cnt);
|
||
}
|
||
let mut popcnt = _mm512_reduce_add_epi64(acc) as u64;
|
||
|
||
let tail_start = chunks * 64;
|
||
popcnt += popcount_sse(&a[tail_start..], &b[tail_start..]);
|
||
|
||
super::popcount_to_score(a.len(), popcnt)
|
||
}
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests {
|
||
use rand::SeedableRng as _;
|
||
use rand::prelude::StdRng;
|
||
|
||
use super::super::super::shared::random_bytes;
|
||
use super::super::score_1bit_internal_scalar;
|
||
use super::super::shared::PARITY_BYTE_LENS;
|
||
use super::*;
|
||
|
||
#[test]
|
||
fn test_score_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 &byte_len in PARITY_BYTE_LENS {
|
||
let a = random_bytes(&mut rng, byte_len);
|
||
let b = random_bytes(&mut rng, byte_len);
|
||
let scalar = score_1bit_internal_scalar(&a, &b);
|
||
let got = unsafe { score_1bit_internal_sse(&a, &b) };
|
||
assert_eq!(
|
||
scalar.to_bits(),
|
||
got.to_bits(),
|
||
"sse mismatch at byte_len={byte_len}",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_score_avx2_matches_scalar() {
|
||
if !std::is_x86_feature_detected!("avx2") {
|
||
return;
|
||
}
|
||
let mut rng = StdRng::seed_from_u64(7);
|
||
for &byte_len in PARITY_BYTE_LENS {
|
||
let a = random_bytes(&mut rng, byte_len);
|
||
let b = random_bytes(&mut rng, byte_len);
|
||
let scalar = score_1bit_internal_scalar(&a, &b);
|
||
let got = unsafe { score_1bit_internal_avx2(&a, &b) };
|
||
assert_eq!(
|
||
scalar.to_bits(),
|
||
got.to_bits(),
|
||
"avx2 mismatch at byte_len={byte_len}",
|
||
);
|
||
}
|
||
}
|
||
|
||
#[test]
|
||
fn test_score_avx512_vpopcntdq_matches_scalar() {
|
||
if !(std::is_x86_feature_detected!("avx512f")
|
||
&& std::is_x86_feature_detected!("avx512vpopcntdq"))
|
||
{
|
||
return;
|
||
}
|
||
let mut rng = StdRng::seed_from_u64(7);
|
||
for &byte_len in PARITY_BYTE_LENS {
|
||
let a = random_bytes(&mut rng, byte_len);
|
||
let b = random_bytes(&mut rng, byte_len);
|
||
let scalar = score_1bit_internal_scalar(&a, &b);
|
||
let got = unsafe { score_1bit_internal_avx512_vpopcntdq(&a, &b) };
|
||
assert_eq!(
|
||
scalar.to_bits(),
|
||
got.to_bits(),
|
||
"avx512 mismatch at byte_len={byte_len}",
|
||
);
|
||
}
|
||
}
|
||
|
||
/// Overflow safety at 64 KiB (524 288 bits) with `a = all 0xFF`,
|
||
/// `b = all 0x00` — every bit disagrees, so `popcnt = n_bits`. Each
|
||
/// SIMD path must match scalar (u64 throughout) exactly; a mismatch
|
||
/// would mean an intermediate `u16`/`u32`/u64 lane overflowed.
|
||
#[test]
|
||
fn test_score_overflow_safety_64k() {
|
||
let byte_len = 65_536 / 8;
|
||
let a = vec![0xFF_u8; byte_len];
|
||
let b = vec![0x00_u8; byte_len];
|
||
let scalar = score_1bit_internal_scalar(&a, &b);
|
||
|
||
unsafe {
|
||
if std::is_x86_feature_detected!("ssse3") && std::is_x86_feature_detected!("sse4.1") {
|
||
let sse = score_1bit_internal_sse(&a, &b);
|
||
assert_eq!(scalar.to_bits(), sse.to_bits(), "sse overflow at 64k");
|
||
}
|
||
if std::is_x86_feature_detected!("avx2") {
|
||
let avx2 = score_1bit_internal_avx2(&a, &b);
|
||
assert_eq!(scalar.to_bits(), avx2.to_bits(), "avx2 overflow at 64k");
|
||
}
|
||
if std::is_x86_feature_detected!("avx512f")
|
||
&& std::is_x86_feature_detected!("avx512vpopcntdq")
|
||
{
|
||
let avx512 = score_1bit_internal_avx512_vpopcntdq(&a, &b);
|
||
assert_eq!(scalar.to_bits(), avx512.to_bits(), "avx512 overflow at 64k");
|
||
}
|
||
}
|
||
}
|
||
}
|