This is an automated email from the ASF dual-hosted git repository.
dheres 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 d42610711d Add has_true() and has_false() to BooleanArray (#9511)
d42610711d is described below
commit d42610711d12a03b810bf0297d38a36029093304
Author: Adrian Garcia Badaracco <[email protected]>
AuthorDate: Wed Mar 18 00:31:25 2026 -0500
Add has_true() and has_false() to BooleanArray (#9511)
## Motivation
When working with `BooleanArray`, a common pattern is checking whether
*any* true or false value exists — e.g.
`arr.true_count() > 0` or `arr.false_count() == 0`. This currently
requires `true_count()` / `false_count()`, which scan the **entire**
bitmap to count every set bit (via `popcount`), even though we only need
to know if at least one exists.
This PR adds `has_true()` and `has_false()` methods that short-circuit
as soon as they find a matching value, providing both:
1. **Better performance** — faster on large arrays in the best case
2. **More ergonomic API** — `arr.has_true()` expresses intent more
clearly than `arr.true_count() > 0`
## Callsites in DataFusion
There are several places in DataFusion that would benefit from these
methods:
- **`datafusion/functions-nested/src/array_has.rs`** —
`eq_array.true_count() > 0` → `eq_array.has_true()`
- **`datafusion/physical-plan/src/topk/mod.rs`** — `filter.true_count()
== 0` check → `!filter.has_true()`
- **`datafusion/datasource-parquet/src/metadata.rs`** —
`exactness.true_count() == 0` and `combined_mask.true_count() > 0`
- **`datafusion/physical-plan/src/joins/nested_loop_join.rs`** —
`bitmap.true_count() == 0` checks
- **`datafusion/physical-expr-common/src/physical_expr.rs`** —
`selection_count == 0` from `selection.true_count()`
- **`datafusion/physical-expr/src/expressions/binary.rs`** —
short-circuit checks for AND/OR
## Benchmark Results
```
Scenario true_count has_true has_false
Speedup (best)
─────────────────────────────────────────────────────────────────────────────────────────────
all_true, 64 4.32 ns 4.08 ns 4.76 ns
~1.1x
all_false, 64 4.30 ns 4.25 ns 4.52 ns
~1.0x
all_true, 1024 5.15 ns 4.52 ns 4.99 ns
~1.1x
all_false, 1024 5.17 ns 4.55 ns 5.00 ns
~1.1x
mixed_early, 1024 5.22 ns — 5.04 ns
~1.0x
nulls_all_true, 1024 12.84 ns 4.10 ns 12.92 ns
~3.1x
all_true, 65536 100.06 ns 5.96 ns 49.70 ns
~16.8x (has_true)
all_false, 65536 99.33 ns 49.30 ns 6.19 ns
~16.0x (has_false)
mixed_early, 65536 100.10 ns — 6.20 ns
~16.1x (has_false)
nulls_all_true, 65536 522.94 ns 4.05 ns 521.82 ns
~129x (has_true)
```
The key wins are on larger arrays (65,536 elements), where
`has_true`/`has_false` are **up to 16-129x faster** than
`true_count()` in best-case scenarios (early short-circuit). Even in
worst case (must scan entire array), performance is
comparable to `true_count`.
## Implementation
The implementation processes bits in 64-bit chunks using
`UnalignedBitChunk`, which handles arbitrary bit offsets and aligns
data for SIMD-friendly processing.
- **`has_true` (no nulls):** OR-folds 64-bit chunks, short-circuits when
any bit is set
- **`has_false` (no nulls):** AND-folds 64-bit chunks, short-circuits
when any bit is unset (with padding bits masked to 1)
- **With nulls:** Iterates paired `(null, value)` chunks, checking `null
& value != 0` (has_true) or `null & !value != 0`
(has_false)
### Alternatives considered
1. **Fully vectorized (no early stopping):** Would process the entire
bitmap like `true_count()` but with simpler bitwise ops
instead of popcount. Marginally faster than `true_count()` but misses
the main optimization opportunity.
2. **Per-element iteration with early stopping:** `self.iter().any(|v| v
== Some(true))`. Simple but processes one bit at a
time, missing SIMD vectorization of the inner loop. Our approach
processes 64 bits at a time while still supporting early
exit.
The chosen approach balances SIMD-friendly bulk processing (64 bits per
iteration) with early termination, giving the best of
both worlds.
## Test Plan
- Unit tests covering: all-true, all-false, mixed, empty, nullable
(all-valid-true, all-valid-false, all-null), non-aligned
lengths (65 elements, 64+1 with trailing false)
- Criterion benchmarks comparing `has_true`/`has_false` vs `true_count`
across sizes (64, 1024, 65536) and data distributions
🤖 Generated with [Claude Code](https://claude.com/claude-code
---------
Co-authored-by: Claude Opus 4.6 <[email protected]>
---
arrow-array/Cargo.toml | 4 +
arrow-array/benches/boolean_array.rs | 77 ++++++++++++
arrow-array/src/array/boolean_array.rs | 209 ++++++++++++++++++++++++++++++++-
3 files changed, 288 insertions(+), 2 deletions(-)
diff --git a/arrow-array/Cargo.toml b/arrow-array/Cargo.toml
index 6be5a6daab..da8ef98a10 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 0000000000..03b601075b
--- /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<bool> = 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<Option<bool>> = (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);
diff --git a/arrow-array/src/array/boolean_array.rs
b/arrow-array/src/array/boolean_array.rs
index 582627b243..1a2dd986ad 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;
@@ -156,7 +157,18 @@ impl BooleanArray {
&self.values
}
- /// Returns the number of non null, true values within this array
+ /// 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 {
match self.nulls() {
Some(nulls) => {
@@ -171,11 +183,80 @@ 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()
}
+ /// 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 => {
+ let bit_chunks = self.unaligned_bit_chunks();
+ bit_chunks.prefix().unwrap_or(0) != 0
+ || bit_chunks
+ .chunks()
+ .chunks(Self::CHUNK_FOLD_BLOCK_SIZE)
+ .any(|block| block.iter().fold(0u64, |acc, &c| acc |
c) != 0)
+ || bit_chunks.suffix().unwrap_or(0) != 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 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);
+ let trail_mask = if bit_chunks.trailing_padding() == 0 {
+ u64::MAX
+ } else {
+ (1u64 << (64 - bit_chunks.trailing_padding())) - 1
+ };
+ 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),
+ (None, Some(_)) => (0, !trail_mask),
+ (None, None) => (0, 0),
+ };
+ bit_chunks
+ .prefix()
+ .is_some_and(|v| (v | prefix_fill) != u64::MAX)
+ || bit_chunks
+ .chunks()
+ .chunks(Self::CHUNK_FOLD_BLOCK_SIZE)
+ .any(|block| block.iter().fold(u64::MAX, |acc, &c| acc
& c) != u64::MAX)
+ || bit_chunks
+ .suffix()
+ .is_some_and(|v| (v | suffix_fill) != u64::MAX)
+ }
+ }
+ }
+
/// Returns the boolean value at index `i`.
///
/// Note: This method does not check for nulls and the value is arbitrary
@@ -854,4 +935,128 @@ 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::<bool>::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_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
+ 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());
+ }
+
+ #[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}");
+ }
+ }
}