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-rust.git
The following commit(s) were added to refs/heads/main by this push:
new d5229495 perf(btree): optimize sparse IN queries (#868)
d5229495 is described below
commit d5229495a206e49a9ad4b5c397a506937baddc22
Author: Jingsong Lee <[email protected]>
AuthorDate: Fri Sep 18 09:17:11 2026 +0800
perf(btree): optimize sparse IN queries (#868)
---
crates/paimon/src/btree/reader.rs | 111 +++++++++++++-------------------------
crates/paimon/src/btree/tests.rs | 100 ++++++++++++++++++++++++++++++++++
2 files changed, 137 insertions(+), 74 deletions(-)
diff --git a/crates/paimon/src/btree/reader.rs
b/crates/paimon/src/btree/reader.rs
index f9836332..11d48ebd 100644
--- a/crates/paimon/src/btree/reader.rs
+++ b/crates/paimon/src/btree/reader.rs
@@ -309,103 +309,66 @@ impl<F: Fn(&[u8], &[u8]) -> Ordering>
BTreeIndexReader<F> {
self.range_query(from, to, true, true).await
}
- /// In query: sort keys and do a single sequential scan (merge-join style).
+ /// IN query: group sorted target keys by data block, then seek each key
within its block.
+ /// This reads every target block at most once and skips unrelated blocks
and entries between
+ /// sparse target keys.
pub async fn query_in(&self, keys: &[&[u8]]) -> io::Result<RoaringTreemap>
{
if keys.is_empty() {
return Ok(RoaringTreemap::new());
}
let cmp = &self.key_comparator;
+ let (Some(min_key), Some(max_key)) = (self.min_key.as_deref(),
self.max_key.as_deref())
+ else {
+ return Ok(RoaringTreemap::new());
+ };
- // Sort query keys
+ // Sort, deduplicate, and discard keys outside this file's bounds
before resolving blocks.
let mut sorted_keys: Vec<&[u8]> = keys.to_vec();
sorted_keys.sort_by(|a, b| cmp(a, b));
sorted_keys.dedup_by(|a, b| cmp(a, b) == Ordering::Equal);
+ sorted_keys.retain(|key| {
+ cmp(key, min_key) != Ordering::Less && cmp(key, max_key) !=
Ordering::Greater
+ });
+ if sorted_keys.is_empty() {
+ return Ok(RoaringTreemap::new());
+ }
- let mut result = RoaringTreemap::new();
- let mut key_idx = 0;
-
- // Seek in index block to the first data block
+ // The index block is already resident in memory. Resolve every target
key to its first
+ // possible data block and coalesce adjacent targets that share the
same block handle.
let index_block = self.sst_reader.index_block();
- let (_, mut index_iter) = index_block.seek_and_iter(sorted_keys[0],
cmp);
+ let mut target_blocks: Vec<(BlockHandle, Vec<&[u8]>)> = Vec::new();
+ for key in sorted_keys {
+ let (_, mut index_iter) = index_block.seek_and_iter(key, cmp);
+ let Some((_last_key, handle_bytes)) = index_iter.next() else {
+ break;
+ };
+ let handle = BlockHandle::decode(handle_bytes)?;
- // First block: seek within
- let first_block = match index_iter.next() {
- Some((_key, handle_bytes)) => {
- let handle = BlockHandle::decode(handle_bytes)?;
- self.read_data_block(&handle).await?
+ match target_blocks.last_mut() {
+ Some((current, block_keys))
+ if current.offset == handle.offset && current.size ==
handle.size =>
+ {
+ block_keys.push(key);
+ }
+ _ => target_blocks.push((handle, vec![key])),
}
- None => return Ok(result),
- };
-
- let (_, seeked) = first_block.seek_and_iter(sorted_keys[0], cmp);
- let mut offset = seeked.offset;
-
- if self.scan_block_in(
- &first_block,
- &mut offset,
- &sorted_keys,
- &mut key_idx,
- &mut result,
- )? {
- return Ok(result);
}
- while let Some((_key, handle_bytes)) = index_iter.next() {
- let handle = BlockHandle::decode(handle_bytes)?;
+ let mut result = RoaringTreemap::new();
+ for (handle, block_keys) in target_blocks {
let block = self.read_data_block(&handle).await?;
- let mut block_offset = 0;
- if self.scan_block_in(
- &block,
- &mut block_offset,
- &sorted_keys,
- &mut key_idx,
- &mut result,
- )? {
- return Ok(result);
+ for key in block_keys {
+ let (found, mut entry_iter) = block.seek_and_iter(key, cmp);
+ if let (true, Some((_entry_key, value))) = (found,
entry_iter.next()) {
+ insert_row_ids_into(value, &mut result)?;
+ }
}
}
Ok(result)
}
- /// Scan a block for IN query. Returns true when all keys are consumed.
- fn scan_block_in(
- &self,
- block: &BlockReader,
- offset: &mut usize,
- sorted_keys: &[&[u8]],
- key_idx: &mut usize,
- result: &mut RoaringTreemap,
- ) -> io::Result<bool> {
- let cmp = &self.key_comparator;
- while *offset < block.data.len() {
- let (entry_key, value, next_offset) = block.read_entry_at(*offset);
- *offset = next_offset;
-
- // Advance key_idx past keys smaller than current entry
- while *key_idx < sorted_keys.len()
- && cmp(sorted_keys[*key_idx], entry_key) == Ordering::Less
- {
- *key_idx += 1;
- }
-
- if *key_idx >= sorted_keys.len() {
- return Ok(true);
- }
-
- // Past the last query key
- if cmp(entry_key, sorted_keys[sorted_keys.len() - 1]) ==
Ordering::Greater {
- return Ok(true);
- }
-
- if cmp(entry_key, sorted_keys[*key_idx]) == Ordering::Equal {
- insert_row_ids_into(value, result)?;
- }
- }
- Ok(false)
- }
-
/// Not equal query.
pub async fn query_not_equal(&self, key: &[u8]) ->
io::Result<RoaringTreemap> {
let mut result = self.all_non_null_rows().await?;
diff --git a/crates/paimon/src/btree/tests.rs b/crates/paimon/src/btree/tests.rs
index 873c7124..252ff883 100644
--- a/crates/paimon/src/btree/tests.rs
+++ b/crates/paimon/src/btree/tests.rs
@@ -21,8 +21,26 @@ use crate::btree::query::IndexQuery;
use crate::btree::reader::BTreeIndexReader;
use crate::btree::test_util::{BytesFileRead, VecFileWrite};
use crate::btree::writer::BTreeIndexWriter;
+use crate::io::FileRead;
use crate::spec::{DataType, Datum, PredicateOperator, VarCharType};
use bytes::Bytes;
+use std::ops::Range;
+use std::sync::atomic::{AtomicUsize, Ordering as AtomicOrdering};
+use std::sync::{Arc, Mutex};
+
+#[derive(Clone)]
+struct RecordingFileRead {
+ data: Bytes,
+ ranges: Arc<Mutex<Vec<Range<u64>>>>,
+}
+
+#[async_trait::async_trait]
+impl FileRead for RecordingFileRead {
+ async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+ self.ranges.lock().unwrap().push(range.clone());
+ Ok(self.data.slice(range.start as usize..range.end as usize))
+ }
+}
fn int_key(v: i32) -> Vec<u8> {
v.to_be_bytes().to_vec()
@@ -310,6 +328,88 @@ async fn test_in_query() {
assert!(bm.contains(8));
}
+#[tokio::test]
+async fn test_sparse_in_query_reads_only_target_blocks_once() {
+ let buf = VecFileWrite::new();
+ let mut writer = BTreeIndexWriter::new(Box::new(buf.clone()), 64,
BlockCompressionType::None);
+
+ for i in 0..1000 {
+ writer.write(Some(&int_key(i * 2)), i as i64).await.unwrap();
+ }
+
+ let write_result = writer.finish().await.unwrap();
+ let data = Bytes::from(buf.to_vec());
+ let file_size = data.len() as u64;
+ let ranges = Arc::new(Mutex::new(Vec::new()));
+ let reader = BTreeIndexReader::open(
+ Box::new(RecordingFileRead {
+ data,
+ ranges: ranges.clone(),
+ }),
+ file_size,
+ &write_result.meta,
+ int_cmp,
+ )
+ .await
+ .unwrap();
+ ranges.lock().unwrap().clear();
+
+ // Unsorted, duplicate, missing, and out-of-bounds keys exercise filtering
as well as lookup.
+ // Each nearby group belongs to one target block, with large gaps between
the groups.
+ let keys = [1998, -2, 2, 1000, 3, 1001, 1002, 4, 1996, 2001, 1000]
+ .into_iter()
+ .map(int_key)
+ .collect::<Vec<_>>();
+ let key_refs = keys.iter().map(Vec::as_slice).collect::<Vec<_>>();
+ let rows = reader.query_in(&key_refs).await.unwrap();
+
+ assert_eq!(
+ rows.iter().collect::<Vec<_>>(),
+ vec![1, 2, 500, 501, 998, 999]
+ );
+ assert_eq!(ranges.lock().unwrap().len(), 3);
+}
+
+#[tokio::test]
+async fn test_sparse_in_query_seeks_each_key_within_target_block() {
+ let buf = VecFileWrite::new();
+ let mut writer =
+ BTreeIndexWriter::new(Box::new(buf.clone()), 1_000_000,
BlockCompressionType::None);
+
+ for i in 0..4096 {
+ writer.write(Some(&int_key(i)), i as i64).await.unwrap();
+ }
+
+ let write_result = writer.finish().await.unwrap();
+ let data = Bytes::from(buf.to_vec());
+ let file_size = data.len() as u64;
+ let comparisons = Arc::new(AtomicUsize::new(0));
+ let query_comparisons = comparisons.clone();
+ let reader = BTreeIndexReader::open(
+ Box::new(BytesFileRead(data)),
+ file_size,
+ &write_result.meta,
+ move |a, b| {
+ query_comparisons.fetch_add(1, AtomicOrdering::Relaxed);
+ int_cmp(a, b)
+ },
+ )
+ .await
+ .unwrap();
+ comparisons.store(0, AtomicOrdering::Relaxed);
+
+ let keys = [int_key(1), int_key(4094)];
+ let key_refs = keys.iter().map(Vec::as_slice).collect::<Vec<_>>();
+ let rows = reader.query_in(&key_refs).await.unwrap();
+
+ assert_eq!(rows.iter().collect::<Vec<_>>(), vec![1, 4094]);
+ let comparison_count = comparisons.load(AtomicOrdering::Relaxed);
+ assert!(
+ comparison_count < 100,
+ "sparse IN should seek target keys instead of scanning the block;
comparisons: {comparison_count}"
+ );
+}
+
#[tokio::test]
async fn test_string_keys() {
let buf = VecFileWrite::new();