Skip to content

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

Merged
Xuanwo merged 2 commits into
lance-format:mainfrom
u70b3:perf-kmeans-strided-data-view
Aug 23, 2026
Merged

Xuanwo merged 2 commits 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 applies the same row limit before divide_to_subvectors. KMeans continues to receive contiguous arrays and keeps the existing hot loops, while PQ only materializes the prefix it can actually pass to training.

The original version used strided KMeans views. Review and controlled rebenchmarking found that this changed public KMeansAlgo signatures and regressed the sampled 8-bit case. The strided implementation was removed rather than hiding that regression behind the oversized-input improvement.

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 and sample_size() -> usize APIs; add a fallible try_sample_size() path and route all in-repository training and dataset sampling through it.
  • Reject both centroid-count shift overflow and sample-size multiplication overflow with contextual InvalidInput errors before loading training data.
  • Add deterministic coverage proving that sampling before materialization produces the same codebook as the previous materialize-then-sample order.
  • Cover overflow error variants/messages and the num_rows > FixedSizeListArray::len() validation path.
  • 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: 1011 passed, 2 ignored, 0 failed.
  • cargo test -p lance-index doctests: 9 passed.
  • cargo check -p lance --lib.
  • cargo clippy --all --tests --benches -- -D warnings.
  • cargo fmt --all -- --check.

Performance

Methodology:

  • Final PR head: f97653099, rebased onto main at d20ca47d5.
  • Performance binaries: main at af8e30869; PR at a0338e740. The only intervening main commit was the 11.0.0-beta.4 release metadata/lockfile version bump; it did not change PQ, KMeans, or other compiled code paths.
  • 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. Slow cases use Criterion's minimum 10 iterations, so their actual collection time is longer.
  • One accepted low-load paired run per build. Before every run, a 30 s gate required whole-system idle >=95% and each pinned CPU's idle >=90%. Accepted gates measured 95.38-97.97% whole-system idle and 93.69-96.55% minimum pinned CPU idle.
  • Attempts were discarded and retried when effective CPU utilization or involuntary context switches showed that external load returned mid-run.
  • The controlled harness was byte-identical in both builds (SHA-256 3e5208f32229626ab1696de9cde3ecf8d872eb32369d6fa67eeeb277af4dcba4) and temporarily used fixed-seed input arrays; that seed-only harness change is not part of the PR. KMeans centroid initialization still uses its existing OS-seeded path, so small changes with overlapping confidence intervals are treated as noise.

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

input bits main this PR change
sampled row cap 4 619.43 ms 622.12 ms +0.4%
sampled row cap 8 14.784 s 14.388 s -2.7%
4M rows 4 1.9857 s 621.30 ms -68.7%
4M rows 8 16.112 s 15.131 s -6.1%

Both sampled-case confidence intervals overlap, so no sampled-path performance change was observed. The 4M-row 4-bit intervals are clearly separated. The 4M-row 8-bit intervals overlap, so its -6.1% point-estimate change should be treated as directional rather than conclusive.

For 4M rows, peak RSS changed as follows:

bits main this PR reduction
4 4.009 GiB 2.020 GiB 49.6%
8 4.010 GiB 2.044 GiB 49.0%

The previous PR numbers were discarded because they used different random input arrays between builds and did not reject runs when host load returned during a long measurement. The refreshed results control input data, gate every run on low load, and exclude visibly contended attempts.

@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

✅ All modified and coverable lines are covered by tests.

📢 Thoughts on this report? Let us know!

@u70b3
u70b3 marked this pull request as ready for review July 23, 2026 05:02
@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

@u70b3
u70b3 force-pushed the perf-kmeans-strided-data-view branch 6 times, most recently from 97e593d to 9380dce Compare August 6, 2026 12:50
@u70b3

u70b3 commented Aug 6, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main. The only blocking check is Lance Gatekeeper, which stopped with "A maintainer must inspect this pull request activity before retrying." — everything else is green.

Could a maintainer clear the hold and take a look? Happy to address any feedback.

@u70b3
u70b3 force-pushed the perf-kmeans-strided-data-view branch 5 times, most recently from a0338e7 to f976530 Compare August 11, 2026 05:05

u70b3 commented Aug 11, 2026

Copy link
Copy Markdown
Contributor Author

@Xuanwo This PR optimizes PQ training by sampling before sub-vector materialization. It has now been rebased onto the latest main and rebenchmarked with fixed input data, strict low-load gates, and contended runs excluded. Could you please take a look from the index/PQ perspective when you have time, especially at the sampling boundary and the fallible try_sample_size() path? Thanks!

@u70b3
u70b3 force-pushed the perf-kmeans-strided-data-view branch from f976530 to eb70db0 Compare August 18, 2026 09:51
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 18, 2026
@u70b3
u70b3 force-pushed the perf-kmeans-strided-data-view branch from eb70db0 to 81230e3 Compare August 19, 2026 02:56
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 81230e3 to a8171f8 Compare August 21, 2026 12:13
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 21, 2026
lance-gatekeeper[bot]

This comment was marked as outdated.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 21, 2026
@lance-gatekeeper lance-gatekeeper Bot removed the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 23, 2026

@lance-gatekeeper lance-gatekeeper 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.

✅ Gate recommendation: approve.

The patch remains unchanged after the latest base merge. PQ/KMeans sampling and quantizer construction are unaffected, and the nearby index-metadata change integrates cleanly with ordinary vector-index creation. Keeping bounded contiguous materialization remains preferable to a strided KMeans interface because training reuses each sub-vector; checked sample sizing still rejects overflow before current dataset training data is loaded.

@lance-gatekeeper lance-gatekeeper Bot added the K-approved Latest Gatekeeper recommendation permits acceptance. label Aug 23, 2026
@Xuanwo
Xuanwo merged commit 352a334 into lance-format:main Aug 23, 2026
39 checks passed
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 K-approved Latest Gatekeeper recommendation permits acceptance. performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants