Skip to content

Commit e235fe5

Browse files
authored
Merge pull request #391 from EnRaiha/feat/280-bbq-fused-v2
feat(vector): fuse the BBQ rerank distance kernels
2 parents f18b31e + fff1264 commit e235fe5

13 files changed

Lines changed: 886 additions & 58 deletions

File tree

‎Cargo.lock‎

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

‎nodedb-vector/Cargo.toml‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,3 +44,13 @@ rand = { workspace = true }
4444
tempfile = { workspace = true }
4545
libc = { workspace = true }
4646
nodedb-wal = { workspace = true }
47+
48+
# `fluxbench` pulls `tokio`, which does not build for wasm32. Keeping it out of
49+
# the wasm dev-dependency set is what lets the crate's own wasm tests build and
50+
# run under a wasm runner, instead of only being compile-checked.
51+
[target.'cfg(not(target_arch = "wasm32"))'.dev-dependencies]
52+
fluxbench = { workspace = true }
53+
54+
[[bench]]
55+
name = "bbq_kernel"
56+
harness = false
Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
// SPDX-License-Identifier: Apache-2.0
2+
3+
//! BBQ kernel benches: the fused path against a reconstruct-and-measure
4+
//! baseline.
5+
//!
6+
//! The baseline models the shape of the pre-fusion rerank pass — decode the
7+
//! prepared payload into a `Vec<f32>` per candidate, then reconstruct each
8+
//! dimension — so the allocation and pass counts are comparable. It is an
9+
//! approximation of that path, not a copy of it: it uses a fixed `1/√dim` scale
10+
//! in place of the candidate's stored corrective factor, and reads the sign bits
11+
//! at a fixed header offset. It measures pass and allocation shape, not the
12+
//! codec's arithmetic.
13+
//!
14+
//! Fixtures are built once per thread, outside the timed region, so only the
15+
//! kernel loop is measured.
16+
//!
17+
//! Run with: cargo bench -p nodedb-vector --bench bbq_kernel
18+
19+
use fluxbench::bench;
20+
use fluxbench::prelude::*;
21+
use std::hint::black_box;
22+
23+
use nodedb_vector::rerank::codec::{PreparedQuery, RerankCodec};
24+
use nodedb_vector::rerank::codecs::BbqRerank;
25+
26+
/// Installs fluxbench's tracking allocator so the harness reports heap bytes and
27+
/// allocation counts per benchmark: the fused path shows zero allocations per
28+
/// candidate, the baseline shows one `Vec<f32>`.
29+
#[global_allocator]
30+
static ALLOC: fluxbench::TrackingAllocator = fluxbench::TrackingAllocator;
31+
32+
const OVERSAMPLE: u8 = 4;
33+
const CANDIDATES: usize = 256;
34+
35+
fn det_vec(i: usize, dim: usize) -> Vec<f32> {
36+
(0..dim)
37+
.map(|j| (((i * 31 + j) % 100) as f32 / 100.0) - 0.5)
38+
.collect()
39+
}
40+
41+
/// Trained codec, prepared query, and 256 encoded candidates for `dim`.
42+
fn setup(dim: usize) -> (BbqRerank, PreparedQuery, Vec<Vec<u8>>) {
43+
let vecs: Vec<Vec<f32>> = (0..CANDIDATES).map(|i| det_vec(i, dim)).collect();
44+
let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
45+
let mut codec = BbqRerank::new(dim, OVERSAMPLE);
46+
codec.train(&refs).expect("train");
47+
let prepared = codec.prepare_query(&vecs[0]).expect("prepare_query");
48+
let encoded: Vec<Vec<u8>> = vecs
49+
.iter()
50+
.map(|v| codec.encode(v).expect("encode"))
51+
.collect();
52+
(codec, prepared, encoded)
53+
}
54+
55+
thread_local! {
56+
static STATE_128: (BbqRerank, PreparedQuery, Vec<Vec<u8>>) = setup(128);
57+
static STATE_768: (BbqRerank, PreparedQuery, Vec<Vec<u8>>) = setup(768);
58+
}
59+
60+
/// The baseline: decode the prepared payload to `Vec<f32>` per candidate, then
61+
/// reconstruct each dimension. `encoded` carries a 32-byte quant header before
62+
/// the sign bits, and the reconstruction uses a fixed `1/√dim` scale rather than
63+
/// the candidate's stored `residual_norm` — see the module docs.
64+
fn unfused_l2(payload: &[u8], encoded: &[u8], dim: usize) -> f32 {
65+
let centered: Vec<f32> = payload[4..]
66+
.as_chunks::<4>()
67+
.0
68+
.iter()
69+
.map(|b| f32::from_le_bytes(*b))
70+
.collect();
71+
let scale = 1.0f32 / (dim as f32).sqrt();
72+
let mut acc = 0.0f32;
73+
for i in 0..dim {
74+
let bit = (encoded[32 + i / 8] >> (7 - (i % 8))) & 1;
75+
let recon = if bit != 0 { scale } else { -scale };
76+
let d = centered[i] - recon;
77+
acc += d * d;
78+
}
79+
acc.sqrt()
80+
}
81+
82+
#[bench(id = "bbq_fused_128", group = "bbq_kernel")]
83+
fn bbq_fused_128(b: &mut Bencher) {
84+
STATE_128.with(|(codec, prepared, encoded)| {
85+
b.iter(|| {
86+
let mut acc = 0.0f32;
87+
for e in encoded {
88+
acc += codec.distance_prepared(prepared, e).expect("distance");
89+
}
90+
black_box(acc)
91+
});
92+
});
93+
}
94+
95+
#[bench(id = "bbq_unfused_128", group = "bbq_kernel")]
96+
fn bbq_unfused_128(b: &mut Bencher) {
97+
STATE_128.with(|(_codec, prepared, encoded)| {
98+
let payload = match prepared {
99+
PreparedQuery::Bytes(b) => b.as_slice(),
100+
_ => panic!("bbq prepared form is Bytes"),
101+
};
102+
b.iter(|| {
103+
let mut acc = 0.0f32;
104+
for e in encoded {
105+
acc += unfused_l2(payload, e, 128);
106+
}
107+
black_box(acc)
108+
});
109+
});
110+
}
111+
112+
#[bench(id = "bbq_fused_768", group = "bbq_kernel")]
113+
fn bbq_fused_768(b: &mut Bencher) {
114+
STATE_768.with(|(codec, prepared, encoded)| {
115+
b.iter(|| {
116+
let mut acc = 0.0f32;
117+
for e in encoded {
118+
acc += codec.distance_prepared(prepared, e).expect("distance");
119+
}
120+
black_box(acc)
121+
});
122+
});
123+
}
124+
125+
#[bench(id = "bbq_unfused_768", group = "bbq_kernel")]
126+
fn bbq_unfused_768(b: &mut Bencher) {
127+
STATE_768.with(|(_codec, prepared, encoded)| {
128+
let payload = match prepared {
129+
PreparedQuery::Bytes(b) => b.as_slice(),
130+
_ => panic!("bbq prepared form is Bytes"),
131+
};
132+
b.iter(|| {
133+
let mut acc = 0.0f32;
134+
for e in encoded {
135+
acc += unfused_l2(payload, e, 768);
136+
}
137+
black_box(acc)
138+
});
139+
});
140+
}
141+
142+
fn main() {
143+
if let Err(e) = fluxbench::run() {
144+
eprintln!("Error: {e}");
145+
std::process::exit(1);
146+
}
147+
}

‎nodedb-vector/src/distance/simd/avx2.rs‎

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
#![cfg(target_arch = "x86_64")]
66

7+
use super::bbq::{assert_payload_shapes, l2_scalar_from_bytes, recon_scale};
8+
79
pub fn l2_squared(a: &[f32], b: &[f32]) -> f32 {
810
assert_eq!(a.len(), b.len(), "avx2 l2: length mismatch");
911
// SAFETY: caller verified avx2+fma via is_x86_feature_detected.
@@ -114,3 +116,60 @@ unsafe fn hsum256(v: std::arch::x86_64::__m256) -> f32 {
114116
let sums2 = _mm_add_ss(sums, shuf2);
115117
_mm_cvtss_f32(sums2)
116118
}
119+
120+
/// Safe entry for `SimdRuntime`; the feature guard lives in `SimdRuntime::detect`.
121+
pub fn l2_bbq(centered: &[u8], packed: &[u8], residual_norm: f32, dim: usize) -> f32 {
122+
assert_payload_shapes(centered, packed, dim);
123+
// SAFETY: selected only when `detect()` observed this tier's features, and
124+
// both slices were just checked against `dim`.
125+
unsafe { l2_bbq_impl(centered, packed, residual_norm, dim) }
126+
}
127+
128+
#[target_feature(enable = "avx2,fma")]
129+
unsafe fn l2_bbq_impl(centered: &[u8], packed: &[u8], residual_norm: f32, dim: usize) -> f32 {
130+
use std::arch::x86_64::*;
131+
132+
let scale = recon_scale(residual_norm, dim);
133+
let scale_v = _mm256_set1_ps(scale);
134+
let mut acc = _mm256_setzero_ps();
135+
136+
let mut i = 0;
137+
while i + 8 <= dim {
138+
// SAFETY: `i + 8 <= dim`, and `l2_bbq` asserted
139+
// `centered.len() >= dim * 4`, so the 32-byte unaligned load stays in
140+
// bounds; `i / 8` is in range because eight dims consume one byte.
141+
let q = unsafe { _mm256_loadu_ps(centered.as_ptr().add(i * 4).cast::<f32>()) };
142+
let byte = unsafe { *packed.get_unchecked(i / 8) } as usize;
143+
let signs = unsafe { _mm256_load_ps(SIGN_LANES[byte].0.as_ptr()) };
144+
let recon = _mm256_mul_ps(signs, scale_v);
145+
let d = _mm256_sub_ps(q, recon);
146+
acc = _mm256_fmadd_ps(d, d, acc);
147+
i += 8;
148+
}
149+
150+
let sum = unsafe { hsum256(acc) } + l2_scalar_from_bytes(centered, packed, scale, i, dim);
151+
sum.sqrt()
152+
}
153+
154+
/// `±1.0` lane patterns for every packed byte, MSB-first, 32-byte aligned for
155+
/// an aligned load. Precomputed `reverse_bits` mapping (dim `k` → lane `k`).
156+
#[derive(Clone, Copy)]
157+
#[repr(align(32))]
158+
struct Aligned8([f32; 8]);
159+
160+
static SIGN_LANES: [Aligned8; 256] = build_sign_lanes();
161+
162+
const fn build_sign_lanes() -> [Aligned8; 256] {
163+
let mut table = [Aligned8([0.0; 8]); 256];
164+
let mut byte = 0usize;
165+
while byte < 256 {
166+
let mut lane = 0usize;
167+
while lane < 8 {
168+
let bit = (byte >> (7 - lane)) & 1;
169+
table[byte].0[lane] = if bit == 1 { 1.0 } else { -1.0 };
170+
lane += 1;
171+
}
172+
byte += 1;
173+
}
174+
table
175+
}

‎nodedb-vector/src/distance/simd/avx512.rs‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@
44
55
#![cfg(target_arch = "x86_64")]
66

7+
use super::bbq::{assert_payload_shapes, l2_scalar_from_bytes, recon_scale};
8+
79
pub fn l2_squared(a: &[f32], b: &[f32]) -> f32 {
810
assert_eq!(a.len(), b.len(), "avx512 l2: length mismatch");
911
unsafe { l2_impl(a, b) }
@@ -99,3 +101,39 @@ unsafe fn ip_impl(a: &[f32], b: &[f32]) -> f32 {
99101
-dot
100102
}
101103
}
104+
105+
/// Safe entry for `SimdRuntime`; the feature guard lives in `SimdRuntime::detect`.
106+
pub fn l2_bbq(centered: &[u8], packed: &[u8], residual_norm: f32, dim: usize) -> f32 {
107+
assert_payload_shapes(centered, packed, dim);
108+
// SAFETY: selected only when `detect()` observed this tier's features, and
109+
// both slices were just checked against `dim`.
110+
unsafe { l2_bbq_impl(centered, packed, residual_norm, dim) }
111+
}
112+
113+
#[target_feature(enable = "avx512f")]
114+
unsafe fn l2_bbq_impl(centered: &[u8], packed: &[u8], residual_norm: f32, dim: usize) -> f32 {
115+
use std::arch::x86_64::*;
116+
117+
let scale = recon_scale(residual_norm, dim);
118+
let pos = _mm512_set1_ps(scale);
119+
let neg = _mm512_set1_ps(-scale);
120+
let mut acc = _mm512_setzero_ps();
121+
122+
let mut i = 0;
123+
while i + 16 <= dim {
124+
// SAFETY: `i + 16 <= dim`, and `l2_bbq` asserted
125+
// `centered.len() >= dim * 4`; two packed bytes are in range because
126+
// 16 dims consume exactly two bytes.
127+
let q = unsafe { _mm512_loadu_ps(centered.as_ptr().add(i * 4).cast::<f32>()) };
128+
let b0 = unsafe { *packed.get_unchecked(i / 8) };
129+
let b1 = unsafe { *packed.get_unchecked(i / 8 + 1) };
130+
let mask: __mmask16 = (b0.reverse_bits() as u16) | ((b1.reverse_bits() as u16) << 8);
131+
let recon = _mm512_mask_blend_ps(mask, neg, pos);
132+
let d = _mm512_sub_ps(q, recon);
133+
acc = _mm512_fmadd_ps(d, d, acc);
134+
i += 16;
135+
}
136+
137+
let sum = _mm512_reduce_add_ps(acc) + l2_scalar_from_bytes(centered, packed, scale, i, dim);
138+
sum.sqrt()
139+
}

0 commit comments

Comments
 (0)