This is an automated email from the ASF dual-hosted git repository. github-merge-queue[bot] pushed a commit to branch gh-readonly-queue/main/pr-11829-d03274a7f69d9ec18e2d74920f8b880dd0a722a0 in repository https://gitbox.apache.org/repos/asf/arrow-rs.git
commit b90914fecd0ba85a8827f386af3bcc51cce5b162 Author: Matt Butrovich <[email protected]> AuthorDate: Fri Oct 23 05:35:26 2026 +0000 fix(arrow-select): bounds-check take_record_batch once, with a faster check_bounds for null indices (#11829) # Which issue does this PR close? - Closes #11825. # Rationale for this change #10945 is not in a release yet, so this fix should go in 60.1.0 (#10525). # What changes are included in this PR? `take_record_batch` now checks the indices once against the row count with `check_bounds`, then takes every column unchecked. It used to rely on a checked take of the first column, which reads no index for some types. In local testing, the new `check_bounds` call made `take_record_batch` with null indices noticeably slower, because `check_bounds` checks null indices one at a time. So this PR also makes `check_bounds` run its vectorized loop first, and fall back to the null-aware loop only if that finds a value out of bounds. New benchmarks cover this path. # Are these changes tested? `take_record_batch` had no tests of its own, only its doc example and one test through `BatchCoalescer::push_batch_with_indices`. The new `test_take_record_batch*` tests in `arrow-select/src/take.rs` cover columns whose take reads no index, out-of-bounds and negative indices, null index slots holding out-of-bounds values, empty batches, and batches with no columns. On the benchmark bot, the `check_bounds` optimization makes `take` with null indices 1.7-1.9x faster ([default](https://github.com/apache/arrow-rs/pull/11829#issuecomment-6350317380), [aligned](https://github.com/apache/arrow-rs/pull/11829#issuecomment-6350327308)). `take_record_batch` is within 3% of `main`, except 5 primitive columns, which is 7-8% slower ([default](https://github.com/apache/arrow-rs/pull/11829#issuecomment-6350319889), [aligned](https://github.com/apache/arrow-rs/pull/11829#issuecomment-6350328767)). Against 60.0.0 it is 1-4% faster, except string and binary columns ([run](https://github.com/apache/arrow-rs/pull/11829#issuecomment-6351018551)). <img width="1600" height="452" alt="check-bounds-bot" src="https://github.com/user-attachments/assets/9f589430-3197-4a08-9608-7a186347f1e0" /> <img width="1600" height="868" alt="take-record-batch-bot" src="https://github.com/user-attachments/assets/7fd88471-346c-4f84-929d-52ddb4429c8f" /> # Are there any significant user-facing changes? `take_record_batch` returns an error for an out-of-bounds index. 60.0.0 panicked for most column types, and accepted the index when no column read it, for example in a batch of only `Null` columns. --- arrow-select/src/take.rs | 195 +++++++++++++++++++++++++++++++++++++----- arrow/benches/take_kernels.rs | 13 +++ 2 files changed, 185 insertions(+), 23 deletions(-) diff --git a/arrow-select/src/take.rs b/arrow-select/src/take.rs index e4e5f534bf..410fff1a48 100644 --- a/arrow-select/src/take.rs +++ b/arrow-select/src/take.rs @@ -207,7 +207,14 @@ where }; }; - if indices.null_count() > 0 { + // Null slots usually hold in-bounds values, so first check every value with a loop that + // vectorizes, and look at validity only if that finds one out of bounds + let in_bounds = indices.values().iter().fold(true, |in_bounds, &i| { + in_bounds & (i >= T::Native::ZERO) & (i < len) + }); + if in_bounds { + Ok(()) + } else if indices.null_count() > 0 { indices.iter().flatten().try_for_each(|index| { if index >= len { return Err(ArrowError::ComputeError(format!( @@ -217,20 +224,13 @@ where Ok(()) }) } else { - let in_bounds = indices.values().iter().fold(true, |in_bounds, &i| { - in_bounds & (i >= T::Native::ZERO) & (i < len) - }); - - if !in_bounds { - for &index in indices.values() { - if index < T::Native::ZERO || index >= len { - return Err(ArrowError::ComputeError(format!( - "Array index out of bounds, cannot get item at index {index} from {len} entries" - ))); - } + for &index in indices.values() { + if index < T::Native::ZERO || index >= len { + return Err(ArrowError::ComputeError(format!( + "Array index out of bounds, cannot get item at index {index} from {len} entries" + ))); } } - Ok(()) } } @@ -1671,6 +1671,12 @@ to_indices_reinterpret!(Int64Type, UInt64Type); /// /// This function will call [`take`] on each array of the [`RecordBatch`] and assemble a new [`RecordBatch`]. /// +/// # Errors +/// This function errors whenever: +/// * `indices` is not an integer array +/// * A non-null index is not less than the number of rows +/// * A taken column's offsets overflow, e.g. more than `i32::MAX` bytes of `Utf8` data +/// /// # Example /// ``` /// # use std::sync::Arc; @@ -1712,15 +1718,16 @@ pub fn take_record_batch( downcast_integer_array!( indices => { let indices = indices.to_indices(); - let cols = record_batch.columns(); - let mut columns = Vec::with_capacity(cols.len()); - if let Some(first) = cols.first() { - columns.push(take_impl::<_, true>(first.as_ref(), &indices)?); - for col in &cols[1..] { - columns.push(take_impl::<_, false>(col.as_ref(), &indices)?); - } - } - // Safety: indices were validated by the first take_impl call + // Check once against the row count, which every column shares. A checked take of + // one column would not do: some types (e.g. `Null`) read no index. + check_bounds(record_batch.num_rows(), &indices)?; + let columns = record_batch + .columns() + .iter() + .map(|c| take_impl::<_, false>(c.as_ref(), &indices)) + .collect::<Result<Vec<_>, _>>()?; + // Safety: take returns one column per input column, of the same type and with + // indices.len() rows Ok(unsafe { RecordBatch::new_unchecked(record_batch.schema(), columns, indices.len()) }) }, d => Err(ArrowError::InvalidArgumentError(format!("Take only supported for integers, got {d:?}"))) @@ -1732,7 +1739,7 @@ mod tests { use super::*; use arrow_array::builder::*; use arrow_buffer::{IntervalDayTime, IntervalMonthDayNano}; - use arrow_schema::{Field, Fields, TimeUnit, UnionFields}; + use arrow_schema::{Field, Fields, Schema, TimeUnit, UnionFields}; use num_traits::ToPrimitive; fn test_take_decimal_arrays( @@ -4200,4 +4207,146 @@ mod tests { assert_eq!(result.len(), 2); } + + /// Columns whose `take` reads no index, so taking them alone does not check bounds + fn zero_width_columns(len: usize) -> Vec<ArrayRef> { + vec![ + Arc::new(NullArray::new(len)), + Arc::new(StructArray::new_empty_fields(len, None)), + Arc::new( + FixedSizeListArray::try_new_with_length( + Field::new_list_field(DataType::Int32, true).into(), + 0, + Arc::new(Int32Array::new_null(0)), + None, + len, + ) + .unwrap(), + ), + Arc::new( + FixedSizeBinaryArray::try_new_with_len( + 0, + Buffer::from(Vec::<u8>::new()), + None, + len, + ) + .unwrap(), + ), + ] + } + + /// A batch of 3 rows with `first` followed by columns whose `take` reads every index + fn batch_with_first_column(first: ArrayRef) -> RecordBatch { + RecordBatch::try_from_iter([ + ("first", first), + ( + "i", + Arc::new(Int32Array::from(vec![Some(1), None, Some(3)])) as ArrayRef, + ), + ("s", Arc::new(StringArray::from(vec!["a", "bb", "ccc"]))), + ( + "l", + Arc::new(ListArray::from_iter_primitive::<Int32Type, _, _>(vec![ + Some(vec![Some(1)]), + None, + Some(vec![Some(3), None]), + ])), + ), + ]) + .unwrap() + } + + #[test] + fn test_take_record_batch() { + let first: ArrayRef = Arc::new(BooleanArray::from(vec![true, false, true])); + let mut columns = vec![first]; + columns.extend(zero_width_columns(3)); + for first in columns { + let batch = batch_with_first_column(first); + let indices = UInt32Array::from(vec![Some(2), None, Some(0), Some(2)]); + let taken = take_record_batch(&batch, &indices).unwrap(); + + assert_eq!(taken.schema(), batch.schema()); + assert_eq!(taken.num_rows(), 4); + for (taken, column) in taken.columns().iter().zip(batch.columns()) { + assert_eq!(taken, &take(column, &indices, None).unwrap()); + } + } + } + + #[test] + fn test_take_record_batch_out_of_bounds() { + let indices: [ArrayRef; 5] = [ + // One past the end + Arc::new(UInt32Array::from(vec![0, 3])), + Arc::new(UInt32Array::from(vec![None, Some(3)])), + Arc::new(Int32Array::from(vec![-1])), + Arc::new(Int8Array::from(vec![i8::MAX])), + Arc::new(UInt64Array::from(vec![u64::MAX])), + ]; + let first: ArrayRef = Arc::new(Int64Array::from(vec![1, 2, 3])); + let mut columns = vec![first]; + columns.extend(zero_width_columns(3)); + for first in columns { + let batch = batch_with_first_column(first); + for indices in &indices { + let err = take_record_batch(&batch, indices).unwrap_err(); + assert!( + err.to_string().contains("Array index out of bounds"), + "{}: {err}", + batch.column(0).data_type() + ); + } + } + } + + #[test] + fn test_take_record_batch_null_index_out_of_bounds_value() { + // The value in a null index slot is never read, so it need not be in bounds + let indices = UInt32Array::new( + vec![2, u32::MAX, 0].into(), + Some(NullBuffer::from(vec![true, false, true])), + ); + for first in zero_width_columns(3) { + let batch = batch_with_first_column(first); + let taken = take_record_batch(&batch, &indices).unwrap(); + + assert_eq!(taken.num_rows(), 3); + assert_eq!( + taken.column(1).as_primitive::<Int32Type>(), + &Int32Array::from(vec![Some(3), None, Some(1)]) + ); + assert_eq!( + taken.column(2).as_string::<i32>(), + &StringArray::from(vec![Some("ccc"), None, Some("a")]) + ); + } + } + + #[test] + fn test_take_record_batch_empty() { + let batch = batch_with_first_column(Arc::new(NullArray::new(3))).slice(0, 0); + + let taken = take_record_batch(&batch, &UInt32Array::from(Vec::<u32>::new())).unwrap(); + assert_eq!(taken.num_rows(), 0); + assert_eq!(taken.schema(), batch.schema()); + + let taken = take_record_batch(&batch, &UInt32Array::from(vec![None, None])).unwrap(); + assert_eq!(taken.num_rows(), 2); + assert_eq!(taken.column(1).null_count(), 2); + + assert!(take_record_batch(&batch, &UInt32Array::from(vec![0])).is_err()); + } + + #[test] + fn test_take_record_batch_no_columns() { + let options = RecordBatchOptions::new().with_row_count(Some(3)); + let batch = + RecordBatch::try_new_with_options(Arc::new(Schema::empty()), vec![], &options).unwrap(); + + let taken = take_record_batch(&batch, &UInt32Array::from(vec![2, 0, 2, 1])).unwrap(); + assert_eq!(taken.num_rows(), 4); + + assert!(take_record_batch(&batch, &UInt32Array::from(vec![3])).is_err()); + } } diff --git a/arrow/benches/take_kernels.rs b/arrow/benches/take_kernels.rs index e7d7fe97dd..2e93028e01 100644 --- a/arrow/benches/take_kernels.rs +++ b/arrow/benches/take_kernels.rs @@ -201,6 +201,19 @@ fn add_benchmark(c: &mut Criterion) { c.bench_function("take check bounds i32 1024", |b| { b.iter(|| bench_take_bounds_check(&values, &indices)) }); + let indices = create_random_index(1024, 0.5); + c.bench_function("take check bounds i32 null indices 1024", |b| { + b.iter(|| bench_take_bounds_check(&values, &indices)) + }); + // The value in a null slot is unspecified and need not be in bounds + let indices = UInt32Array::new( + indices.iter().map(|i| i.unwrap_or(u32::MAX)).collect(), + indices.nulls().cloned(), + ); + c.bench_function( + "take check bounds i32 null indices out of bounds 1024", + |b| b.iter(|| bench_take_bounds_check(&values, &indices)), + ); let values = create_boolean_array(512, 0.0, 0.5); let indices = create_random_index(512, 0.0);
