use rayon::prelude::*;
use crate::rank::{rank_to_bucket, rank_transform};
pub struct MultiBucketBitmap {
dim: usize,
bits: u8,
n_buckets: usize,
qwords_per_bitmap: usize,
n_vectors: usize,
bitmaps: Vec<u64>,
}
impl MultiBucketBitmap {
pub fn new(dim: usize, bits: u8) -> Self {
assert!(matches!(bits, 1 | 2 | 4), "bits must be 1, 2, or 4");
assert!(dim > 0, "dim must be > 0");
assert_eq!(dim % 64, 0, "dim must be a multiple of 64");
let n_buckets = 1usize << bits;
let qpb = dim / 64;
assert_eq!(
dim % n_buckets,
0,
"dim must be a multiple of 2^bits for constant-composition",
);
Self {
dim,
bits,
n_buckets,
qwords_per_bitmap: qpb,
n_vectors: 0,
bitmaps: Vec::new(),
}
}
pub fn add(&mut self, vectors: &[f32]) {
let n = vectors.len() / self.dim;
assert_eq!(vectors.len(), n * self.dim);
crate::util::assert_all_finite(vectors);
let qpb = self.qwords_per_bitmap;
let nb = self.n_buckets;
let per_doc = nb * qpb;
let start = self.bitmaps.len();
self.bitmaps.resize(start + n * per_doc, 0u64);
let dim = self.dim;
let bits = self.bits;
self.bitmaps[start..]
.par_chunks_mut(per_doc)
.zip(vectors.par_chunks(dim))
.for_each(|(out, v)| {
let ranks = rank_transform(v);
for j in 0..dim {
let b = rank_to_bucket(ranks[j], dim, bits) as usize;
out[b * qpb + j / 64] |= 1u64 << (j % 64);
}
});
self.n_vectors += n;
}
pub fn query_bitmaps_from_ranks(&self, q: &[f32]) -> Vec<u64> {
assert_eq!(q.len(), self.dim);
crate::util::assert_all_finite(q);
let qpb = self.qwords_per_bitmap;
let nb = self.n_buckets;
let bits = self.bits;
let dim = self.dim;
let ranks = rank_transform(q);
let mut out = vec![0u64; nb * qpb];
for j in 0..dim {
let b = rank_to_bucket(ranks[j], dim, bits) as usize;
out[b * qpb + j / 64] |= 1u64 << (j % 64);
}
out
}
pub fn outer_product_weights(&self) -> Vec<f32> {
let nb = self.n_buckets;
let c = (nb as f32 - 1.0) / 2.0;
let mut w = vec![0.0f32; nb * nb];
for a in 0..nb {
for b in 0..nb {
w[a * nb + b] = (a as f32 - c) * (b as f32 - c);
}
}
w
}
pub fn diagonal_weights(&self) -> Vec<f32> {
let nb = self.n_buckets;
let mut w = vec![0.0f32; nb * nb];
for a in 0..nb {
w[a * nb + a] = 1.0;
}
w
}
pub fn banded_weights(&self, half_width: usize) -> Vec<f32> {
let nb = self.n_buckets;
let c = (nb as f32 - 1.0) / 2.0;
let mut w = vec![0.0f32; nb * nb];
for a in 0..nb {
for b in 0..nb {
if a.abs_diff(b) <= half_width {
w[a * nb + b] = (a as f32 - c) * (b as f32 - c);
}
}
}
w
}
pub fn bilinear_score(&self, q_bitmaps: &[u64], w: &[f32], doc_idx: usize) -> f32 {
let qpb = self.qwords_per_bitmap;
let nb = self.n_buckets;
assert!(
doc_idx < self.n_vectors,
"bilinear_score: doc_idx {doc_idx} out of range (n_vectors {})",
self.n_vectors,
);
debug_assert_eq!(q_bitmaps.len(), nb * qpb);
debug_assert_eq!(w.len(), nb * nb);
let doc_base = doc_idx * nb * qpb;
let mut acc = 0.0f32;
for a in 0..nb {
for b in 0..nb {
let weight = w[a * nb + b];
if weight == 0.0 {
continue;
}
let q_off = a * qpb;
let d_off = doc_base + b * qpb;
let mut overlap: u32 = 0;
for k in 0..qpb {
overlap += (q_bitmaps[q_off + k] & self.bitmaps[d_off + k]).count_ones();
}
acc += weight * (overlap as f32);
}
}
acc
}
pub fn top_m_bilinear(&self, q_bitmaps: &[u64], w: &[f32], m: usize) -> Vec<u32> {
let m_eff = m.min(self.n_vectors);
if m_eff == 0 {
return Vec::new();
}
let n = self.n_vectors;
let mut scores = vec![0.0f32; n];
scores.par_iter_mut().enumerate().for_each(|(di, s)| {
*s = self.bilinear_score(q_bitmaps, w, di);
});
let mut idx: Vec<u32> = (0..n as u32).collect();
idx.select_nth_unstable_by(m_eff - 1, |&a, &b| {
scores[b as usize]
.partial_cmp(&scores[a as usize])
.unwrap_or(std::cmp::Ordering::Equal)
});
let mut head = idx[..m_eff].to_vec();
head.sort_unstable_by(|&a, &b| {
scores[b as usize]
.partial_cmp(&scores[a as usize])
.unwrap_or(std::cmp::Ordering::Equal)
});
head
}
pub fn contingency_row(&self, q_bitmaps: &[u64], doc_idx: usize) -> Vec<u32> {
let qpb = self.qwords_per_bitmap;
let nb = self.n_buckets;
assert!(
doc_idx < self.n_vectors,
"contingency_row: doc_idx {doc_idx} out of range (n_vectors {})",
self.n_vectors,
);
assert_eq!(
q_bitmaps.len(),
nb * qpb,
"contingency_row: q_bitmaps must be nb * qpb words",
);
let doc_base = doc_idx * nb * qpb;
let doc = &self.bitmaps[doc_base..doc_base + nb * qpb];
let mut table = vec![0u32; nb * nb];
contingency_accumulate(q_bitmaps, doc, nb, qpb, &mut table);
table
}
pub fn diagonal_overlap_row(&self, q_bitmaps: &[u64], doc_idx: usize) -> Vec<u32> {
let qpb = self.qwords_per_bitmap;
let nb = self.n_buckets;
assert!(
doc_idx < self.n_vectors,
"diagonal_overlap_row: doc_idx {doc_idx} out of range (n_vectors {})",
self.n_vectors,
);
assert_eq!(
q_bitmaps.len(),
nb * qpb,
"diagonal_overlap_row: q_bitmaps must be nb * qpb words",
);
let doc_base = doc_idx * nb * qpb;
let doc = &self.bitmaps[doc_base..doc_base + nb * qpb];
let mut diag = vec![0u32; nb];
diagonal_accumulate(q_bitmaps, doc, nb, qpb, &mut diag);
diag
}
#[must_use = "this scans every doc's bitmaps to build contingency tables; dropping the result discards that work"]
pub fn project_all_batched(&self, q_bitmaps: &[u64], weights: &[&[f32]]) -> Vec<Vec<f32>> {
let qpb = self.qwords_per_bitmap;
let nb = self.n_buckets;
assert_eq!(
q_bitmaps.len(),
nb * qpb,
"project_all_batched: q_bitmaps must be nb * qpb words",
);
for (p, w) in weights.iter().enumerate() {
assert_eq!(
w.len(),
nb * nb,
"project_all_batched: weights[{p}] must be an nb * nb matrix",
);
}
let n = self.n_vectors;
let n_proj = weights.len();
if n == 0 || n_proj == 0 {
return vec![Vec::new(); n];
}
let per_doc = nb * qpb;
let bitmaps = &self.bitmaps;
(0..n)
.into_par_iter()
.map(|di| {
let doc = &bitmaps[di * per_doc..(di + 1) * per_doc];
assert!(
nb * nb <= 256,
"project_all_batched: nb={nb} exceeds stack table capacity \
(nb*nb={} > 256); bits must be <=4 so nb<=16",
nb * nb,
);
let mut table = [0u32; 256];
let table = &mut table[..nb * nb];
contingency_accumulate(q_bitmaps, doc, nb, qpb, table);
project_table(table, weights)
})
.collect()
}
#[doc(hidden)]
pub fn diagonal_overlap_row_scalar(&self, q_bitmaps: &[u64], doc_idx: usize) -> Vec<u32> {
let qpb = self.qwords_per_bitmap;
let nb = self.n_buckets;
assert!(
doc_idx < self.n_vectors,
"diagonal_overlap_row_scalar: doc_idx {doc_idx} out of range (n_vectors {})",
self.n_vectors,
);
assert_eq!(
q_bitmaps.len(),
nb * qpb,
"diagonal_overlap_row_scalar: q_bitmaps must be nb * qpb words",
);
let doc_base = doc_idx * nb * qpb;
let doc = &self.bitmaps[doc_base..doc_base + nb * qpb];
let mut diag = vec![0u32; nb];
diagonal_accumulate_scalar(q_bitmaps, doc, nb, qpb, &mut diag);
diag
}
#[doc(hidden)]
#[must_use = "this scans every doc's bitmaps to build contingency tables; dropping the result discards that work"]
pub fn project_all_batched_scalar(
&self,
q_bitmaps: &[u64],
weights: &[&[f32]],
) -> Vec<Vec<f32>> {
let qpb = self.qwords_per_bitmap;
let nb = self.n_buckets;
assert_eq!(
q_bitmaps.len(),
nb * qpb,
"project_all_batched_scalar: q_bitmaps must be nb * qpb words",
);
for (p, w) in weights.iter().enumerate() {
assert_eq!(
w.len(),
nb * nb,
"project_all_batched_scalar: weights[{p}] must be an nb * nb matrix",
);
}
let n = self.n_vectors;
let n_proj = weights.len();
if n == 0 || n_proj == 0 {
return vec![Vec::new(); n];
}
let per_doc = nb * qpb;
let bitmaps = &self.bitmaps;
(0..n)
.into_par_iter()
.map(|di| {
let doc = &bitmaps[di * per_doc..(di + 1) * per_doc];
assert!(
nb * nb <= 256,
"project_all_batched_scalar: nb={nb} exceeds stack table capacity \
(nb*nb={} > 256); bits must be <=4 so nb<=16",
nb * nb,
);
let mut table = [0u32; 256];
let table = &mut table[..nb * nb];
contingency_accumulate_scalar(q_bitmaps, doc, nb, qpb, table);
project_table(table, weights)
})
.collect()
}
pub fn len(&self) -> usize {
self.n_vectors
}
pub fn is_empty(&self) -> bool {
self.n_vectors == 0
}
pub fn dim(&self) -> usize {
self.dim
}
pub fn bits(&self) -> u8 {
self.bits
}
pub fn n_buckets(&self) -> usize {
self.n_buckets
}
pub fn bytes_per_vec(&self) -> usize {
self.qwords_per_bitmap * self.n_buckets * 8
}
pub fn byte_size(&self) -> usize {
self.bitmaps.len() * std::mem::size_of::<u64>()
}
}
fn contingency_accumulate(
q_bitmaps: &[u64],
doc: &[u64],
nb: usize,
qpb: usize,
table: &mut [u32],
) {
debug_assert_eq!(q_bitmaps.len(), nb * qpb);
debug_assert_eq!(doc.len(), nb * qpb);
debug_assert_eq!(table.len(), nb * nb);
#[cfg(target_arch = "x86_64")]
let use_avx512vpop =
is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512vpopcntdq");
#[cfg(not(target_arch = "x86_64"))]
let use_avx512vpop = false;
if use_avx512vpop {
#[cfg(target_arch = "x86_64")]
unsafe {
contingency_accumulate_avx512vpop(q_bitmaps, doc, nb, qpb, table);
return;
}
}
contingency_accumulate_scalar(q_bitmaps, doc, nb, qpb, table);
}
fn project_table(table: &[u32], weights: &[&[f32]]) -> Vec<f32> {
weights
.iter()
.map(|w| {
w.iter()
.zip(table.iter())
.map(|(&weight, &c)| weight * c as f32)
.sum()
})
.collect()
}
fn contingency_accumulate_scalar(
q_bitmaps: &[u64],
doc: &[u64],
nb: usize,
qpb: usize,
table: &mut [u32],
) {
for a in 0..nb {
let q_off = a * qpb;
let row = a * nb;
for b in 0..nb {
let d_off = b * qpb;
let mut overlap: u32 = 0;
for w in 0..qpb {
overlap += (q_bitmaps[q_off + w] & doc[d_off + w]).count_ones();
}
table[row + b] = overlap;
}
}
}
fn diagonal_accumulate(q_bitmaps: &[u64], doc: &[u64], nb: usize, qpb: usize, diag: &mut [u32]) {
debug_assert_eq!(q_bitmaps.len(), nb * qpb);
debug_assert_eq!(doc.len(), nb * qpb);
debug_assert_eq!(diag.len(), nb);
#[cfg(target_arch = "x86_64")]
let use_avx512vpop =
is_x86_feature_detected!("avx512f") && is_x86_feature_detected!("avx512vpopcntdq");
#[cfg(not(target_arch = "x86_64"))]
let use_avx512vpop = false;
if use_avx512vpop {
#[cfg(target_arch = "x86_64")]
unsafe {
diagonal_accumulate_avx512vpop(q_bitmaps, doc, nb, qpb, diag);
return;
}
}
diagonal_accumulate_scalar(q_bitmaps, doc, nb, qpb, diag);
}
fn diagonal_accumulate_scalar(
q_bitmaps: &[u64],
doc: &[u64],
nb: usize,
qpb: usize,
diag: &mut [u32],
) {
#[allow(clippy::needless_range_loop)]
for a in 0..nb {
let off = a * qpb;
let mut overlap: u32 = 0;
for w in 0..qpb {
overlap += (q_bitmaps[off + w] & doc[off + w]).count_ones();
}
diag[a] = overlap;
}
}
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx512f,avx512vpopcntdq")]
unsafe fn contingency_accumulate_avx512vpop(
q_bitmaps: &[u64],
doc: &[u64],
nb: usize,
qpb: usize,
table: &mut [u32],
) {
use std::arch::x86_64::*;
unsafe {
let full_blocks = qpb / 8;
let rem = qpb % 8;
let tail_mask: __mmask8 = if rem == 0 { 0 } else { (1u8 << rem) - 1 };
let q_ptr = q_bitmaps.as_ptr();
let d_ptr = doc.as_ptr();
for a in 0..nb {
let q_base = a * qpb;
let row = a * nb;
for b in 0..nb {
let d_base = b * qpb;
let mut acc = _mm512_setzero_si512();
for blk in 0..full_blocks {
let w = blk * 8;
let q_zmm = _mm512_loadu_si512(q_ptr.add(q_base + w) as *const __m512i);
let d_zmm = _mm512_loadu_si512(d_ptr.add(d_base + w) as *const __m512i);
let and_zmm = _mm512_and_si512(q_zmm, d_zmm);
let pop_zmm = _mm512_popcnt_epi64(and_zmm);
acc = _mm512_add_epi64(acc, pop_zmm);
}
if rem != 0 {
let w = full_blocks * 8;
let q_zmm =
_mm512_maskz_loadu_epi64(tail_mask, q_ptr.add(q_base + w) as *const i64);
let d_zmm =
_mm512_maskz_loadu_epi64(tail_mask, d_ptr.add(d_base + w) as *const i64);
let and_zmm = _mm512_and_si512(q_zmm, d_zmm);
let pop_zmm = _mm512_popcnt_epi64(and_zmm);
acc = _mm512_add_epi64(acc, pop_zmm);
}
let sum: i64 = _mm512_reduce_add_epi64(acc);
table[row + b] = sum as u32;
}
}
}
}
#[cfg(target_arch = "x86_64")]
#[target_feature(enable = "avx512f,avx512vpopcntdq")]
unsafe fn diagonal_accumulate_avx512vpop(
q_bitmaps: &[u64],
doc: &[u64],
nb: usize,
qpb: usize,
diag: &mut [u32],
) {
use std::arch::x86_64::*;
unsafe {
let full_blocks = qpb / 8;
let rem = qpb % 8;
let tail_mask: __mmask8 = if rem == 0 { 0 } else { (1u8 << rem) - 1 };
let q_ptr = q_bitmaps.as_ptr();
let d_ptr = doc.as_ptr();
#[allow(clippy::needless_range_loop)]
for a in 0..nb {
let base = a * qpb;
let mut acc = _mm512_setzero_si512();
for blk in 0..full_blocks {
let w = blk * 8;
let q_zmm = _mm512_loadu_si512(q_ptr.add(base + w) as *const __m512i);
let d_zmm = _mm512_loadu_si512(d_ptr.add(base + w) as *const __m512i);
let and_zmm = _mm512_and_si512(q_zmm, d_zmm);
let pop_zmm = _mm512_popcnt_epi64(and_zmm);
acc = _mm512_add_epi64(acc, pop_zmm);
}
if rem != 0 {
let w = full_blocks * 8;
let q_zmm = _mm512_maskz_loadu_epi64(tail_mask, q_ptr.add(base + w) as *const i64);
let d_zmm = _mm512_maskz_loadu_epi64(tail_mask, d_ptr.add(base + w) as *const i64);
let and_zmm = _mm512_and_si512(q_zmm, d_zmm);
let pop_zmm = _mm512_popcnt_epi64(and_zmm);
acc = _mm512_add_epi64(acc, pop_zmm);
}
let sum: i64 = _mm512_reduce_add_epi64(acc);
diag[a] = sum as u32;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::Contingency;
use rand::{RngExt, SeedableRng};
use rand_chacha::ChaCha8Rng;
fn doc_codes(idx: &MultiBucketBitmap, doc_idx: usize) -> Vec<u8> {
let nb = idx.n_buckets();
let qpb = idx.qwords_per_bitmap;
let dim = idx.dim();
let base = doc_idx * nb * qpb;
let mut codes = vec![0u8; dim];
for b in 0..nb {
let off = base + b * qpb;
#[allow(clippy::needless_range_loop)]
for j in 0..dim {
if (idx.bitmaps[off + j / 64] >> (j % 64)) & 1 == 1 {
codes[j] = b as u8;
}
}
}
codes
}
fn query_codes(q_bitmaps: &[u64], nb: usize, qpb: usize, dim: usize) -> Vec<u8> {
let mut codes = vec![0u8; dim];
for b in 0..nb {
let off = b * qpb;
#[allow(clippy::needless_range_loop)]
for j in 0..dim {
if (q_bitmaps[off + j / 64] >> (j % 64)) & 1 == 1 {
codes[j] = b as u8;
}
}
}
codes
}
fn reference_table(q_bitmaps: &[u64], doc: &[u64], nb: usize, qpb: usize) -> Vec<u32> {
let mut table = vec![0u32; nb * nb];
for a in 0..nb {
for b in 0..nb {
let mut overlap = 0u32;
for w in 0..qpb {
overlap += (q_bitmaps[a * qpb + w] & doc[b * qpb + w]).count_ones();
}
table[a * nb + b] = overlap;
}
}
table
}
fn make_corpus(seed: u64, n: usize, dim: usize) -> Vec<f32> {
let mut rng = ChaCha8Rng::seed_from_u64(seed);
(0..n * dim).map(|_| rng.random_range(-1.0..1.0)).collect()
}
#[test]
fn parity_scalar_simd_diagonal_dense() {
for &dim in &[384usize, 768, 1024] {
for bits in [2u8, 4u8] {
let nb = 1usize << bits;
let qpb = dim / 64;
let n = 24;
let corpus = make_corpus(0xC0FFEE ^ dim as u64 ^ (bits as u64) << 32, n, dim);
let mut idx = MultiBucketBitmap::new(dim, bits);
idx.add(&corpus);
let mut rng = ChaCha8Rng::seed_from_u64(0x5EED ^ dim as u64);
let query: Vec<f32> = (0..dim).map(|_| rng.random_range(-1.0..1.0)).collect();
let q_bitmaps = idx.query_bitmaps_from_ranks(&query);
let q_codes = query_codes(&q_bitmaps, nb, qpb, dim);
for di in 0..n {
let doc_base = di * nb * qpb;
let doc = &idx.bitmaps[doc_base..doc_base + nb * qpb];
let want = reference_table(&q_bitmaps, doc, nb, qpb);
let mut scalar = vec![0u32; nb * nb];
contingency_accumulate_scalar(&q_bitmaps, doc, nb, qpb, &mut scalar);
assert_eq!(
scalar, want,
"scalar kernel != reference (dim={dim}, bits={bits}, doc={di})",
);
let dispatched = idx.contingency_row(&q_bitmaps, di);
assert_eq!(
dispatched, want,
"dispatched (SIMD) != reference (dim={dim}, bits={bits}, doc={di})",
);
let diag = idx.diagonal_overlap_row(&q_bitmaps, di);
let want_diag: Vec<u32> = (0..nb).map(|a| want[a * nb + a]).collect();
assert_eq!(
diag, want_diag,
"diagonal fast path != table diagonal (dim={dim}, bits={bits}, doc={di})",
);
let d_codes = doc_codes(&idx, di);
let dense = Contingency::new(&q_codes, &d_codes, nb).unwrap();
assert_eq!(
dispatched,
dense.counts(),
"dispatched (SIMD) != dense Contingency (dim={dim}, bits={bits}, doc={di})",
);
assert_eq!(
diag.iter().sum::<u32>(),
dense.diagonal_agreement(),
"diagonal sum != dense diagonal_agreement (dim={dim}, bits={bits}, doc={di})",
);
}
}
}
}
#[test]
fn project_all_batched_matches_bilinear_score() {
let dim = 768;
let bits = 4u8;
let n = 40;
let corpus = make_corpus(0xBA7C4, n, dim);
let mut idx = MultiBucketBitmap::new(dim, bits);
idx.add(&corpus);
let mut rng = ChaCha8Rng::seed_from_u64(0xD0C5);
let query: Vec<f32> = (0..dim).map(|_| rng.random_range(-1.0..1.0)).collect();
let q_bitmaps = idx.query_bitmaps_from_ranks(&query);
let outer = idx.outer_product_weights();
let diagonal = idx.diagonal_weights();
let banded = idx.banded_weights(1);
let weights: Vec<&[f32]> = vec![&outer, &diagonal, &banded];
let batched = idx.project_all_batched(&q_bitmaps, &weights);
assert_eq!(batched.len(), n);
for (di, row) in batched.iter().enumerate() {
assert_eq!(row.len(), 3);
for (p, w) in weights.iter().enumerate() {
let want = idx.bilinear_score(&q_bitmaps, w, di);
assert_eq!(
row[p], want,
"project_all_batched[{di}][{p}] != bilinear_score",
);
}
}
}
#[test]
fn diagonal_row_sums_to_diagonal_bilinear_score() {
let dim = 384;
let bits = 2u8;
let n = 16;
let corpus = make_corpus(0xD1A6, n, dim);
let mut idx = MultiBucketBitmap::new(dim, bits);
idx.add(&corpus);
let mut rng = ChaCha8Rng::seed_from_u64(0xF00D);
let query: Vec<f32> = (0..dim).map(|_| rng.random_range(-1.0..1.0)).collect();
let q_bitmaps = idx.query_bitmaps_from_ranks(&query);
let w = idx.diagonal_weights();
for di in 0..n {
let diag_sum: u32 = idx.diagonal_overlap_row(&q_bitmaps, di).iter().sum();
assert_eq!(diag_sum as f32, idx.bilinear_score(&q_bitmaps, &w, di));
}
}
#[test]
fn empty_index_project_all_batched_is_empty() {
let idx = MultiBucketBitmap::new(256, 2);
let q_bitmaps = idx.query_bitmaps_from_ranks(&vec![0.0f32; 256]);
let diag = idx.diagonal_weights();
let weights: Vec<&[f32]> = vec![&diag];
assert!(idx.project_all_batched(&q_bitmaps, &weights).is_empty());
}
#[test]
fn project_all_batched_no_weights_yields_empty_rows() {
let dim = 256;
let mut idx = MultiBucketBitmap::new(dim, 2);
idx.add(&make_corpus(1, 5, dim));
let q_bitmaps = idx.query_bitmaps_from_ranks(&vec![0.5f32; dim]);
let weights: Vec<&[f32]> = Vec::new();
let out = idx.project_all_batched(&q_bitmaps, &weights);
assert_eq!(out.len(), 5);
assert!(out.iter().all(|row| row.is_empty()));
}
}