Skip to content

perf: improve splitting of the offset vector in GroupColumn::take_n implementations, adding a new helper fn#23730

Open
mzabaluev wants to merge 12 commits into
apache:mainfrom
mzabaluev:take-n-offsets
Open

perf: improve splitting of the offset vector in GroupColumn::take_n implementations, adding a new helper fn#23730
mzabaluev wants to merge 12 commits into
apache:mainfrom
mzabaluev:take-n-offsets

Conversation

@mzabaluev

@mzabaluev mzabaluev commented Jul 20, 2026

Copy link
Copy Markdown
Contributor

Rationale for this change

The take_n implementation for ByteGroupValueBuilder uses split_vec_min_alloc to split the offset vector, then appends the n-th offset to the prefix vector. In case when the prefix is the newly allocated vector, it was initially reserved for size n, so it's highly likely to reallocate. On the remaining side of the split, the n-th offset is subtracted from the remaining offsets in place after the values have been copied to the new location. This may be slower than a single read-subtract-write pass.

The implementation in #23648 will benefit from the same optimization.

What changes are included in this PR?

Add a utility function named take_n_offsets in datafusion-common, which minimizes allocation in the way of split_vec_min_alloc, but reserves the right capacity at the outset and shifts the remaining offsets in a single pass with the copying.

Are these changes tested?

Added a micro-benchmark that shows improvement on the drain-like branch of the split, and no change on the split_off-like branch.
To keep results in context, the timed function is GroupValues::emit on a setup with two grouped columns of byte array types; one of them a string column, to try and catch any regressions in future specialization or refactoring.

Benchmark results (on a GCE instance type C4, Intel Xeon Emerald Rapids host CPU)
emit_first_small/grp_1000_emit_250/i32_offsets
                        time:   [4.4314 µs 4.4387 µs 4.4449 µs]
                        change: [−4.5277% −3.6745% −2.7621%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 1 outliers among 200 measurements (0.50%)
  1 (0.50%) low mild
emit_first_small/grp_1000_emit_500/i32_offsets
                        time:   [6.8075 µs 6.8151 µs 6.8217 µs]
                        change: [−4.2235% −3.7161% −3.2217%] (p = 0.00 < 0.05)
                        Performance has improved.
emit_first_small/grp_1000_emit_501/i32_offsets
                        time:   [6.8145 µs 6.8234 µs 6.8315 µs]
                        change: [−1.9465% −1.3716% −0.7934%] (p = 0.00 < 0.05)
                        Change within noise threshold.
Found 4 outliers among 200 measurements (2.00%)
  4 (2.00%) low mild
emit_first_small/grp_1000_emit_750/i32_offsets
                        time:   [9.0353 µs 9.0424 µs 9.0488 µs]
                        change: [−0.9841% −0.5617% −0.1450%] (p = 0.01 < 0.05)
                        Change within noise threshold.
Found 9 outliers among 200 measurements (4.50%)
  9 (4.50%) low mild
emit_first_small/grp_1000_emit_250/i64_offsets
                        time:   [4.8850 µs 4.8913 µs 4.8970 µs]
                        change: [−6.7829% −5.7609% −4.6782%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 31 outliers among 200 measurements (15.50%)
  30 (15.00%) low mild
  1 (0.50%) high severe
emit_first_small/grp_1000_emit_500/i64_offsets
                        time:   [7.1636 µs 7.1697 µs 7.1756 µs]
                        change: [−4.6454% −4.0830% −3.5196%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 19 outliers among 200 measurements (9.50%)
  19 (9.50%) low mild
emit_first_small/grp_1000_emit_501/i64_offsets
                        time:   [7.0936 µs 7.1023 µs 7.1105 µs]
                        change: [−2.6868% −1.9954% −1.3005%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 20 outliers among 200 measurements (10.00%)
  20 (10.00%) low mild
emit_first_small/grp_1000_emit_750/i64_offsets
                        time:   [9.0548 µs 9.0630 µs 9.0714 µs]
                        change: [−1.5948% −1.1789% −0.7650%] (p = 0.00 < 0.05)
                        Change within noise threshold.

emit_first_large/grp_1000000_emit_250000/i32_offsets
                        time:   [8.2874 ms 8.3514 ms 8.4191 ms]
                        change: [−9.3008% −8.1703% −7.0221%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 1 outliers among 50 measurements (2.00%)
  1 (2.00%) high mild
emit_first_large/grp_1000000_emit_500000/i32_offsets
                        time:   [10.058 ms 10.092 ms 10.128 ms]
                        change: [−5.5967% −4.9794% −4.4147%] (p = 0.00 < 0.05)
                        Performance has improved.
emit_first_large/grp_1000000_emit_500001/i32_offsets
                        time:   [9.7622 ms 9.7907 ms 9.8198 ms]
                        change: [−6.6324% −5.9999% −5.3676%] (p = 0.00 < 0.05)
                        Performance has improved.
emit_first_large/grp_1000000_emit_750000/i32_offsets
                        time:   [8.7413 ms 8.7672 ms 8.7940 ms]
                        change: [−2.7388% −2.3169% −1.9052%] (p = 0.00 < 0.05)
                        Performance has improved.
emit_first_large/grp_1000000_emit_250000/i64_offsets
                        time:   [9.0865 ms 9.1262 ms 9.1672 ms]
                        change: [−10.418% −9.7068% −8.9824%] (p = 0.00 < 0.05)
                        Performance has improved.
emit_first_large/grp_1000000_emit_500000/i64_offsets
                        time:   [10.589 ms 10.627 ms 10.669 ms]
                        change: [−12.767% −12.141% −11.515%] (p = 0.00 < 0.05)
                        Performance has improved.
Found 4 outliers among 50 measurements (8.00%)
  4 (8.00%) high mild
emit_first_large/grp_1000000_emit_500001/i64_offsets
                        time:   [10.100 ms 10.142 ms 10.183 ms]
                        change: [−4.3816% −3.7565% −3.1631%] (p = 0.00 < 0.05)
                        Performance has improved.
emit_first_large/grp_1000000_emit_750000/i64_offsets
                        time:   [8.9364 ms 8.9675 ms 8.9989 ms]
                        change: [−2.2674% −1.7610% −1.2792%] (p = 0.00 < 0.05)
                        Performance has improved.

I set --turbo-mode=ALL_CORE_MAX in the instance parameters and pinned CPU cores to reduce variation.

Are there any user-facing changes?

utils::take_n_offsets function is added in datafusion-common.

@github-actions github-actions Bot added common Related to common crate physical-plan Changes to the physical-plan crate labels Jul 20, 2026
@codecov-commenter

codecov-commenter commented Jul 20, 2026

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 80.70%. Comparing base (050046a) to head (16319b8).
⚠️ Report is 2 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23730      +/-   ##
==========================================
- Coverage   80.71%   80.70%   -0.01%     
==========================================
  Files        1089     1089              
  Lines      368750   368765      +15     
  Branches   368750   368765      +15     
==========================================
- Hits       297631   297630       -1     
- Misses      53375    53386      +11     
- Partials    17744    17749       +5     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@mzabaluev-flarion
mzabaluev-flarion force-pushed the take-n-offsets branch 3 times, most recently from 9bb1d54 to e8a0f93 Compare July 21, 2026 12:02
A refinement of split_vec_min_alloc for splitting offset buffers.
To improve performance of the current use in `BytesGroupValues::take_n`,
this function adjusts the remaining offsets when they are copied to
the new location, rather than two passes of copying then subtracting
in place. It also accounts for n + 1 length of the resulting
prefix vector, which current split_vec_min_alloc falls on the wrong
side of.
Benchmark the performance of take_n for ByteGroupValueBuilder
in the context of the multi-group-by operation where it is used
in production.
Drop the returned groups from emit(First(n)) in the benchmarked
function, to eliminate accumulating allocation behavior that's not
characteristic of the use sites.
This is faster and could be reused with other GroupColumn
implementations.
/// Allocates for whichever side is smaller, so the new allocation is
/// `min(n + 1, vec.len() - n)`. This matters when the split emits a prefix
/// under memory pressure, where `n` can be close to `vec.len()`.
pub fn take_n_offsets<O>(offsets: &mut Vec<O>, n: usize) -> Vec<O>

@mzabaluev mzabaluev Jul 21, 2026

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I thought of adding this as a method to OffsetBufferBuilder to make the return value correct-by-construction rather than just assuming the offsets can be subtracted the cut-off value from. But that would go against the general push towards Vec in arrow-rs#10245.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The linked issue is over 2 years old and doesn't mention anything about Vec. Did you link the right one?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

That issue is in arrow-rs, thanks, fixed.

Comment thread datafusion/common/src/utils/mod.rs Outdated
Comment thread datafusion/common/src/utils/mod.rs Outdated
mzabaluev and others added 5 commits July 21, 2026 23:14
The loop does n + 1 iterations instead of n,
but who's counting.

Co-authored-by: Trương Hoàng Long <longtruong2411@gmail.com>
Created to test the new function take_n_offsets, but benchmarks for
the similar split_vec_min_alloc can be added later.

@KonaeAkira KonaeAkira 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.

LGTM. Thanks for running benches to refute my theory.

@mzabaluev

Copy link
Copy Markdown
Contributor Author

As seen in the take_n_offsets_large benchmark cases I have added in f80450a, even the n == offsets.len() / 2 - 1 case would in fact have performed better on the split-off branch.
The CPU performance optimum (ignoring memory pressure issues) looks to be somewhere below the half-point.

O: OffsetSizeTrait,
{
let cut_offset = offsets[n];
if n < offsets.len() - n {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Suggested change
if n < offsets.len() - n {
if n <= offsets.len() / 2 {

Why not this?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This also pushes one item above the promise to allocate for the smaller part.

In the take_n_offsets drain-and-shift branch.
The rewritten, safe version vectorizes just as well and performs
the same in the benchmarks.
Comment on lines +431 to +436
let new_len = offsets.len() - n;
// Shift the retained tail down to the front and rebase it to start at 0
// in a single fused pass.
for i in 0..new_len {
offsets[i] = offsets[n + i] - cut_offset;
}

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I have rewritten the loop of contention now.

However, there is a near 100% performance cliff at the threshold point between this and the else branch, for large array lengths that don't fit into cache (for small arrays, there is a smaller bump in the other way). So it's tempting to make the split-off threshold at a lower fraction of the length, at the cost of making larger allocations. I suspect that the real fix, however, would be in implementing something proposed in #19906 and #19649.

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

Labels

common Related to common crate physical-plan Changes to the physical-plan crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants