This is an automated email from the ASF dual-hosted git repository.

alamb 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 b5794fbda0 perf(parquet): avoid redundant copies in mask-backed 
intersection/union (#10446)
b5794fbda0 is described below

commit b5794fbda02c45675e6ce83c916dae70c5df477a
Author: Huaijin <[email protected]>
AuthorDate: Sat Aug 15 00:16:21 2026 +0800

    perf(parquet): avoid redundant copies in mask-backed intersection/union 
(#10446)
    
    # Which issue does this PR close?
    
    - Closes #10425.
    
    # Rationale for this change
    
    The bitwise `intersection`/`union` path, taken when both operands are
    mask-backed, does avoidable copying in two places:
    
    - **Unequal lengths** allocate twice — once for the result over the
    common prefix, then again for a `BooleanBufferBuilder` that appends that
    prefix and the longer side's tail.
    - **Equal lengths** go through `BitAnd`/`BitOr` on `&BooleanBuffer`,
    which normalise the result to a zero bit offset. That normalisation is a
    second allocation plus a shifting copy of the whole mask, and it happens
    whenever `from_bitwise_binary_op` returns a non-zero offset — i.e. when
    both operands share a non-zero sub-64-bit alignment.
    
    Masks here come from `BooleanBuffer::slice`, so a non-zero offset is the
    ordinary case.
    
    # What changes are included in this PR?
    
    Unequal lengths now copy the longer mask once and apply `&=`/`|=` in
    place over the common prefix, leaving the tail where it already is.
    Equal lengths build the result directly with
    `BooleanBuffer::from_bitwise_binary_op`, keeping whatever offset it
    produces. Both keep the sub-byte offset rather than re-aligning, and
    consumers are already offset-aware.
    
    Only the longer mask's own byte range is copied, so a result derived
    from a small slice of a large buffer does not retain the large
    allocation.
    
    The uneven path is also what motivated #10444 — it reads the bits past
    the common prefix back out, so it relies on the in-place op leaving them
    alone.
    
    # Are these changes tested?
    
    `test_mask_algebra_with_offsets` sweeps a 7×7 offset grid against twelve
    length pairs, equal and unequal; `test_mask_algebra_fuzz` runs 200
    randomized rounds biased towards equal lengths;
    `test_mask_algebra_does_not_retain_backing_buffer` covers the
    allocation-retention case above. All compare against a bit-by-bit
    reference. `cargo test -p parquet --all-features` passes (1305 tests);
    clippy and fmt clean.
    
    This PR also adds `mask_intersection`/`mask_union` to
    `parquet/benches/row_selector.rs`, since the existing
    `intersection`/`union` benchmarks build operands with `from_filters` and
    never reach this path.
    
    ```
    CARGO_PROFILE_BENCH_CODEGEN_UNITS=1 cargo bench -p parquet --bench 
row_selector -- 'mask_intersection|mask_union'
    ```
    
    3M rows, ~1/3 density. Rows are the operand length ratio, columns are
    the `(left, right)` bit offsets the operands carry. Whether the two
    offsets agree mod 64 decides which path `from_bitwise_binary_op` takes,
    so both are covered.
    
    Speedup (`main` / this PR), as `intersection` / `union`:
    
    | | `(0, 0)` | `(3, 3)` | `(3, 67)` | `(3, 5)` |
    | --- | --- | --- | --- | --- |
    | equal | 1.00 / 1.09 | **2.23 / 2.12** | **2.09 / 2.24** | **3.55 /
    3.26** |
    | tail1 | 1.18 / 1.17 | **5.90 / 5.96** | **6.03 / 5.98** | **5.15 /
    4.55** |
    | tail1of3 | 1.22 / 1.21 | **5.68 / 5.69** | **5.74 / 5.38** | **5.03 /
    4.30** |
    | tail_most | 1.49 / 1.51 | **2.75 / 2.60** | **2.77 / 2.71** | **2.63 /
    2.69** |
    
    `equal` at `(0, 0)` is the one case with nothing to save: the operands
    are already aligned, so the old code never paid for normalisation, and
    old and new produce identical buffers. Its confidence intervals overlap
    (11.3±0.21µs vs 11.3±0.28µs, 10.0±0.92µs vs 10.9±0.86µs).
    
    `codegen-units=1` because the default bench profile is noisy here: the
    shifting path is sensitive to codegen-unit partitioning, enough to move
    these numbers by ±10% in either direction.
    
    # Are there any user-facing changes?
    
    No public signature changes and no change to which rows a selection
    selects. The underlying mask layout can differ, though:
    `RowSelection::as_mask()` is public, and where equal-length
    `intersection`/`union` previously always returned a zero-offset
    `BooleanBuffer`, the result may now carry a non-zero offset, which is
    observable through `values()`, `inner()` and `ptr_eq()`. `as_mask()`
    does not promise a normalised layout and callers must already honour
    `BooleanBuffer::offset()`, so this should not be a breaking change.
    
    ---------
    
    Co-authored-by: Andrew Lamb <[email protected]>
---
 .../src/arrow/arrow_reader/selection/algebra.rs    | 220 ++++++++++++++++++---
 1 file changed, 191 insertions(+), 29 deletions(-)

diff --git a/parquet/src/arrow/arrow_reader/selection/algebra.rs 
b/parquet/src/arrow/arrow_reader/selection/algebra.rs
index ce44882858..3d11a13540 100644
--- a/parquet/src/arrow/arrow_reader/selection/algebra.rs
+++ b/parquet/src/arrow/arrow_reader/selection/algebra.rs
@@ -23,7 +23,7 @@
 //! [`BooleanBuffer`] masks.
 
 use super::{MaskRunIter, RowSelection, RowSelectionInner, RowSelector};
-use arrow_buffer::{BooleanBuffer, BooleanBufferBuilder};
+use arrow_buffer::{BooleanBuffer, BooleanBufferBuilder, MutableBuffer, 
bit_util};
 use std::cmp::Ordering;
 use std::iter::Peekable;
 
@@ -269,39 +269,76 @@ pub(super) fn union_row_selections(left: &[RowSelector], 
right: &[RowSelector])
 /// Bitwise AND of two mask-backed selections. Longer side's tail passes 
through.
 pub(super) fn intersect_masks(l: &BooleanBuffer, r: &BooleanBuffer) -> 
BooleanBuffer {
     if l.len() == r.len() {
-        return l & r;
-    }
-    let common = l.len().min(r.len());
-    let head = &l.slice(0, common) & &r.slice(0, common);
-    let (longer, longer_len) = if l.len() > r.len() {
-        (l, l.len())
-    } else {
-        (r, r.len())
-    };
-    let tail = longer.slice(common, longer_len - common);
-    let mut builder = BooleanBufferBuilder::new(longer_len);
-    builder.append_buffer(&head);
-    builder.append_buffer(&tail);
-    builder.finish()
+        return combine_equal_length_masks(l, r, |a, b| a & b);
+    }
+    combine_unequal_length_masks(l, r, |a, b| a & b)
 }
 
 /// Bitwise OR of two mask-backed selections. Longer side's tail passes 
through.
 pub(super) fn union_masks(l: &BooleanBuffer, r: &BooleanBuffer) -> 
BooleanBuffer {
     if l.len() == r.len() {
-        return l | r;
-    }
-    let common = l.len().min(r.len());
-    let head = &l.slice(0, common) | &r.slice(0, common);
-    let (longer, longer_len) = if l.len() > r.len() {
-        (l, l.len())
-    } else {
-        (r, r.len())
-    };
-    let tail = longer.slice(common, longer_len - common);
-    let mut builder = BooleanBufferBuilder::new(longer_len);
-    builder.append_buffer(&head);
-    builder.append_buffer(&tail);
-    builder.finish()
+        return combine_equal_length_masks(l, r, |a, b| a | b);
+    }
+    combine_unequal_length_masks(l, r, |a, b| a | b)
+}
+
+/// Combines two masks of equal length with the bitwise operation `op`.
+///
+/// `BitAnd`/`BitOr` on `&BooleanBuffer` normalise the result to a zero bit 
offset,
+/// which costs a second allocation and a shifting copy when both operands
+/// share the same non-zero sub-64-bit alignment, causing
+/// `from_bitwise_binary_op` to return a non-zero-offset result. Building the
+/// buffer directly keeps the offset, as the unequal-length path does.
+fn combine_equal_length_masks<F>(l: &BooleanBuffer, r: &BooleanBuffer, op: F) 
-> BooleanBuffer
+where
+    F: FnMut(u64, u64) -> u64,
+{
+    BooleanBuffer::from_bitwise_binary_op(
+        l.values(),
+        l.offset(),
+        r.values(),
+        r.offset(),
+        l.len(),
+        op,
+    )
+}
+
+/// Combines two masks of unequal length with the bitwise operation `op`,
+/// passing the longer side's tail through unchanged.
+///
+/// The longer mask is copied once into a [`MutableBuffer`] and `op` is then
+/// applied in place over the common prefix. This avoids materialising the
+/// prefix into its own buffer and copying both prefix and tail again through a
+/// [`BooleanBufferBuilder`].
+///
+/// Neither the mask offsets nor the prefix length are assumed to be byte
+/// aligned: the copy keeps the longer mask's offset within its first byte so 
it
+/// stays a plain byte copy, and that offset is carried over to the result. 
Only
+/// the longer mask's own byte range is copied, so the result does not retain 
the
+/// backing allocation it was sliced from.
+fn combine_unequal_length_masks<F>(l: &BooleanBuffer, r: &BooleanBuffer, op: 
F) -> BooleanBuffer
+where
+    F: FnMut(u64, u64) -> u64,
+{
+    let (longer, shorter) = if l.len() > r.len() { (l, r) } else { (r, l) };
+
+    let sub_byte_offset = longer.offset() % 8;
+    let start_byte = longer.offset() / 8;
+    let end_byte = bit_util::ceil(longer.offset() + longer.len(), 8);
+    let bytes = &longer.values()[start_byte..end_byte];
+    let mut buffer = MutableBuffer::new(bytes.len());
+    buffer.extend_from_slice(bytes);
+
+    bit_util::apply_bitwise_binary_op(
+        buffer.as_slice_mut(),
+        sub_byte_offset,
+        shorter.values(),
+        shorter.offset(),
+        shorter.len(),
+        op,
+    );
+
+    BooleanBuffer::new(buffer.into(), sub_byte_offset, longer.len())
 }
 
 /// Applies `other` to the selected rows of `mask`, preserving the original 
row domain.
@@ -825,4 +862,129 @@ mod tests {
         let bits: Vec<bool> = (0..5).map(|i| r_mask.value(i)).collect();
         assert_eq!(bits, vec![true, true, false, false, true]);
     }
+
+    /// Expected result of combining two masks of possibly differing lengths:
+    /// `op` over the common prefix, then the longer side's tail unchanged.
+    fn expected_combined(l: &[bool], r: &[bool], op: fn(bool, bool) -> bool) 
-> Vec<bool> {
+        let common = l.len().min(r.len());
+        let longer = if l.len() > r.len() { l } else { r };
+        (0..common)
+            .map(|i| op(l[i], r[i]))
+            .chain(longer[common..].iter().copied())
+            .collect()
+    }
+
+    fn assert_mask_eq(actual: &BooleanBuffer, expected: &[bool], context: 
&str) {
+        assert_eq!(actual.len(), expected.len(), "{context}: length");
+        let actual: Vec<bool> = actual.iter().collect();
+        assert_eq!(actual, expected, "{context}");
+    }
+
+    #[test]
+    fn test_mask_algebra_with_offsets() {
+        // Offsets and lengths that are not byte (or word) aligned on either 
side,
+        // so the common prefix can start and end mid byte. Covers both the 
equal
+        // and uneven length paths.
+        let base: Vec<bool> = (0..600).map(|i| i % 7 == 0 || i % 3 == 
1).collect();
+        let other: Vec<bool> = (0..600).map(|i| i % 5 == 2 || i % 11 == 
4).collect();
+        let base = BooleanBuffer::from(base);
+        let other = BooleanBuffer::from(other);
+
+        for l_offset in [0, 1, 5, 8, 13, 64, 67] {
+            for r_offset in [0, 1, 3, 8, 60, 64, 70] {
+                for (l_len, r_len) in [
+                    (0, 9),
+                    (9, 0),
+                    (1, 200),
+                    (200, 1),
+                    (63, 130),
+                    (321, 65),
+                    (0, 0),
+                    (1, 1),
+                    (63, 63),
+                    (64, 64),
+                    (200, 200),
+                    (321, 321),
+                ] {
+                    let l = base.slice(l_offset, l_len);
+                    let r = other.slice(r_offset, r_len);
+                    let l_bits: Vec<bool> = l.iter().collect();
+                    let r_bits: Vec<bool> = r.iter().collect();
+                    let context =
+                        format!("l_offset={l_offset} r_offset={r_offset} 
lens=({l_len},{r_len})");
+
+                    assert_mask_eq(
+                        &intersect_masks(&l, &r),
+                        &expected_combined(&l_bits, &r_bits, |a, b| a && b),
+                        &format!("intersect {context}"),
+                    );
+                    assert_mask_eq(
+                        &union_masks(&l, &r),
+                        &expected_combined(&l_bits, &r_bits, |a, b| a || b),
+                        &format!("union {context}"),
+                    );
+                }
+            }
+        }
+    }
+
+    #[test]
+    fn test_mask_algebra_does_not_retain_backing_buffer() {
+        // A short slice of a long mask must not keep the long allocation 
alive,
+        // including when the other operand is empty and contributes nothing.
+        // Comparing capacities rather than lengths, since a shallow slice 
reports a
+        // short length while still holding the original allocation through 
its `Arc`.
+        let long = BooleanBuffer::from((0..80_000).map(|i| i % 3 == 
0).collect::<Vec<bool>>());
+        assert!(long.inner().capacity() >= 10_000);
+
+        for (l, r) in [
+            (long.slice(5, 40), BooleanBuffer::new_unset(0)),
+            (long.slice(5, 40), BooleanBuffer::new_set(7)),
+            (BooleanBuffer::new_set(7), long.slice(5, 40)),
+        ] {
+            for combined in [intersect_masks(&l, &r), union_masks(&l, &r)] {
+                assert!(
+                    combined.inner().capacity() < long.inner().capacity(),
+                    "result retained the original backing allocation"
+                );
+            }
+        }
+    }
+
+    #[test]
+    fn test_mask_algebra_fuzz() {
+        let mut rng = rng();
+        for _ in 0..200 {
+            let l_offset = rng.random_range(0..70);
+            let r_offset = rng.random_range(0..70);
+            let l_len = rng.random_range(0..300);
+            // Bias towards equal lengths so that path is hit often
+            let r_len = match rng.random_bool(0.25) {
+                true => l_len,
+                false => rng.random_range(0..300),
+            };
+
+            let l_bits: Vec<bool> = (0..l_offset + l_len)
+                .map(|_| rng.random_bool(0.5))
+                .collect();
+            let r_bits: Vec<bool> = (0..r_offset + r_len)
+                .map(|_| rng.random_bool(0.5))
+                .collect();
+            let l = BooleanBuffer::from(l_bits).slice(l_offset, l_len);
+            let r = BooleanBuffer::from(r_bits).slice(r_offset, r_len);
+            let l_bits: Vec<bool> = l.iter().collect();
+            let r_bits: Vec<bool> = r.iter().collect();
+
+            assert_mask_eq(
+                &intersect_masks(&l, &r),
+                &expected_combined(&l_bits, &r_bits, |a, b| a && b),
+                "intersect",
+            );
+            assert_mask_eq(
+                &union_masks(&l, &r),
+                &expected_combined(&l_bits, &r_bits, |a, b| a || b),
+                "union",
+            );
+        }
+    }
 }

Reply via email to