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


##########
core/src/pq.rs:
##########
@@ -436,7 +535,29 @@ impl ProductQuantizer {
                             let c_off = c_base + j * chunk_dim;
                             fvec_norm_l2sqr(&self.centroids[c_off..c_off + 
chunk_dim])
                         };
-                        table[t_base + j] = (q_norm + c_norm - 2.0 * 
table[t_base + j]).max(0.0);
+                        let approximate = q_norm + c_norm - 2.0 * table[t_base 
+ j];
+                        // Cover the three reductions and final subtraction 
before trusting SGEMM.
+                        let error_bound =
+                            16.0 * chunk_dim as f32 * f32::EPSILON * (q_norm + 
c_norm);
+                        table[t_base + j] =
+                            if !approximate.is_finite() || approximate <= 
error_bound {

Review Comment:
   **[Major correctness]** This fallback only asks whether each individual 
expanded distance is near its own cancellation bound. It does not protect the 
ordering when two large, nonzero candidate distances differ by less than their 
numerical error.
   
   I reproduced this on `7296eaa`: the direct/FMA add path selects code 1 
(`88314130 < 88314136`), but ordinary in-memory search, unoptimized reader 
search, ephemeral reuse, resident precomputation, and optimized 
serialized-reader search all rank code 0 first. The new per-sub exact fallback 
is not triggered for this subspace.
   
   Please make the direct-encoded 8-bit table contract canonical across all 
search paths, or use a pairwise error/margin rule that recomputes candidates 
whose ordering is not provably stable. The ephemeral f64-to-f32 path also needs 
to preserve the same f32/FMA ordering used for persisted code assignment.



##########
core/src/pq.rs:
##########
@@ -370,6 +386,87 @@ 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) {
+            self.encode_batch_8bit_transposed(data, n, codes);

Review Comment:
   **[Major x86_64 fallback regression]** This now transposes the full codebook 
for every eligible add, even when runtime dispatch selects the scalar scorer. 
For `d=768,m=192`, that is about 0.75 MiB of allocation/writes before 
processing a single row.
   
   On a supported x86_64 runtime without AVX2/FMA, versus `314c6bf`, I measured 
approximately +39%/+46%/+37%/+45% for rows 1/7/8/31; rows=32 was effectively 
unchanged, isolating the fixed transpose cost.
   
   Please restore a small-batch direct path when `score_argmin_kernel` resolves 
to scalar, use a backend-specific threshold, or cache the transposed codebook 
after training.



##########
core/src/pq.rs:
##########
@@ -22,6 +22,34 @@ use crate::distance::{
 use crate::kmeans::{self, KMeansConfig};
 use rayon::prelude::*;
 
+pub(crate) fn l2_argmin_is_stable(
+    values: &[f32],
+    common_offset: f64,
+    mut error_bound: impl FnMut(usize) -> f64,
+) -> bool {
+    let Some((best_index, &best_value)) = values

Review Comment:
   **[Major correctness]** This proves only that the global centroid argmin is 
stable, then treats the entire sub-table as safe. IVF-PQ scanning consumes 
arbitrary persisted code entries, not necessarily the global table minimum.
   
   On `9e6cb1b`, adding an unused code 2 that is a clearly separated global 
argmin suppresses fallback while stored codes 0/1 remain reversed. Add emits 
codes `[0,1]`; canonical direct/FMA distances rank id 1 first, but ordinary, 
reopened-reader, ephemeral, resident-reader, and in-memory precomputed searches 
all return id 0.
   
   Please certify the ordering of entries that scanning can consume, or use the 
canonical direct/FMA table. Add an unused-global-minimum variant to the 
five-path regression test; the current test has one disputed stored code as the 
global argmin and therefore misses this case.



##########
core/src/pq.rs:
##########
@@ -436,7 +600,22 @@ impl ProductQuantizer {
                             let c_off = c_base + j * chunk_dim;
                             fvec_norm_l2sqr(&self.centroids[c_off..c_off + 
chunk_dim])
                         };
-                        table[t_base + j] = (q_norm + c_norm - 2.0 * 
table[t_base + j]).max(0.0);
+                        table[t_base + j] = q_norm + c_norm - 2.0 * 
table[t_base + j];
+                    }
+                    let table = &mut table[t_base..t_base + self.ksub];
+                    let stable = l2_argmin_is_stable(table, 0.0, |j| {

Review Comment:
   **[Major search regression]** The table has already been populated, but this 
call performs a `min_by` pass and another `all()` pass over all 256 entries, 
including repeated per-code bound computation. The same policy is present in 
resident and ephemeral table construction.
   
   Against `7296eaa`, a standard-scale short-list benchmark measured ordinary 
search about +36%, resident precomputed about +56%, and ephemeral reuse about 
+217% on the latest head.
   
   Please track the best/runner-up values and error metadata while producing or 
combining the table, rather than rescanning the completed table twice.



##########
core/src/ivfpq.rs:
##########
@@ -1086,6 +1162,25 @@ fn combine_stable_ephemeral_tables(
             sim_table[offset] =
                 (residual_norm + list_table[offset] - 2.0 * 
query_table[offset]).max(0.0) as f32;
         }
+        if !l2_argmin_is_stable(&sim_table[table_base..table_base + pq.ksub], 
0.0, |code| {
+            let offset = table_base + code;
+            16.0 * range.len() as f64
+                * f64::from(f32::EPSILON)
+                * (residual_norm + list_table[offset].abs() + 2.0 * 
query_table[offset].abs())
+        }) {
+            let residual_query = residual_query.get_or_insert_with(|| {

Review Comment:
   **[Major fallback regression]** When this table is unstable, the fallback 
allocates a fresh full-dimensional residual vector for every query/list pair 
and recomputes the full exact PQ table after the ephemeral list/query tables 
have already been built.
   
   For `nq=64,nprobe=8,d=64`, large-offset Auto ephemeral search is about `6.6 
ms` here versus `0.46 ms` at `7296eaa` (~14x), with exactly 512 extra 
allocations and 131,072 extra bytes—matching `nq*nprobe` residual vectors.
   
   Please reuse a per-worker scratch buffer and avoid the ephemeral route when 
scale/fallback history indicates that exact recomputation will be required, so 
the search does not pay both algorithms.



-- 
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