This is an automated email from the ASF dual-hosted git repository.
JingsongLi pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/paimon-vector-index.git
The following commit(s) were added to refs/heads/main by this push:
new a722d21 ivfpq: Push down row filters for 8-bit batch scans (#64)
a722d21 is described below
commit a722d213b95a923157292bd8595b324ed2cbad65
Author: shyjsarah <[email protected]>
AuthorDate: Wed Jul 29 22:12:53 2026 +0800
ivfpq: Push down row filters for 8-bit batch scans (#64)
---
core/src/ivfpq.rs | 623 ++++++++++++++++++++++++++++++++++++++++++++++++------
1 file changed, 564 insertions(+), 59 deletions(-)
diff --git a/core/src/ivfpq.rs b/core/src/ivfpq.rs
index 7237d93..0f25512 100644
--- a/core/src/ivfpq.rs
+++ b/core/src/ivfpq.rs
@@ -374,6 +374,25 @@ impl IVFPQIndex {
let use_precomputed = !self.precomputed_table.is_empty();
let use_fastscan = !self.fastscan_codes.is_empty() && self.pq.nbits ==
4;
+ let matching_rows_by_list = filter.map(|filter| {
+ let mut probed_lists = vec![false; self.nlist];
+ for probe_indices in &all_probe_indices {
+ for &list_id in probe_indices {
+ probed_lists[list_id] = true;
+ }
+ }
+ self.ids
+ .iter()
+ .zip(probed_lists)
+ .map(|(ids, probed)| {
+ if probed {
+ matching_rows(ids, Some(filter)).unwrap()
+ } else {
+ MatchingRows::Sparse(Vec::new())
+ }
+ })
+ .collect::<Vec<_>>()
+ });
let results: Vec<Vec<(f32, i64)>> = (0..nq)
.into_par_iter()
@@ -398,6 +417,10 @@ impl IVFPQIndex {
if count == 0 {
continue;
}
+ let matching_rows =
matching_rows_by_list.as_ref().map(|rows| &rows[list_id]);
+ if matching_rows.is_some_and(MatchingRows::is_empty) {
+ continue;
+ }
// Precomputed sim_table omits ||q-c||²; add it as dis0.
// Non-precomputed path computes from residual_query,
already full distance.
@@ -428,13 +451,14 @@ impl IVFPQIndex {
m,
&mut dists,
);
- for i in 0..count {
- if let Some(f) = filter {
- if !f.contains(self.ids[list_id][i]) {
- continue;
- }
+ if let Some(rows) = matching_rows {
+ for position in rows.positions() {
+ heap.push(dis0 + dists[position],
self.ids[list_id][position]);
+ }
+ } else {
+ for i in 0..count {
+ heap.push(dis0 + dists[i],
self.ids[list_id][i]);
}
- heap.push(dis0 + dists[i], self.ids[list_id][i]);
}
} else if self.pq.nbits == 4 {
scan_codes_4bit(
@@ -445,7 +469,7 @@ impl IVFPQIndex {
m,
ksub,
dis0,
- filter,
+ matching_rows,
&mut heap,
);
} else {
@@ -457,7 +481,7 @@ impl IVFPQIndex {
m,
ksub,
dis0,
- filter,
+ matching_rows,
&mut heap,
);
}
@@ -747,6 +771,149 @@ fn invalid_merge_input(message: impl Into<String>) ->
io::Error {
io::Error::new(io::ErrorKind::InvalidInput, message.into())
}
+enum MatchingRows {
+ Sparse(Vec<usize>),
+ Bitmap { words: Vec<u64>, len: usize },
+}
+
+impl MatchingRows {
+ fn len(&self) -> usize {
+ match self {
+ Self::Sparse(positions) => positions.len(),
+ Self::Bitmap { len, .. } => *len,
+ }
+ }
+
+ fn is_empty(&self) -> bool {
+ self.len() == 0
+ }
+
+ fn contains(&self, position: usize) -> bool {
+ match self {
+ Self::Sparse(positions) =>
positions.binary_search(&position).is_ok(),
+ Self::Bitmap { words, .. } => words
+ .get(position / 64)
+ .is_some_and(|word| word & (1u64 << (position % 64)) != 0),
+ }
+ }
+
+ fn positions(&self) -> MatchingRowIter<'_> {
+ match self {
+ Self::Sparse(positions) => MatchingRowIter::Sparse {
+ positions,
+ index: 0,
+ },
+ Self::Bitmap { words, .. } => MatchingRowIter::Bitmap {
+ words,
+ word_index: 0,
+ word: 0,
+ word_base: 0,
+ },
+ }
+ }
+
+ #[cfg(test)]
+ fn storage_bytes(&self) -> usize {
+ match self {
+ Self::Sparse(positions) => positions.capacity() *
std::mem::size_of::<usize>(),
+ Self::Bitmap { words, .. } => words.capacity() *
std::mem::size_of::<u64>(),
+ }
+ }
+}
+
+enum MatchingRowIter<'a> {
+ Sparse {
+ positions: &'a [usize],
+ index: usize,
+ },
+ Bitmap {
+ words: &'a [u64],
+ word_index: usize,
+ word: u64,
+ word_base: usize,
+ },
+}
+
+impl Iterator for MatchingRowIter<'_> {
+ type Item = usize;
+
+ fn next(&mut self) -> Option<Self::Item> {
+ match self {
+ Self::Sparse { positions, index } => {
+ let position = positions.get(*index).copied();
+ *index += usize::from(position.is_some());
+ position
+ }
+ Self::Bitmap {
+ words,
+ word_index,
+ word,
+ word_base,
+ } => loop {
+ if *word != 0 {
+ let bit = word.trailing_zeros() as usize;
+ *word &= *word - 1;
+ return Some(*word_base + bit);
+ }
+ let next_word = words.get(*word_index).copied()?;
+ *word = next_word;
+ *word_base = *word_index * 64;
+ *word_index += 1;
+ },
+ }
+ }
+}
+
+fn matching_rows(ids: &[i64], filter: Option<&dyn RowIdFilter>) ->
Option<MatchingRows> {
+ filter.map(|filter| {
+ let bitmap_words = ids.len().div_ceil(64);
+ let sparse_limit =
+ bitmap_words.saturating_mul(std::mem::size_of::<u64>()) /
std::mem::size_of::<usize>();
+ let mut positions = Vec::new();
+ let mut bitmap = None::<Vec<u64>>;
+ let mut matching_count = 0usize;
+
+ for (position, &id) in ids.iter().enumerate() {
+ if !filter.contains(id) {
+ continue;
+ }
+ matching_count += 1;
+ if let Some(words) = bitmap.as_mut() {
+ words[position / 64] |= 1u64 << (position % 64);
+ } else if positions.len() < sparse_limit {
+ positions.push(position);
+ } else {
+ let mut words = vec![0u64; bitmap_words];
+ for previous in positions.drain(..) {
+ words[previous / 64] |= 1u64 << (previous % 64);
+ }
+ words[position / 64] |= 1u64 << (position % 64);
+ bitmap = Some(words);
+ }
+ }
+
+ match bitmap {
+ Some(words) => MatchingRows::Bitmap {
+ words,
+ len: matching_count,
+ },
+ None => MatchingRows::Sparse(positions),
+ }
+ })
+}
+
+// Sparse row-major scans give up four-code ILP, while sparse transposed scans
+// replace sequential column reads with random row lookups. Keep separate,
+// conservative crossover points instead of applying one threshold to every
+// kernel. Packed 4-bit and FastScan paths retain their normal distance kernel
+// to preserve score semantics.
+const ROW_MAJOR_SPARSE_SCAN_DIVISOR: usize = 4;
+const TRANSPOSED_SPARSE_SCAN_DIVISOR: usize = 8;
+
+fn should_scan_sparse(count: usize, matching_rows: &MatchingRows, divisor:
usize) -> bool {
+ matching_rows.len().saturating_mul(divisor) <= count
+}
+
/// Scan 4-bit packed codes using u8-domain accumulation.
fn scan_codes_4bit(
sim_table: &[f32],
@@ -756,19 +923,20 @@ fn scan_codes_4bit(
m: usize,
_ksub: usize,
dis0: f32,
- filter: Option<&dyn RowIdFilter>,
+ matching_rows: Option<&MatchingRows>,
heap: &mut TopKHeap,
) {
let mut dists = vec![0.0f32; count];
crate::distance::scan_4bit_simd(sim_table, codes, count, m, &mut dists);
- for i in 0..count {
- if let Some(f) = filter {
- if !f.contains(ids[i]) {
- continue;
- }
+ if let Some(rows) = matching_rows {
+ for position in rows.positions() {
+ heap.push(dis0 + dists[position], ids[position]);
+ }
+ } else {
+ for i in 0..count {
+ heap.push(dis0 + dists[i], ids[i]);
}
- heap.push(dis0 + dists[i], ids[i]);
}
}
@@ -781,7 +949,7 @@ fn scan_codes_4bit_transposed(
count: usize,
m: usize,
dis0: f32,
- filter: Option<&dyn RowIdFilter>,
+ matching_rows: Option<&MatchingRows>,
heap: &mut TopKHeap,
) {
let cs = m / 2;
@@ -835,13 +1003,14 @@ fn scan_codes_4bit_transposed(
}
}
- for i in 0..count {
- if let Some(f) = filter {
- if !f.contains(ids[i]) {
- continue;
- }
+ if let Some(rows) = matching_rows {
+ for position in rows.positions() {
+ heap.push(dis0 + dists[position], ids[position]);
+ }
+ } else {
+ for i in 0..count {
+ heap.push(dis0 + dists[i], ids[i]);
}
- heap.push(dis0 + dists[i], ids[i]);
}
}
@@ -856,11 +1025,24 @@ fn scan_codes_transposed_with_scratch(
m: usize,
ksub: usize,
dis0: f32,
- filter: Option<&dyn RowIdFilter>,
+ matching_rows: Option<&MatchingRows>,
heap: &mut TopKHeap,
dists: &mut Vec<f32>,
) {
debug_assert!(m > 0);
+ if let Some(rows) =
+ matching_rows.filter(|rows| should_scan_sparse(count, rows,
TRANSPOSED_SPARSE_SCAN_DIVISOR))
+ {
+ for row in rows.positions() {
+ let mut distance = dis0;
+ for sub in 0..m {
+ distance += sim_table[sub * ksub + codes[sub * count + row] as
usize];
+ }
+ heap.push(distance, ids[row]);
+ }
+ return;
+ }
+
dists.resize(count, 0.0);
let table = &sim_table[..ksub];
let column = &codes[..count];
@@ -902,13 +1084,14 @@ fn scan_codes_transposed_with_scratch(
}
}
- for i in 0..count {
- if let Some(f) = filter {
- if !f.contains(ids[i]) {
- continue;
- }
+ if let Some(rows) = matching_rows {
+ for position in rows.positions() {
+ heap.push(dists[position], ids[position]);
+ }
+ } else {
+ for i in 0..count {
+ heap.push(dists[i], ids[i]);
}
- heap.push(dists[i], ids[i]);
}
}
@@ -921,9 +1104,20 @@ fn scan_codes_batched(
m: usize,
ksub: usize,
dis0: f32,
- filter: Option<&dyn RowIdFilter>,
+ matching_rows: Option<&MatchingRows>,
heap: &mut TopKHeap,
) {
+ if let Some(rows) =
+ matching_rows.filter(|rows| should_scan_sparse(count, rows,
ROW_MAJOR_SPARSE_SCAN_DIVISOR))
+ {
+ for position in rows.positions() {
+ let code = &codes[position * m..(position + 1) * m];
+ let distance = dis0 + pq_distance_from_table(sim_table, code, m,
ksub);
+ heap.push(distance, ids[position]);
+ }
+ return;
+ }
+
let mut i = 0;
while i + 4 <= count {
@@ -937,28 +1131,19 @@ fn scan_codes_batched(
for j in 0..4 {
let idx = i + j;
- let id = ids[idx];
- if let Some(f) = filter {
- if !f.contains(id) {
- continue;
- }
+ if matching_rows.is_none_or(|rows| rows.contains(idx)) {
+ heap.push(dis0 + dists[j], ids[idx]);
}
- heap.push(dis0 + dists[j], id);
}
i += 4;
}
while i < count {
- let code = &codes[i * m..(i + 1) * m];
- let dist = dis0 + pq_distance_from_table(sim_table, code, m, ksub);
- let id = ids[i];
- if let Some(f) = filter {
- if !f.contains(id) {
- i += 1;
- continue;
- }
+ if matching_rows.is_none_or(|rows| rows.contains(i)) {
+ let code = &codes[i * m..(i + 1) * m];
+ let dist = dis0 + pq_distance_from_table(sim_table, code, m, ksub);
+ heap.push(dist, ids[i]);
}
- heap.push(dist, id);
i += 1;
}
}
@@ -967,7 +1152,6 @@ struct ReaderSearchContext<'a> {
q: &'a [f32],
ip_table: &'a [f32],
use_precomputed: bool,
- filter: Option<&'a dyn RowIdFilter>,
d: usize,
m: usize,
ksub: usize,
@@ -1088,6 +1272,7 @@ pub fn search_with_reader_filter<R: SeekRead>(
let transposed_codes = reader.transposed_codes;
let mut scratch = ReaderScanScratch::default();
reader.for_each_streamed_list_chunk(first_list, |ids, codes| {
+ let positions = matching_rows(ids, filter);
scan_reader_codes(
&sim_table,
codes,
@@ -1097,7 +1282,7 @@ pub fn search_with_reader_filter<R: SeekRead>(
pq_nbits,
transposed_codes,
dis0,
- filter,
+ positions.as_ref(),
&mut scratch.distances,
&mut heap,
);
@@ -1132,7 +1317,6 @@ pub fn search_with_reader_filter<R: SeekRead>(
q: &q,
ip_table: &ip_table,
use_precomputed,
- filter,
d,
m,
ksub,
@@ -1147,7 +1331,15 @@ pub fn search_with_reader_filter<R: SeekRead>(
.par_iter()
.map_init(ReaderScanScratch::default, |scratch, (entry, dis0)| {
let mut local_heap = TopKHeap::new(k);
- scan_reader_list(entry, *dis0, &ctx, scratch, &mut local_heap);
+ let positions = matching_rows(&entry.ids, filter);
+ scan_reader_list(
+ entry,
+ *dis0,
+ &ctx,
+ positions.as_ref(),
+ scratch,
+ &mut local_heap,
+ );
local_heap.into_sorted()
})
.collect::<Vec<_>>();
@@ -1183,9 +1375,13 @@ fn scan_reader_list(
entry: &InvertedListPayload,
dis0: f32,
ctx: &ReaderSearchContext<'_>,
+ matching_rows: Option<&MatchingRows>,
scratch: &mut ReaderScanScratch,
heap: &mut TopKHeap,
) {
+ if matching_rows.is_some_and(MatchingRows::is_empty) {
+ return;
+ }
fill_reader_sim_table(entry.list_id, ctx, &mut scratch.sim_table);
scan_reader_codes(
&scratch.sim_table,
@@ -1196,7 +1392,7 @@ fn scan_reader_list(
ctx.pq.nbits,
ctx.transposed_codes,
dis0,
- ctx.filter,
+ matching_rows,
&mut scratch.distances,
heap,
);
@@ -1241,7 +1437,6 @@ fn reader_sim_table<R: SeekRead>(
q: query,
ip_table,
use_precomputed,
- filter: None,
d: reader.d,
m: reader.m,
ksub: reader.ksub,
@@ -1267,22 +1462,54 @@ fn scan_reader_codes(
pq_nbits: usize,
transposed_codes: bool,
dis0: f32,
- filter: Option<&dyn RowIdFilter>,
+ matching_rows: Option<&MatchingRows>,
distances: &mut Vec<f32>,
heap: &mut TopKHeap,
) {
+ if matching_rows.is_some_and(MatchingRows::is_empty) {
+ return;
+ }
let is_4bit = pq_nbits == 4;
let count = ids.len();
if is_4bit && transposed_codes {
- scan_codes_4bit_transposed(sim_table, codes, ids, count, m, dis0,
filter, heap);
+ scan_codes_4bit_transposed(sim_table, codes, ids, count, m, dis0,
matching_rows, heap);
} else if is_4bit {
- scan_codes_4bit(sim_table, codes, ids, count, m, ksub, dis0, filter,
heap);
+ scan_codes_4bit(
+ sim_table,
+ codes,
+ ids,
+ count,
+ m,
+ ksub,
+ dis0,
+ matching_rows,
+ heap,
+ );
} else if transposed_codes {
scan_codes_transposed_with_scratch(
- sim_table, codes, ids, count, m, ksub, dis0, filter, heap,
distances,
+ sim_table,
+ codes,
+ ids,
+ count,
+ m,
+ ksub,
+ dis0,
+ matching_rows,
+ heap,
+ distances,
);
} else {
- scan_codes_batched(sim_table, codes, ids, count, m, ksub, dis0,
filter, heap);
+ scan_codes_batched(
+ sim_table,
+ codes,
+ ids,
+ count,
+ m,
+ ksub,
+ dis0,
+ matching_rows,
+ heap,
+ );
}
}
@@ -1442,6 +1669,7 @@ pub fn search_batch_reader_filter<R: SeekRead>(
// distance buffer instead of retaining one per query.
let mut distances = Vec::new();
reader.for_each_streamed_list_chunk(first_list, |ids, codes| {
+ let positions = matching_rows(ids, filter);
for (query_index, dis0, sim_table) in &query_tables {
scan_reader_codes(
sim_table,
@@ -1452,7 +1680,7 @@ pub fn search_batch_reader_filter<R: SeekRead>(
pq_nbits,
transposed_codes,
*dis0,
- filter,
+ positions.as_ref(),
&mut distances,
&mut heaps[*query_index],
);
@@ -1469,6 +1697,10 @@ pub fn search_batch_reader_filter<R: SeekRead>(
for (position, list) in loaded_lists.iter().enumerate() {
list_positions[list.list_id] = position;
}
+ let matching_rows_by_list = loaded_lists
+ .iter()
+ .map(|list| matching_rows(&list.ids, filter))
+ .collect::<Vec<_>>();
let rows = (0..nq)
.into_par_iter()
@@ -1482,7 +1714,6 @@ pub fn search_batch_reader_filter<R: SeekRead>(
&[]
},
use_precomputed,
- filter,
d,
m,
ksub,
@@ -1505,7 +1736,14 @@ pub fn search_batch_reader_filter<R: SeekRead>(
} else {
0.0
};
- scan_reader_list(&loaded_lists[position], dis0, &ctx, &mut
scratch, &mut heap);
+ scan_reader_list(
+ &loaded_lists[position],
+ dis0,
+ &ctx,
+ matching_rows_by_list[position].as_ref(),
+ &mut scratch,
+ &mut heap,
+ );
}
heap.into_sorted()
})
@@ -1646,8 +1884,28 @@ mod tests {
use rand::rngs::StdRng;
use rand::{Rng, SeedableRng};
use std::io::Cursor;
+ use std::sync::atomic::{AtomicUsize, Ordering};
use std::sync::{Arc, Mutex};
+ struct CountingFilter {
+ contains_calls: AtomicUsize,
+ }
+
+ impl CountingFilter {
+ fn new() -> Self {
+ Self {
+ contains_calls: AtomicUsize::new(0),
+ }
+ }
+ }
+
+ impl RowIdFilter for CountingFilter {
+ fn contains(&self, id: i64) -> bool {
+ self.contains_calls.fetch_add(1, Ordering::Relaxed);
+ id % 7 == 0
+ }
+ }
+
#[derive(Default)]
struct ReaderStats {
pread_calls: usize,
@@ -1804,6 +2062,72 @@ mod tests {
}
}
+ #[test]
+ fn in_memory_batch_filter_only_evaluates_probed_lists() {
+ let d = 16;
+ let nlist = 8;
+ let m = 4;
+ let n = 800;
+ let nq = 4;
+ let k = 5;
+ let nprobe = 2;
+ let data = generate_clustered_data(n, d, nlist, 51);
+ let ids = (0..n as i64).collect::<Vec<_>>();
+ let mut index = IVFPQIndex::new(d, nlist, m, MetricType::L2, false);
+ index.train(&data, n);
+ index.add(&data, &ids, n);
+
+ let queries = &data[..nq * d];
+ let processed_queries = index.preprocess_queries(queries, nq);
+ let (probe_indices, _) = kmeans::find_topk_batch(
+ &processed_queries,
+ nq,
+ &index.quantizer_centroids,
+ nlist,
+ d,
+ nprobe,
+ );
+ let mut probed_lists = vec![false; nlist];
+ for query_probes in probe_indices {
+ for list_id in query_probes {
+ probed_lists[list_id] = true;
+ }
+ }
+ let expected_calls = index
+ .ids
+ .iter()
+ .zip(probed_lists)
+ .filter_map(|(ids, probed)| probed.then_some(ids.len()))
+ .sum::<usize>();
+
+ let filter = CountingFilter::new();
+ let mut distances = vec![0.0f32; nq * k];
+ let mut labels = vec![-1i64; nq * k];
+ index.search_with_filter(
+ queries,
+ nq,
+ k,
+ nprobe,
+ Some(&filter),
+ &mut distances,
+ &mut labels,
+ );
+
+ assert!(
+ labels.iter().filter(|&&id| id >= 0).all(|id| id % 7 == 0),
+ "filtered search returned a disallowed row ID"
+ );
+ assert_eq!(
+ filter.contains_calls.load(Ordering::Relaxed),
+ expected_calls,
+ "the filter should only evaluate rows from lists probed by this
batch"
+ );
+ assert!(
+ expected_calls < n,
+ "the test must leave at least one inverted list unprobed"
+ );
+ }
+
#[test]
fn test_batch_search() {
let d = 16;
@@ -2127,6 +2451,144 @@ mod tests {
.collect::<Vec<_>>();
expected.sort_by(|left, right| left.0.total_cmp(&right.0));
assert_eq!(heap.into_sorted(), expected);
+
+ let matching_positions = (0..count).step_by(5).collect::<Vec<_>>();
+ let matching_rows = MatchingRows::Sparse(matching_positions);
+ let mut filtered_heap = TopKHeap::new(matching_rows.len());
+ scan_codes_transposed_with_scratch(
+ &table,
+ &codes,
+ &ids,
+ count,
+ m,
+ ksub,
+ dis0,
+ Some(&matching_rows),
+ &mut filtered_heap,
+ &mut scratch,
+ );
+ let filtered_expected = expected
+ .into_iter()
+ .filter(|(_, id)| (id - ids[0]) % 5 == 0)
+ .collect::<Vec<_>>();
+ assert_eq!(filtered_heap.into_sorted(), filtered_expected);
+ }
+
+ #[test]
+ fn row_major_sparse_scan_matches_exact_distances() {
+ let count = 400;
+ let m = 8;
+ let ksub = 256;
+ let dis0 = 1.25;
+ let ids = (10_000..10_000 + count as i64).collect::<Vec<_>>();
+ let codes = (0..count * m)
+ .map(|index| ((index * 37 + 11) % ksub) as u8)
+ .collect::<Vec<_>>();
+ let table = (0..m * ksub)
+ .map(|index| ((index * 29 + 7) % 113) as f32 * 0.03125)
+ .collect::<Vec<_>>();
+ let matching_positions = (0..count).step_by(3).collect::<Vec<_>>();
+ let mut expected = matching_positions
+ .iter()
+ .map(|&position| {
+ let code = &codes[position * m..(position + 1) * m];
+ (
+ dis0 + pq_distance_from_table(&table, code, m, ksub),
+ ids[position],
+ )
+ })
+ .collect::<Vec<_>>();
+ expected.sort_unstable_by_key(|&(_, id)| id);
+
+ let mut filtered_heap = TopKHeap::new(matching_positions.len());
+ let matching_rows = MatchingRows::Sparse(matching_positions);
+ scan_codes_batched(
+ &table,
+ &codes,
+ &ids,
+ count,
+ m,
+ ksub,
+ dis0,
+ Some(&matching_rows),
+ &mut filtered_heap,
+ );
+
+ let mut actual = filtered_heap.into_sorted();
+ actual.sort_unstable_by_key(|&(_, id)| id);
+ assert_eq!(actual, expected);
+ }
+
+ #[test]
+ fn matching_rows_adapts_sparse_positions_to_bounded_bitmap() {
+ let ids = (0..1024i64).collect::<Vec<_>>();
+ let sparse_filter = [3i64, 511,
900].into_iter().collect::<HashSet<_>>();
+ let sparse = matching_rows(&ids, Some(&sparse_filter)).unwrap();
+ assert!(matches!(sparse, MatchingRows::Sparse(_)));
+ assert_eq!(sparse.positions().collect::<Vec<_>>(), vec![3, 511, 900]);
+
+ let dense_filter = ids.iter().copied().collect::<HashSet<_>>();
+ let dense = matching_rows(&ids, Some(&dense_filter)).unwrap();
+ assert!(matches!(dense, MatchingRows::Bitmap { .. }));
+ assert_eq!(dense.len(), ids.len());
+ assert_eq!(
+ dense.positions().collect::<Vec<_>>(),
+ (0..ids.len()).collect::<Vec<_>>()
+ );
+ assert!(
+ dense.storage_bytes() <= ids.len().div_ceil(64) * size_of::<u64>(),
+ "dense match storage must be bounded to one bit per row"
+ );
+ }
+
+ #[test]
+ fn row_major_dense_bitmap_scan_matches_exact_distances() {
+ let count = 1024;
+ let m = 8;
+ let ksub = 256;
+ let dis0 = 2.5;
+ let ids = (20_000..20_000 + count as i64).collect::<Vec<_>>();
+ let codes = (0..count * m)
+ .map(|index| ((index * 73 + 19) % ksub) as u8)
+ .collect::<Vec<_>>();
+ let table = (0..m * ksub)
+ .map(|index| ((index * 31 + 5) % 127) as f32 * 0.015625)
+ .collect::<Vec<_>>();
+ let filter = ids
+ .iter()
+ .copied()
+ .filter(|id| id % 4 != 0)
+ .collect::<HashSet<_>>();
+ let matching_rows = matching_rows(&ids, Some(&filter)).unwrap();
+ assert!(matches!(matching_rows, MatchingRows::Bitmap { .. }));
+
+ let mut expected = matching_rows
+ .positions()
+ .map(|position| {
+ let code = &codes[position * m..(position + 1) * m];
+ (
+ dis0 + pq_distance_from_table(&table, code, m, ksub),
+ ids[position],
+ )
+ })
+ .collect::<Vec<_>>();
+ expected.sort_unstable_by_key(|&(_, id)| id);
+
+ let mut heap = TopKHeap::new(matching_rows.len());
+ scan_codes_batched(
+ &table,
+ &codes,
+ &ids,
+ count,
+ m,
+ ksub,
+ dis0,
+ Some(&matching_rows),
+ &mut heap,
+ );
+ let mut actual = heap.into_sorted();
+ actual.sort_unstable_by_key(|&(_, id)| id);
+ assert_eq!(actual, expected);
}
#[test]
@@ -2663,6 +3125,49 @@ mod tests {
}
}
+ #[test]
+ fn test_batch_reader_evaluates_filter_once_per_loaded_row() {
+ use crate::io::{write_index, IVFPQIndexReader, PosWriter};
+
+ let d = 16;
+ let nlist = 1;
+ let m = 4;
+ let n = 500;
+ let k = 5;
+ let nq = 4;
+ let nprobe = 1;
+
+ let data = generate_clustered_data(n, d, 1, 42);
+ let ids: Vec<i64> = (0..n as i64).collect();
+
+ let mut index = IVFPQIndex::new(d, nlist, m, MetricType::L2, false);
+ index.train(&data, n);
+ index.add(&data, &ids, n);
+
+ let mut buf = Vec::new();
+ let mut writer = PosWriter::new(&mut buf);
+ write_index(&index, &mut writer).unwrap();
+
+ let filter = CountingFilter::new();
+ let queries = &data[..nq * d];
+ let mut reader = IVFPQIndexReader::open(Cursor::new(buf)).unwrap();
+ let (result_ids, _) =
+ search_batch_reader_filter(&mut reader, queries, nq, k, nprobe,
Some(&filter)).unwrap();
+
+ assert!(
+ result_ids
+ .iter()
+ .filter(|&&id| id >= 0)
+ .all(|id| id % 7 == 0),
+ "filtered batch search returned a disallowed row ID"
+ );
+ assert_eq!(
+ filter.contains_calls.load(Ordering::Relaxed),
+ n,
+ "the shared filter should be evaluated once per loaded row, not
once per query"
+ );
+ }
+
#[test]
fn test_batch_reader_empty_roaring_filter_returns_empty_results() {
use crate::io::{write_index, IVFPQIndexReader, PosWriter};