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 cec2d4c1 perf(vindex): execute compatible queries through native batch 
search (#646)
cec2d4c1 is described below

commit cec2d4c104590c25a20e16d6e479ea2bac371dec
Author: Junrui Lee <[email protected]>
AuthorDate: Tue Aug 4 21:38:31 2026 +0800

    perf(vindex): execute compatible queries through native batch search (#646)
---
 crates/paimon/src/table/vector_search_builder.rs | 196 +++++--
 crates/paimon/src/vindex/range_reader.rs         |  78 ++-
 crates/paimon/src/vindex/reader.rs               | 715 +++++++++++++++++++++--
 3 files changed, 880 insertions(+), 109 deletions(-)

diff --git a/crates/paimon/src/table/vector_search_builder.rs 
b/crates/paimon/src/table/vector_search_builder.rs
index d882a551..b2d67597 100644
--- a/crates/paimon/src/table/vector_search_builder.rs
+++ b/crates/paimon/src/table/vector_search_builder.rs
@@ -65,6 +65,7 @@ use arrow_select::interleave::interleave_record_batch;
 use futures::{stream, TryStreamExt};
 use paimon_vindex_core::distance::MetricType;
 use paimon_vindex_core::index::VectorIndexReader as VIndexReader;
+use paimon_vindex_core::io::SeekRead;
 use roaring::RoaringTreemap;
 use std::cmp::Ordering;
 use std::collections::{BinaryHeap, HashMap, HashSet};
@@ -98,11 +99,30 @@ impl VectorIndexBackend {
     }
 }
 
-fn current_tokio_runtime_handle() -> crate::Result<tokio::runtime::Handle> {
-    tokio::runtime::Handle::try_current().map_err(|error| 
crate::Error::UnexpectedError {
-        message: "Vector index range reader requires a Tokio 
runtime".to_string(),
-        source: Some(Box::new(error)),
+async fn execute_vindex_searches<S: SeekRead + 'static>(
+    io_meta: GlobalIndexIOMeta,
+    options: HashMap<String, String>,
+    vector_searches: Vec<VectorSearch>,
+    source: S,
+    file_name: String,
+    shard_concurrency: usize,
+) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
+    let panic_context = if vector_searches.len() > 1 {
+        "vindex global-index batch search task failed"
+    } else {
+        "vindex global-index search task failed"
+    };
+    execute_global_index(panic_context, move || {
+        let mut reader = VindexVectorGlobalIndexReader::new(io_meta, options)
+            .with_batch_shard_concurrency(shard_concurrency);
+        reader
+            .visit_batch_vector_search(&vector_searches, |_| Ok(source))
+            .map_err(|e| crate::Error::DataInvalid {
+                message: format!("Failed to read vindex index file '{}': {}", 
file_name, e),
+                source: Some(Box::new(e)),
+            })
     })
+    .await
 }
 
 pub struct VectorSearchBuilder<'a> {
@@ -839,7 +859,8 @@ async fn plan_and_search_pk_candidates_batch(
                     reader.visit_batch_vector_search(searches, |_| 
Ok(Cursor::new(data)))
                 }
                 VectorIndexBackend::Vindex => {
-                    let mut reader = 
VindexVectorGlobalIndexReader::new(io_meta, options.clone());
+                    let mut reader = 
VindexVectorGlobalIndexReader::new(io_meta, options.clone())
+                        .with_batch_shard_concurrency(concurrency);
                     reader.visit_batch_vector_search(searches, |_| 
Ok(Cursor::new(data)))
                 }
             }
@@ -1424,10 +1445,21 @@ async fn evaluate_batch_vector_search(
     let mut merged = vec![SearchResult::empty(); vector_searches.len()];
     if !vector_entries.is_empty() {
         let concurrency = core_options.global_index_thread_num()?;
+        if concurrency > tokio::sync::Semaphore::MAX_PERMITS {
+            return Err(crate::Error::DataInvalid {
+                message: format!(
+                    "Global index thread count must not exceed {}",
+                    tokio::sync::Semaphore::MAX_PERMITS
+                ),
+                source: None,
+            });
+        }
         ensure_global_index_executor_capacity(concurrency);
+        let range_read_permits = 
Arc::new(tokio::sync::Semaphore::new(concurrency));
         let futures: Vec<_> = vector_entries
             .into_iter()
             .map(|entry| {
+                let range_read_permits = Arc::clone(&range_read_permits);
                 let global_meta = 
entry.index_file.global_index_meta.as_ref().unwrap();
                 let backend = 
VectorIndexBackend::from_index_type(&entry.index_file.index_type)
                     .expect("filtered vector index type");
@@ -1482,64 +1514,62 @@ async fn evaluate_batch_vector_search(
                             .await?
                         }
                         VectorIndexBackend::Vindex => {
-                            if vector_searches.len() > 1 {
-                                let data = input.read().await.map_err(|e| {
-                                    crate::Error::DataInvalid {
-                                        message: format!(
-                                            "Failed to read vindex index file 
'{}': {}",
-                                            file_name, e
-                                        ),
-                                        source: None,
-                                    }
-                                })?;
-                                execute_global_index(
-                                    "vindex global-index batch search task 
failed",
-                                    move || {
-                                        let mut reader = 
VindexVectorGlobalIndexReader::new(
-                                            io_meta, options,
-                                        );
-                                        
reader.visit_batch_vector_search(&vector_searches, |_| {
-                                            Ok(Cursor::new(data))
-                                        })
-                                    },
-                                )
-                                .await?
-                            } else {
-                                let file_reader = 
input.reader().await.map_err(|e| {
-                                    crate::Error::DataInvalid {
-                                        message: format!(
-                                            "Failed to open vindex file '{}' 
for range reads: {}",
-                                            file_name, e
-                                        ),
-                                        source: None,
-                                    }
-                                })?;
-                                let source = VindexFileReader::new(
-                                    Arc::new(file_reader),
-                                    current_tokio_runtime_handle()?,
-                                    file_size,
-                                    file_name.clone(),
-                                );
-                                execute_global_index(
-                                    "vindex global-index search task failed",
-                                    move || {
-                                        let mut reader = 
VindexVectorGlobalIndexReader::new(
-                                            io_meta, options,
-                                        );
-                                        reader
-                                            
.visit_batch_vector_search(&vector_searches, |_| {
-                                                Ok(source)
-                                            })
-                                            .map_err(|e| 
crate::Error::DataInvalid {
-                                                message: format!(
-                                                    "Failed to read vindex 
index file '{}': {}",
-                                                    file_name, e
-                                                ),
-                                                source: Some(Box::new(e)),
-                                            })
-                                    },
-                                )
-                                .await?
+                            match tokio::runtime::Handle::try_current() {
+                                Ok(runtime) => {
+                                    let file_reader = 
input.reader().await.map_err(|e| {
+                                        crate::Error::DataInvalid {
+                                            message: format!(
+                                                "Failed to open vindex file 
'{}' for range reads: {}",
+                                                file_name, e
+                                            ),
+                                            source: None,
+                                        }
+                                    })?;
+                                    let source = 
VindexFileReader::new_with_permits(
+                                        Arc::new(file_reader),
+                                        runtime,
+                                        range_read_permits,
+                                        file_size,
+                                        file_name.clone(),
+                                    );
+                                    execute_vindex_searches(
+                                        io_meta,
+                                        options,
+                                        vector_searches,
+                                        source,
+                                        file_name,
+                                        concurrency,
+                                    )
+                                    .await?
+                                }
+                                Err(_) if query_count > 1 => {
+                                    let data = input.read().await.map_err(|e| {
+                                        crate::Error::DataInvalid {
+                                            message: format!(
+                                                "Failed to read vindex index 
file '{}': {}",
+                                                file_name, e
+                                            ),
+                                            source: None,
+                                        }
+                                    })?;
+                                    execute_vindex_searches(
+                                        io_meta,
+                                        options,
+                                        vector_searches,
+                                        Cursor::new(data),
+                                        file_name,
+                                        concurrency,
+                                    )
+                                    .await?
+                                }
+                                Err(error) => {
+                                    return Err(crate::Error::UnexpectedError {
+                                        message:
+                                            "Vector index range reader 
requires a Tokio runtime"
+                                                .to_string(),
+                                        source: Some(Box::new(error)),
+                                    });
+                                }
                             }
                         }
                     };
@@ -3627,6 +3657,46 @@ mod tests {
         });
     }
 
+    #[test]
+    fn test_batch_vindex_outside_tokio_uses_buffered_fallback() {
+        futures::executor::block_on(async {
+            let file_io = 
crate::io::FileIOBuilder::new("memory").build().unwrap();
+            let index = build_vindex_segment_bytes("l2");
+            file_io
+                .new_output("memory:///test_table/index/test.idx")
+                .unwrap()
+                .write(bytes::Bytes::from(index.clone()))
+                .await
+                .unwrap();
+            let fields = vec![make_field(2, "embedding")];
+            let searches = vec![
+                VectorSearch::new(vec![1.0, 0.0], 2, 
"embedding".to_string()).unwrap(),
+                VectorSearch::new(vec![0.0, 1.0], 2, 
"embedding".to_string()).unwrap(),
+            ];
+            let options = HashMap::new();
+            let mut entry = make_lumina_entry("test.idx", IVF_FLAT_IDENTIFIER, 
FileKind::Add, 2);
+            entry.index_file.file_size = index.len() as i64;
+            entry.index_file.row_count = 3;
+            entry
+                .index_file
+                .global_index_meta
+                .as_mut()
+                .unwrap()
+                .row_range_end = 2;
+
+            let results = evaluate_batch_vector_search(
+                eval_context(&file_io, &options, &fields, None),
+                &[entry],
+                &searches,
+            )
+            .await
+            .expect("batch vindex search should fall back to buffered I/O 
outside Tokio");
+
+            assert_eq!(results.len(), searches.len());
+            assert!(results.iter().all(|result| !result.is_empty()));
+        });
+    }
+
     #[tokio::test]
     async fn test_execute_fails_closed_when_query_auth_enabled() {
         let table = crate::table::query_auth_table();
diff --git a/crates/paimon/src/vindex/range_reader.rs 
b/crates/paimon/src/vindex/range_reader.rs
index 1e66472c..07431749 100644
--- a/crates/paimon/src/vindex/range_reader.rs
+++ b/crates/paimon/src/vindex/range_reader.rs
@@ -67,16 +67,33 @@ pub(crate) struct VindexFileReader {
 }
 
 impl VindexFileReader {
+    #[cfg(test)]
     pub(crate) fn new(
         reader: Arc<dyn FileRead>,
         runtime: tokio::runtime::Handle,
         file_size: u64,
         path: String,
+    ) -> Self {
+        Self::new_with_permits(
+            reader,
+            runtime,
+            Arc::new(tokio::sync::Semaphore::new(RANGE_READ_CONCURRENCY)),
+            file_size,
+            path,
+        )
+    }
+
+    pub(crate) fn new_with_permits(
+        reader: Arc<dyn FileRead>,
+        runtime: tokio::runtime::Handle,
+        permits: Arc<tokio::sync::Semaphore>,
+        file_size: u64,
+        path: String,
     ) -> Self {
         Self {
             reader,
             runtime,
-            permits: 
Arc::new(tokio::sync::Semaphore::new(RANGE_READ_CONCURRENCY)),
+            permits,
             file_size,
             path,
             scalar_cache: None,
@@ -288,6 +305,7 @@ mod tests {
     use super::*;
     use crate::io::FileIO;
     use async_trait::async_trait;
+    use std::sync::atomic::{AtomicUsize, Ordering};
     use std::sync::Mutex;
     use std::time::Duration;
 
@@ -324,6 +342,12 @@ mod tests {
         runtime_id: Mutex<Option<tokio::runtime::Id>>,
     }
 
+    struct ConcurrencyTrackingRead {
+        data: Bytes,
+        active: AtomicUsize,
+        max_active: AtomicUsize,
+    }
+
     #[async_trait]
     impl FileRead for RuntimeTrackingRead {
         async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
@@ -332,6 +356,17 @@ mod tests {
         }
     }
 
+    #[async_trait]
+    impl FileRead for ConcurrencyTrackingRead {
+        async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
+            let active = self.active.fetch_add(1, Ordering::SeqCst) + 1;
+            self.max_active.fetch_max(active, Ordering::SeqCst);
+            tokio::time::sleep(Duration::from_millis(25)).await;
+            self.active.fetch_sub(1, Ordering::SeqCst);
+            Ok(self.data.slice(range.start as usize..range.end as usize))
+        }
+    }
+
     #[async_trait]
     impl FileRead for TrackingRead {
         async fn read(&self, range: Range<u64>) -> crate::Result<Bytes> {
@@ -557,6 +592,47 @@ mod tests {
         assert_eq!(cloned.read_capabilities(), 
SeekReadCapabilities::default());
     }
 
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn shared_permits_bound_reads_across_independent_readers() {
+        let data = Bytes::from(vec![8u8; 1024]);
+        let tracking = Arc::new(ConcurrencyTrackingRead {
+            data: data.clone(),
+            active: AtomicUsize::new(0),
+            max_active: AtomicUsize::new(0),
+        });
+        let permits = Arc::new(tokio::sync::Semaphore::new(1));
+        let make_reader = |path: &str| {
+            let source: Arc<dyn FileRead> = tracking.clone();
+            VindexFileReader::new_with_permits(
+                source,
+                tokio::runtime::Handle::current(),
+                Arc::clone(&permits),
+                data.len() as u64,
+                path.to_string(),
+            )
+        };
+        let mut first_reader = make_reader("first.index");
+        let mut second_reader = make_reader("second.index");
+        assert!(Arc::ptr_eq(&first_reader.permits, &second_reader.permits));
+
+        let first = tokio::task::spawn_blocking(move || {
+            let mut output = [0u8; 128];
+            first_reader
+                .pread(&mut [ReadRequest::new(0, &mut output)])
+                .unwrap();
+        });
+        let second = tokio::task::spawn_blocking(move || {
+            let mut output = [0u8; 128];
+            second_reader
+                .pread(&mut [ReadRequest::new(128, &mut output)])
+                .unwrap();
+        });
+        first.await.unwrap();
+        second.await.unwrap();
+
+        assert_eq!(tracking.max_active.load(Ordering::SeqCst), 1);
+    }
+
     #[test]
     fn local_fs_read_completes_with_one_host_blocking_thread() {
         let temp_dir = tempfile::tempdir().unwrap();
diff --git a/crates/paimon/src/vindex/reader.rs 
b/crates/paimon/src/vindex/reader.rs
index ecc39a57..3dc9dda1 100644
--- a/crates/paimon/src/vindex/reader.rs
+++ b/crates/paimon/src/vindex/reader.rs
@@ -15,6 +15,7 @@
 // specific language governing permissions and limitations
 // under the License.
 
+use crate::spec::CoreOptions;
 use crate::vector_search::{GlobalIndexIOMeta, VectorSearch};
 use paimon_vindex_core::distance::MetricType;
 use paimon_vindex_core::index::{
@@ -27,6 +28,7 @@ use std::io;
 
 const DEFAULT_NPROBE: usize = 16;
 const NPROBE_PARAMETER: &str = "ivf.nprobe";
+const NATIVE_BATCH_OPERATION_WORKING_SET_BYTES: usize = 64 * 1024 * 1024;
 
 trait ErasedSeekRead: Send {
     fn pread_erased(&mut self, ranges: &mut [ReadRequest<'_>]) -> 
io::Result<()>;
@@ -76,6 +78,7 @@ impl SeekRead for VindexInput {
 pub struct VindexVectorGlobalIndexReader {
     io_meta: GlobalIndexIOMeta,
     options: HashMap<String, String>,
+    batch_shard_concurrency: Option<usize>,
     reader: Option<VIndexReader<VindexInput>>,
     metadata: Option<VectorIndexMetadata>,
 }
@@ -85,17 +88,23 @@ impl VindexVectorGlobalIndexReader {
         Self {
             io_meta,
             options,
+            batch_shard_concurrency: None,
             reader: None,
             metadata: None,
         }
     }
 
+    pub(crate) fn with_batch_shard_concurrency(mut self, concurrency: usize) 
-> Self {
+        self.batch_shard_concurrency = Some(concurrency.max(1));
+        self
+    }
+
     pub fn visit_vector_search<S: SeekRead + 'static>(
         &mut self,
         vector_search: &VectorSearch,
         stream_fn: impl FnOnce(&str) -> crate::Result<S>,
     ) -> crate::Result<Option<HashMap<u64, f32>>> {
-        self.ensure_loaded(stream_fn)?;
+        self.ensure_loaded(stream_fn, |_| Ok(()))?;
         self.search(vector_search)
     }
 
@@ -104,11 +113,56 @@ impl VindexVectorGlobalIndexReader {
         vector_searches: &[VectorSearch],
         stream_fn: impl FnOnce(&str) -> crate::Result<S>,
     ) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
-        self.ensure_loaded(stream_fn)?;
-        vector_searches
-            .iter()
-            .map(|vector_search| self.search(vector_search))
-            .collect()
+        self.ensure_loaded(stream_fn, |_| Ok(()))?;
+        self.search_batch(vector_searches)
+    }
+
+    #[cfg(test)]
+    pub(crate) fn load<S: SeekRead + 'static>(
+        &mut self,
+        stream_fn: impl FnOnce(&str) -> crate::Result<S>,
+    ) -> crate::Result<()> {
+        self.ensure_loaded(stream_fn, |_| Ok(()))
+    }
+
+    pub(crate) fn metadata(&self) -> crate::Result<&VectorIndexMetadata> {
+        self.metadata
+            .as_ref()
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: "vindex metadata not initialized".to_string(),
+                source: None,
+            })
+    }
+
+    pub(crate) fn search_batch(
+        &mut self,
+        vector_searches: &[VectorSearch],
+    ) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
+        let shard_concurrency = match self.batch_shard_concurrency {
+            Some(concurrency) => concurrency,
+            None => CoreOptions::new(&self.options).global_index_thread_num()?,
+        };
+        let reader = self
+            .reader
+            .as_mut()
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: "vindex reader not initialized".to_string(),
+                source: None,
+            })?;
+        let metadata = self
+            .metadata
+            .as_ref()
+            .ok_or_else(|| crate::Error::DataInvalid {
+                message: "vindex metadata not initialized".to_string(),
+                source: None,
+            })?;
+        search_batch_vindex(
+            reader,
+            metadata,
+            &self.options,
+            vector_searches,
+            shard_concurrency,
+        )
     }
 
     fn search(&mut self, vector_search: &VectorSearch) -> 
crate::Result<Option<HashMap<u64, f32>>> {
@@ -130,12 +184,38 @@ impl VindexVectorGlobalIndexReader {
         search_vindex(reader, metadata, &self.options, vector_search)
     }
 
-    fn ensure_loaded<S: SeekRead + 'static>(
+    fn ensure_loaded<S, F>(
         &mut self,
         stream_fn: impl FnOnce(&str) -> crate::Result<S>,
-    ) -> crate::Result<()> {
+        validate: F,
+    ) -> crate::Result<()>
+    where
+        S: SeekRead + 'static,
+        F: FnOnce(&VectorIndexMetadata) -> crate::Result<()>,
+    {
+        self.ensure_loaded_with_optimizer(stream_fn, validate, |reader| {
+            reader
+                .optimize_for_search()
+                .map_err(|e| crate::Error::DataInvalid {
+                    message: format!("Failed to optimize paimon-vindex-core 
reader: {}", e),
+                    source: Some(Box::new(e)),
+                })
+        })
+    }
+
+    fn ensure_loaded_with_optimizer<S, F, O>(
+        &mut self,
+        stream_fn: impl FnOnce(&str) -> crate::Result<S>,
+        validate: F,
+        optimize: O,
+    ) -> crate::Result<()>
+    where
+        S: SeekRead + 'static,
+        F: FnOnce(&VectorIndexMetadata) -> crate::Result<()>,
+        O: FnOnce(&mut VIndexReader<VindexInput>) -> crate::Result<()>,
+    {
         if self.reader.is_some() {
-            return Ok(());
+            return validate(self.metadata()?);
         }
 
         let source = stream_fn(&self.io_meta.file_path)?;
@@ -146,12 +226,8 @@ impl VindexVectorGlobalIndexReader {
             }
         })?;
         let metadata = reader.metadata();
-        reader
-            .optimize_for_search()
-            .map_err(|e| crate::Error::DataInvalid {
-                message: format!("Failed to optimize paimon-vindex-core 
reader: {}", e),
-                source: Some(Box::new(e)),
-            })?;
+        validate(&metadata)?;
+        optimize(&mut reader)?;
 
         self.reader = Some(reader);
         self.metadata = Some(metadata);
@@ -165,12 +241,35 @@ fn search_vindex(
     options: &HashMap<String, String>,
     vector_search: &VectorSearch,
 ) -> crate::Result<Option<HashMap<u64, f32>>> {
-    let expected_dim = metadata.dimension;
-    if vector_search.vector.len() != expected_dim {
+    let Some(prepared) = prepare_search(metadata, options, vector_search)? 
else {
+        return Ok(None);
+    };
+    let (labels, distances) = execute_scalar_search(reader, vector_search, 
&prepared)?;
+    let id_to_scores = collect_results(&labels, &distances, prepared.top_k, 
metadata.metric);
+    if id_to_scores.is_empty() {
+        return Ok(None);
+    }
+
+    Ok(Some(id_to_scores))
+}
+
+#[derive(Clone, PartialEq, Eq)]
+struct PreparedSearch {
+    top_k: usize,
+    nprobe: usize,
+    filter_bytes: Option<Vec<u8>>,
+}
+
+fn prepare_search(
+    metadata: &VectorIndexMetadata,
+    options: &HashMap<String, String>,
+    vector_search: &VectorSearch,
+) -> crate::Result<Option<PreparedSearch>> {
+    if vector_search.vector.len() != metadata.dimension {
         return Err(crate::Error::DataInvalid {
             message: format!(
                 "Query vector dimension mismatch: index expects {}, but got 
{}",
-                expected_dim,
+                metadata.dimension,
                 vector_search.vector.len()
             ),
             source: None,
@@ -178,48 +277,195 @@ fn search_vindex(
     }
 
     let count = usize::try_from(metadata.total_vectors).unwrap_or(0);
-    let effective_k = std::cmp::min(vector_search.limit, count);
-    if effective_k == 0 {
+    let mut top_k = vector_search.limit.min(count);
+    if top_k == 0 {
         return Ok(None);
     }
-
     let nprobe = int_parameter(options, NPROBE_PARAMETER, DEFAULT_NPROBE)?;
-    let params = VectorSearchParams::new(effective_k, nprobe);
 
-    let (labels, distances) = if let Some(include_ids) = 
&vector_search.include_row_ids {
+    let filter_bytes = if let Some(include_ids) = 
&vector_search.include_row_ids {
         if include_ids.is_empty() {
             return Ok(None);
         }
-        let ek = std::cmp::min(effective_k, include_ids.len() as usize);
-        let params = VectorSearchParams::new(params.top_k.min(ek), nprobe);
-        let mut filter_bytes = Vec::new();
+        top_k = top_k.min(include_ids.len() as usize);
+        let mut bytes = Vec::new();
         include_ids
-            .serialize_into(&mut filter_bytes)
+            .serialize_into(&mut bytes)
             .map_err(|e| crate::Error::DataInvalid {
                 message: format!("Failed to serialize vector search row-id 
filter: {}", e),
                 source: Some(Box::new(e)),
             })?;
-        reader
-            .search_with_roaring_filter(&vector_search.vector, params, 
&filter_bytes)
-            .map_err(|e| crate::Error::DataInvalid {
-                message: format!("paimon-vindex-core filtered search failed: 
{}", e),
-                source: Some(Box::new(e)),
-            })?
+        Some(bytes)
     } else {
-        reader
-            .search(&vector_search.vector, params)
+        None
+    };
+
+    Ok(Some(PreparedSearch {
+        top_k,
+        nprobe,
+        filter_bytes,
+    }))
+}
+
+fn execute_scalar_search(
+    reader: &mut VIndexReader<impl SeekRead>,
+    vector_search: &VectorSearch,
+    prepared: &PreparedSearch,
+) -> crate::Result<(Vec<i64>, Vec<f32>)> {
+    let params = VectorSearchParams::new(prepared.top_k, prepared.nprobe);
+    match &prepared.filter_bytes {
+        Some(filter) => reader
+            .search_with_roaring_filter(&vector_search.vector, params, filter)
             .map_err(|e| crate::Error::DataInvalid {
-                message: format!("paimon-vindex-core search failed: {}", e),
+                message: format!("paimon-vindex-core filtered search failed: 
{}", e),
                 source: Some(Box::new(e)),
-            })?
-    };
+            }),
+        None => {
+            reader
+                .search(&vector_search.vector, params)
+                .map_err(|e| crate::Error::DataInvalid {
+                    message: format!("paimon-vindex-core search failed: {}", 
e),
+                    source: Some(Box::new(e)),
+                })
+        }
+    }
+}
 
-    let id_to_scores = collect_results(&labels, &distances, effective_k, 
metadata.metric);
-    if id_to_scores.is_empty() {
-        return Ok(None);
+fn search_batch_vindex(
+    reader: &mut VIndexReader<impl SeekRead>,
+    metadata: &VectorIndexMetadata,
+    options: &HashMap<String, String>,
+    vector_searches: &[VectorSearch],
+    shard_concurrency: usize,
+) -> crate::Result<Vec<Option<HashMap<u64, f32>>>> {
+    let mut results: Vec<Option<HashMap<u64, f32>>> =
+        (0..vector_searches.len()).map(|_| None).collect();
+    let mut groups: Vec<(PreparedSearch, Vec<usize>)> = Vec::new();
+
+    for (index, search) in vector_searches.iter().enumerate() {
+        let Some(prepared) = prepare_search(metadata, options, search)? else {
+            continue;
+        };
+        if let Some((_, indices)) = groups.iter_mut().find(|(key, _)| key == 
&prepared) {
+            indices.push(index);
+        } else {
+            groups.push((prepared, vec![index]));
+        }
     }
 
-    Ok(Some(id_to_scores))
+    for (prepared, indices) in groups {
+        let chunk_size = native_batch_chunk_size(metadata, &prepared, 
shard_concurrency);
+        for indices in indices.chunks(chunk_size) {
+            if indices.len() == 1 {
+                let index = indices[0];
+                let (labels, distances) =
+                    execute_scalar_search(reader, &vector_searches[index], 
&prepared)?;
+                let map = collect_results(&labels, &distances, prepared.top_k, 
metadata.metric);
+                if !map.is_empty() {
+                    results[index] = Some(map);
+                }
+                continue;
+            }
+
+            let mut queries = Vec::with_capacity(indices.len() * 
metadata.dimension);
+            for &index in indices {
+                queries.extend_from_slice(&vector_searches[index].vector);
+            }
+            let params = VectorSearchParams::new(prepared.top_k, 
prepared.nprobe);
+            let (labels, distances) = match &prepared.filter_bytes {
+                Some(filter) => reader
+                    .search_batch_with_roaring_filter(&queries, indices.len(), 
params, filter)
+                    .map_err(|e| crate::Error::DataInvalid {
+                        message: format!("paimon-vindex-core filtered batch 
search failed: {}", e),
+                        source: Some(Box::new(e)),
+                    })?,
+                None => reader
+                    .search_batch(&queries, indices.len(), params)
+                    .map_err(|e| crate::Error::DataInvalid {
+                        message: format!("paimon-vindex-core batch search 
failed: {}", e),
+                        source: Some(Box::new(e)),
+                    })?,
+            };
+            let expected = indices.len() * prepared.top_k;
+            if labels.len() != expected || distances.len() != expected {
+                return Err(crate::Error::DataInvalid {
+                    message: format!(
+                        "paimon-vindex-core batch search returned 
labels/distances of length {}/{}, expected {expected}",
+                        labels.len(),
+                        distances.len()
+                    ),
+                    source: None,
+                });
+            }
+            for (query_index, &result_index) in indices.iter().enumerate() {
+                let start = query_index * prepared.top_k;
+                let end = start + prepared.top_k;
+                let map = collect_results(
+                    &labels[start..end],
+                    &distances[start..end],
+                    prepared.top_k,
+                    metadata.metric,
+                );
+                if !map.is_empty() {
+                    results[result_index] = Some(map);
+                }
+            }
+        }
+    }
+
+    Ok(results)
+}
+
+fn native_batch_chunk_size(
+    metadata: &VectorIndexMetadata,
+    prepared: &PreparedSearch,
+    shard_concurrency: usize,
+) -> usize {
+    let per_shard_budget = NATIVE_BATCH_OPERATION_WORKING_SET_BYTES
+        .checked_div(shard_concurrency.max(1))
+        .unwrap_or(0);
+    let filter_bytes = prepared
+        .filter_bytes
+        .as_ref()
+        .map_or(0, |filter| filter.len().saturating_mul(2));
+    let query_budget = per_shard_budget.saturating_sub(filter_bytes);
+    query_budget
+        .checked_div(native_batch_query_working_set_bytes(metadata, prepared))
+        .unwrap_or(0)
+        .max(1)
+}
+
+fn native_batch_query_working_set_bytes(
+    metadata: &VectorIndexMetadata,
+    prepared: &PreparedSearch,
+) -> usize {
+    let query_vectors = metadata
+        .dimension
+        .saturating_mul(std::mem::size_of::<f32>() * 2);
+    let centroid_products = 
metadata.nlist.saturating_mul(std::mem::size_of::<f32>());
+    let probe_results = prepared
+        .nprobe
+        .min(metadata.nlist)
+        .saturating_mul(std::mem::size_of::<usize>() + 
std::mem::size_of::<f32>());
+    let top_k_results = prepared.top_k.saturating_mul(
+        std::mem::size_of::<i64>() + std::mem::size_of::<f32>() + 
std::mem::size_of::<(f32, i64)>(),
+    );
+    let pq_tables = match (metadata.pq_m, metadata.pq_bits) {
+        (Some(m), Some(bits)) => 1usize
+            .checked_shl(bits as u32)
+            .unwrap_or(usize::MAX)
+            .saturating_mul(m)
+            .saturating_mul(std::mem::size_of::<f32>()),
+        _ => 0,
+    };
+
+    query_vectors
+        .saturating_add(centroid_products)
+        .saturating_add(probe_results)
+        .saturating_add(top_k_results)
+        .saturating_add(pq_tables)
+        .saturating_add(256)
+        .max(1)
 }
 
 fn collect_results(
@@ -305,6 +551,8 @@ mod tests {
     use bytes::Bytes;
     use paimon_vindex_core::index::{VectorIndexConfig, VectorIndexTrainer, 
VectorIndexWriter};
     use paimon_vindex_core::io::{PosWriter, SeekReadCapabilities};
+    use std::cell::Cell;
+    use std::io::Cursor;
     use std::ops::Range;
     use std::sync::atomic::{AtomicUsize, Ordering};
     use std::sync::{Arc, Mutex};
@@ -385,17 +633,65 @@ mod tests {
         Bytes::from(output)
     }
 
-    fn query() -> VectorSearch {
+    fn query_for_cluster(cluster: usize, limit: usize) -> VectorSearch {
+        let cluster = cluster as f32 * 100.0;
         VectorSearch::new(
             (0..TEST_DIMENSION)
-                .map(|dimension| dimension as f32 * 0.01)
+                .map(|dimension| cluster + dimension as f32 * 0.01)
                 .collect(),
-            10,
+            limit,
             "embedding".to_string(),
         )
         .unwrap()
     }
 
+    fn query() -> VectorSearch {
+        query_for_cluster(0, 10)
+    }
+
+    async fn tracked_batch_search(
+        index: Bytes,
+        query_count: usize,
+    ) -> (Vec<Option<HashMap<u64, f32>>>, usize) {
+        tracked_batch_search_with_options(
+            index,
+            query_count,
+            HashMap::from([(NPROBE_PARAMETER.to_string(), "1".to_string())]),
+            32,
+        )
+        .await
+    }
+
+    async fn tracked_batch_search_with_options(
+        index: Bytes,
+        query_count: usize,
+        options: HashMap<String, String>,
+        shard_concurrency: usize,
+    ) -> (Vec<Option<HashMap<u64, f32>>>, usize) {
+        let tracking = TrackingIndexRead::new(index.clone());
+        let source: Arc<dyn FileRead> = tracking.clone();
+        let runtime = tokio::runtime::Handle::current();
+        let results = tokio::task::spawn_blocking(move || {
+            let source = VindexFileReader::new(
+                source,
+                runtime,
+                index.len() as u64,
+                "batch.index".to_string(),
+            );
+            let io_meta =
+                GlobalIndexIOMeta::new("batch.index".to_string(), index.len() 
as u64, Vec::new());
+            let searches = vec![query(); query_count];
+            let mut reader = VindexVectorGlobalIndexReader::new(io_meta, 
options)
+                .with_batch_shard_concurrency(shard_concurrency);
+            reader
+                .visit_batch_vector_search(&searches, |_| Ok(source))
+                .unwrap()
+        })
+        .await
+        .unwrap();
+        (results, tracking.bytes_read.load(Ordering::SeqCst))
+    }
+
     #[test]
     fn test_convert_distance_to_score() {
         assert_eq!(convert_distance_to_score(0.0, MetricType::L2), 1.0);
@@ -434,6 +730,43 @@ mod tests {
         assert!(!result.contains_key(&3));
     }
 
+    #[test]
+    fn native_batch_chunk_size_tracks_working_set_inputs() {
+        let base_metadata = VectorIndexMetadata {
+            index_type: paimon_vindex_core::index::IndexType::IvfFlat,
+            dimension: 128,
+            nlist: 256,
+            metric: MetricType::L2,
+            total_vectors: 8192,
+            pq_m: None,
+            pq_bits: None,
+            rq_bits: None,
+            diskann: None,
+        };
+        let base_prepared = PreparedSearch {
+            top_k: 10,
+            nprobe: 16,
+            filter_bytes: None,
+        };
+        let base = native_batch_chunk_size(&base_metadata, &base_prepared, 32);
+
+        let mut larger_index = base_metadata.clone();
+        larger_index.dimension *= 2;
+        larger_index.nlist *= 2;
+        assert!(native_batch_chunk_size(&larger_index, &base_prepared, 32) < 
base);
+
+        let mut larger_top_k = base_prepared.clone();
+        larger_top_k.top_k *= 4;
+        assert!(native_batch_chunk_size(&base_metadata, &larger_top_k, 32) < 
base);
+
+        let mut pq_metadata = base_metadata.clone();
+        pq_metadata.pq_m = Some(64);
+        pq_metadata.pq_bits = Some(8);
+        assert!(native_batch_chunk_size(&pq_metadata, &base_prepared, 32) < 
base);
+
+        assert!(native_batch_chunk_size(&base_metadata, &base_prepared, 64) < 
base);
+    }
+
     #[test]
     fn test_int_parameter() {
         let mut options = HashMap::new();
@@ -500,4 +833,296 @@ mod tests {
             "range search unexpectedly read the entire index"
         );
     }
+
+    #[test]
+    fn mixed_batch_matches_scalar_searches_and_preserves_order() {
+        let index = build_ivf_flat_index();
+        let options = HashMap::from([(NPROBE_PARAMETER.to_string(), 
"1".to_string())]);
+        let mut include_row_ids = roaring::RoaringTreemap::new();
+        include_row_ids.insert(0);
+        include_row_ids.insert(16);
+        include_row_ids.insert(32);
+        let searches = vec![
+            query_for_cluster(0, 4),
+            query_for_cluster(5, 1),
+            query_for_cluster(15, 4),
+            query_for_cluster(0, 10).with_include_row_ids(include_row_ids),
+        ];
+
+        let scalar_meta =
+            GlobalIndexIOMeta::new("scalar.index".to_string(), index.len() as 
u64, Vec::new());
+        let mut scalar_reader = 
VindexVectorGlobalIndexReader::new(scalar_meta, options.clone());
+        scalar_reader
+            .load(|_| Ok(Cursor::new(index.clone())))
+            .unwrap();
+        let expected: Vec<_> = searches
+            .iter()
+            .map(|search| scalar_reader.search(search).unwrap())
+            .collect();
+
+        let batch_meta =
+            GlobalIndexIOMeta::new("batch.index".to_string(), index.len() as 
u64, Vec::new());
+        let mut batch_reader = VindexVectorGlobalIndexReader::new(batch_meta, 
options);
+        let actual = batch_reader
+            .visit_batch_vector_search(&searches, |_| Ok(Cursor::new(index)))
+            .unwrap();
+
+        assert_eq!(actual, expected);
+        assert_ne!(
+            actual[0], actual[2],
+            "interleaved batch groups lost query order"
+        );
+        assert_eq!(actual[1].as_ref().map(HashMap::len), Some(1));
+        assert_eq!(actual[3].as_ref().map(HashMap::len), Some(3));
+    }
+
+    #[test]
+    fn metadata_validation_runs_before_optimization() {
+        let index = build_ivf_flat_index();
+        let io_meta = GlobalIndexIOMeta::new(
+            "validated.index".to_string(),
+            index.len() as u64,
+            Vec::new(),
+        );
+        let mut reader = VindexVectorGlobalIndexReader::new(io_meta, 
HashMap::new());
+        let optimized = Cell::new(false);
+
+        let error = reader
+            .ensure_loaded_with_optimizer(
+                |_| Ok(Cursor::new(index)),
+                |metadata| {
+                    assert_eq!(metadata.dimension, TEST_DIMENSION);
+                    Err(crate::Error::DataInvalid {
+                        message: "rejected test metric".to_string(),
+                        source: None,
+                    })
+                },
+                |_| {
+                    optimized.set(true);
+                    Ok(())
+                },
+            )
+            .unwrap_err();
+
+        assert!(error.to_string().contains("rejected test metric"));
+        assert!(!optimized.get());
+        assert!(reader.metadata().is_err());
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn batch_search_reuses_probed_lists_and_avoids_full_file_read() {
+        let index = build_ivf_flat_index();
+        let batch_index = index.clone();
+        let options = HashMap::from([(NPROBE_PARAMETER.to_string(), 
"1".to_string())]);
+        let search = query();
+
+        let scalar_tracking = TrackingIndexRead::new(index.clone());
+        let scalar_source: Arc<dyn FileRead> = scalar_tracking.clone();
+        let scalar_options = options.clone();
+        let scalar_search = search.clone();
+        let scalar_runtime = tokio::runtime::Handle::current();
+        let scalar_results = tokio::task::spawn_blocking(move || {
+            let source = VindexFileReader::new(
+                scalar_source,
+                scalar_runtime,
+                index.len() as u64,
+                "scalar.index".to_string(),
+            );
+            let io_meta =
+                GlobalIndexIOMeta::new("scalar.index".to_string(), index.len() 
as u64, Vec::new());
+            let mut reader = VindexVectorGlobalIndexReader::new(io_meta, 
scalar_options);
+            reader.load(|_| Ok(source)).unwrap();
+            vec![
+                reader.search(&scalar_search).unwrap(),
+                reader.search(&scalar_search).unwrap(),
+            ]
+        })
+        .await
+        .unwrap();
+
+        let batch_tracking = TrackingIndexRead::new(batch_index.clone());
+        let batch_source: Arc<dyn FileRead> = batch_tracking.clone();
+        let batch_options = options;
+        let batch_search = search.clone();
+        let batch_runtime = tokio::runtime::Handle::current();
+        let batch_results = tokio::task::spawn_blocking(move || {
+            let source = VindexFileReader::new(
+                batch_source,
+                batch_runtime,
+                batch_index.len() as u64,
+                "batch.index".to_string(),
+            );
+            let io_meta = GlobalIndexIOMeta::new(
+                "batch.index".to_string(),
+                batch_index.len() as u64,
+                Vec::new(),
+            );
+            let mut reader = VindexVectorGlobalIndexReader::new(io_meta, 
batch_options);
+            reader
+                .visit_batch_vector_search(&[batch_search.clone(), 
batch_search], |_| Ok(source))
+                .unwrap()
+        })
+        .await
+        .unwrap();
+
+        assert_eq!(batch_results, scalar_results);
+        let scalar_bytes = scalar_tracking.bytes_read.load(Ordering::SeqCst);
+        let batch_bytes = batch_tracking.bytes_read.load(Ordering::SeqCst);
+        assert!(
+            batch_bytes < scalar_bytes,
+            "batch should read a shared probed list once: batch={batch_bytes}, 
scalar={scalar_bytes}"
+        );
+        assert!(
+            batch_bytes < batch_tracking.data.len() / 2,
+            "nprobe=1 should read substantially less than the full index: 
read={batch_bytes}, file={} ",
+            batch_tracking.data.len()
+        );
+        assert!(batch_tracking
+            .ranges()
+            .iter()
+            .all(|range| { range.start != 0 || range.end != 
batch_tracking.data.len() as u64 }));
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn filtered_batch_matches_scalar_searches_and_reuses_probed_lists() {
+        let index = build_ivf_flat_index();
+        let batch_index = index.clone();
+        let options = HashMap::from([(NPROBE_PARAMETER.to_string(), 
"1".to_string())]);
+        let mut include_row_ids = roaring::RoaringTreemap::new();
+        for row_id in (0..256).step_by(16) {
+            include_row_ids.insert(row_id);
+        }
+        let searches = vec![
+            query().with_include_row_ids(include_row_ids.clone()),
+            query().with_include_row_ids(include_row_ids),
+        ];
+
+        let scalar_tracking = TrackingIndexRead::new(index.clone());
+        let scalar_source: Arc<dyn FileRead> = scalar_tracking.clone();
+        let scalar_options = options.clone();
+        let scalar_searches = searches.clone();
+        let scalar_runtime = tokio::runtime::Handle::current();
+        let scalar_results = tokio::task::spawn_blocking(move || {
+            let source = VindexFileReader::new(
+                scalar_source,
+                scalar_runtime,
+                index.len() as u64,
+                "scalar-filtered.index".to_string(),
+            );
+            let io_meta = GlobalIndexIOMeta::new(
+                "scalar-filtered.index".to_string(),
+                index.len() as u64,
+                Vec::new(),
+            );
+            let mut reader = VindexVectorGlobalIndexReader::new(io_meta, 
scalar_options);
+            reader.load(|_| Ok(source)).unwrap();
+            scalar_searches
+                .iter()
+                .map(|search| reader.search(search).unwrap())
+                .collect::<Vec<_>>()
+        })
+        .await
+        .unwrap();
+
+        let batch_tracking = TrackingIndexRead::new(batch_index.clone());
+        let batch_source: Arc<dyn FileRead> = batch_tracking.clone();
+        let batch_runtime = tokio::runtime::Handle::current();
+        let batch_results = tokio::task::spawn_blocking(move || {
+            let source = VindexFileReader::new(
+                batch_source,
+                batch_runtime,
+                batch_index.len() as u64,
+                "batch-filtered.index".to_string(),
+            );
+            let io_meta = GlobalIndexIOMeta::new(
+                "batch-filtered.index".to_string(),
+                batch_index.len() as u64,
+                Vec::new(),
+            );
+            let mut reader = VindexVectorGlobalIndexReader::new(io_meta, 
options);
+            reader
+                .visit_batch_vector_search(&searches, |_| Ok(source))
+                .unwrap()
+        })
+        .await
+        .unwrap();
+
+        assert_eq!(batch_results, scalar_results);
+        let scalar_bytes = scalar_tracking.bytes_read.load(Ordering::SeqCst);
+        let batch_bytes = batch_tracking.bytes_read.load(Ordering::SeqCst);
+        assert!(
+            batch_bytes < scalar_bytes,
+            "filtered batch should read a shared probed list once: 
batch={batch_bytes}, scalar={scalar_bytes}"
+        );
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn large_homogeneous_batch_reuses_lists_across_previous_boundary() {
+        let previous_batch_boundary = 16;
+        let index = build_ivf_flat_index();
+        let (within_limit_results, within_limit_bytes) =
+            tracked_batch_search(index.clone(), previous_batch_boundary).await;
+        let (over_limit_results, over_limit_bytes) =
+            tracked_batch_search(index, previous_batch_boundary + 1).await;
+
+        assert_eq!(within_limit_results.len(), previous_batch_boundary);
+        assert_eq!(over_limit_results.len(), previous_batch_boundary + 1);
+        assert!(within_limit_results
+            .iter()
+            .all(|result| result == &within_limit_results[0]));
+        assert!(over_limit_results
+            .iter()
+            .all(|result| result == &over_limit_results[0]));
+        assert_eq!(over_limit_results[0], within_limit_results[0]);
+        assert_eq!(
+            over_limit_bytes, within_limit_bytes,
+            "compatible queries should reuse the same probed list across the 
former 16-query boundary"
+        );
+    }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
+    async fn homogeneous_batch_chunks_at_working_set_boundary() {
+        let shard_concurrency = 4096;
+        let metadata = VectorIndexMetadata {
+            index_type: paimon_vindex_core::index::IndexType::IvfFlat,
+            dimension: TEST_DIMENSION,
+            nlist: 16,
+            metric: MetricType::L2,
+            total_vectors: 8192,
+            pq_m: None,
+            pq_bits: None,
+            rq_bits: None,
+            diskann: None,
+        };
+        let options = HashMap::from([
+            (NPROBE_PARAMETER.to_string(), "1".to_string()),
+            ("global-index.thread-num".to_string(), "1".to_string()),
+        ]);
+        let prepared = prepare_search(&metadata, &options, &query())
+            .unwrap()
+            .unwrap();
+        let chunk_size = native_batch_chunk_size(&metadata, &prepared, 
shard_concurrency);
+        assert!(chunk_size > 16);
+
+        let index = build_ivf_flat_index();
+        let (within_results, within_bytes) = tracked_batch_search_with_options(
+            index.clone(),
+            chunk_size,
+            options.clone(),
+            shard_concurrency,
+        )
+        .await;
+        let (over_results, over_bytes) =
+            tracked_batch_search_with_options(index, chunk_size + 1, options, 
shard_concurrency)
+                .await;
+
+        assert_eq!(within_results.len(), chunk_size);
+        assert_eq!(over_results.len(), chunk_size + 1);
+        assert!(within_results
+            .iter()
+            .all(|result| result == &within_results[0]));
+        assert!(over_results.iter().all(|result| result == &over_results[0]));
+        assert_eq!(over_results[0], within_results[0]);
+        assert!(over_bytes > within_bytes);
+    }
 }

Reply via email to