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 0a5979560a Replace OffsetBufferBuilder with Vec in scalar byte-array 
zip (#10913)
0a5979560a is described below

commit 0a5979560ac67135134c3618519b9b7384edf4d2
Author: Yash Kucheriya <[email protected]>
AuthorDate: Wed Sep 16 19:55:05 2026 -0700

    Replace OffsetBufferBuilder with Vec in scalar byte-array zip (#10913)
    
    # Which issue does this PR close?
    
    Part of #10245.
    
    # Rationale for this change
    
    The byte-array scalar zip path is one of the remaining
    `OffsetBufferBuilder` callsites in #10245. Building cumulative offsets
    directly in a `Vec` avoids checking each offset through the builder,
    which benefits masks containing long runs of the same value.
    
    # What changes are included in this PR?
    
    `BytesScalarImpl::create_output_on_non_nulls` builds offsets with
    `Vec<T::Offset>`. The mixed-mask path checks the total byte size and
    offset range before allocating the output. An overflowing size returns
    `MemoryError`; a size outside the offset type returns
    `OffsetOverflowError`. Every generated offset is bounded by that checked
    total.
    
    The existing all-true, all-false, and nullable-scalar fast paths are
    unchanged.
    
    # Are these changes tested?
    
    - `cargo test -p arrow-select --quiet`: 418 unit tests and 17
    documentation tests passed.
    - `cargo clippy -p arrow-select --all-targets --all-features -- -D
    warnings`: passed.
    - `cargo +stable fmt --all -- --check`: passed.
    
    The new regression test uses a 64 KiB binary scalar and a mixed mask
    whose output would require 2 GiB. It checks that the call returns
    `OffsetOverflowError` before allocating that output, with the large
    scalar on either side of the mask. Existing tests cover normal and large
    byte arrays, nulls, fragmented masks, and one-sided scalar paths.
    
    ## Benchmarks
    
    The project runner measured the previous revision, `4829c8f6d`,
    
[here](https://github.com/apache/arrow-rs/pull/10913#issuecomment-5467796574).
    Those results do not include the review fix.
    
    Local measurements using the existing `arrow/benches/zip_kernels.rs`
    benchmark, on macOS arm64 with Rust 1.97.1:
    
    | Case, 8,192 rows | Upstream `cbbb56bba` | Previous PR `4829c8f6d` |
    Revised `1ec832648` |
    | --- | ---: | ---: | ---: |
    | Short strings, 50% true | 40.00 us | 38.78 us | 38.40 us |
    | Short strings, true then false | 7.04 us | 2.24 us | 2.24 us |
    | Long strings, 50% true | 65.12 us | 64.14 us | 63.88 us |
    | Long strings, true then false | 41.90 us | 35.69 us | 36.84 us |
    | Short bytes, 50% true | 36.66 us | 34.84 us | 32.96 us |
    | Short bytes, true then false | 7.01 us | 2.08 us | 1.99 us |
    | Long bytes, 50% true | 54.93 us | 52.28 us | 53.47 us |
    | Long bytes, true then false | 32.06 us | 25.53 us | 26.71 us |
    
    These are short, sequential local runs, not a controlled performance
    study. The revision remains faster than the upstream baseline in these
    cases, but some long-value cases are slower than the previous PR
    revision. A project-runner rerun would help confirm the impact of the
    added checks.
    
    Command, run for each revision with separate Criterion baselines:
    
    ```sh
    cargo bench -p arrow --features test_utils --bench zip_kernels -- \
      
'zip_8192_from_(long|short).+non_nulls_scalars/(50pct_true|true_then_false)$' \
      --sample-size 20 --warm-up-time 0.5 --measurement-time 1
    ```
    
    # Are there any user-facing changes?
    
    Mixed-mask scalar byte-array output that exceeds the offset range now
    returns an error before output allocation instead of panicking. No
    public API changes.
    
    # Automated assistance
    
    AI assistance was used to prepare the refactor, review fix, regression
    test, and validation. The commands and measurements above were run
    locally.
    
    ---------
    
    Co-authored-by: Yash Kuceriya <[email protected]>
---
 arrow-select/src/zip.rs | 69 ++++++++++++++++++++++++++++++++++++++-----------
 1 file changed, 54 insertions(+), 15 deletions(-)

diff --git a/arrow-select/src/zip.rs b/arrow-select/src/zip.rs
index c0dcf6fbd9..1abde41d16 100644
--- a/arrow-select/src/zip.rs
+++ b/arrow-select/src/zip.rs
@@ -25,8 +25,8 @@ use arrow_array::types::{
 };
 use arrow_array::*;
 use arrow_buffer::{
-    BooleanBuffer, Buffer, MutableBuffer, NullBuffer, OffsetBuffer, 
OffsetBufferBuilder,
-    ScalarBuffer, ToByteSlice,
+    ArrowNativeType, BooleanBuffer, Buffer, MutableBuffer, NullBuffer, 
OffsetBuffer, ScalarBuffer,
+    ToByteSlice,
 };
 use arrow_data::transform::MutableArrayData;
 use arrow_data::{ArrayData, ByteView};
@@ -595,10 +595,19 @@ impl<T: ByteArrayType> BytesScalarImpl<T> {
             }
         }
 
-        let total_number_of_bytes =
-            true_count * truthy_val.len() + (predicate.len() - true_count) * 
falsy_val.len();
+        let total_number_of_bytes = true_count
+            .checked_mul(truthy_val.len())
+            .and_then(|truthy_bytes| {
+                let falsy_bytes = (predicate.len() - 
true_count).checked_mul(falsy_val.len())?;
+                truthy_bytes.checked_add(falsy_bytes)
+            })
+            .ok_or_else(|| ArrowError::MemoryError("zip output size 
overflow".to_string()))?;
+        T::Offset::from_usize(total_number_of_bytes)
+            .ok_or(ArrowError::OffsetOverflowError(total_number_of_bytes))?;
         let mut mutable = MutableBuffer::with_capacity(total_number_of_bytes);
-        let mut offset_buffer_builder = 
OffsetBufferBuilder::<T::Offset>::new(predicate.len());
+        let mut offsets = Vec::<T::Offset>::with_capacity(predicate.len() + 1);
+        offsets.push(T::Offset::usize_as(0));
+        let mut current_offset: usize = 0;
 
         // keep track of how much is filled
         let mut filled = 0;
@@ -606,6 +615,7 @@ impl<T: ByteArrayType> BytesScalarImpl<T> {
         let truthy_len = truthy_val.len();
         let falsy_len = falsy_val.len();
 
+        // Each run's offsets are bounded by the checked total output size.
         SlicesIterator::from(predicate).try_for_each(|(start, end)| -> 
Result<(), ArrowError> {
             // the gap needs to be filled with falsy values
             if start > filled {
@@ -615,9 +625,12 @@ impl<T: ByteArrayType> BytesScalarImpl<T> {
                     .try_repeat_slice_n_times(falsy_val, false_repeat_count)
                     .map_err(|e| ArrowError::MemoryError(e.to_string()))?;
 
-                for _ in 0..false_repeat_count {
-                    offset_buffer_builder.push_length(falsy_len)
-                }
+                let start_offset = current_offset;
+                current_offset += falsy_len * false_repeat_count;
+                offsets.extend(
+                    (1..=false_repeat_count)
+                        .map(|index| T::Offset::usize_as(start_offset + index 
* falsy_len)),
+                );
             }
 
             let true_repeat_count = end - start;
@@ -626,9 +639,12 @@ impl<T: ByteArrayType> BytesScalarImpl<T> {
                 .try_repeat_slice_n_times(truthy_val, true_repeat_count)
                 .map_err(|e| ArrowError::MemoryError(e.to_string()))?;
 
-            for _ in 0..true_repeat_count {
-                offset_buffer_builder.push_length(truthy_len)
-            }
+            let start_offset = current_offset;
+            current_offset += truthy_len * true_repeat_count;
+            offsets.extend(
+                (1..=true_repeat_count)
+                    .map(|index| T::Offset::usize_as(start_offset + index * 
truthy_len)),
+            );
             filled = end;
             Ok(())
         })?;
@@ -640,12 +656,18 @@ impl<T: ByteArrayType> BytesScalarImpl<T> {
                 .try_repeat_slice_n_times(falsy_val, false_repeat_count)
                 .map_err(|e| ArrowError::MemoryError(e.to_string()))?;
 
-            for _ in 0..false_repeat_count {
-                offset_buffer_builder.push_length(falsy_len)
-            }
+            let start_offset = current_offset;
+            current_offset += falsy_len * false_repeat_count;
+            offsets.extend(
+                (1..=false_repeat_count)
+                    .map(|index| T::Offset::usize_as(start_offset + index * 
falsy_len)),
+            );
         }
 
-        Ok((mutable.into(), offset_buffer_builder.finish()))
+        debug_assert_eq!(current_offset, total_number_of_bytes);
+        // SAFETY: offsets start at zero, are monotonically increasing, and 
fit in T::Offset.
+        let offsets = unsafe { OffsetBuffer::new_unchecked(offsets.into()) };
+        Ok((mutable.into(), offsets))
     }
 }
 
@@ -1292,6 +1314,23 @@ mod test {
         assert_eq!(actual, &expected);
     }
 
+    #[test]
+    fn test_zip_scalar_bytes_offset_overflow() {
+        // Repeating a 64 KiB scalar 32,768 times exceeds i32::MAX.
+        // The size must be rejected before allocating the output buffer.
+        let value = vec![0_u8; 65_536];
+        let large = 
Scalar::new(BinaryArray::from_iter_values([value.as_slice()]));
+        let empty = 
Scalar::new(BinaryArray::from_iter_values([b"".as_slice()]));
+        let mask = BooleanArray::from_iter((0..65_536).map(|i| Some(i % 2 == 
0)));
+
+        for (truthy, falsy) in [(&large, &empty), (&empty, &large)] {
+            assert!(matches!(
+                zip(&mask, truthy, falsy),
+                Err(ArrowError::OffsetOverflowError(2_147_483_648))
+            ));
+        }
+    }
+
     #[test]
     fn test_zip_scalar_bytes_only_taking_one_side() {
         let mask_len = 5;

Reply via email to