This is an automated email from the ASF dual-hosted git repository.
Jefffrey pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
The following commit(s) were added to refs/heads/main by this push:
new 34b49efced perf: Use interleave for fragmented zip masks (#10368)
34b49efced is described below
commit 34b49efced33a5c6fce086b69fdc9fa5f0d29620
Author: Johan Vaz <[email protected]>
AuthorDate: Sat Aug 29 14:49:22 2026 +0800
perf: Use interleave for fragmented zip masks (#10368)
# Which issue does this PR close?
- Closes #5097.
# Rationale for this change
`zip` currently uses `MutableArrayData`, which is efficient when a mask
contains long runs but performs one extension per run. Fragmented
array/array masks therefore pay substantial per-run overhead, while the
specialized interleave kernels are faster for this shape.
# What changes are included in this PR?
- Count true runs in large masks and route fragmented array/array inputs
to `interleave`.
- Keep the existing `MutableArrayData` path for inputs shorter than
1,024 rows, scalar inputs, and masks with fewer runs.
- Treat null mask entries as false before dispatch, preserving existing
`zip` semantics.
- Add a contiguous `true_then_false` case to the existing zip benchmark
matrix. The broader direct-interleave benchmark expansion was removed
after review.
The run-count threshold is conservative: interleave is selected only
above one true run per eight rows. Counting the complete bitmap also
avoids sampling errors for masks whose fragmentation is unevenly
distributed.
# Are these changes tested?
Yes. The tests cover dispatch decisions for short, fragmented,
sparse/dense, contiguous, unevenly fragmented, and offset masks, plus
end-to-end fragmented array selection with null mask and input values.
Validation on final head `709a774a`:
- `CARGO_BUILD_JOBS=2 cargo test -p arrow-select --all-features` — 394
unit tests and 17 doctests passed.
- `CARGO_BUILD_JOBS=2 cargo clippy -p arrow-select --all-targets
--all-features -- -D warnings` — passed.
- `CARGO_BUILD_JOBS=2 cargo clippy -p arrow --bench zip_kernels
--features=async,test_utils -- -D warnings` — passed.
- `cargo fmt --all -- --check` — passed.
- `git diff --check` — passed.
Criterion used 30 samples, a 2-second warm-up, and a 5-second
measurement. On 8,192-row array/array inputs with random 50%-true masks,
the existing run path was forced by temporarily raising the local
dispatch threshold; the benchmark-only control was then restored:
| Input | Run path | Adaptive | Improvement |
|---|---:|---:|---:|
| i32 | 76.889 us | 33.676 us | 56.20% |
| short strings | 101.600 us | 69.580 us | 31.52% |
| long strings | 214.880 us | 125.560 us | 41.57% |
| short bytes | 103.680 us | 70.169 us | 32.32% |
| long bytes | 218.090 us | 123.040 us | 43.58% |
| short string views | 85.114 us | 38.201 us | 55.12% |
| longer string views | 84.633 us | 49.308 us | 41.74% |
At the 1,024-row boundary, i32 measured 14.350 us on the forced run path
versus 4.6005 us on the adaptive path. The contiguous `true_then_false`
case remains on the run-oriented path.
# Are there any user-facing changes?
No API or semantic changes. Fragmented array/array `zip` calls use a
faster internal implementation; scalar, short, and contiguous-mask calls
retain the existing path.
# AI assistance disclosure
AI assistance was used to draft parts of the adaptive dispatch, tests,
benchmark changes, and review follow-up. The resulting diff was reviewed
against the `zip`, `MutableArrayData`, `BooleanBuffer`, and `interleave`
implementations; an edge-sampling flaw found during review was replaced
with a complete run count and covered by a regression test. The
validation commands above were run on the final branch head.
---------
Co-authored-by: Jeffrey Vo <[email protected]>
---
arrow-select/src/zip.rs | 131 ++++++++++++++++++++++++++++++++++++++++++-
arrow/benches/zip_kernels.rs | 4 ++
2 files changed, 134 insertions(+), 1 deletion(-)
diff --git a/arrow-select/src/zip.rs b/arrow-select/src/zip.rs
index 14a3dd60a4..c0dcf6fbd9 100644
--- a/arrow-select/src/zip.rs
+++ b/arrow-select/src/zip.rs
@@ -145,6 +145,41 @@ pub fn zip(
zip_impl(mask, &truthy, truthy_is_scalar, &falsy, falsy_is_scalar)
}
+fn count_true_runs(mask: &BooleanBuffer) -> usize {
+ let mut slices = 0;
+ let mut previous = 0;
+ for chunk in mask.bit_chunks().iter_padded() {
+ let starts = chunk & !((chunk << 1) | previous);
+ slices += starts.count_ones() as usize;
+ previous = chunk >> 63;
+ }
+ slices
+}
+
+fn should_use_interleave(mask: &BooleanBuffer) -> bool {
+ const MIN_LEN: usize = 1024;
+
+ // Interleave's fixed dispatch and index construction costs are not
competitive
+ // for small arrays. For larger arrays, use the run count that determines
the
+ // amount of work performed by the MutableArrayData implementation.
+ mask.len() >= MIN_LEN && count_true_runs(mask) > mask.len() / 8
+}
+
+fn interleave_arrays(
+ mask: &BooleanBuffer,
+ truthy: &ArrayData,
+ falsy: &ArrayData,
+) -> Result<ArrayRef, ArrowError> {
+ let truthy = make_array(truthy.clone());
+ let falsy = make_array(falsy.clone());
+ let indices: Vec<_> = mask
+ .iter()
+ .enumerate()
+ .map(|(idx, selected)| (usize::from(!selected), idx))
+ .collect();
+ crate::interleave::interleave(&[truthy.as_ref(), falsy.as_ref()], &indices)
+}
+
fn zip_impl(
mask: &BooleanArray,
truthy: &ArrayData,
@@ -152,6 +187,11 @@ fn zip_impl(
falsy: &ArrayData,
falsy_is_scalar: bool,
) -> Result<ArrayRef, ArrowError> {
+ let mask_buffer = maybe_prep_null_mask_filter(mask);
+ if !truthy_is_scalar && !falsy_is_scalar &&
should_use_interleave(&mask_buffer) {
+ return interleave_arrays(&mask_buffer, truthy, falsy);
+ }
+
let mut mutable = MutableArrayData::new(vec![truthy, falsy], false,
truthy.len());
// the SlicesIterator slices only the true values. So the gaps left by
this iterator we need to
@@ -160,7 +200,6 @@ fn zip_impl(
// keep track of how much is filled
let mut filled = 0;
- let mask_buffer = maybe_prep_null_mask_filter(mask);
for (start, end) in SlicesIterator::from(&mask_buffer) {
// the gap needs to be filled with falsy values
if start > filled {
@@ -866,6 +905,96 @@ mod test {
use super::*;
use arrow_array::types::Int32Type;
+ #[test]
+ fn test_count_true_runs() {
+ let assert_runs = |values: &[bool], expected| {
+ let mask: BooleanBuffer = values.iter().copied().collect();
+ assert_eq!(count_true_runs(&mask), expected, "mask: {values:?}");
+ };
+
+ assert_runs(&[], 0);
+ assert_runs(&[false, false, false], 0);
+ assert_runs(&[true, true, true], 1);
+ assert_runs(&[true, false, true, true, false, true], 3);
+
+ // Exercise runs crossing 64-bit chunk boundaries and trailing padding.
+ let mut values = vec![false; 130];
+ values[0] = true;
+ values[63..66].fill(true);
+ values[128..].fill(true);
+ assert_runs(&values, 3);
+
+ // Exercise a non-zero bit offset, as masks may be sliced.
+ let mut offset_values = vec![false; 135];
+ offset_values[3..133].copy_from_slice(&values);
+ let offset_mask: BooleanBuffer = offset_values.into_iter().collect();
+ assert_eq!(count_true_runs(&offset_mask.slice(3, 130)), 3);
+ }
+
+ #[test]
+ fn test_should_use_interleave() {
+ let short: BooleanBuffer = (0..64).map(|i| i % 2 == 0).collect();
+ assert!(!should_use_interleave(&short));
+
+ let fragmented: BooleanBuffer = (0..8192).map(|i| i % 2 ==
0).collect();
+ assert!(should_use_interleave(&fragmented));
+
+ let long_runs: BooleanBuffer = (0..8192).map(|i| i < 4096).collect();
+ assert!(!should_use_interleave(&long_runs));
+
+ let sparse: BooleanBuffer = (0..8192).map(|i| i % 10 == 0).collect();
+ assert!(!should_use_interleave(&sparse));
+
+ let dense: BooleanBuffer = (0..8192).map(|i| i % 10 != 0).collect();
+ assert!(!should_use_interleave(&dense));
+
+ let fragmented_head: BooleanBuffer = (0..8192).map(|i| i < 256 && i %
2 == 0).collect();
+ assert!(!should_use_interleave(&fragmented_head));
+
+ let fragmented_edges: BooleanBuffer = (0..8192)
+ .map(|i| !(256..7936).contains(&i) && i % 2 == 0)
+ .collect();
+ assert!(!should_use_interleave(&fragmented_edges));
+
+ // Exercise arbitrary bit offsets as masks may be sliced
+ let offset: BooleanBuffer = (0..8195).map(|i| i >= 3 && i % 2 ==
1).collect();
+ assert!(should_use_interleave(&offset.slice(3, 8192)));
+ }
+
+ #[test]
+ fn test_interleave_arrays() {
+ let mask = BooleanArray::from(vec![Some(true), None, Some(true),
Some(false)]);
+ let mask = maybe_prep_null_mask_filter(&mask);
+ let truthy = Int32Array::from(vec![Some(1), None, Some(3),
Some(4)]).to_data();
+ let falsy = Int32Array::from(vec![Some(10), Some(20), None,
Some(40)]).to_data();
+ let expected = Int32Array::from(vec![Some(1), Some(20), Some(3),
Some(40)]);
+
+ let actual = interleave_arrays(&mask, &truthy, &falsy).unwrap();
+ assert_eq!(actual.as_primitive::<Int32Type>(), &expected);
+ }
+
+ #[test]
+ fn test_zip_fragmented_array_mask() {
+ let mask: BooleanArray = (0..8192)
+ .map(|i| match i % 3 {
+ 0 => Some(true),
+ 1 => Some(false),
+ _ => None,
+ })
+ .collect();
+ let truthy: Int32Array = (0..8192).map(|i| (i % 7 !=
0).then_some(i)).collect();
+ let falsy: Int32Array = (0..8192).map(|i| (i % 11 !=
0).then_some(-i)).collect();
+ let expected: Int32Array = (0..8192)
+ .map(|i| {
+ let array = if i % 3 == 0 { &truthy } else { &falsy };
+ array.is_valid(i).then(|| array.value(i))
+ })
+ .collect();
+
+ let actual = zip(&mask, &truthy, &falsy).unwrap();
+ assert_eq!(actual.as_primitive::<Int32Type>(), &expected);
+ }
+
#[test]
fn test_zip_kernel_one() {
let a = Int32Array::from(vec![Some(5), None, Some(7), None, Some(1)]);
diff --git a/arrow/benches/zip_kernels.rs b/arrow/benches/zip_kernels.rs
index c798bee3b6..03f20214a8 100644
--- a/arrow/benches/zip_kernels.rs
+++ b/arrow/benches/zip_kernels.rs
@@ -169,6 +169,10 @@ fn mask_cases(len: usize) -> Vec<(&'static str,
BooleanArray)> {
("99pct_true", create_boolean_array(len, 0.0, 0.99)),
("90pct_true", create_boolean_array(len, 0.0, 0.9)),
("50pct_true", create_boolean_array(len, 0.0, 0.5)),
+ (
+ "true_then_false",
+ BooleanArray::from_iter((0..len).map(|i| i < len / 2)),
+ ),
("10pct_true", create_boolean_array(len, 0.0, 0.1)),
("1pct_true", create_boolean_array(len, 0.0, 0.01)),
("all_false", create_boolean_array(len, 0.0, 0.0)),