shyjsarah commented on code in PR #82:
URL: 
https://github.com/apache/paimon-vector-index/pull/82#discussion_r3900226691


##########
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);

Review Comment:
   **[Major correctness]** This switches persisted code assignment to direct 
squared differences, but IVF-PQ search still scores those codes with the 
norm/dot-product distance table. Those arithmetic definitions can disagree 
materially, not just on ulp-level ties.
   
   I reproduced an end-to-end public API case with finite values where exact 
query rows are assigned code 1, but search scores code 1 as `8388608` and a 
farther code-0 row as `0`, returning the farther row first. The merge base 
returns an exact row first.
   
   Please make assignment and all IVF-PQ search-table paths use the same 
numerical distance definition, and add the train/add/write/open/search 
reproduction as a regression test.



##########
core/src/pq.rs:
##########
@@ -370,6 +376,113 @@ impl ProductQuantizer {
         );
     }
 
+    /// Blocked batch encode for the IVF-PQ add path.
+    ///
+    /// The nbits=8 path uses a transposed-codebook kernel: per sub-quantizer
+    /// the centroids are transposed once to `[dsub][ksub]` so the inner
+    /// distance loop is stride-1 over `ksub` and runs on SIMD (NEON/AVX2,
+    /// scalar fallback). This removes the per-vector-per-sub GEMM calls and
+    /// their distance-table memory traffic. Distances are accumulated directly
+    /// from coordinate differences, avoiding the cancellation possible in the
+    /// norm/dot identity used by [`Self::encode_batch`]. The different 
arithmetic
+    /// can produce different valid codes, so use this only where codes are 
freshly
+    /// produced (index build), not where byte-stable output is pinned. Trained
+    /// codebooks are expected to contain only finite values.
+    pub(crate) fn encode_batch_blocked(&self, data: &[f32], n: usize, codes: 
&mut [u8]) {
+        // The 4-bit packed path keeps the original per-vector implementation.
+        if self.nbits == 8 && (0..self.m).all(|sub| self.chunk_dim(sub) >= 4) {
+            if n < ENCODE_TRANSPOSE_MIN_ROWS {
+                self.encode_batch_8bit_direct(data, n, codes);

Review Comment:
   **[Major x86_64 performance regression]** This unconditional `<32` branch 
routes small adds through the generic per-centroid direct loop. On optimized 
x86_64, its inner `f32::mul_add` lowers to repeated `fmaf` calls.
   
   For the included 31-row `d=768,m=192` shape, I measured about `3.63 ms` here 
versus `1.11 ms` at the merge base (>3x slower). Please retain the previous 
batch path on x86 unless a target-specific direct kernel is available, or 
choose the threshold per backend.



##########
core/src/pq.rs:
##########
@@ -527,6 +640,189 @@ fn argmin_code(distances: &[f32]) -> u8 {
     best as u8
 }
 
+/// Row block for the transposed batch-encode path. 512 rows keeps the
+/// per-thread score buffer at 1 KiB and yields ~20 blocks per thread at the
+/// production 32,768-row add batch.
+const MAX_ENCODE_BLOCK_ROWS: usize = 512;
+/// Below this row count the one-time codebook transpose is not worth it.
+const ENCODE_TRANSPOSE_MIN_ROWS: usize = 32;
+
+fn encode_block_rows(rows: usize, workers: usize) -> usize {
+    rows.div_ceil(workers.max(1))
+        .clamp(1, MAX_ENCODE_BLOCK_ROWS)
+}
+
+/// Squared L2 with the same accumulation order as the transposed kernels.
+#[inline]
+fn fvec_l2sqr_fma(a: &[f32], b: &[f32]) -> f32 {
+    debug_assert_eq!(a.len(), b.len());
+    debug_assert!(!a.is_empty());
+    let diff = a[0] - b[0];
+    let mut sum = diff * diff;
+    for i in 1..a.len() {
+        let diff = a[i] - b[i];
+        sum = diff.mul_add(diff, sum);
+    }
+    sum
+}
+
+/// Squared-L2 argmin over a transposed sub-codebook.
+///
+/// `t` is `[dsub][ksub]` (stride-1 over `j`) and `scores` is a reusable
+/// `ksub`-sized scratch buffer. Ties resolve to the smallest index, matching
+/// `argmin_code`'s strictly-smaller update rule.
+#[inline]
+fn score_argmin(q: &[f32], t: &[f32], ksub: usize, scores: &mut [f32]) -> u8 {
+    assert_eq!(q.len().checked_mul(ksub), Some(t.len()));
+    assert!(scores.len() >= ksub);
+    #[cfg(target_arch = "aarch64")]
+    {
+        if q.len() == 4 && ksub.is_multiple_of(4) {
+            // SAFETY: NEON is baseline on aarch64; slice bounds checked by 
caller.
+            return unsafe { score_argmin_neon_d4(q, t, ksub) };
+        }
+    }
+    #[cfg(target_arch = "x86_64")]
+    {
+        if q.len() == 4
+            && ksub.is_multiple_of(8)
+            && is_x86_feature_detected!("avx2")
+            && is_x86_feature_detected!("fma")
+        {
+            // SAFETY: AVX2 and FMA presence checked above; slice bounds 
checked by caller.
+            return unsafe { score_argmin_avx2_d4(q, t, ksub) };
+        }
+    }
+    score_argmin_scalar(q, t, ksub, scores)

Review Comment:
   **[Major x86_64 performance regression]** The outer gate enables this 
transposed path for every `dsub>=4`, but only `dsub==4` has an AVX2/NEON 
specialization. `dsub=8` reaches this scalar fallback, whose `mul_add` also 
becomes inner-loop `fmaf` calls on optimized x86_64.
   
   For the documented 32,768-row `dsub=8` case, I measured about `212 ms` here 
versus `74 ms` at the merge base (~3x slower). Please keep unsupported shapes 
on the matrixmultiply path until a wider SIMD kernel is available, or add an 
arbitrary-`dsub` AVX2 implementation.



-- 
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.

To unsubscribe, e-mail: [email protected]

For queries about this service, please contact Infrastructure at:
[email protected]

Reply via email to