Skip to content

perf: Optimize MutableBuffer::extend for exact-size iterators - #11656

Open
neilconway wants to merge 7 commits into
apache:mainfrom
neilconway:neilc/mutable-buffer-extend-exact-size
Open

neilconway wants to merge 7 commits into
apache:mainfrom
neilconway:neilc/mutable-buffer-extend-exact-size

Conversation

@neilconway

@neilconway neilconway commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

  • N/A

Rationale for this change

MutableBuffer::extend checks the remaining capacity before writing each item from the iterator to the buffer. Checking buffer capacity for every item has non-trivial overhead and can also inhibit vectorization. When given an exact-size iterator (size hint returns lower == upper), we can skip the capacity check, which also improves the compiler's ability to vectorize the loop.

The most important in-tree caller of MutableBuffer::extend is MutableArrayData::try_extend, when copying string views and dictionary keys. This optimization initially regressed the string view case by about 3x on ARM, because the new MutableBuffer::extend logic resulted in the compiler inserting a branch on the length of the string that is poorly predicted on data containing a mix of inline and out-of-line strings. x86 didn't regress because it always had a poorly-predicted branch. We address the ARM regression by adjusting the view-copying logic to avoid the branch, which speeds up both ARM and x86. We also add the buffer offset to each view with a shift instead of unpacking and repacking the view, which avoids an x86 slowdown when copying long runs of strings longer than 12 bytes.

In turn, an important in-tree caller of MutableArrayData::try_extend is zip. The two optimizations above improve zip for string views by 15-77% on x86 and 8-21% on ARM, depending on the data distribution. This should in turn help CASE, COALESCE, and NVL2 in DataFusion, for certain shapes and data types.

Along the way, we extend the benchmarks for MutableBuffer::extend to cover more iterator shapes, and extend the MutableArrayData benchmarks to cover more data types. We also revise how data is generated for the zip_kernels and mutable_array benchmarks: the previous approach repeatedly passed the same input data to the functions on every iteration. This allows the branch predictor to do much better than it would on real-world data, which initially hid the ARM regression described above.

Benchmarks, each version run twice in alternating order. "Runs X% apart" marks cases whose runs differed by more than 2%.

  • AMD EPYC Milan (x86)
    • zip_kernels, string views, 8192 rows of 3 to 19 bytes
      • array vs scalar
        • all_true: 28.78 µs → 6.52 µs (−77.3%) (runs 4.4% apart)
        • 99pct_true: 33.37 µs → 10.64 µs (−68.1%) (runs 2.7% apart)
        • 90pct_true: 62.80 µs → 38.76 µs (−38.3%)
        • 50pct_true: 135.0 µs → 107.6 µs (−20.3%)
        • true_then_false: 62.37 µs → 43.84 µs (−29.7%)
        • 10pct_true: 122.1 µs → 99.44 µs (−18.6%) (runs 7.8% apart)
        • 1pct_true: 104.0 µs → 87.08 µs (−16.2%)
        • all_false: 96.82 µs → 80.49 µs (−16.9%)
        • 50pct_nulls: 129.8 µs → 106.3 µs (−18.1%)
      • scalar vs array
        • all_true: 105.1 µs → 89.23 µs (−15.1%)
        • 99pct_true: 111.6 µs → 92.74 µs (−16.9%) (runs 4.2% apart)
        • 90pct_true: 129.9 µs → 104.6 µs (−19.5%) (runs 5.4% apart)
        • 50pct_true: 150.0 µs → 114.6 µs (−23.6%) (runs 3.4% apart)
        • true_then_false: 69.37 µs → 50.29 µs (−27.5%)
        • 10pct_true: 71.28 µs → 45.67 µs (−35.9%)
        • 1pct_true: 39.04 µs → 15.93 µs (−59.2%)
        • all_false: 28.99 µs → 6.69 µs (−76.9%) (runs 2.1% apart)
        • 50pct_nulls: 109.5 µs → 79.76 µs (−27.2%)
      • array vs array
        • all_true: 29.10 µs → 6.86 µs (−76.4%) (runs 2.6% apart)
        • 99pct_true: 35.70 µs → 11.91 µs (−66.6%) (runs 4.2% apart)
        • 90pct_true: 74.77 µs → 47.03 µs (−37.1%) (runs 15.3% apart)
        • 50pct_true: 82.80 µs → 79.33 µs (−4.2%) (runs 3.3% apart)
        • true_then_false: 29.44 µs → 7.13 µs (−75.8%) (runs 4.5% apart)
        • 10pct_true: 73.33 µs → 45.95 µs (−37.3%) (runs 8.7% apart)
        • 1pct_true: 35.27 µs → 11.58 µs (−67.2%)
        • all_false: 29.06 µs → 7.11 µs (−75.5%) (runs 3.0% apart)
        • 50pct_nulls: 82.67 µs → 80.15 µs (−3.0%) (runs 2.8% apart)
    • mutable_array
      • mutable str_view 1024: 334.2 µs → 252.8 µs (−24.3%) (runs 5.9% apart)
      • mutable str_view short slices 1024: 19.01 µs → 16.88 µs (−11.2%)
      • mutable dict 1024: 56.49 µs → 47.86 µs (−15.3%) (runs 3.0% apart)
      • mutable dict short slices 1024: 18.99 µs → 16.29 µs (−14.2%)
    • mutable_buffer_extend
      • chunks of 16 items on average
        • copied u8: 31.80 µs → 24.55 µs (−22.8%)
        • map u8: 37.25 µs → 29.40 µs (−21.1%)
        • map i32: 34.87 µs → 28.24 µs (−19.0%) (runs 4.6% apart)
        • map u128: 39.23 µs → 36.36 µs (−7.3%)
        • range i32: 31.60 µs → 24.27 µs (−23.2%) (runs 4.3% apart)
        • repeat_n u32: 31.68 µs → 22.77 µs (−28.1%)
        • chain i32: 55.05 µs → 42.48 µs (−22.8%)
        • filter i32: 32.92 µs → 33.11 µs (+0.6%)
      • chunks of 1024 items on average
        • copied u8: 0.95 µs → 0.84 µs (−11.1%) (runs 9.7% apart)
        • map u8: 1.41 µs → 1.06 µs (−24.8%) (runs 10.3% apart)
        • map i32: 3.61 µs → 3.59 µs (−0.6%) (runs 3.0% apart)
        • map u128: 24.37 µs → 19.48 µs (−20.1%)
        • range i32: 2.65 µs → 2.60 µs (−2.0%) (runs 6.4% apart)
        • repeat_n u32: 2.63 µs → 2.64 µs (+0.4%) (runs 6.2% apart)
        • chain i32: 24.43 µs → 16.70 µs (−31.6%) (runs 2.7% apart)
        • filter i32: 18.49 µs → 18.60 µs (+0.6%)
  • Apple M4 Max (ARM)
    • zip_kernels, string views, 8192 rows of 3 to 19 bytes
      • array vs scalar
        • all_true: 4.31 µs → 3.51 µs (−18.6%)
        • 99pct_true: 7.14 µs → 6.07 µs (−15.0%) (runs 2.2% apart)
        • 90pct_true: 23.53 µs → 20.41 µs (−13.3%) (runs 3.5% apart)
        • 50pct_true: 63.17 µs → 55.61 µs (−12.0%)
        • true_then_false: 24.94 µs → 19.71 µs (−21.0%)
        • 10pct_true: 53.17 µs → 43.69 µs (−17.8%)
        • 1pct_true: 49.02 µs → 39.02 µs (−20.4%)
        • all_false: 45.40 µs → 35.68 µs (−21.4%)
        • 50pct_nulls: 59.53 µs → 50.41 µs (−15.3%)
      • scalar vs array
        • all_true: 48.88 µs → 39.62 µs (−19.0%)
        • 99pct_true: 52.48 µs → 43.02 µs (−18.0%)
        • 90pct_true: 56.97 µs → 47.94 µs (−15.8%)
        • 50pct_true: 67.46 µs → 60.21 µs (−10.7%)
        • true_then_false: 27.88 µs → 22.77 µs (−18.3%)
        • 10pct_true: 27.66 µs → 24.19 µs (−12.5%)
        • 1pct_true: 10.58 µs → 9.71 µs (−8.2%)
        • all_false: 4.35 µs → 3.55 µs (−18.5%)
        • 50pct_nulls: 46.12 µs → 40.06 µs (−13.1%)
      • array vs array
        • all_true: 4.40 µs → 3.62 µs (−17.9%)
        • 99pct_true: 7.49 µs → 6.38 µs (−14.9%)
        • 90pct_true: 24.45 µs → 21.27 µs (−13.0%)
        • 50pct_true: 48.94 µs → 49.48 µs (+1.1%)
        • true_then_false: 4.48 µs → 3.66 µs (−18.3%)
        • 10pct_true: 24.16 µs → 21.32 µs (−11.7%)
        • 1pct_true: 7.47 µs → 6.37 µs (−14.7%)
        • all_false: 4.39 µs → 3.60 µs (−18.0%)
        • 50pct_nulls: 48.86 µs → 49.85 µs (+2.0%)
    • mutable_array
      • mutable str_view 1024: 179.6 µs → 142.9 µs (−20.5%)
      • mutable str_view short slices 1024: 10.44 µs → 8.34 µs (−20.2%) (runs 4.5% apart)
      • mutable dict 1024: 36.08 µs → 31.81 µs (−11.8%)
      • mutable dict short slices 1024: 11.97 µs → 8.03 µs (−32.9%) (runs 8.4% apart)
    • mutable_buffer_extend
      • chunks of 16 items on average
        • copied u8: 11.82 µs → 12.10 µs (+2.4%) (runs 12.3% apart)
        • map u8: 20.12 µs → 8.92 µs (−55.7%) (runs 8.8% apart)
        • map i32: 19.56 µs → 12.15 µs (−37.9%) (runs 25.3% apart)
        • map u128: 21.02 µs → 14.22 µs (−32.4%) (runs 8.0% apart)
        • range i32: 17.74 µs → 11.20 µs (−36.9%) (runs 20.8% apart)
        • repeat_n u32: 18.11 µs → 9.94 µs (−45.1%) (runs 27.5% apart)
        • chain i32: 31.15 µs → 24.28 µs (−22.1%) (runs 2.5% apart)
        • filter i32: 17.88 µs → 18.85 µs (+5.4%) (runs 4.2% apart)
      • chunks of 1024 items on average
        • copied u8: 0.56 µs → 0.53 µs (−6.0%) (runs 13.2% apart)
        • map u8: 0.77 µs → 0.54 µs (−29.6%) (runs 11.7% apart)
        • map i32: 2.18 µs → 2.02 µs (−7.4%) (runs 25.1% apart)
        • map u128: 12.02 µs → 9.76 µs (−18.8%)
        • range i32: 1.79 µs → 1.82 µs (+1.4%) (runs 7.0% apart)
        • repeat_n u32: 1.73 µs → 1.73 µs (−0.1%) (runs 9.2% apart)
        • chain i32: 14.68 µs → 12.72 µs (−13.3%) (runs 3.7% apart)
        • filter i32: 9.04 µs → 9.36 µs (+3.5%) (runs 15.4% apart)

What changes are included in this PR?

  • Optimize MutableBuffer::extend for exact size iterators; add tests for buggy iterator hints
  • Optimize MutableArrayData::try_extend when copying string views
  • Extend benchmarks for MutableBuffer and MutableArrayData
  • Revise data generation for zip and MutableArrayData benchmarks to make branch prediction behavior more realistic

Are these changes tested?

Yes; existing tests pass, new tests added.

Are there any significant user-facing changes?

When MutableBuffer::extend is passed an iterator whose size hint reports an exact length (the lower bound equals the upper bound) but that actually yields more items than the hint reports, the extra items are silently dropped. Previously, those items were appended. I think this is acceptable: the iterator's size hint is buggy, and including the extra items is not clearly better than omitting them.

AI usage

Claude Code (Opus 5.5), reviewed with Codex (Astra 6.1).

…ata` benchmarks, with new inputs each iteration

`MutableBuffer::extend` had one benchmark, which copies `u32` slices.
Add an arrow-buffer benchmark that appends 32Ki items, one `extend` call
per chunk, with chunks of 16 or 1024 items on average. It covers these
iterator shapes:

- copied slices of `u8`, and mapped slices of `u8`, `i32` and `u128`.
- an `i32` range.
- `repeat_n`.
- a `Chain` of two slices.
- a `Filter`, whose size hint, unlike the others', is not exact.

`mutable_array` only covered `Utf8`. Add `Utf8View` and dictionary
cases, with the existing long random slices and with slices of 1 to 8
rows. Every case now starts with room for all the rows it copies, so its
time goes to the copies rather than to growing the buffers from 5 rows.

`zip_kernels` and `mutable_array` ran every iteration on the same input.
The CPU's branch predictor then learns that input's exact sequence of
branch outcomes, such as which string views are stored inline or how
long each copied run is. A query sees each batch once, so it never gets
that benefit, and code that branches on the data looks faster than it
would be in a query.

On an AMD EPYC Milan server, zipping two arrays of 8192 string views of
3 to 19 bytes with an all-true mask (one copy, with a branch per view on
whether it is inline) took about 3 branch misses per call when every
call used the same arrays. Cycling through 32 or more pairs of arrays
raised that to about 3,400, and the call took 3.4x as long. The
predictor memorized up to 32Ki to 64Ki views, depending on the build,
and almost none at 128Ki. With lists of 1024 slices of 1 to 8 rows, it
memorized up to 8 lists; from 32 lists on, it missed about once per
slice.

Every data input now changes each iteration, cycling through:

- `zip_kernels`: 64 arrays for each array input and 64 masks for each
  random mask case (512Ki rows).
- `mutable_array`: 64 arrays and 64 lists of slices.
- `mutable_buffer_extend`: 16 splits of the items into chunks, and 16
  arrays of 32Ki items for each case that reads input data (512Ki
  items).

Scalars and the constant masks stay the same on every iteration, as they
would across the batches of a query.

`mutable_buffer_extend` chunks have random lengths, 1 to 31 or 1 to
2047 items. With fixed lengths, the predictor would learn when each
call's loop ends, and a loop that handles several items per pass would
never have items left over.
`MutableBuffer::extend` checks the remaining capacity before writing
each item, then pushes any items left once the capacity runs out. When
the iterator's size hint gives an exact length, reserve that many items
and zip the iterator with `0..len` instead. For slice iterators and
other iterators that support random access, `zip` then walks both sides
by index, and the compiler can often vectorize the loop. Iterators
without an exact size hint, such as `Filter`, keep the existing loop.

The new loop sets the buffer's length from the loop index after each
item. Adding the item size to the length instead made the compiler keep
a running total in vector registers: on x86 the vectorized loop then
handled half as many `i32` items per pass, and 1024-item calls on
mapped `i32` slices were 38% to 45% slower than before.

`MutableArrayData` copies string views and dictionary keys through this
method, and so do `BufferBuilder::append_n` and collecting into a
`MutableBuffer`. Its string view copy returned inline views (12 bytes
or fewer) unchanged and added the buffer offset to the others. On an
Apple M4 Max, the new loop turned that choice into a branch per view,
which the CPU often guesses wrong when short and long strings are
mixed, and zipping string view arrays took up to 3.3x as long as
before. The copy now adds 0 to inline views instead, so every view
takes the same path. The existing loop already had that branch on x86,
so view copies get faster there too.

Benchmarks, each version run twice in alternating order. Changes are
against the benchmark commit. The two runs of a version were within 2%
of each other for over 90% of the cases; larger gaps that matter for a
result are noted.

- AMD EPYC Milan:
  - `zip_kernels` string views: -16% to -69%. With an all-true or
    all-false mask, a zip took 9 µs instead of 28 µs, and its branch
    misses per 8192 views fell from 3,464 to 13. The two 50% masks
    with arrays on both sides, which `zip` copies with `interleave`,
    are -2%. `Utf8` is within 1.5%.
  - `mutable_array`: `str_view` -18% with short slices and +12% with
    long slices, whose strings of 0 to 399 bytes are almost all longer
    than 12 bytes, so the CPU always guessed the branch right. `dict`
    -10% with long slices and -14% with short ones. `Utf8` unchanged:
    with nulls, one run without this change took 29 ms, while the
    other three, and four more runs of each version made separately,
    took 35.5 to 37 ms.
  - `mutable_buffer_extend`: calls of 16 items -9% to -31%, except
    `Filter` +2%. Calls of 1024 items: mapped slices -5% (`i32`, with
    runs 5% apart) to -21%, copied `u8` -6%, the others within 3%.
- Apple M4 Max:
  - `zip_kernels` string views: -3% to -22%. The two 50% masks with
    arrays on both sides are within 1%. `Utf8` is within 2.1%.
  - `mutable_array`: `str_view` -17% with short slices and -6% with long
    slices. `dict` -14% with long slices and -31% with short ones.
    `Utf8` unchanged.
  - `mutable_buffer_extend`: calls of 16 items -22% to -57%, except
    copied `u8` (-5%, with runs 14% apart) and `Filter` (+11%, 0.4 to
    1.4 ns per call; slower in every run with this change, including
    earlier runs of it). Calls of 1024 items: mapped `u8` -31%, mapped
    `u128` -19%, `Chain` -9% (with runs 6% apart), the others within 5%
    except `Filter`, whose runs were up to 24% apart.

An iterator whose size hint reports an exact length but that yields
more items now has the extra items dropped; before, they were appended.
Such an iterator breaks the `Iterator::size_hint` contract. An iterator
that yields fewer items than it reports still has all of them appended.

A lower bound whose size in bytes overflows `usize` is now ignored.
Before, the multiplication panicked in debug builds and wrapped in
release builds.
@github-actions github-actions Bot added the arrow-core Core crates related to representing arrow data label Oct 17, 2026

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

Thanks @neilconway. Cycling through inputs in the benchmarks makes the branch-prediction results much easier to trust.

Comment thread arrow-buffer/src/buffer/mutable.rs
@@ -226,13 +226,12 @@ fn build_extend_view(array: &ArrayData, buffer_offset: u32) -> Extend<'_> {
mutable
.buffer1
.extend(views[start..start + len].iter().map(|v| {

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.

Do you know where the extra time goes in mutable str_view 1024 on x86? The description shows 318.2 us to 356.8 us (+12.1%) there, while the other x86 mutable_array cases improve. On an M5 Max I see the same benchmark improve by 3.6% (168.2 us to 162.1 us). This is the long-slice case with strings of 0 to 399 bytes. Few of those strings are inline, so the old branch was well predicted. If the cost comes from the branchless select, it would help to know before trading it for the gains in the short-slice cases.

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.

Yeah, this is a good call out; the branchless view copy is indeed the culprit here. I had Claude explore a bunch of alternatives and it seems that rather than unpacking and repacking the view, we can use this trick instead:

--- a/arrow-data/src/transform/mod.rs
+++ b/arrow-data/src/transform/mod.rs
@@ -20,7 +20,7 @@
 //! Provides utilities for creating, manipulating, and converting Arrow arrays
 //! made of primitive types, strings, and nested types.

-use super::{ArrayData, ArrayDataBuilder, ByteView, data::new_buffers};
+use super::{ArrayData, ArrayDataBuilder, data::new_buffers};
 use crate::bit_mask::set_bits;
 use arrow_buffer::buffer::{BooleanBuffer, NullBuffer};
 use arrow_buffer::{ArrowNativeType, Buffer, IntervalMonthDayNano, MutableBuffer, bit_util, i256};
@@ -229,9 +229,10 @@ fn build_extend_view(array: &ArrayData, buffer_offset: u32) -> Extend<'_> {
                     // A view of 12 bytes or fewer stores the string inline and
                     // has no buffer index, so adding 0 leaves it unchanged.
                     let offset = if *v as u32 > 12 { buffer_offset } else { 0 };
-                    let mut view = ByteView::from(*v);
-                    view.buffer_index += offset;
-                    u128::from(view)
+                    // The buffer index is bits 64 to 95 of a view. The new index
+                    // is less than the number of data buffers, so the sum never
+                    // carries into the bits above it.
+                    v + ((offset as u128) << 64)

That results in turning the x86 regression into a nice win (~20%) on both x86 and ARM.

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.

the branchless view copy is indeed the culprit here. I had Claude explore a bunch of alternatives and it seems that rather than unpacking and repacking the view, we can use this trick instead

The codegen agrees with that. I built arrow-data with the release profile for x86_64-unknown-linux-gnu (rustc 1.99.0). At e2eecf1, the repack sends the high half of each view through an SSE register to clear the old buffer index:

movq    xmm1, qword ptr [r8 + 8]       # high 64 bits of the view
...
add     r11d, dword ptr [r8 + 8]       # new buffer index
pand    xmm1, xmm0                     # clear the old buffer index
movq    rbx, xmm1
or      rbx, r11                       # merge in the new one

At 2edf0bb, the offset is added straight into the high word from memory. There is no SSE round trip, and LLVM unrolls the loop by two:

cmp     r15d, 13
mov     r12d, 0
cmovae  r12d, r11d                     # buffer_offset, or 0 for an inline view
add     r12, qword ptr [r8 + r14 - 8]  # high 64 bits + offset

On aarch64 the and / add w / orr repack becomes a single add x13, x15, x13. That takes the loop from 11 to 9 instructions per view.

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.

Thanks for checking this!

Comment thread arrow-data/src/transform/mod.rs Outdated
`MutableArrayData` adds a buffer offset to the buffer index of each
string view it copies, and adds 0 to views of inline strings. It
unpacked each view into a `ByteView`, added the offset to the
`buffer_index` field, and packed the fields back into a `u128`. Now it
shifts the offset to the position of the buffer index, bits 64 to 95,
and adds it to the whole view. The new index is less than the number of
data buffers, so the sum never carries into the bits above it.

After the previous commit, x86 copies of long runs of views that all
hold buffer indices were up to 18% slower than before it. A null-free
column of strings longer than 12 bytes has only such views. The shifted
add makes those copies 12% to 23% faster than before the previous
commit.

Benchmarks on an AMD EPYC Milan server, each version run twice in
alternating order. Changes are against the benchmark commit, then
against the previous commit:

- `mutable_array` `str_view` with long slices, whose strings of 0 to
  399 bytes are almost all longer than 12 bytes: -23% (the previous
  commit: +10%), -30% against the previous commit. With short slices:
  -12%, +3% against the previous commit.
- `zip_kernels` string views, two arrays: -36% to -77%, and -27% to +1%
  against the previous commit. The exception is 10% true rows, where one
  of the two runs of this commit was 11% slower than the other; three
  more runs of each version put this commit 1% faster than the previous
  one.
- `merge` of null-free string view arrays, with a benchmark that is not
  part of this change: every case is 10% to 81% faster. The seven cases
  that the previous commit made 7.5% to 18% slower, copies of 13- to
  40-byte strings in long runs, are 12% to 16% faster. Against the
  previous commit: -27% to +2.6%.
- `take` on lists of 0 to 8 string views, also with a benchmark that is
  not part of this change: -38%, +2.4% against the previous commit.
When an iterator's size hint reports an exact length,
`MutableBuffer::extend` appends at most that many items, so it drops the
extra items of an iterator whose size hint is wrong. The `Extend` and
`FromIterator` impls of `MutableBuffer` and `BufferBuilder` all append
this way, and their docs now say so.

The string view copy compares a view's length with
`MAX_INLINE_VIEW_LEN` instead of a literal 12, as `ByteView` does.
@neilconway

Copy link
Copy Markdown
Contributor Author

Thanks for the review, @mbutrovich !

@mbutrovich

Copy link
Copy Markdown
Contributor

Thanks for the review, @mbutrovich !

Sure thing, I'll take another pass today.

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

This LGTM, thanks @neilconway!

@Rich-T-kid Rich-T-kid 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.

@neilconway a large portion of this PR seems to be benchmarks. Could you split this into a separate PR?

@neilconway

Copy link
Copy Markdown
Contributor Author

@neilconway a large portion of this PR seems to be benchmarks. Could you split this into a separate PR?

Sure, done in #11742 @Rich-T-kid

The comment on the shifted add said that "the new index is less than
the number of data buffers", without saying which index or why that
rules out a carry. It now says that adding `buffer_offset` gives the
string's buffer index in the output, which fits in 32 bits:
`MutableArrayData::with_capacities` panics if the total number of data
buffers in its input arrays doesn't fit in 32 bits.

The comment on the length check now speaks of a view of a string of 12
bytes or fewer, since every view is 16 bytes.
Jefffrey pushed a commit to mbutrovich/arrow-rs that referenced this pull request Oct 23, 2026
…e#11742)

# Which issue does this PR close?

- N/A. Split out of apache#11656, which uses these benchmarks.

# Rationale for this change

Extend the benchmarks for `MutableBuffer::extend` to cover more iterator
shapes, and extend the `MutableArrayData` benchmarks to cover more data
types. We also revise how data is generated for the `zip_kernels` and
`mutable_array` benchmarks: the previous approach repeatedly passed the
same input data to the functions on every iteration. This allows the
branch predictor to do much better than it would on real-world data,
which initially hid the ARM regression described above.

These improvements are intended to evaluate apache#11656 more accurately.

# What changes are included in this PR?

- Add an arrow-buffer `mutable_buffer_extend` benchmark that appends
32Ki items, one `extend` call per chunk, with chunks of random length
(16 or 1024 items on average). It covers copied and mapped slices, an
`i32` range, `repeat_n`, a `Chain` of two slices, and a `Filter`, whose
size hint is not exact.
- Add `Utf8View` and dictionary cases to `mutable_array`, with the
existing long random slices and with slices of 1 to 8 rows. Every case
now starts with room for all the rows it copies, so its time goes to the
copies rather than to growing the buffers.
- Cycle `zip_kernels`, `mutable_array` and `mutable_buffer_extend`
through 16 to 64 inputs instead of reusing one. Scalars and the constant
masks stay the same, as they would across the batches of a query.

# Are these changes tested?

Benchmark-only change.

# Are there any significant user-facing changes?

No.

# AI usage

Claude Code (Opus 5.5), reviewed with Codex (Astra 6.1).

Co-authored-by: Ed Seidl <etseidl@users.noreply.github.com>
@Rich-T-kid

Copy link
Copy Markdown
Contributor

run benchmark mutable_buffer_extend

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark starting (GKE) | trigger

Target: mutable_buffer_extend

Sharding factor: 1 (1 workers).

Comparing neilc/mutable-buffer-extend-exact-size (be88f77) to df4dc3e (merge-base) diff

Run configuration
run benchmark mutable_buffer_extend
shards: 1

Results will be posted when all workers finish.


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark completed (GKE) | trigger

Target: mutable_buffer_extend

Comparing neilc/mutable-buffer-extend-exact-size (be88f77) to df4dc3e (merge-base) diff

Run configuration
run benchmark mutable_buffer_extend
shards: 1
Details
group                                     base                                   changed
-----                                     ----                                   -------
MutableBuffer extend/chain i32/1024       1.00     22.4±0.02µs        ? ?/sec    1.13     25.3±0.01µs        ? ?/sec
MutableBuffer extend/chain i32/16         1.10     50.1±0.07µs        ? ?/sec    1.00     45.7±0.03µs        ? ?/sec
MutableBuffer extend/copied u8/1024       1.02    949.3±1.25ns        ? ?/sec    1.00    931.9±7.09ns        ? ?/sec
MutableBuffer extend/copied u8/16         1.27     30.1±0.04µs        ? ?/sec    1.00     23.8±0.02µs        ? ?/sec
MutableBuffer extend/filter i32/1024      1.00     16.9±0.01µs        ? ?/sec    1.00     16.9±0.02µs        ? ?/sec
MutableBuffer extend/filter i32/16        1.00     33.2±0.04µs        ? ?/sec    1.03     34.3±0.07µs        ? ?/sec
MutableBuffer extend/map i32/1024         1.00      2.7±0.21µs        ? ?/sec    1.04      2.8±0.14µs        ? ?/sec
MutableBuffer extend/map i32/16           1.59     36.8±0.03µs        ? ?/sec    1.00     23.1±0.05µs        ? ?/sec
MutableBuffer extend/map u128/1024        1.42     27.3±0.14µs        ? ?/sec    1.00     19.2±0.23µs        ? ?/sec
MutableBuffer extend/map u128/16          1.19     38.5±0.17µs        ? ?/sec    1.00     32.3±0.16µs        ? ?/sec
MutableBuffer extend/map u8/1024          1.12   1107.9±2.44ns        ? ?/sec    1.00   991.0±10.52ns        ? ?/sec
MutableBuffer extend/map u8/16            1.40     38.1±0.03µs        ? ?/sec    1.00     27.3±0.02µs        ? ?/sec
MutableBuffer extend/range i32/1024       1.00      3.4±0.00µs        ? ?/sec    3.48     11.8±0.01µs        ? ?/sec
MutableBuffer extend/range i32/16         1.23     28.8±0.02µs        ? ?/sec    1.00     23.3±0.03µs        ? ?/sec
MutableBuffer extend/repeat_n u32/1024    1.06   1704.3±1.75ns        ? ?/sec    1.00   1607.0±1.19ns        ? ?/sec
MutableBuffer extend/repeat_n u32/16      1.22     26.4±0.02µs        ? ?/sec    1.00     21.6±0.05µs        ? ?/sec

Per-runner information
mutable_buffer_extend — shard 1/1

Node: gk3-benchmark-cluster-nap-m22gfx50-6285b8fc-jhdt

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

uname:

Linux bench-c6367512032-3794-n95z2 6.12.105+ #1 SMP Sat Sep  5 07:54:11 UTC 2026 aarch64 GNU/Linux

BENCH_COMMAND:

cargo bench --features=arrow,async,test_common,experimental,object_store --bench mutable_buffer_extend
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Resource Usage

base (merge-base)

Metric Value
Wall time 155.0s
Peak memory 21.3 MiB
Avg memory 20.2 MiB
CPU user 153.0s
CPU sys 0.0s
Peak spill 0 B

branch

Metric Value
Wall time 150.0s
Peak memory 21.3 MiB
Avg memory 20.2 MiB
CPU user 146.2s
CPU sys 0.0s
Peak spill 0 B

File an issue against this benchmark runner

@Rich-T-kid
Rich-T-kid self-requested a review October 23, 2026 15:26
@neilconway

neilconway commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

Interesting bot results, with a significant regression on one of the extend range cases. The bot uses Rust 1.98.1, which is a bit surprising, but I can repro the regression locally using 1.98.1, whereas 1.99.0 and 1.99.1 are fine and don't regress.

This branch has not been deployed

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

Labels

arrow-core Core crates related to representing arrow data performance

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants