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 f1ba4352bb fix(arrow-buffer): preserve bits outside the requested 
range in in-place bitwise ops (#10444)
f1ba4352bb is described below

commit f1ba4352bb583c47feba8a008516b1be21edf7f8
Author: Huaijin <[email protected]>
AuthorDate: Tue Aug 4 02:24:15 2026 +0800

    fix(arrow-buffer): preserve bits outside the requested range in in-place 
bitwise ops (#10444)
    
    # Which issue does this PR close?
    
    No dedicated issue — found while implementing #10425, whose optimization
    is the first caller to depend on the behaviour fixed here. Happy to file
    one if you'd prefer it tracked separately.
    
    # Rationale for this change
    
    `apply_bitwise_binary_op` and `apply_bitwise_unary_op` are built around
    the invariant that only the bits in `offset_in_bits..offset_in_bits +
    len_in_bits` are modified — the helpers implementing it
    (`align_to_byte`, `set_remainder_bits`,
    `handle_mutable_buffer_remainder_unary`) all say so in their doc
    comments. Two paths didn't honour it:
    
    **1. `align_to_byte` ignored `len_in_bits`**, writing every bit from
    `bit_offset` to the byte boundary. If the range started and ended inside
    the same non-byte-aligned byte, the trailing bits were overwritten with
    `op` applied to padding:
    
    ```rust
    let mut left = vec![0b11111111u8, 0b11111111u8];
    apply_bitwise_binary_op(&mut left, 1, &[0u8, 0u8], 0, 1, |a, b| a & b);
    // expected [0b11111101, 0b11111111], got [0b00000001, 0b11111111]
    ```
    
    **2. `set_remainder_bits` zeroed the boundary byte's out-of-range
    bits**, because it read that byte into the *low* bits of a `u64` and
    masked it with `!((1 << remainder_len) - 1)`. Once the remainder spanned
    more than one byte, the mask selected nothing. This hit any length
    leaving a remainder of 9..63 bits that isn't a multiple of 8.
    
    No existing caller was affected, which is why it went unnoticed:
    `BooleanBufferBuilder::append_packed_range` copies with `|_a, b| b` into
    capacity `advance()` just zeroed, and the `BooleanBuffer`/`BooleanArray`
    in-place paths run only on uniquely owned buffers and re-wrap with the
    original offset/length. So the out-of-range bits were either already
    zero or unobservable.
    
    # What changes are included in this PR?
    
    - `align_to_byte` takes `len_in_bits` and masks its write to
    `bit_offset..bit_offset + min(8 - bit_offset, len_in_bits)`.
    - `set_remainder_bits` shifts the boundary byte to the position it
    actually occupies in the word before masking.
    - Both public functions now document the preservation guarantee.
    
    # Are these changes tested?
    
    Yes. The gap existed because the two shared test helpers only asserted
    bits *inside* the operated range. They now also assert every bit outside
    it is byte-for-byte identical before and after, which retroactively
    covers all their call sites — that alone caught bug 2 in 11 pre-existing
    tests. Added `test_ops_ending_inside_the_first_partial_byte` (sweeps
    every offset/len pair inside one partial byte, for AND/OR/XOR and NOT)
    plus minimal regression tests for the two reproducers.
    
    `cargo test -p arrow-buffer` passes (342 unit + 54 doc), as do
    `arrow-array`, `arrow` and `arrow-select`. Clippy and fmt clean.
    
    # Are there any user-facing changes?
    
    A behaviour change in two public functions, in the direction of the
    documented intent: bits outside the requested range are no longer
    clobbered. No signature changes, and no existing caller could observe
    the old behaviour. The guarantee is now explicit in the rustdoc.
---
 arrow-buffer/src/util/bit_util.rs | 152 +++++++++++++++++++++++++++++++++++---
 1 file changed, 142 insertions(+), 10 deletions(-)

diff --git a/arrow-buffer/src/util/bit_util.rs 
b/arrow-buffer/src/util/bit_util.rs
index 920ecba6d6..7530670554 100644
--- a/arrow-buffer/src/util/bit_util.rs
+++ b/arrow-buffer/src/util/bit_util.rs
@@ -168,6 +168,10 @@ pub(crate) fn read_up_to_byte_from_offset(
 /// * `len_in_bits` - Number of bits to process
 /// * `op` - Binary operation to apply (e.g., `|a, b| a & b`). Applied a word 
at a time
 ///
+/// Only the bits in `left_offset_in_bits..left_offset_in_bits + len_in_bits` 
are
+/// modified. Bits of `left` outside that range are left unchanged, including 
the
+/// bits sharing a byte with either end of the range.
+///
 /// # Example: Modify entire buffer
 /// ```
 /// # use arrow_buffer::MutableBuffer;
@@ -247,6 +251,7 @@ pub fn apply_bitwise_binary_op<F>(
                 // Hope it gets inlined
                 &mut |left| op(left, right_first_byte as u64),
                 left_offset_in_bits,
+                bits_to_next_byte,
             );
         }
 
@@ -280,6 +285,10 @@ pub fn apply_bitwise_binary_op<F>(
 /// * `len_in_bits` - Number of bits to process
 /// * `op` - Unary operation to apply (e.g., `|a| !a`). Applied a word at a 
time
 ///
+/// Only the bits in `offset_in_bits..offset_in_bits + len_in_bits` are 
modified.
+/// Bits outside that range are left unchanged, including the bits sharing a 
byte
+/// with either end of the range.
+///
 /// # Example: Modify entire buffer
 /// ```
 /// # use arrow_buffer::MutableBuffer;
@@ -325,7 +334,7 @@ pub fn apply_bitwise_unary_op<F>(
     if is_mutable_buffer_byte_aligned {
         byte_aligned_bitwise_unary_op_helper(buffer, offset_in_bits, 
len_in_bits, op);
     } else {
-        align_to_byte(buffer, &mut op, offset_in_bits);
+        align_to_byte(buffer, &mut op, offset_in_bits, len_in_bits);
 
         // If we are not byte aligned we will read the first few bits
         let bits_to_next_byte = 8 - left_bit_offset;
@@ -449,13 +458,23 @@ fn byte_aligned_bitwise_unary_op_helper<F>(
 /// * `op` - Unary operation to apply
 /// * `buffer` - The mutable buffer to modify
 /// * `offset_in_bits` - Starting bit offset (not byte-aligned)
-fn align_to_byte<F>(buffer: &mut [u8], op: &mut F, offset_in_bits: usize)
-where
+/// * `remaining_len_in_bits` - Number of bits still to process starting at 
`offset_in_bits`.
+///   When this is smaller than the number of bits left in the byte, the 
trailing bits of
+///   the byte are left untouched.
+fn align_to_byte<F>(
+    buffer: &mut [u8],
+    op: &mut F,
+    offset_in_bits: usize,
+    remaining_len_in_bits: usize,
+) where
     F: FnMut(u64) -> u64,
 {
     let byte_offset = offset_in_bits / 8;
     let bit_offset = offset_in_bits % 8;
 
+    // Byte aligned offsets must take the byte aligned path instead
+    debug_assert_ne!(bit_offset, 0, "offset_in_bits must not be byte aligned");
+
     // 1. read the first byte from the buffer
     let first_byte: u8 = buffer[byte_offset];
 
@@ -468,12 +487,16 @@ where
     // 4. Shift back the result to the original position
     let result_first_byte = result_first_byte << bit_offset;
 
-    // 5. Mask the bits that are outside the relevant bits in the byte
-    //    so the bits until bit_offset are 1 and the rest are 0
-    let mask_for_first_bit_offset = (1 << bit_offset) - 1;
+    // 5. Mask in only the bits the caller asked to process, i.e. the bits in
+    //    `bit_offset..bit_offset + bits_in_this_byte`. The request may end 
before the
+    //    byte boundary, in which case the trailing bits must be preserved as 
well.
+    //
+    //    `bit_offset` is in `1..=7` per the assert above, so 
`bits_in_this_byte` is at
+    //    most 7 and `bits_in_this_byte + bit_offset <= 8`, keeping the mask 
within a `u8`.
+    let bits_in_this_byte = (8 - bit_offset).min(remaining_len_in_bits);
+    let write_mask = ((1u8 << bits_in_this_byte) - 1) << bit_offset;
 
-    let result_first_byte =
-        (first_byte & mask_for_first_bit_offset) | (result_first_byte & 
!mask_for_first_bit_offset);
+    let result_first_byte = (first_byte & !write_mask) | (result_first_byte & 
write_mask);
 
     // 6. write back the result to the buffer
     buffer[byte_offset] = result_first_byte;
@@ -719,7 +742,9 @@ fn set_remainder_bits(start_remainder_mut_slice: &mut [u8], 
rem: u64, remainder_
             // Unwrap as we already validated the slice is not empty
             .unwrap();
 
-        let current = *current as u64;
+        // Shift the boundary byte to the position it occupies within `rem`, 
otherwise
+        // its bits would be compared against the wrong end of the mask below
+        let current = (*current as u64) << ((start_remainder_mut_slice.len() - 
1) * 8);
 
         // Mask where the bits that are inside the remainder are 1
         // and the bits outside the remainder are 0
@@ -740,7 +765,7 @@ fn set_remainder_bits(start_remainder_mut_slice: &mut [u8], 
rem: u64, remainder_
 
     // Write back the result to the mutable slice
     {
-        let remainder_bytes = self::ceil(remainder_len, 8);
+        let remainder_bytes = start_remainder_mut_slice.len();
 
         // we are counting starting from the least significant bit, so 
to_le_bytes should be correct
         let rem = &rem.to_le_bytes()[0..remainder_bytes];
@@ -1105,6 +1130,8 @@ mod tests {
             .map(|(l, r)| expected_op(*l, *r))
             .collect();
 
+        let before = left_buffer.as_slice().to_vec();
+
         apply_bitwise_binary_op(
             left_buffer.as_slice_mut(),
             left_offset_in_bits,
@@ -1122,6 +1149,41 @@ mod tests {
             "Failed with left_offset={}, right_offset={}, len={}",
             left_offset_in_bits, right_offset_in_bits, len_in_bits
         );
+
+        assert_bits_outside_range_preserved(
+            &before,
+            left_buffer.as_slice(),
+            left_offset_in_bits,
+            len_in_bits,
+            &format!(
+                "left_offset={}, right_offset={}, len={}",
+                left_offset_in_bits, right_offset_in_bits, len_in_bits
+            ),
+        );
+    }
+
+    /// Asserts that every bit outside `offset_in_bits..offset_in_bits + 
len_in_bits`
+    /// is identical in `before` and `after`.
+    fn assert_bits_outside_range_preserved(
+        before: &[u8],
+        after: &[u8],
+        offset_in_bits: usize,
+        len_in_bits: usize,
+        context: &str,
+    ) {
+        assert_eq!(before.len(), after.len());
+        for i in 0..before.len() * 8 {
+            if i >= offset_in_bits && i < offset_in_bits + len_in_bits {
+                continue;
+            }
+            assert_eq!(
+                get_bit(before, i),
+                get_bit(after, i),
+                "bit {} outside the requested range was modified ({})",
+                i,
+                context
+            );
+        }
     }
 
     /// Verifies that a unary operation applied to a buffer using u64 chunks
@@ -1146,6 +1208,8 @@ mod tests {
             .map(|b| expected_op(*b))
             .collect();
 
+        let before = buffer.as_slice().to_vec();
+
         apply_bitwise_unary_op(buffer.as_slice_mut(), offset_in_bits, 
len_in_bits, op);
 
         let result: Vec<bool> =
@@ -1156,6 +1220,14 @@ mod tests {
             "Failed with offset={}, len={}",
             offset_in_bits, len_in_bits
         );
+
+        assert_bits_outside_range_preserved(
+            &before,
+            buffer.as_slice(),
+            offset_in_bits,
+            len_in_bits,
+            &format!("offset={}, len={}", offset_in_bits, len_in_bits),
+        );
     }
 
     // Helper to create test data of specific length
@@ -1424,6 +1496,66 @@ mod tests {
         );
     }
 
+    /// Ranges that start and end inside the same non-byte-aligned byte must 
not
+    /// touch the trailing bits of that byte.
+    #[test]
+    fn test_ops_ending_inside_the_first_partial_byte() {
+        let (left, right) = create_test_data(32);
+        for offset in 1..8 {
+            // Inclusive so the range ending exactly on the byte boundary is 
covered too
+            for len in 1..=(8 - offset) {
+                test_all_binary_ops(&left, &right, offset, offset, len);
+                test_all_binary_ops(&left, &right, offset, (offset + 3) % 8, 
len);
+                test_mutable_buffer_unary_op_helper(&left, offset, len, |a| 
!a, |a| !a);
+            }
+        }
+    }
+
+    #[test]
+    fn test_and_within_first_partial_byte_preserves_trailing_bits() {
+        let mut left = vec![0b11111111u8, 0b11111111u8];
+        let right = vec![0b00000000u8, 0b00000000u8];
+        // AND a single bit at bit offset 1: only bit 1 may be cleared
+        apply_bitwise_binary_op(&mut left, 1, &right, 0, 1, |a, b| a & b);
+        assert_eq!(left, vec![0b11111101u8, 0b11111111u8]);
+    }
+
+    #[test]
+    fn test_not_within_first_partial_byte_preserves_trailing_bits() {
+        let mut buffer = vec![0b00000000u8];
+        // NOT two bits at bit offset 3: only bits 3 and 4 may be flipped
+        apply_bitwise_unary_op(&mut buffer, 3, 2, |a| !a);
+        assert_eq!(buffer, vec![0b00011000u8]);
+    }
+
+    /// When the remainder spans more than one byte, the byte holding the end 
of the
+    /// range is the *last* byte of the remainder, not the first. Its bits 
above the
+    /// remainder must survive.
+    #[test]
+    fn test_or_with_multi_byte_remainder_preserves_boundary_bits() {
+        let mut left = vec![0b00000000u8, 0b00000000u8, 0b11110000u8];
+        let right = vec![0b11111111u8, 0b11111111u8, 0b11111111u8];
+        // OR over 20 bits: bits 20..24 of `left` are outside the range and 
must stay set
+        apply_bitwise_binary_op(&mut left, 0, &right, 0, 20, |a, b| a | b);
+        assert_eq!(
+            left,
+            vec![0b11111111u8, 0b11111111u8, 0b11111111u8],
+            "the boundary byte lost its out-of-range bits"
+        );
+    }
+
+    #[test]
+    fn test_not_with_multi_byte_remainder_preserves_boundary_bits() {
+        let mut buffer = vec![0b00000000u8, 0b00000000u8, 0b11111111u8];
+        // NOT over 20 bits: only bits 16..20 of the last byte may be flipped
+        apply_bitwise_unary_op(&mut buffer, 0, 20, |a| !a);
+        assert_eq!(
+            buffer,
+            vec![0b11111111u8, 0b11111111u8, 0b11110000u8],
+            "the boundary byte lost its out-of-range bits"
+        );
+    }
+
     #[test]
     fn test_bitwise_binary_op_offset_out_of_bounds() {
         let input = vec![0b10101010u8, 0b01010101u8];

Reply via email to