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 26044a3 Optimize IVF-RQ search hot paths (#57)
26044a3 is described below
commit 26044a3d371f81aaad534e188280f1493610eb6b
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Jul 10 10:20:01 2026 +0800
Optimize IVF-RQ search hot paths (#57)
---
core/src/ivfrq_io.rs | 267 ++++++++++++++++++++++++++++++++++++++++++---------
core/src/kmeans.rs | 59 +++++++++++-
core/src/rq.rs | 47 +++++++--
core/src/topk.rs | 69 ++++++++++---
4 files changed, 373 insertions(+), 69 deletions(-)
diff --git a/core/src/ivfrq_io.rs b/core/src/ivfrq_io.rs
index ea1bbcb..c4da6fa 100644
--- a/core/src/ivfrq_io.rs
+++ b/core/src/ivfrq_io.rs
@@ -39,6 +39,7 @@ const FLAG_DELTA_IDS: u32 = 1 << 0;
const REQUIRED_FLAGS: u32 = FLAG_DELTA_IDS;
const SUPPORTED_FLAGS: u32 = REQUIRED_FLAGS;
const FACTOR_BYTES: usize = 12;
+const MAX_RQ_BATCH_READ_BYTES: usize = 16 * 1024 * 1024;
pub const IVF_RQ_NUM_BITS_ONE: u32 = 1;
pub const IVF_RQ_ROTATION_TYPE_KAC: u32 = 1;
@@ -208,6 +209,15 @@ pub struct IVFRQIndexReader<R: SeekRead> {
loaded: bool,
}
+#[derive(Clone, Copy)]
+struct RQListPayloadMeta {
+ list_id: usize,
+ count: usize,
+ offset: u64,
+ id_bytes_len: usize,
+ payload_len: usize,
+}
+
impl<R: SeekRead> IVFRQIndexReader<R> {
pub fn open(mut reader: R) -> io::Result<Self> {
let mut cursor = PreadCursor::new(&mut reader, 0);
@@ -334,6 +344,93 @@ impl<R: SeekRead> IVFRQIndexReader<R> {
pub fn read_inverted_list(&mut self, list_id: usize) ->
io::Result<RQReadList> {
self.ensure_loaded()?;
+ let Some(meta) = self.list_payload_meta(list_id)? else {
+ return Ok(RQReadList {
+ list_id,
+ ids: Vec::new(),
+ codes: Vec::new(),
+ factors: Vec::new(),
+ });
+ };
+ let mut payload = vec![0u8; meta.payload_len];
+ self.reader
+ .pread(&mut [ReadRequest::new(meta.offset, &mut payload)])?;
+ self.decode_inverted_list_payload(meta, &payload)
+ }
+
+ fn read_inverted_lists(&mut self, list_ids: &[usize]) ->
io::Result<Vec<RQReadList>> {
+ self.ensure_loaded()?;
+
+ let mut results: Vec<Option<RQReadList>> = (0..list_ids.len()).map(|_|
None).collect();
+ let mut metas = Vec::new();
+ let mut payloads = Vec::new();
+
+ for (input_index, &list_id) in list_ids.iter().enumerate() {
+ let Some(meta) = self.list_payload_meta(list_id)? else {
+ results[input_index] = Some(RQReadList {
+ list_id,
+ ids: Vec::new(),
+ codes: Vec::new(),
+ factors: Vec::new(),
+ });
+ continue;
+ };
+ metas.push((input_index, meta));
+ payloads.push(vec![0u8; meta.payload_len]);
+ }
+
+ if !metas.is_empty() {
+ {
+ let mut requests: Vec<_> = payloads
+ .iter_mut()
+ .zip(metas.iter())
+ .map(|(payload, (_, meta))| {
+ ReadRequest::new(meta.offset, payload.as_mut_slice())
+ })
+ .collect();
+ self.reader.pread(&mut requests)?;
+ }
+
+ for ((input_index, meta), payload) in
metas.into_iter().zip(payloads) {
+ results[input_index] =
Some(self.decode_inverted_list_payload(meta, &payload)?);
+ }
+ }
+
+ results
+ .into_iter()
+ .map(|result| {
+ result.ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ "missing IVF-RQ inverted list read result",
+ )
+ })
+ })
+ .collect()
+ }
+
+ fn batch_read_end(&self, list_ids: &[usize]) -> io::Result<usize> {
+ let mut payload_bytes = 0usize;
+ for (index, &list_id) in list_ids.iter().enumerate() {
+ let Some(meta) = self.list_payload_meta(list_id)? else {
+ continue;
+ };
+ let next_payload_bytes =
+ payload_bytes.checked_add(meta.payload_len).ok_or_else(|| {
+ io::Error::new(
+ io::ErrorKind::InvalidData,
+ "IVF-RQ batched payload size overflow",
+ )
+ })?;
+ if index > 0 && next_payload_bytes > MAX_RQ_BATCH_READ_BYTES {
+ return Ok(index);
+ }
+ payload_bytes = next_payload_bytes;
+ }
+ Ok(list_ids.len())
+ }
+
+ fn list_payload_meta(&self, list_id: usize) ->
io::Result<Option<RQListPayloadMeta>> {
if list_id >= self.nlist {
return Err(io::Error::new(
io::ErrorKind::InvalidInput,
@@ -342,12 +439,7 @@ impl<R: SeekRead> IVFRQIndexReader<R> {
}
let count = self.list_counts[list_id] as usize;
if count == 0 {
- return Ok(RQReadList {
- list_id,
- ids: Vec::new(),
- codes: Vec::new(),
- factors: Vec::new(),
- });
+ return Ok(None);
}
if !self.delta_ids {
return Err(io::Error::new(
@@ -366,22 +458,46 @@ impl<R: SeekRead> IVFRQIndexReader<R> {
.ok_or_else(|| {
io::Error::new(io::ErrorKind::InvalidData, "IVF-RQ list
payload overflow")
})?;
- let mut payload = vec![0u8; payload_len];
- self.reader
- .pread(&mut [ReadRequest::new(offset, &mut payload)])?;
+ Ok(Some(RQListPayloadMeta {
+ list_id,
+ count,
+ offset,
+ id_bytes_len,
+ payload_len,
+ }))
+ }
+
+ fn decode_inverted_list_payload(
+ &self,
+ meta: RQListPayloadMeta,
+ payload: &[u8],
+ ) -> io::Result<RQReadList> {
+ if payload.len() != meta.payload_len {
+ return Err(io::Error::new(
+ io::ErrorKind::InvalidData,
+ format!(
+ "IVF-RQ list {} payload length {} does not match expected
{}",
+ meta.list_id,
+ payload.len(),
+ meta.payload_len
+ ),
+ ));
+ }
let base_id = i64::from_le_bytes(payload[0..8].try_into().unwrap());
let encoded_len =
i32::from_le_bytes(payload[8..12].try_into().unwrap());
- if encoded_len < 0 || encoded_len as usize != id_bytes_len {
+ if encoded_len < 0 || encoded_len as usize != meta.id_bytes_len {
return Err(io::Error::new(
io::ErrorKind::InvalidData,
"IVF-RQ id_bytes_len mismatch",
));
}
- let ids = decode_delta_varint_ids(base_id, &payload[12..12 +
id_bytes_len], count)?;
- let code_start = 12 + id_bytes_len;
+ let ids =
+ decode_delta_varint_ids(base_id, &payload[12..12 +
meta.id_bytes_len], meta.count)?;
+ let code_start = 12 + meta.id_bytes_len;
+ let code_bytes = checked_list_bytes(meta.count, self.code_size)?;
let factor_start = code_start + code_bytes;
Ok(RQReadList {
- list_id,
+ list_id: meta.list_id,
ids,
codes: payload[code_start..factor_start].to_vec(),
factors: bytes_to_factors(&payload[factor_start..])?,
@@ -528,39 +644,39 @@ pub fn
search_batch_ivfrq_reader_filter_with_query_bits<R: SeekRead>(
}
let mut heaps: Vec<TopKHeap> = (0..nq).map(|_| TopKHeap::new(k)).collect();
- for list_id in unique_lists {
- let count = reader.list_counts[list_id] as usize;
- if count == 0 {
- continue;
- }
- let read_list = reader.read_inverted_list(list_id)?;
- if read_list.list_id != list_id {
- return Err(io::Error::new(
- io::ErrorKind::InvalidData,
- "IVF-RQ inverted list read returned wrong list id",
- ));
- }
- let centroid = &reader.quantizer_centroids[list_id *
reader.d..(list_id + 1) * reader.d];
- for &qi in &list_to_queries[list_id] {
- let query = &processed[qi * reader.d..(qi + 1) * reader.d];
- let rotated_query_residual =
- rotated_residual(query, centroid, reader.d, &reader.rotation);
- let distance_context =
reader.quantizer.prepare_distance_context_with_query_bits(
- rotated_query_residual,
- query,
- count >= RQ_BYTE_LUT_MIN_LIST_SIZE,
- query_bits,
- );
- scan_read_list(
- &read_list,
- &reader.quantizer,
- reader.code_size,
- reader.metric,
- &distance_context,
- filter,
- &mut heaps[qi],
- );
+ let mut batch_start = 0;
+ while batch_start < unique_lists.len() {
+ let batch_end = batch_start +
reader.batch_read_end(&unique_lists[batch_start..])?;
+ for read_list in
reader.read_inverted_lists(&unique_lists[batch_start..batch_end])? {
+ let list_id = read_list.list_id;
+ let count = read_list.ids.len();
+ if count == 0 {
+ continue;
+ }
+ let centroid =
+ &reader.quantizer_centroids[list_id * reader.d..(list_id + 1)
* reader.d];
+ for &qi in &list_to_queries[list_id] {
+ let query = &processed[qi * reader.d..(qi + 1) * reader.d];
+ let rotated_query_residual =
+ rotated_residual(query, centroid, reader.d,
&reader.rotation);
+ let distance_context =
reader.quantizer.prepare_distance_context_with_query_bits(
+ rotated_query_residual,
+ query,
+ count >= RQ_BYTE_LUT_MIN_LIST_SIZE,
+ query_bits,
+ );
+ scan_read_list(
+ &read_list,
+ &reader.quantizer,
+ reader.code_size,
+ reader.metric,
+ &distance_context,
+ filter,
+ &mut heaps[qi],
+ );
+ }
}
+ batch_start = batch_end;
}
let mut result_ids = vec![-1i64; nq * k];
@@ -962,8 +1078,41 @@ fn decode_roaring_filter(bytes: &[u8]) ->
io::Result<RoaringTreemap> {
#[cfg(test)]
mod tests {
use super::*;
- use crate::io::PosWriter;
- use std::io::Cursor;
+ use crate::io::{PosWriter, ReadRequest, SeekRead};
+ use std::io::{Cursor, Read, Seek, SeekFrom};
+ use std::sync::{Arc, Mutex};
+
+ #[derive(Default)]
+ struct ReaderStats {
+ max_ranges_per_batch: usize,
+ }
+
+ struct CountingPreadCursor {
+ inner: Cursor<Vec<u8>>,
+ stats: Arc<Mutex<ReaderStats>>,
+ }
+
+ impl CountingPreadCursor {
+ fn new(data: Vec<u8>, stats: Arc<Mutex<ReaderStats>>) -> Self {
+ Self {
+ inner: Cursor::new(data),
+ stats,
+ }
+ }
+ }
+
+ impl SeekRead for CountingPreadCursor {
+ fn pread(&mut self, ranges: &mut [ReadRequest<'_>]) -> io::Result<()> {
+ let mut stats = self.stats.lock().unwrap();
+ stats.max_ranges_per_batch =
stats.max_ranges_per_batch.max(ranges.len());
+ drop(stats);
+ for range in ranges {
+ self.inner.seek(SeekFrom::Start(range.pos))?;
+ self.inner.read_exact(range.buf)?;
+ }
+ Ok(())
+ }
+ }
#[test]
fn ivfrq_write_read_search_roundtrip() {
@@ -1027,6 +1176,30 @@ mod tests {
}
}
+ #[test]
+ fn ivfrq_batch_reader_reads_lists_in_one_pread_batch() {
+ let mut index = IVFRQIndex::new(8, 3, MetricType::L2);
+ index.quantizer_centroids = vec![0.0; index.nlist * index.d];
+ index.ids = vec![vec![10], vec![20], vec![30]];
+ index.codes = vec![vec![0b0000_0001], vec![0b0000_0010],
vec![0b0000_0100]];
+ index.factors = vec![
+ vec![RQCodeFactors::zero()],
+ vec![RQCodeFactors::zero()],
+ vec![RQCodeFactors::zero()],
+ ];
+
+ let mut bytes = Vec::new();
+ write_ivfrq_index(&index, &mut PosWriter::new(&mut bytes)).unwrap();
+ let stats = Arc::new(Mutex::new(ReaderStats::default()));
+ let stream = CountingPreadCursor::new(bytes, Arc::clone(&stats));
+ let mut reader = IVFRQIndexReader::open(stream).unwrap();
+
+ let (ids, _) = search_batch_ivfrq_reader(&mut reader, &[0.0; 8], 1, 1,
3).unwrap();
+
+ assert!(ids[0] >= 0);
+ assert_eq!(stats.lock().unwrap().max_ranges_per_batch, 3);
+ }
+
#[test]
fn ivfrq_reader_rejects_invalid_query_bits() {
let d = 8;
diff --git a/core/src/kmeans.rs b/core/src/kmeans.rs
index 2e1ee6a..8d03e8f 100644
--- a/core/src/kmeans.rs
+++ b/core/src/kmeans.rs
@@ -515,10 +515,13 @@ pub fn find_topk(
nprobe: usize,
) -> (Vec<usize>, Vec<f32>) {
let nprobe = nprobe.min(k);
+ if nprobe == 0 {
+ return (Vec::new(), Vec::new());
+ }
let mut dists: Vec<(f32, usize)> = (0..k)
.map(|c| (fvec_l2sqr(point, ¢roids[c * d..(c + 1) * d]), c))
.collect();
- dists.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
+ select_topk_prefix(&mut dists, nprobe);
let indices: Vec<usize> = dists[..nprobe].iter().map(|&(_, i)|
i).collect();
let distances: Vec<f32> = dists[..nprobe].iter().map(|&(d, _)|
d).collect();
(indices, distances)
@@ -535,6 +538,9 @@ pub fn find_topk_batch(
nprobe: usize,
) -> (Vec<Vec<usize>>, Vec<Vec<f32>>) {
let nprobe = nprobe.min(k);
+ if nprobe == 0 {
+ return (vec![Vec::new(); nq], vec![Vec::new(); nq]);
+ }
if nq == 1 {
let (indices, distances) = find_topk(&queries[..d], centroids, k, d,
nprobe);
@@ -565,7 +571,7 @@ pub fn find_topk_batch(
(dist.max(0.0), c)
})
.collect();
- dists.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
+ select_topk_prefix(&mut dists, nprobe);
all_indices.push(dists[..nprobe].iter().map(|&(_, i)| i).collect());
all_distances.push(dists[..nprobe].iter().map(|&(d, _)| d).collect());
@@ -574,6 +580,20 @@ pub fn find_topk_batch(
(all_indices, all_distances)
}
+fn select_topk_prefix(dists: &mut [(f32, usize)], nprobe: usize) {
+ debug_assert!(nprobe > 0 && nprobe <= dists.len());
+ if nprobe < dists.len() {
+ dists.select_nth_unstable_by(nprobe - 1, compare_distance_then_index);
+ }
+ dists[..nprobe].sort_by(compare_distance_then_index);
+}
+
+fn compare_distance_then_index(left: &(f32, usize), right: &(f32, usize)) ->
std::cmp::Ordering {
+ left.0
+ .total_cmp(&right.0)
+ .then_with(|| left.1.cmp(&right.1))
+}
+
// --- Streaming Coreset K-means ---
/// Streaming k-means trainer for very large datasets.
@@ -779,6 +799,41 @@ mod tests {
assert_eq!(indices[0], 0);
}
+ #[test]
+ fn test_find_topk_batch_matches_full_sort_with_ties() {
+ let d = 2;
+ let k = 32;
+ let nprobe = 5;
+ let centroids: Vec<f32> = (0..k)
+ .flat_map(|i| [i as f32 % 4.0, (i / 4) as f32])
+ .collect();
+ let queries = vec![0.5, 0.5, 2.5, 3.5, 1.5, 1.5];
+ let (actual_indices, actual_distances) =
+ find_topk_batch(&queries, 3, ¢roids, k, d, nprobe);
+
+ for qi in 0..3 {
+ let query = &queries[qi * d..(qi + 1) * d];
+ let mut expected: Vec<(f32, usize)> = (0..k)
+ .map(|ci| (fvec_l2sqr(query, ¢roids[ci * d..(ci + 1) *
d]), ci))
+ .collect();
+ expected.sort_by(compare_distance_then_index);
+ assert_eq!(
+ actual_indices[qi],
+ expected[..nprobe]
+ .iter()
+ .map(|&(_, index)| index)
+ .collect::<Vec<_>>()
+ );
+ assert_eq!(
+ actual_distances[qi],
+ expected[..nprobe]
+ .iter()
+ .map(|&(distance, _)| distance)
+ .collect::<Vec<_>>()
+ );
+ }
+ }
+
#[test]
fn test_find_nearest_batch_matches_scalar() {
let d = 5;
diff --git a/core/src/rq.rs b/core/src/rq.rs
index 2843ccd..c248c70 100644
--- a/core/src/rq.rs
+++ b/core/src/rq.rs
@@ -340,14 +340,19 @@ impl RaBitQuantizer {
for byte_idx in 0..code_size {
let dim_base = byte_idx * 8;
let dim_end = (dim_base + 8).min(self.d);
- for pattern in 0..256usize {
- let mut sum = 0.0f32;
- for dim in dim_base..dim_end {
- let bit = (pattern >> (dim - dim_base)) & 1;
- let value = rotated_query_residual[dim];
- sum += if bit != 0 { value } else { -value };
- }
- byte_signed_sums[byte_idx * 256 + pattern] = sum;
+ let lut = &mut byte_signed_sums[byte_idx * 256..(byte_idx + 1) *
256];
+ lut[0] = -rotated_query_residual[dim_base..dim_end]
+ .iter()
+ .sum::<f32>();
+ for pattern in 1..256usize {
+ let bit = pattern.trailing_zeros() as usize;
+ let previous = pattern & (pattern - 1);
+ let value = if dim_base + bit < dim_end {
+ rotated_query_residual[dim_base + bit]
+ } else {
+ 0.0
+ };
+ lut[pattern] = lut[previous] + 2.0 * value;
}
}
byte_signed_sums
@@ -595,6 +600,32 @@ mod tests {
}
}
+ #[test]
+ fn byte_lut_matches_scalar_signed_sum_for_every_pattern() {
+ let d = 13;
+ let quantizer = RaBitQuantizer::new(d);
+ let residual: Vec<f32> = (0..d).map(|i| i as f32 * 0.37 -
2.1).collect();
+ let lut = quantizer.build_byte_signed_sums(&residual);
+ let mut code = vec![0u8; quantizer.code_size()];
+
+ for first_byte in 0..=u8::MAX {
+ for second_byte in 0..=u8::MAX {
+ code[0] = first_byte;
+ code[1] = second_byte;
+ let scalar: f32 = residual
+ .iter()
+ .enumerate()
+ .map(|(dim, &value)| if get_bit(&code, dim) { value } else
{ -value })
+ .sum();
+ let actual = lut[first_byte as usize] + lut[256 + second_byte
as usize];
+ assert!(
+ (actual - scalar).abs() < 1e-5,
+ "code {first_byte:#010b} {second_byte:#010b}: {actual} !=
{scalar}"
+ );
+ }
+ }
+ }
+
#[test]
fn quantized_query_bit_planes_match_scalar_quantization() {
let d = 24;
diff --git a/core/src/topk.rs b/core/src/topk.rs
index 112f76d..6407d7f 100644
--- a/core/src/topk.rs
+++ b/core/src/topk.rs
@@ -39,26 +39,22 @@ impl TopKHeap {
if let Some(&idx) = self.positions.get(&id) {
if dist < self.data[idx].0 {
self.data[idx].0 = dist;
+ self.sift_down(idx);
}
return;
}
if self.data.len() < self.k {
self.positions.insert(id, self.data.len());
self.data.push((dist, id));
+ self.sift_up(self.data.len() - 1);
return;
}
- if let Some((worst_idx, _)) = self
- .data
- .iter()
- .enumerate()
- .max_by(|(_, a), (_, b)| a.0.total_cmp(&b.0))
- {
- if dist < self.data[worst_idx].0 {
- let old_id = self.data[worst_idx].1;
- self.positions.remove(&old_id);
- self.data[worst_idx] = (dist, id);
- self.positions.insert(id, worst_idx);
- }
+ if dist < self.data[0].0 {
+ let old_id = self.data[0].1;
+ self.positions.remove(&old_id);
+ self.data[0] = (dist, id);
+ self.positions.insert(id, 0);
+ self.sift_down(0);
}
}
@@ -70,6 +66,43 @@ impl TopKHeap {
pub(crate) fn len(&self) -> usize {
self.data.len()
}
+
+ fn sift_up(&mut self, mut index: usize) {
+ while index > 0 {
+ let parent = (index - 1) / 2;
+ if self.data[parent].0.total_cmp(&self.data[index].0).is_ge() {
+ break;
+ }
+ self.swap_entries(parent, index);
+ index = parent;
+ }
+ }
+
+ fn sift_down(&mut self, mut index: usize) {
+ loop {
+ let left = index * 2 + 1;
+ let right = left + 1;
+ let mut worst = index;
+ if left < self.data.len() &&
self.data[left].0.total_cmp(&self.data[worst].0).is_gt() {
+ worst = left;
+ }
+ if right < self.data.len() &&
self.data[right].0.total_cmp(&self.data[worst].0).is_gt()
+ {
+ worst = right;
+ }
+ if worst == index {
+ break;
+ }
+ self.swap_entries(index, worst);
+ index = worst;
+ }
+ }
+
+ fn swap_entries(&mut self, left: usize, right: usize) {
+ self.data.swap(left, right);
+ self.positions.insert(self.data[left].1, left);
+ self.positions.insert(self.data[right].1, right);
+ }
}
#[cfg(test)]
@@ -87,4 +120,16 @@ mod tests {
assert_eq!(heap.into_sorted(), vec![(1.0, 7), (3.0, 9)]);
}
+
+ #[test]
+ fn test_topk_heap_keeps_max_at_root_after_duplicate_update() {
+ let mut heap = TopKHeap::new(3);
+ heap.push(8.0, 1);
+ heap.push(6.0, 2);
+ heap.push(4.0, 3);
+ heap.push(1.0, 1);
+ heap.push(5.0, 4);
+
+ assert_eq!(heap.into_sorted(), vec![(1.0, 1), (4.0, 3), (5.0, 4)]);
+ }
}