Skip to content

perf: make compaction bin splitting linear#7763

Open
HaroldBenoit wants to merge 1 commit into
lance-format:mainfrom
HaroldBenoit:haroldbenoit-microsoft-linear-compaction-planning
Open

perf: make compaction bin splitting linear#7763
HaroldBenoit wants to merge 1 commit into
lance-format:mainfrom
HaroldBenoit:haroldbenoit-microsoft-linear-compaction-planning

Conversation

@HaroldBenoit

@HaroldBenoit HaroldBenoit commented Jul 13, 2026

Copy link
Copy Markdown

Summary

  • replace repeated suffix sums and front drains in CandidateBin::split_for_size with a single consuming pass
  • preserve fragment order, grouping boundaries, candidacy, pos_range, and index-clearing behavior
  • add deterministic edge-case and randomized equivalence coverage against the previous algorithm
  • add an ignored scalability regression test covering 10K, 100K, and 1M fragments

Performance

Release timings for uniform one-row fragments grouped at 30 rows:

Fragments Time
10K 459.542 us
100K 3.76375 ms
1M 36.413916 ms

Testing

  • cargo test -p lance candidate_bin_split --lib --quiet
  • cargo test -p lance test_candidate_bin --lib --quiet
  • cargo test -p lance benchmark_candidate_bin_split_scalability --lib --release -- --ignored --nocapture
  • cargo fmt --all -- --check
  • cargo clippy -p lance --lib --tests --quiet -- -D warnings

Summary by CodeRabbit

  • Bug Fixes

    • Improved compaction planning to split candidate data into more accurate, size-aware bins.
    • Ensured underfilled trailing data remains attached to the final bin.
    • Improved handling of row counts and bin boundaries for large datasets.
  • Tests

    • Added broader coverage for deterministic and randomized bin-splitting scenarios.
    • Added scalability benchmarking for large inputs.

Co-authored-by: Copilot App <223556219+Copilot@users.noreply.github.com>
@coderabbitai

coderabbitai Bot commented Jul 13, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Changes

The compaction planner rewrites candidate bin splitting to track row counts incrementally with u128, preserve metadata and ordering, reset split indices, and retain underfilled tails. Tests add equality support, deterministic coverage, randomized reference comparisons, and a scalability benchmark.

Compaction bin splitting

Layer / File(s) Summary
Candidate bin splitting algorithm
rust/lance/src/dataset/optimize.rs
CandidateBin and CompactionCandidacy derive PartialEq; split_for_size uses incremental u128 row-count tracking and preserves trailing underfilled fragments.
Splitting behavior validation
rust/lance/src/dataset/optimize.rs
Test helpers and deterministic, randomized, metadata-preservation, degenerate-input, and scalability coverage validate the new splitting behavior.

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

Suggested reviewers: jackye1995

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: a performance-focused linear rewrite of compaction bin splitting.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

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

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

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

Inline comments:
In `@rust/lance/src/dataset/optimize.rs`:
- Around line 2396-2409: Update the rstest attributes in
test_candidate_bin_split_large_and_small_edges to use readable case names with
the #[case::{name}(...)] syntax, assigning distinct descriptive names to each
row_counts scenario while preserving the existing arguments and assertions.
- Around line 1421-1486: Update the reset logic in split_for_size so
current_fragments, current_candidacy, and current_row_counts retain or receive a
capacity estimate after each cut instead of being left with zero capacity by
mem::take. Use the remaining fragment count or another safe upper-bound
estimate, while preserving the existing bin contents, cut behavior, and final
indices handling.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: ASSERTIVE

Plan: Pro Plus

Run ID: 57d002e0-046d-4914-ba0c-98ed9047cb67

📥 Commits

Reviewing files that changed from the base of the PR and between ae25040 and c38a3e0.

📒 Files selected for processing (1)
  • rust/lance/src/dataset/optimize.rs

Comment on lines +1421 to 1486
fn split_for_size(self, min_num_rows: usize) -> Vec<Self> {
debug_assert_eq!(self.fragments.len(), self.candidacy.len());
debug_assert_eq!(self.fragments.len(), self.row_counts.len());
debug_assert_eq!(self.fragments.len(), self.pos_range.len());

loop {
let mut bin_len = 0;
let mut bin_row_count = 0;
while bin_row_count < min_num_rows && bin_len < self.row_counts.len() {
bin_row_count += self.row_counts[bin_len];
bin_len += 1;
}
if self.row_counts.is_empty() || min_num_rows == 0 {
return vec![self];
}

// If there's enough remaining to make another worthwhile bin, then
// push what we have as a bin.
if self.row_counts[bin_len..].iter().sum::<usize>() >= min_num_rows {
let Self {
fragments,
pos_range,
candidacy,
row_counts,
indices,
} = self;

// A CandidateBin cannot contain enough entries for this sum to overflow u128.
let mut remaining_row_count = row_counts
.iter()
.map(|&row_count| row_count as u128)
.sum::<u128>();
let min_num_rows = min_num_rows as u128;
let mut bins = Vec::new();
let mut current_fragments = Vec::new();
let mut current_candidacy = Vec::new();
let mut current_row_counts = Vec::new();
let mut current_row_count = 0_u128;
let mut current_pos = pos_range.start;

for ((fragment, candidacy), row_count) in
fragments.into_iter().zip(candidacy).zip(row_counts)
{
let row_count = row_count as u128;
remaining_row_count -= row_count;
current_row_count += row_count;
current_fragments.push(fragment);
current_candidacy.push(candidacy);
current_row_counts.push(row_count as usize);

if current_row_count >= min_num_rows && remaining_row_count >= min_num_rows {
let next_pos = current_pos + current_fragments.len();
bins.push(Self {
fragments: self.fragments.drain(0..bin_len).collect(),
pos_range: self.pos_range.start..(self.pos_range.start + bin_len),
candidacy: self.candidacy.drain(0..bin_len).collect(),
row_counts: self.row_counts.drain(0..bin_len).collect(),
fragments: std::mem::take(&mut current_fragments),
pos_range: current_pos..next_pos,
candidacy: std::mem::take(&mut current_candidacy),
row_counts: std::mem::take(&mut current_row_counts),
// By the time we are splitting for size we are done considering indices
indices: Vec::new(),
});
self.pos_range.start += bin_len;
} else {
// Otherwise, just push the remaining fragments into the last bin
bins.push(self);
break;
current_pos = next_pos;
current_row_count = 0;
}
}

// Any underfilled tail stays attached to the current group.
bins.push(Self {
fragments: current_fragments,
pos_range: current_pos..pos_range.end,
candidacy: current_candidacy,
row_counts: current_row_counts,
indices,
});

bins
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Correctness verified — matches prior semantics with linear complexity.

Traced the cut condition (current_row_count >= min_num_rows && remaining_row_count >= min_num_rows) against split_for_size_reference for exact-multiple, boundary-sum, tail-merge, and all-in-one-bin cases; all produce identical output, and this is now backed by the randomized equivalence test.

One nit: current_fragments, current_candidacy, and current_row_counts are reset via mem::take (→ capacity 0) after every cut and regrow from scratch for each new bin. For workloads like the benchmarked 1M-fragment case (~30K+ bins), this causes repeated reallocation as each bin regrows to its final size.

♻️ Suggested capacity hint on reset
-                bins.push(Self {
-                    fragments: std::mem::take(&mut current_fragments),
+                bins.push(Self {
+                    fragments: std::mem::replace(&mut current_fragments, Vec::with_capacity(4)),
                     pos_range: current_pos..next_pos,
-                    candidacy: std::mem::take(&mut current_candidacy),
+                    candidacy: std::mem::replace(&mut current_candidacy, Vec::with_capacity(4)),
                     row_counts: std::mem::take(&mut current_row_counts),

As per coding guidelines, "Use Vec::with_capacity() when size is known or estimable, and prefer over-estimating capacity to multiple reallocations."

📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
fn split_for_size(self, min_num_rows: usize) -> Vec<Self> {
debug_assert_eq!(self.fragments.len(), self.candidacy.len());
debug_assert_eq!(self.fragments.len(), self.row_counts.len());
debug_assert_eq!(self.fragments.len(), self.pos_range.len());
loop {
let mut bin_len = 0;
let mut bin_row_count = 0;
while bin_row_count < min_num_rows && bin_len < self.row_counts.len() {
bin_row_count += self.row_counts[bin_len];
bin_len += 1;
}
if self.row_counts.is_empty() || min_num_rows == 0 {
return vec![self];
}
// If there's enough remaining to make another worthwhile bin, then
// push what we have as a bin.
if self.row_counts[bin_len..].iter().sum::<usize>() >= min_num_rows {
let Self {
fragments,
pos_range,
candidacy,
row_counts,
indices,
} = self;
// A CandidateBin cannot contain enough entries for this sum to overflow u128.
let mut remaining_row_count = row_counts
.iter()
.map(|&row_count| row_count as u128)
.sum::<u128>();
let min_num_rows = min_num_rows as u128;
let mut bins = Vec::new();
let mut current_fragments = Vec::new();
let mut current_candidacy = Vec::new();
let mut current_row_counts = Vec::new();
let mut current_row_count = 0_u128;
let mut current_pos = pos_range.start;
for ((fragment, candidacy), row_count) in
fragments.into_iter().zip(candidacy).zip(row_counts)
{
let row_count = row_count as u128;
remaining_row_count -= row_count;
current_row_count += row_count;
current_fragments.push(fragment);
current_candidacy.push(candidacy);
current_row_counts.push(row_count as usize);
if current_row_count >= min_num_rows && remaining_row_count >= min_num_rows {
let next_pos = current_pos + current_fragments.len();
bins.push(Self {
fragments: self.fragments.drain(0..bin_len).collect(),
pos_range: self.pos_range.start..(self.pos_range.start + bin_len),
candidacy: self.candidacy.drain(0..bin_len).collect(),
row_counts: self.row_counts.drain(0..bin_len).collect(),
fragments: std::mem::take(&mut current_fragments),
pos_range: current_pos..next_pos,
candidacy: std::mem::take(&mut current_candidacy),
row_counts: std::mem::take(&mut current_row_counts),
// By the time we are splitting for size we are done considering indices
indices: Vec::new(),
});
self.pos_range.start += bin_len;
} else {
// Otherwise, just push the remaining fragments into the last bin
bins.push(self);
break;
current_pos = next_pos;
current_row_count = 0;
}
}
// Any underfilled tail stays attached to the current group.
bins.push(Self {
fragments: current_fragments,
pos_range: current_pos..pos_range.end,
candidacy: current_candidacy,
row_counts: current_row_counts,
indices,
});
bins
}
fn split_for_size(self, min_num_rows: usize) -> Vec<Self> {
debug_assert_eq!(self.fragments.len(), self.candidacy.len());
debug_assert_eq!(self.fragments.len(), self.row_counts.len());
debug_assert_eq!(self.fragments.len(), self.pos_range.len());
if self.row_counts.is_empty() || min_num_rows == 0 {
return vec![self];
}
let Self {
fragments,
pos_range,
candidacy,
row_counts,
indices,
} = self;
// A CandidateBin cannot contain enough entries for this sum to overflow u128.
let mut remaining_row_count = row_counts
.iter()
.map(|&row_count| row_count as u128)
.sum::<u128>();
let min_num_rows = min_num_rows as u128;
let mut bins = Vec::new();
let mut current_fragments = Vec::new();
let mut current_candidacy = Vec::new();
let mut current_row_counts = Vec::new();
let mut current_row_count = 0_u128;
let mut current_pos = pos_range.start;
for ((fragment, candidacy), row_count) in
fragments.into_iter().zip(candidacy).zip(row_counts)
{
let row_count = row_count as u128;
remaining_row_count -= row_count;
current_row_count += row_count;
current_fragments.push(fragment);
current_candidacy.push(candidacy);
current_row_counts.push(row_count as usize);
if current_row_count >= min_num_rows && remaining_row_count >= min_num_rows {
let next_pos = current_pos + current_fragments.len();
bins.push(Self {
fragments: std::mem::replace(&mut current_fragments, Vec::with_capacity(4)),
pos_range: current_pos..next_pos,
candidacy: std::mem::replace(&mut current_candidacy, Vec::with_capacity(4)),
row_counts: std::mem::take(&mut current_row_counts),
// By the time we are splitting for size we are done considering indices
indices: Vec::new(),
});
current_pos = next_pos;
current_row_count = 0;
}
}
// Any underfilled tail stays attached to the current group.
bins.push(Self {
fragments: current_fragments,
pos_range: current_pos..pos_range.end,
candidacy: current_candidacy,
row_counts: current_row_counts,
indices,
});
bins
}
🤖 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/src/dataset/optimize.rs` around lines 1421 - 1486, Update the
reset logic in split_for_size so current_fragments, current_candidacy, and
current_row_counts retain or receive a capacity estimate after each cut instead
of being left with zero capacity by mem::take. Use the remaining fragment count
or another safe upper-bound estimate, while preserving the existing bin
contents, cut behavior, and final indices handling.

Source: Coding guidelines

Comment on lines +2396 to +2409
#[rstest]
#[case(vec![1_000], 500)]
#[case(vec![10], 500)]
#[case(vec![1_000, 10], 500)]
#[case(vec![10, 1_000], 500)]
fn test_candidate_bin_split_large_and_small_edges(
#[case] row_counts: Vec<usize>,
#[case] min_num_rows: usize,
) {
let original = candidate_bin(row_counts);
let split = original.clone().split_for_size(min_num_rows);

assert_eq!(split, vec![original]);
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Name the rstest cases.

#[case(vec![1_000], 500)] etc. lack readable names. As per coding guidelines, "use #[case::{name}(...)] for readable Rust case names."

♻️ Suggested fix
-    #[case(vec![1_000], 500)]
-    #[case(vec![10], 500)]
-    #[case(vec![1_000, 10], 500)]
-    #[case(vec![10, 1_000], 500)]
+    #[case::large_only(vec![1_000], 500)]
+    #[case::small_only(vec![10], 500)]
+    #[case::large_then_small(vec![1_000, 10], 500)]
+    #[case::small_then_large(vec![10, 1_000], 500)]
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
#[rstest]
#[case(vec![1_000], 500)]
#[case(vec![10], 500)]
#[case(vec![1_000, 10], 500)]
#[case(vec![10, 1_000], 500)]
fn test_candidate_bin_split_large_and_small_edges(
#[case] row_counts: Vec<usize>,
#[case] min_num_rows: usize,
) {
let original = candidate_bin(row_counts);
let split = original.clone().split_for_size(min_num_rows);
assert_eq!(split, vec![original]);
}
#[rstest]
#[case::large_only(vec![1_000], 500)]
#[case::small_only(vec![10], 500)]
#[case::large_then_small(vec![1_000, 10], 500)]
#[case::small_then_large(vec![10, 1_000], 500)]
fn test_candidate_bin_split_large_and_small_edges(
#[case] row_counts: Vec<usize>,
#[case] min_num_rows: usize,
) {
let original = candidate_bin(row_counts);
let split = original.clone().split_for_size(min_num_rows);
assert_eq!(split, vec![original]);
}
🤖 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/src/dataset/optimize.rs` around lines 2396 - 2409, Update the
rstest attributes in test_candidate_bin_split_large_and_small_edges to use
readable case names with the #[case::{name}(...)] syntax, assigning distinct
descriptive names to each row_counts scenario while preserving the existing
arguments and assertions.

Source: Coding guidelines

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant