From eb18beb5ec6b363fb7fe7007f47de5fec9aa1b33 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:29:48 +0100 Subject: [PATCH 1/8] Add has_true() and has_false() to BooleanArray Short-circuiting methods that return early on the first matching chunk, avoiding full popcount scans. Useful for replacing common patterns like `true_count() == 0` or `true_count() == len`. Co-Authored-By: Claude Opus 4.6 --- arrow-array/src/array/boolean_array.rs | 116 +++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/arrow-array/src/array/boolean_array.rs b/arrow-array/src/array/boolean_array.rs index 582627b24396..d0f9548a4638 100644 --- a/arrow-array/src/array/boolean_array.rs +++ b/arrow-array/src/array/boolean_array.rs @@ -176,6 +176,47 @@ impl BooleanArray { self.len() - self.null_count() - self.true_count() } + /// Returns whether there is at least one non-null `true` value in this array. + /// + /// This is more efficient than `true_count() > 0` because it can short-circuit + /// as soon as a `true` value is found, without counting all set bits. + /// + /// Null values are not counted as `true`. Returns `false` for empty arrays. + pub fn has_true(&self) -> bool { + match self.nulls() { + Some(nulls) => { + let null_chunks = nulls.inner().bit_chunks().iter_padded(); + let value_chunks = self.values().bit_chunks().iter_padded(); + null_chunks.zip(value_chunks).any(|(n, v)| (n & v) != 0) + } + None => self.values().bit_chunks().iter_padded().any(|v| v != 0), + } + } + + /// Returns whether there is at least one non-null `false` value in this array. + /// + /// This is more efficient than `false_count() > 0` because it can short-circuit + /// as soon as a `false` value is found, without counting all set bits. + /// + /// Null values are not counted as `false`. Returns `false` for empty arrays. + pub fn has_false(&self) -> bool { + match self.nulls() { + Some(nulls) => { + let null_chunks = nulls.inner().bit_chunks().iter_padded(); + let value_chunks = self.values().bit_chunks().iter_padded(); + null_chunks.zip(value_chunks).any(|(n, v)| (n & !v) != 0) + } + None => { + let chunks = self.values().bit_chunks(); + if chunks.iter().any(|chunk| chunk != u64::MAX) { + return true; + } + let remainder_len = chunks.remainder_len(); + remainder_len > 0 && chunks.remainder_bits() != (1u64 << remainder_len) - 1 + } + } + } + /// Returns the boolean value at index `i`. /// /// Note: This method does not check for nulls and the value is arbitrary @@ -854,4 +895,79 @@ mod tests { assert!(sliced.is_valid(1)); assert!(!sliced.value(1)); } + + #[test] + fn test_has_true_has_false_all_true() { + let arr = BooleanArray::from(vec![true, true, true]); + assert!(arr.has_true()); + assert!(!arr.has_false()); + } + + #[test] + fn test_has_true_has_false_all_false() { + let arr = BooleanArray::from(vec![false, false, false]); + assert!(!arr.has_true()); + assert!(arr.has_false()); + } + + #[test] + fn test_has_true_has_false_mixed() { + let arr = BooleanArray::from(vec![true, false, true]); + assert!(arr.has_true()); + assert!(arr.has_false()); + } + + #[test] + fn test_has_true_has_false_empty() { + let arr = BooleanArray::from(Vec::::new()); + assert!(!arr.has_true()); + assert!(!arr.has_false()); + } + + #[test] + fn test_has_true_has_false_nulls_all_valid_true() { + let arr = BooleanArray::from(vec![Some(true), None, Some(true)]); + assert!(arr.has_true()); + assert!(!arr.has_false()); + } + + #[test] + fn test_has_true_has_false_nulls_all_valid_false() { + let arr = BooleanArray::from(vec![Some(false), None, Some(false)]); + assert!(!arr.has_true()); + assert!(arr.has_false()); + } + + #[test] + fn test_has_true_has_false_all_null() { + let arr = BooleanArray::new_null(5); + assert!(!arr.has_true()); + assert!(!arr.has_false()); + } + + #[test] + fn test_has_false_non_aligned_all_true() { + // 65 elements: exercises the remainder path in has_false + let arr = BooleanArray::from(vec![true; 65]); + assert!(arr.has_true()); + assert!(!arr.has_false()); + } + + #[test] + fn test_has_false_non_aligned_last_false() { + // 64 trues + 1 false: remainder path should find the false + let mut values = vec![true; 64]; + values.push(false); + let arr = BooleanArray::from(values); + assert!(arr.has_true()); + assert!(arr.has_false()); + } + + #[test] + fn test_has_false_exact_64_all_true() { + // Exactly 64 elements, no remainder + let arr = BooleanArray::from(vec![true; 64]); + assert!(arr.has_true()); + assert!(!arr.has_false()); + } } From 7f748ac38b43fe6a38cdb2b3a05c1e5fe39591ea Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Thu, 5 Mar 2026 00:43:04 +0100 Subject: [PATCH 2/8] Add benchmark for has_true/has_false vs true_count Co-Authored-By: Claude Opus 4.6 --- arrow-array/Cargo.toml | 4 ++ arrow-array/benches/boolean_array.rs | 77 ++++++++++++++++++++++++++++ 2 files changed, 81 insertions(+) create mode 100644 arrow-array/benches/boolean_array.rs diff --git a/arrow-array/Cargo.toml b/arrow-array/Cargo.toml index 6be5a6daab56..da8ef98a1084 100644 --- a/arrow-array/Cargo.toml +++ b/arrow-array/Cargo.toml @@ -92,3 +92,7 @@ harness = false [[bench]] name = "record_batch" harness = false + +[[bench]] +name = "boolean_array" +harness = false diff --git a/arrow-array/benches/boolean_array.rs b/arrow-array/benches/boolean_array.rs new file mode 100644 index 000000000000..03b601075bb8 --- /dev/null +++ b/arrow-array/benches/boolean_array.rs @@ -0,0 +1,77 @@ +// Licensed to the Apache Software Foundation (ASF) under one +// or more contributor license agreements. See the NOTICE file +// distributed with this work for additional information +// regarding copyright ownership. The ASF licenses this file +// to you under the Apache License, Version 2.0 (the +// "License"); you may not use this file except in compliance +// with the License. You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, +// software distributed under the License is distributed on an +// "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY +// KIND, either express or implied. See the License for the +// specific language governing permissions and limitations +// under the License. + +use arrow_array::BooleanArray; +use criterion::*; +use std::hint; + +fn criterion_benchmark(c: &mut Criterion) { + for len in [64, 1024, 65536] { + // All true (no nulls) + let all_true = BooleanArray::from(vec![true; len]); + c.bench_function(&format!("true_count(all_true, {len})"), |b| { + b.iter(|| hint::black_box(&all_true).true_count()); + }); + c.bench_function(&format!("has_true(all_true, {len})"), |b| { + b.iter(|| hint::black_box(&all_true).has_true()); + }); + c.bench_function(&format!("has_false(all_true, {len})"), |b| { + b.iter(|| hint::black_box(&all_true).has_false()); + }); + + // All false (no nulls) + let all_false = BooleanArray::from(vec![false; len]); + c.bench_function(&format!("true_count(all_false, {len})"), |b| { + b.iter(|| hint::black_box(&all_false).true_count()); + }); + c.bench_function(&format!("has_true(all_false, {len})"), |b| { + b.iter(|| hint::black_box(&all_false).has_true()); + }); + c.bench_function(&format!("has_false(all_false, {len})"), |b| { + b.iter(|| hint::black_box(&all_false).has_false()); + }); + + // Mixed: first element differs (best-case short-circuit) + let mut mixed_early: Vec = vec![true; len]; + mixed_early[0] = false; + let mixed_early = BooleanArray::from(mixed_early); + c.bench_function(&format!("true_count(mixed_early, {len})"), |b| { + b.iter(|| hint::black_box(&mixed_early).true_count()); + }); + c.bench_function(&format!("has_false(mixed_early, {len})"), |b| { + b.iter(|| hint::black_box(&mixed_early).has_false()); + }); + + // With nulls: all valid values true + let with_nulls: Vec> = (0..len) + .map(|i| if i % 10 == 0 { None } else { Some(true) }) + .collect(); + let with_nulls = BooleanArray::from(with_nulls); + c.bench_function(&format!("true_count(nulls_all_true, {len})"), |b| { + b.iter(|| hint::black_box(&with_nulls).true_count()); + }); + c.bench_function(&format!("has_true(nulls_all_true, {len})"), |b| { + b.iter(|| hint::black_box(&with_nulls).has_true()); + }); + c.bench_function(&format!("has_false(nulls_all_true, {len})"), |b| { + b.iter(|| hint::black_box(&with_nulls).has_false()); + }); + } +} + +criterion_group!(benches, criterion_benchmark); +criterion_main!(benches); From e035516552afc81cdc5e1d16882272d5b52fa79e Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Thu, 5 Mar 2026 10:45:45 +0100 Subject: [PATCH 3/8] Make has_true()/has_false() SIMD-friendly using UnalignedBitChunk Switch the no-nulls paths from BitChunks::iter_padded() (opaque iterator, prevents auto-vectorization) to UnalignedBitChunk (aligned &[u64] slice that LLVM can vectorize). Process chunks in blocks of 64 u64s with a fold + short-circuit between blocks. Worst-case full-scan at 65536 elements drops from ~255ns to ~50ns (5x), now 2x faster than true_count() (~100ns) thanks to simpler per-element ops (OR/AND fold vs popcount). Co-Authored-By: Claude Opus 4.6 --- arrow-array/src/array/boolean_array.rs | 51 ++++++++++++++++++++++---- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/arrow-array/src/array/boolean_array.rs b/arrow-array/src/array/boolean_array.rs index d0f9548a4638..0989a69164dc 100644 --- a/arrow-array/src/array/boolean_array.rs +++ b/arrow-array/src/array/boolean_array.rs @@ -19,6 +19,7 @@ use crate::array::print_long_array; use crate::builder::BooleanBuilder; use crate::iterator::BooleanIter; use crate::{Array, ArrayAccessor, ArrayRef, Scalar}; +use arrow_buffer::bit_chunk_iterator::UnalignedBitChunk; use arrow_buffer::{BooleanBuffer, Buffer, MutableBuffer, NullBuffer, bit_util}; use arrow_data::{ArrayData, ArrayDataBuilder}; use arrow_schema::DataType; @@ -189,7 +190,19 @@ impl BooleanArray { let value_chunks = self.values().bit_chunks().iter_padded(); null_chunks.zip(value_chunks).any(|(n, v)| (n & v) != 0) } - None => self.values().bit_chunks().iter_padded().any(|v| v != 0), + None => { + let bit_chunks = UnalignedBitChunk::new( + self.values().values(), + self.values().offset(), + self.len(), + ); + bit_chunks.prefix().unwrap_or(0) != 0 + || bit_chunks + .chunks() + .chunks(64) + .any(|block| block.iter().fold(0u64, |acc, &c| acc | c) != 0) + || bit_chunks.suffix().unwrap_or(0) != 0 + } } } @@ -207,12 +220,36 @@ impl BooleanArray { null_chunks.zip(value_chunks).any(|(n, v)| (n & !v) != 0) } None => { - let chunks = self.values().bit_chunks(); - if chunks.iter().any(|chunk| chunk != u64::MAX) { - return true; - } - let remainder_len = chunks.remainder_len(); - remainder_len > 0 && chunks.remainder_bits() != (1u64 << remainder_len) - 1 + let bit_chunks = UnalignedBitChunk::new( + self.values().values(), + self.values().offset(), + self.len(), + ); + // UnalignedBitChunk zeros padding bits; fill them with 1s so + // they don't appear as false values. + let lead_mask = !((1u64 << bit_chunks.lead_padding()) - 1); + let trail_mask = if bit_chunks.trailing_padding() == 0 { + u64::MAX + } else { + (1u64 << (64 - bit_chunks.trailing_padding())) - 1 + }; + // If both prefix and suffix exist, suffix gets trail_mask. + // If only prefix exists, it gets both masks. + let (prefix_fill, suffix_fill) = match (bit_chunks.prefix(), bit_chunks.suffix()) { + (Some(_), Some(_)) => (!lead_mask, !trail_mask), + (Some(_), None) => (!lead_mask | !trail_mask, 0), + _ => (0, 0), + }; + bit_chunks + .prefix() + .map_or(false, |v| (v | prefix_fill) != u64::MAX) + || bit_chunks + .chunks() + .chunks(64) + .any(|block| block.iter().fold(u64::MAX, |acc, &c| acc & c) != u64::MAX) + || bit_chunks + .suffix() + .map_or(false, |v| (v | suffix_fill) != u64::MAX) } } } From 2f00fc14ce1ced398e8f56fdb024d6fe60725565 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:32:52 +0100 Subject: [PATCH 4/8] fix --- arrow-array/src/array/boolean_array.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/arrow-array/src/array/boolean_array.rs b/arrow-array/src/array/boolean_array.rs index 0989a69164dc..323a0835360d 100644 --- a/arrow-array/src/array/boolean_array.rs +++ b/arrow-array/src/array/boolean_array.rs @@ -242,14 +242,14 @@ impl BooleanArray { }; bit_chunks .prefix() - .map_or(false, |v| (v | prefix_fill) != u64::MAX) + .is_some_and(|v| (v | prefix_fill) != u64::MAX) || bit_chunks .chunks() .chunks(64) .any(|block| block.iter().fold(u64::MAX, |acc, &c| acc & c) != u64::MAX) || bit_chunks .suffix() - .map_or(false, |v| (v | suffix_fill) != u64::MAX) + .is_some_and(|v| (v | suffix_fill) != u64::MAX) } } } From 4e8b072b56b7f3c6f8076d2842d55d16434a65af Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Thu, 5 Mar 2026 11:33:33 +0100 Subject: [PATCH 5/8] update docstring --- arrow-array/src/array/boolean_array.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/arrow-array/src/array/boolean_array.rs b/arrow-array/src/array/boolean_array.rs index 323a0835360d..0f40076765bf 100644 --- a/arrow-array/src/array/boolean_array.rs +++ b/arrow-array/src/array/boolean_array.rs @@ -157,7 +157,8 @@ impl BooleanArray { &self.values } - /// Returns the number of non null, true values within this array + /// Returns the number of non null, true values within this array. + /// If you only need to check if there is at least one true value, consider using `has_true()` which can short-circuit and be more efficient. pub fn true_count(&self) -> usize { match self.nulls() { Some(nulls) => { @@ -172,7 +173,8 @@ impl BooleanArray { } } - /// Returns the number of non null, false values within this array + /// Returns the number of non null, false values within this array. + /// If you only need to check if there is at least one false value, consider using `has_false()` which can short-circuit and be more efficient. pub fn false_count(&self) -> usize { self.len() - self.null_count() - self.true_count() } From c88627afea0a60a1943e39f7018cd3f4b33dda6d Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:06:12 -0500 Subject: [PATCH 6/8] Fix has_false() bug with aligned suffix and no prefix When the buffer is 8-byte aligned and >16 bytes, UnalignedBitChunk produces a suffix but no prefix. The wildcard match arm set suffix_fill to 0, so trailing padding bits (zeroed by UnalignedBitChunk) appeared as false values. Add explicit (None, Some(_)) arm to fill trailing padding with 1s. Co-Authored-By: Claude Opus 4.6 (1M context) --- arrow-array/src/array/boolean_array.rs | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/arrow-array/src/array/boolean_array.rs b/arrow-array/src/array/boolean_array.rs index 0f40076765bf..b718505c303d 100644 --- a/arrow-array/src/array/boolean_array.rs +++ b/arrow-array/src/array/boolean_array.rs @@ -240,7 +240,8 @@ impl BooleanArray { let (prefix_fill, suffix_fill) = match (bit_chunks.prefix(), bit_chunks.suffix()) { (Some(_), Some(_)) => (!lead_mask, !trail_mask), (Some(_), None) => (!lead_mask | !trail_mask, 0), - _ => (0, 0), + (None, Some(_)) => (0, !trail_mask), + (None, None) => (0, 0), }; bit_chunks .prefix() @@ -984,6 +985,13 @@ mod tests { assert!(!arr.has_false()); } + #[test] + fn test_has_false_aligned_suffix_all_true() { + let arr = BooleanArray::from(vec![true; 129]); + assert!(arr.has_true()); + assert!(!arr.has_false()); + } + #[test] fn test_has_false_non_aligned_all_true() { // 65 elements: exercises the remainder path in has_false From aae3ac5556ca05c19b670584f5ed00658e401787 Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:36:32 -0500 Subject: [PATCH 7/8] Extract shared constant and helper in has_true/has_false Extract CHUNK_FOLD_BLOCK_SIZE constant and unaligned_bit_chunks() helper to reduce duplication between has_true() and has_false(). Co-Authored-By: Claude Opus 4.6 (1M context) --- arrow-array/src/array/boolean_array.rs | 28 +++++++++++++------------- 1 file changed, 14 insertions(+), 14 deletions(-) diff --git a/arrow-array/src/array/boolean_array.rs b/arrow-array/src/array/boolean_array.rs index b718505c303d..87591395d61d 100644 --- a/arrow-array/src/array/boolean_array.rs +++ b/arrow-array/src/array/boolean_array.rs @@ -157,6 +157,16 @@ impl BooleanArray { &self.values } + /// Block size for chunked fold operations in [`Self::has_true`] and [`Self::has_false`]. + /// Folding this many u64 chunks at a time allows the compiler to autovectorize + /// the inner loop while still enabling short-circuit exits. + const CHUNK_FOLD_BLOCK_SIZE: usize = 64; + + /// Returns an [`UnalignedBitChunk`] over this array's values. + fn unaligned_bit_chunks(&self) -> UnalignedBitChunk<'_> { + UnalignedBitChunk::new(self.values().values(), self.values().offset(), self.len()) + } + /// Returns the number of non null, true values within this array. /// If you only need to check if there is at least one true value, consider using `has_true()` which can short-circuit and be more efficient. pub fn true_count(&self) -> usize { @@ -193,15 +203,11 @@ impl BooleanArray { null_chunks.zip(value_chunks).any(|(n, v)| (n & v) != 0) } None => { - let bit_chunks = UnalignedBitChunk::new( - self.values().values(), - self.values().offset(), - self.len(), - ); + let bit_chunks = self.unaligned_bit_chunks(); bit_chunks.prefix().unwrap_or(0) != 0 || bit_chunks .chunks() - .chunks(64) + .chunks(Self::CHUNK_FOLD_BLOCK_SIZE) .any(|block| block.iter().fold(0u64, |acc, &c| acc | c) != 0) || bit_chunks.suffix().unwrap_or(0) != 0 } @@ -222,11 +228,7 @@ impl BooleanArray { null_chunks.zip(value_chunks).any(|(n, v)| (n & !v) != 0) } None => { - let bit_chunks = UnalignedBitChunk::new( - self.values().values(), - self.values().offset(), - self.len(), - ); + let bit_chunks = self.unaligned_bit_chunks(); // UnalignedBitChunk zeros padding bits; fill them with 1s so // they don't appear as false values. let lead_mask = !((1u64 << bit_chunks.lead_padding()) - 1); @@ -235,8 +237,6 @@ impl BooleanArray { } else { (1u64 << (64 - bit_chunks.trailing_padding())) - 1 }; - // If both prefix and suffix exist, suffix gets trail_mask. - // If only prefix exists, it gets both masks. let (prefix_fill, suffix_fill) = match (bit_chunks.prefix(), bit_chunks.suffix()) { (Some(_), Some(_)) => (!lead_mask, !trail_mask), (Some(_), None) => (!lead_mask | !trail_mask, 0), @@ -248,7 +248,7 @@ impl BooleanArray { .is_some_and(|v| (v | prefix_fill) != u64::MAX) || bit_chunks .chunks() - .chunks(64) + .chunks(Self::CHUNK_FOLD_BLOCK_SIZE) .any(|block| block.iter().fold(u64::MAX, |acc, &c| acc & c) != u64::MAX) || bit_chunks .suffix() From b6d5325bbd995e1a270d2a05e86ce32884862afd Mon Sep 17 00:00:00 2001 From: Adrian Garcia Badaracco <1755071+adriangb@users.noreply.github.com> Date: Tue, 17 Mar 2026 14:45:41 -0500 Subject: [PATCH 8/8] add tests --- arrow-array/src/array/boolean_array.rs | 42 ++++++++++++++++++++++++++ 1 file changed, 42 insertions(+) diff --git a/arrow-array/src/array/boolean_array.rs b/arrow-array/src/array/boolean_array.rs index 87591395d61d..1a2dd986ad25 100644 --- a/arrow-array/src/array/boolean_array.rs +++ b/arrow-array/src/array/boolean_array.rs @@ -1017,4 +1017,46 @@ mod tests { assert!(arr.has_true()); assert!(!arr.has_false()); } + + #[test] + fn test_has_true_has_false_unaligned_slices() { + let cases = [ + (1, 129, true, false), + (3, 130, true, false), + (5, 65, true, false), + (7, 64, true, false), + ]; + + let base = BooleanArray::from(vec![true; 300]); + + for (offset, len, expected_has_true, expected_has_false) in cases { + let arr = base.slice(offset, len); + assert_eq!( + arr.has_true(), + expected_has_true, + "offset={offset} len={len}" + ); + assert_eq!( + arr.has_false(), + expected_has_false, + "offset={offset} len={len}" + ); + } + } + + #[test] + fn test_has_true_has_false_exact_multiples_of_64() { + let cases = [ + (64, true, false), + (128, true, false), + (192, true, false), + (256, true, false), + ]; + + for (len, expected_has_true, expected_has_false) in cases { + let arr = BooleanArray::from(vec![true; len]); + assert_eq!(arr.has_true(), expected_has_true, "len={len}"); + assert_eq!(arr.has_false(), expected_has_false, "len={len}"); + } + } }