Skip to content

perf(index): sample PQ training data before sub-vector copies#7930

Open
u70b3 wants to merge 1 commit into
lance-format:mainfrom
u70b3:perf-kmeans-strided-data-view
Open

perf(index): sample PQ training data before sub-vector copies#7930
u70b3 wants to merge 1 commit into
lance-format:mainfrom
u70b3:perf-kmeans-strided-data-view

Conversation

@u70b3

@u70b3 u70b3 commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Summary

Related to #6928.

PQ codebook training previously materialized every input row into contiguous
per-sub-vector arrays before calling KMeans. KMeans then kept at most
sample_rate * num_centroids rows, so oversized inputs paid to copy and retain
data that training never read.

This PR now applies that same row limit before divide_to_subvectors. KMeans
continues to receive contiguous arrays, but PQ only materializes the rows it can
actually pass to training.

The original version used strided KMeans views. Review and controlled
rebenchmarking found two problems with that approach:

  • it changed public KMeansAlgo method signatures;
  • strided access regressed the sampled 8-bit case from 8.159 s to 17.121 s.

The strided implementation has therefore been removed rather than hiding that
regression behind the oversized-input win.

This is intentionally a first-stage optimization for unused oversampled rows.
At or below the KMeans sample cap, the existing one-time contiguous
materialization remains. A zero-copy or bounded-scratch redesign is still
follow-up work tracked by #6928.

Changes

  • Limit PQ sub-vector materialization to
    min(data.len(), sample_rate * num_centroids) rows.
  • Preserve the existing public KMeans API and its contiguous hot loops.
  • Validate sample-size multiplication overflow with a contextual error.
  • Add deterministic coverage proving that sampling before materialization
    produces the same codebook as the previous materialize-then-sample order.
  • Add sampled and 4M-row PQ build benchmarks for both 4-bit and 8-bit codebooks.
  • Lazily initialize benchmark fixtures so selecting a PQ benchmark no longer
    allocates an unrelated ~16 GiB KMeans fixture.

Validation

  • cargo test -p lance-index: 888 passed, 2 ignored, 0 failed.
  • cargo test -p lance-index doctests: 7 passed.
  • cargo clippy --all --tests --benches -- -D warnings.
  • cargo fmt --all -- --check.

Performance

Methodology:

  • Baseline: main at 8f51a2117; PR: f23982c69.
  • release-with-debug profile.
  • RAYON_NUM_THREADS=6, pinned to six physical cores with
    taskset -c 0,2,4,6,8,10.
  • Criterion: 10 samples, 2 s warm-up, 10 s measurement target.
  • Oversampled results are the mean of two independent Criterion point
    estimates, run in main/PR then PR/main order.
  • Benchmark-only fixture changes were identical in the baseline and PR builds.

End-to-end PQBuildParams::build, 128 dimensions and 16 sub-vectors:

input bits main this PR change
sampled row cap 4 598.90 ms 590.08 ms -1.5%
sampled row cap 8 8.159 s 8.239 s +1.0%
4M rows 4 2.069 s 598.7 ms -71.1%
4M rows 8 9.992 s 8.344 s -16.5%

The sampled-case confidence intervals overlap, so no sampled-path performance
change was observed. For 4M rows, peak RSS changed as follows:

bits main this PR reduction
4 4.203 GB 2.108 GB ~2.0 GiB
8 4.205 GB 2.143 GB ~2.0 GiB

The previous PR numbers were discarded. A filtered PQ benchmark still
initialized an unrelated ~16 GiB bench_train fixture, causing page churn, and
later attempts overlapped an external CPU-saturating workload. Lazy fixture
initialization and idle-host checks removed both sources of interference for
the results above.

@github-actions github-actions Bot added A-index Vector index, linalg, tokenizer performance labels Jul 23, 2026
@coderabbitai

coderabbitai Bot commented Jul 23, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

PQ training now caps sampled rows before sub-vector materialization, detects sample-size overflow, and validates the behavior with tests. KMeans benchmarks reuse cached inputs and add sampled and oversampled PQ build measurements.

Changes

PQ Sampling and Benchmarking

Layer / File(s) Summary
Sampled PQ materialization and validation
rust/lance-index/src/vector/pq/utils.rs, rust/lance-index/src/vector/pq/builder.rs
Sub-vector division accepts a row limit, PQ building applies overflow-safe capped sampling, and tests cover sampled output equivalence and overflow errors.
KMeans and PQ benchmark coverage
rust/lance-index/benches/kmeans.rs
KMeans benchmarks cache generated data, centroids, and indexes, while new groups measure 4- and 8-bit sampled and oversampled PQ builds.

Estimated code review effort: 3 (Moderate) | ~20 minutes

Suggested labels: enhancement

Suggested reviewers: xuanwo, wjones127

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning Issue #6928 asked for strided KMeans data views, but this PR only caps sampling before materializing sub-vectors. Implement the internal KMeansData view and switch PQ training to strided views over the original matrix instead of divide_to_subvectors.
✅ Passed checks (4 passed)
Check name Status Explanation
Out of Scope Changes check ✅ Passed The benchmark and caching changes support the PQ training work and do not appear unrelated to the stated goals.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Title check ✅ Passed It clearly describes the main change: sampling PQ training data before sub-vector copies.
Description check ✅ Passed It is closely related to the changeset and describes the PQ sampling and benchmark updates.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@u70b3
u70b3 marked this pull request as draft July 23, 2026 02:48

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
rust/lance-index/src/vector/pq/utils.rs (1)

8-28: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Consider a compiling doctest for SubVectorKMeansData.

The struct doc explains the row-major layout with a text diagram but has no compiling example exercising new()/vector(). As per coding guidelines, "Document all public APIs with examples and links to relevant structs and methods; keep examples synchronized with actual signatures."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/vector/pq/utils.rs` around lines 8 - 28, Update the
public `SubVectorKMeansData` documentation with a compiling Rust doctest that
constructs an instance through `new()` and exercises `vector()`, using the
actual signatures and a small row-major input that demonstrates sub-vector
selection. Keep the example synchronized with the current API and retain the
existing layout explanation.

Source: Coding guidelines

rust/lance-index/src/vector/kmeans.rs (2)

201-240: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Use debug_assert! instead of assert! for these construction invariants.

dimension > 0 and values.len() % dimension == 0 (and the len <= self.len() check in slice_rows) are internal invariants guaranteed by every current caller, not conditions that prevent data corruption or handle fallible external input. Per guidelines these should be debug_assert!/debug_assert_eq! rather than assert!/assert_eq!, which will panic in release builds too.

As per coding guidelines, "Prefer debug_assert! over assert! for non-safety invariants; reserve assert! for conditions preventing data corruption" and "Do not silently guard against impossible conditions; use debug_assert!, return an explicit error, or remove the check."

♻️ Proposed fix
 impl<'a, T> ContiguousKMeansData<'a, T> {
     pub fn new(values: &'a [T], dimension: usize) -> Self {
-        assert!(dimension > 0, "dimension must be positive, got {dimension}");
-        assert_eq!(
+        debug_assert!(dimension > 0, "dimension must be positive, got {dimension}");
+        debug_assert_eq!(
             values.len() % dimension,
             0,
             "values length {} is not a multiple of dimension {}",
             values.len(),
             dimension
         );
         Self { values, dimension }
     }
 }
     fn slice_rows(&self, len: usize) -> Self {
-        assert!(
+        debug_assert!(
             len <= self.len(),
             "cannot slice {len} rows from {}",
             self.len()
         );
         Self::new(&self.values[..len * self.dimension], self.dimension)
     }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/vector/kmeans.rs` around lines 201 - 240, Update
ContiguousKMeansData::new to use debug_assert! and debug_assert_eq! for the
positive dimension and divisible values-length invariants, and update slice_rows
to use debug_assert! for its len <= self.len() check. Preserve the existing
validation messages and slicing behavior.

Source: Coding guidelines


168-213: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Missing doc examples on new public KMeansData/ContiguousKMeansData API.

Both are pub (required for benches to construct ContiguousKMeansData directly), but their doc comments have no usage example. Given this is a new core abstraction other code (and future external callers) will build on, add a short # Examples block showing ContiguousKMeansData::new + .vector().

As per coding guidelines, "Document all public APIs with examples and links to relevant structs and methods; keep examples synchronized with actual signatures."

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/vector/kmeans.rs` around lines 168 - 213, Add concise `#
Examples` sections to the public `KMeansData` trait and `ContiguousKMeansData`
API documentation, demonstrating construction with `ContiguousKMeansData::new`
and reading a row via `.vector()`. Keep the examples valid for the actual
signatures and link the trait documentation to the relevant concrete type and
methods where appropriate.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rust/lance-index/src/vector/kmeans.rs`:
- Around line 201-240: Update ContiguousKMeansData::new to use debug_assert! and
debug_assert_eq! for the positive dimension and divisible values-length
invariants, and update slice_rows to use debug_assert! for its len <= self.len()
check. Preserve the existing validation messages and slicing behavior.
- Around line 168-213: Add concise `# Examples` sections to the public
`KMeansData` trait and `ContiguousKMeansData` API documentation, demonstrating
construction with `ContiguousKMeansData::new` and reading a row via `.vector()`.
Keep the examples valid for the actual signatures and link the trait
documentation to the relevant concrete type and methods where appropriate.

In `@rust/lance-index/src/vector/pq/utils.rs`:
- Around line 8-28: Update the public `SubVectorKMeansData` documentation with a
compiling Rust doctest that constructs an instance through `new()` and exercises
`vector()`, using the actual signatures and a small row-major input that
demonstrates sub-vector selection. Keep the example synchronized with the
current API and retain the existing layout explanation.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: 2ab61146-2fac-4fbf-91e8-78d357e485ab

📥 Commits

Reviewing files that changed from the base of the PR and between 0e7ac18 and d782e3f.

📒 Files selected for processing (4)
  • rust/lance-index/benches/kmeans.rs
  • rust/lance-index/src/vector/kmeans.rs
  • rust/lance-index/src/vector/pq/builder.rs
  • rust/lance-index/src/vector/pq/utils.rs

@codecov

codecov Bot commented Jul 23, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.84615% with 4 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
rust/lance-index/src/vector/pq/utils.rs 55.55% 4 Missing ⚠️

📢 Thoughts on this report? Let us know!

@u70b3
u70b3 marked this pull request as ready for review July 23, 2026 05:02
PQ KMeans only trains on the sample_rate * num_centroids row prefix. Materialize sub-vectors from that prefix instead of copying every input row, preserving contiguous KMeans access without allocating unused scratch data.

Lazy-initialize benchmark fixtures so filtered PQ benchmarks do not allocate unrelated 16 GiB training data.
@u70b3
u70b3 force-pushed the perf-kmeans-strided-data-view branch from d782e3f to f23982c Compare July 23, 2026 09:56
@u70b3 u70b3 changed the title perf(index): add strided KMeans data view to avoid PQ sub-vector copies perf(index): sample PQ training data before sub-vector copies Jul 23, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🧹 Nitpick comments (3)
rust/lance-index/src/vector/pq/builder.rs (2)

274-287: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Assert on the error variant, not just the message.

The test only checks error.to_string().contains(...). Per coding guidelines, tests should assert on both the error variant and message content.

As per coding guidelines, "Assert on both the error variant and the message content in tests; do not check only is_err()."

✅ Suggested fix
         let error = params.build(&fsl, DistanceType::L2).unwrap_err();
+        assert!(matches!(error, lance_core::Error::InvalidInput { .. }));
         assert!(
             error
                 .to_string()
                 .contains("PQ training sample size overflows")
         );
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/vector/pq/builder.rs` around lines 274 - 287, Update
test_build_rejects_sample_size_overflow to assert that params.build returns the
expected error variant, then separately verify the error message contains “PQ
training sample size overflows.” Replace the message-only assertion while
preserving the existing overflow setup and failure expectation.

Source: Coding guidelines


112-120: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Duplicate sample-size computation vs. existing sample_size() helper.

max_training_rows reimplements self.sample_rate * num_centroids inline with checked_mul, while the existing sample_size() method computes the same value with an unchecked *. Two sources of truth for the same quantity can silently diverge (e.g., if one is updated without the other, or if sample_size() is used elsewhere and overflows unchecked).

♻️ Suggested consolidation
-    fn sample_size(&self) -> usize {
-        self.sample_rate * 2_usize.pow(self.num_bits as u32)
-    }
+    fn sample_size(&self, num_centroids: usize) -> Result<usize> {
+        self.sample_rate.checked_mul(num_centroids).ok_or_else(|| {
+            Error::invalid_input(format!(
+                "PQ training sample size overflows: sample_rate={}, num_centroids={num_centroids}",
+                self.sample_rate
+            ))
+        })
+    }

Then call self.sample_size(num_centroids)? in build_from_fsl instead of duplicating the logic inline.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/vector/pq/builder.rs` around lines 112 - 120,
Consolidate the training-row calculation in build_from_fsl by replacing the
inline checked multiplication with the existing self.sample_size(num_centroids)?
helper. Update sample_size to perform checked multiplication and return the
appropriate Result/error so overflow handling remains consistent for every
caller.
rust/lance-index/src/vector/pq/utils.rs (1)

86-105: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a test case for the num_rows > fsl.len() error path.

The new validation branch (num_rows > fsl.len() at Lines 22-27) has no corresponding test; the current rstest cases (all_rows, sampled_rows) both pass num_rows <= fsl.len(). A case asserting the error and its message would close this gap.

As per coding guidelines, "Every bugfix and feature must have corresponding tests."

✅ Suggested additional case
     #[rstest]
     #[case::all_rows(10)]
     #[case::sampled_rows(3)]
     fn test_divide_to_subvectors(#[case] num_rows: usize) {
         ...
     }
+
+    #[test]
+    fn test_divide_to_subvectors_rejects_num_rows_overflow() {
+        let values = Float32Array::from_iter((0..320).map(|v| v as f32));
+        let mat = FixedSizeListArray::try_new_from_values(values, 32).unwrap();
+        let error = divide_to_subvectors::<Float32Type>(&mat, 4, 11).unwrap_err();
+        assert!(error.to_string().contains("cannot divide 11 rows"));
+    }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@rust/lance-index/src/vector/pq/utils.rs` around lines 86 - 105, Add an rstest
case to test_divide_to_subvectors that passes a num_rows value greater than the
input FixedSizeListArray length, asserts the function returns an error, and
verifies the error message from the num_rows > fsl.len() validation branch.

Source: Coding guidelines

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@rust/lance-index/src/vector/pq/builder.rs`:
- Around line 274-287: Update test_build_rejects_sample_size_overflow to assert
that params.build returns the expected error variant, then separately verify the
error message contains “PQ training sample size overflows.” Replace the
message-only assertion while preserving the existing overflow setup and failure
expectation.
- Around line 112-120: Consolidate the training-row calculation in
build_from_fsl by replacing the inline checked multiplication with the existing
self.sample_size(num_centroids)? helper. Update sample_size to perform checked
multiplication and return the appropriate Result/error so overflow handling
remains consistent for every caller.

In `@rust/lance-index/src/vector/pq/utils.rs`:
- Around line 86-105: Add an rstest case to test_divide_to_subvectors that
passes a num_rows value greater than the input FixedSizeListArray length,
asserts the function returns an error, and verifies the error message from the
num_rows > fsl.len() validation branch.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: QUIET

Plan: Pro Plus

Run ID: c7129d0c-e10e-44cd-9ea3-05e05802eef7

📥 Commits

Reviewing files that changed from the base of the PR and between d782e3f and f23982c.

📒 Files selected for processing (3)
  • rust/lance-index/benches/kmeans.rs
  • rust/lance-index/src/vector/pq/builder.rs
  • rust/lance-index/src/vector/pq/utils.rs

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

A-index Vector index, linalg, tokenizer performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant