This is an automated email from the ASF dual-hosted git repository.

jerry-024 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 2c88a3a  perf: batch IVF-PQ encoding with blocked SGEMM (#82)
2c88a3a is described below

commit 2c88a3a0d4a9f9c5d62d7639832328a620a50533
Author: jerry <[email protected]>
AuthorDate: Wed Sep 2 15:33:13 2026 +0800

    perf: batch IVF-PQ encoding with blocked SGEMM (#82)
---
 .github/workflows/ci.yml        |   2 +-
 core/Cargo.toml                 |   4 +
 core/benches/ivfpq_add_bench.rs | 176 +++++++++++++++++++++++++++++++++
 core/src/ivfpq.rs               |   2 +-
 core/src/pq.rs                  | 211 ++++++++++++++++++++++++++++++++++++++++
 5 files changed, 393 insertions(+), 2 deletions(-)

diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index ad678c5..bc4533d 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -85,7 +85,7 @@ jobs:
         run: cargo fmt --all -- --check
 
       - name: Clippy
-        run: cargo clippy --all-targets --workspace -- -D warnings
+        run: cargo clippy --all-targets --workspace -- -D warnings -A 
clippy::chunks-exact-to-as-chunks
 
   rust-test:
     runs-on: ubuntu-latest
diff --git a/core/Cargo.toml b/core/Cargo.toml
index bd9050d..63d6d5f 100644
--- a/core/Cargo.toml
+++ b/core/Cargo.toml
@@ -65,3 +65,7 @@ harness = false
 [[bench]]
 name = "ivfpq_train_bench"
 harness = false
+
+[[bench]]
+name = "ivfpq_add_bench"
+harness = false
diff --git a/core/benches/ivfpq_add_bench.rs b/core/benches/ivfpq_add_bench.rs
new file mode 100644
index 0000000..6243e6d
--- /dev/null
+++ b/core/benches/ivfpq_add_bench.rs
@@ -0,0 +1,176 @@
+// Licensed to the Apache Software Foundation (ASF) under one
+// or more contributor license agreements.  See the NOTICE file
+// distributed with this work for additional information
+// regarding copyright ownership.  The ASF licenses this file
+// to you under the Apache License, Version 2.0 (the
+// "License"); you may not use this file except in compliance
+// with the License.  You may obtain a copy of the License at
+//
+//   http://www.apache.org/licenses/LICENSE-2.0
+//
+// Unless required by applicable law or agreed to in writing,
+// software distributed under the License is distributed on an
+// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
+// KIND, either express or implied.  See the License for the
+// specific language governing permissions and limitations
+// under the License.
+
+use criterion::{
+    black_box, criterion_group, criterion_main, BatchSize, BenchmarkId, 
Criterion, Throughput,
+};
+use paimon_vindex_core::distance::MetricType;
+use paimon_vindex_core::ivfpq::IVFPQIndex;
+use rand::rngs::StdRng;
+use rand::{Rng, SeedableRng};
+use std::time::Duration;
+
+#[derive(Clone, Copy)]
+struct Case {
+    name: &'static str,
+    d: usize,
+    m: usize,
+    nlist: usize,
+    rows: usize,
+}
+
+const CASES: [Case; 10] = [
+    Case {
+        name: "dsub4",
+        d: 768,
+        m: 192,
+        nlist: 1,
+        rows: 1,
+    },
+    Case {
+        name: "dsub4",
+        d: 768,
+        m: 192,
+        nlist: 1,
+        rows: 7,
+    },
+    Case {
+        name: "dsub4",
+        d: 768,
+        m: 192,
+        nlist: 1,
+        rows: 8,
+    },
+    Case {
+        name: "dsub4",
+        d: 768,
+        m: 192,
+        nlist: 1,
+        rows: 31,
+    },
+    Case {
+        name: "dsub4",
+        d: 768,
+        m: 192,
+        nlist: 1,
+        rows: 32,
+    },
+    Case {
+        name: "dsub4",
+        d: 768,
+        m: 192,
+        nlist: 1,
+        rows: 512,
+    },
+    Case {
+        name: "dsub4",
+        d: 768,
+        m: 192,
+        nlist: 1,
+        rows: 4096,
+    },
+    Case {
+        name: "coverage_dsub4_batch32768",
+        d: 32,
+        m: 8,
+        nlist: 1,
+        rows: 32768,
+    },
+    Case {
+        name: "coverage_dsub8_batch32768",
+        d: 64,
+        m: 8,
+        nlist: 1,
+        rows: 32768,
+    },
+    Case {
+        name: "coverage_e2e_residual",
+        d: 768,
+        m: 192,
+        nlist: 4096,
+        rows: 32,
+    },
+];
+
+fn new_index(
+    case: Case,
+    quantizer_centroids: &[f32],
+    centroids: &[f32],
+    norms: &[f32],
+) -> IVFPQIndex {
+    let mut index = IVFPQIndex::new(case.d, case.nlist, case.m, 
MetricType::L2, false);
+    index.quantizer_centroids = quantizer_centroids.to_vec();
+    index.pq.centroids = centroids.to_vec();
+    index.pq.centroid_norms_cache = norms.to_vec();
+    index
+}
+
+fn bench_ivfpq_add(c: &mut Criterion) {
+    let mut group = c.benchmark_group(format!("ivfpq_add/threads{}", 
rayon::current_num_threads()));
+    for case in CASES {
+        let mut rng = StdRng::seed_from_u64(42);
+        let data = (0..case.rows * case.d)
+            .map(|_| rng.gen_range(-1.0f32..1.0))
+            .collect::<Vec<_>>();
+        let ids = (0..case.rows as i64).collect::<Vec<_>>();
+        let quantizer_centroids = if case.nlist == 1 {
+            vec![0.0; case.d]
+        } else {
+            (0..case.nlist * case.d)
+                .map(|_| rng.gen_range(-1.0f32..1.0))
+                .collect()
+        };
+        let dsub = case.d / case.m;
+        let centroids = (0..case.m * 256 * dsub)
+            .map(|_| rng.gen_range(-1.0f32..1.0))
+            .collect::<Vec<_>>();
+        let norms = centroids
+            .chunks_exact(dsub)
+            .map(|centroid| centroid.iter().map(|value| value * value).sum())
+            .collect::<Vec<_>>();
+
+        group.throughput(Throughput::Elements(case.rows as u64));
+        group.bench_with_input(
+            BenchmarkId::new(
+                case.name,
+                format!(
+                    "d{}_m{}_dsub{dsub}_nlist{}_rows{}",
+                    case.d, case.m, case.nlist, case.rows
+                ),
+            ),
+            &case,
+            |b, &case| {
+                b.iter_batched(
+                    || new_index(case, &quantizer_centroids, &centroids, 
&norms),
+                    |mut index| index.add(black_box(&data), black_box(&ids), 
case.rows),
+                    BatchSize::LargeInput,
+                );
+            },
+        );
+    }
+    group.finish();
+}
+
+criterion_group! {
+    name = benches;
+    config = Criterion::default()
+        .sample_size(10)
+        .warm_up_time(Duration::from_millis(500))
+        .measurement_time(Duration::from_secs(2));
+    targets = bench_ivfpq_add
+}
+criterion_main!(benches);
diff --git a/core/src/ivfpq.rs b/core/src/ivfpq.rs
index 5cf12fc..55b5bd5 100644
--- a/core/src/ivfpq.rs
+++ b/core/src/ivfpq.rs
@@ -252,7 +252,7 @@ impl IVFPQIndex {
 
         let code_size = self.pq.code_size();
         let mut codes = vec![0u8; n * code_size];
-        self.pq.encode_batch(&to_encode, n, &mut codes);
+        self.pq.encode_batch_blocked(&to_encode, n, &mut codes);
 
         for i in 0..n {
             let list_id = assignments[i];
diff --git a/core/src/pq.rs b/core/src/pq.rs
index 0d44d9f..edc6a3e 100644
--- a/core/src/pq.rs
+++ b/core/src/pq.rs
@@ -356,6 +356,12 @@ impl ProductQuantizer {
     }
 
     /// Encode multiple vectors in parallel.
+    ///
+    /// This is the byte-stable path: results are bit-identical to the
+    /// per-vector [`Self::encode`], which golden storage fixtures rely on
+    /// (DiskANN serializes these codes). IVF-PQ's add path uses
+    /// [`Self::encode_batch_blocked`] instead, which batches the same distance
+    /// calculation into larger SGEMM calls.
     pub fn encode_batch(&self, data: &[f32], n: usize, codes: &mut [u8]) {
         let d = self.d;
         let cs = self.code_size();
@@ -370,6 +376,85 @@ impl ProductQuantizer {
         );
     }
 
+    /// Blocked batch encode for the IVF-PQ add path.
+    pub(crate) fn encode_batch_blocked(&self, data: &[f32], n: usize, codes: 
&mut [u8]) {
+        if self.nbits == 8
+            && n >= encode_sgemm_min_rows(rayon::current_num_threads())
+            && (0..self.m).all(|sub| self.chunk_dim(sub) >= 4)
+            && self.centroids.iter().all(|value| value.is_finite())
+        {
+            self.encode_batch_8bit_sgemm(data, n, codes);
+            return;
+        }
+        self.encode_batch(data, n, codes);
+    }
+
+    fn encode_batch_8bit_sgemm(&self, data: &[f32], n: usize, codes: &mut 
[u8]) {
+        let d = self.d;
+        let m = self.m;
+        let ksub = self.ksub;
+        let cs = self.code_size();
+        debug_assert_eq!(cs, m);
+        debug_assert_eq!(ksub, 256);
+
+        let max_dsub = (0..m).map(|sub| 
self.chunk_dim(sub)).max().unwrap_or(0);
+        let computed_norms = self
+            .centroid_norms_cache
+            .is_empty()
+            .then(|| self.compute_centroid_norms());
+        let centroid_norms = computed_norms
+            .as_ref()
+            .unwrap_or(&self.centroid_norms_cache);
+        let block_rows = encode_block_rows(n, rayon::current_num_threads());
+        codes[..n * cs]
+            .par_chunks_mut(block_rows * cs)
+            .enumerate()
+            .for_each_init(
+                || {
+                    (
+                        vec![0.0f32; block_rows * max_dsub],
+                        vec![0.0f32; block_rows * ksub],
+                    )
+                },
+                |(queries, distances), (block_idx, block_codes)| {
+                    let row0 = block_idx * block_rows;
+                    let rows = block_rows.min(n - row0);
+                    let block_data = &data[row0 * d..(row0 + rows) * d];
+
+                    for sub in 0..m {
+                        let range = self.chunk_range(sub);
+                        let dsub = range.len();
+                        for r in 0..rows {
+                            queries[r * dsub..(r + 1) * dsub].copy_from_slice(
+                                &block_data[r * d + range.start..r * d + 
range.end],
+                            );
+                        }
+                        let c_base = self.centroid_chunk_base(sub);
+                        sgemm_a_bt(
+                            rows,
+                            ksub,
+                            dsub,
+                            1.0,
+                            &queries[..rows * dsub],
+                            &self.centroids[c_base..c_base + ksub * dsub],
+                            0.0,
+                            &mut distances[..rows * ksub],
+                        );
+                        for r in 0..rows {
+                            let q_norm = fvec_norm_l2sqr(&queries[r * dsub..(r 
+ 1) * dsub]);
+                            let row_distances = &mut distances[r * ksub..(r + 
1) * ksub];
+                            for j in 0..ksub {
+                                row_distances[j] = (q_norm + 
centroid_norms[sub * ksub + j]
+                                    - 2.0 * row_distances[j])
+                                    .max(0.0);
+                            }
+                            block_codes[r * cs + sub] = 
argmin_code(row_distances);
+                        }
+                    }
+                },
+            );
+    }
+
     /// Decode PQ codes back to an approximate vector.
     pub fn decode(&self, codes: &[u8], x: &mut [f32]) {
         for sub in 0..self.m {
@@ -527,6 +612,20 @@ fn argmin_code(distances: &[f32]) -> u8 {
     best as u8
 }
 
+/// Row block for the batched SGEMM encode path.
+const MAX_ENCODE_BLOCK_ROWS: usize = 512;
+const MIN_ENCODE_BLOCK_ROWS: usize = 4;
+const ENCODE_SGEMM_MIN_ROWS: usize = 32;
+
+fn encode_sgemm_min_rows(workers: usize) -> usize {
+    ENCODE_SGEMM_MIN_ROWS.max(workers.max(1) * MIN_ENCODE_BLOCK_ROWS)
+}
+
+fn encode_block_rows(rows: usize, workers: usize) -> usize {
+    rows.div_ceil(workers.max(1))
+        .clamp(1, MAX_ENCODE_BLOCK_ROWS)
+}
+
 #[cfg(test)]
 mod tests {
     use super::*;
@@ -665,6 +764,118 @@ mod tests {
         assert!((table_distance - decoded_distance).abs() < 1e-4);
     }
 
+    /// Reference per-vector encode used to pin the blocked batch path.
+    fn encode_per_vector(pq: &ProductQuantizer, data: &[f32], n: usize) -> 
Vec<u8> {
+        let cs = pq.code_size();
+        let mut codes = vec![0u8; n * cs];
+        for i in 0..n {
+            pq.encode(
+                &data[i * pq.d..(i + 1) * pq.d],
+                &mut codes[i * cs..(i + 1) * cs],
+            );
+        }
+        codes
+    }
+
+    #[test]
+    fn test_encode_batch_blocked_matches_per_vector() {
+        let d = 32;
+        let m = 8; // dsub = 4: hits the SIMD kernels
+        let mut rng = StdRng::seed_from_u64(20260820);
+        let train: Vec<f32> = (0..3000 * d).map(|_| 
rng.gen_range(-1.0f32..1.0)).collect();
+        let mut pq = ProductQuantizer::new(d, m);
+        pq.train(&train, 3000);
+
+        // Below, exactly at, above, and misaligned against the block size.
+        for n in [
+            1,
+            31,
+            32,
+            33,
+            MAX_ENCODE_BLOCK_ROWS,
+            MAX_ENCODE_BLOCK_ROWS + 7,
+            2048,
+        ] {
+            let data: Vec<f32> = (0..n * d).map(|_| 
rng.gen_range(-1.0f32..1.0)).collect();
+            let reference = encode_per_vector(&pq, &data, n);
+            let mut batch = vec![0u8; n * pq.code_size()];
+            pq.encode_batch_blocked(&data, n, &mut batch);
+            assert_eq!(batch, reference, "n={n}");
+        }
+    }
+
+    #[test]
+    fn test_encode_batch_blocked_non_uniform_chunks() {
+        let d = 13;
+        let m = 3;
+        let mut rng = StdRng::seed_from_u64(20260821);
+        let train: Vec<f32> = (0..2000 * d).map(|_| 
rng.gen_range(-1.0f32..1.0)).collect();
+        let mut pq = ProductQuantizer::with_nbits_balanced(d, m, 8);
+        pq.train(&train, 2000);
+        assert_eq!(pq.chunk_offsets, vec![0, 5, 9, 13]);
+
+        let n = MAX_ENCODE_BLOCK_ROWS + 13;
+        let data: Vec<f32> = (0..n * d).map(|_| 
rng.gen_range(-1.0f32..1.0)).collect();
+        let reference = encode_per_vector(&pq, &data, n);
+        let mut batch = vec![0u8; n * pq.code_size()];
+        pq.encode_batch_blocked(&data, n, &mut batch);
+        assert_eq!(batch, reference);
+    }
+
+    #[test]
+    fn test_encode_batch_blocked_matches_canonical_large_offset() {
+        let mut pq = ProductQuantizer::new(4, 1);
+        pq.centroids = vec![100_000_016.0; pq.d * pq.ksub];
+        pq.centroids[0..4].fill(100_000_008.0);
+        pq.centroids[4..8].fill(100_000_000.0);
+
+        let data = vec![100_000_000.0; 32 * pq.d];
+        let mut canonical = vec![0; 32];
+        pq.encode_batch(&data, 32, &mut canonical);
+        let mut blocked = vec![0; 32];
+        pq.encode_batch_blocked(&data, 32, &mut blocked);
+
+        assert_eq!(blocked, canonical);
+    }
+
+    #[test]
+    fn test_encode_batch_blocked_is_batch_invariant() {
+        let mut pq = ProductQuantizer::new(4, 1);
+        pq.centroids = vec![100.0; pq.d * pq.ksub];
+        pq.centroids[0..4].copy_from_slice(&[0.3658799, 0.06077051, 
-0.46501994, -0.31766486]);
+        pq.centroids[4..8].copy_from_slice(&[0.3658799, 0.06077051, 
-0.46501994, -0.31766483]);
+
+        let mut small = vec![0; 31];
+        pq.encode_batch_blocked(&[0.0; 31 * 4], 31, &mut small);
+        let mut large = vec![0; 32];
+        pq.encode_batch_blocked(&[0.0; 32 * 4], 32, &mut large);
+
+        assert_eq!(small.as_slice(), &large[..31]);
+    }
+
+    #[test]
+    fn test_encode_batch_blocked_nan_centroid_is_batch_invariant() {
+        let mut pq = ProductQuantizer::new(4, 1);
+        pq.centroids = vec![1.0; pq.d * pq.ksub];
+        pq.centroids[0] = f32::NAN;
+        pq.centroids[4..8].fill(0.0);
+
+        let mut small = vec![0; 1];
+        pq.encode_batch_blocked(&[0.0; 4], 1, &mut small);
+        let mut large = vec![0; 32];
+        pq.encode_batch_blocked(&[0.0; 32 * 4], 32, &mut large);
+
+        assert_eq!(small[0], large[0]);
+    }
+
+    #[test]
+    fn test_encode_block_rows_avoids_tiny_sgemm_blocks() {
+        assert_eq!(encode_block_rows(2730, 12), 228);
+        assert_eq!(encode_block_rows(32768, 12), MAX_ENCODE_BLOCK_ROWS);
+        assert_eq!(encode_sgemm_min_rows(8), 32);
+        assert_eq!(encode_sgemm_min_rows(32), 128);
+    }
+
     #[test]
     fn test_odd_4bit_chunk_count_uses_canonical_padding_nibble() {
         let d = 7;

Reply via email to