Files
qdrant/lib/quantization/src/turboquant/simd/query1bit/x64.rs
T
Ivan PleshkovandClaude Fable 5 82b9ed8d32 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>
2026-09-03 12:45:56 +02:00

283 lines
10 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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");
}
}
}
}