Skip to content

Commit 4e7a2fa

Browse files
AdamGSalambclaude
authored
Improve take_bytes perf in the null cases between 10-25% (#9625)
# Which issue does this PR close? - Closes #NNN. # Rationale for this change Just improves performance, I was profiling some things downstream and got curious about how it works. # What changes are included in this PR? The main idea is to use a two-pass approach: 1. Compute byte offsets and collects (start, end) byte ranges 2. Copy byte data via raw pointer writes (`copy_byte_ranges`) This PR also reduces the branching from 4 (one for each nullability combination) to only two. # Are these changes tested? Existing tests # Are there any user-facing changes? None --------- Signed-off-by: Adam Gutglick <adam@spiraldb.com> Co-authored-by: Andrew Lamb <andrew@nerdnetworks.org> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent f001b22 commit 4e7a2fa

1 file changed

Lines changed: 164 additions & 77 deletions

File tree

arrow-select/src/take.rs

Lines changed: 164 additions & 77 deletions
Original file line numberDiff line numberDiff line change
@@ -495,99 +495,124 @@ fn take_bytes<T: ByteArrayType, IndexType: ArrowPrimitiveType>(
495495
array: &GenericByteArray<T>,
496496
indices: &PrimitiveArray<IndexType>,
497497
) -> Result<GenericByteArray<T>, ArrowError> {
498+
let mut values: Vec<u8> = Vec::new();
498499
let mut offsets = Vec::with_capacity(indices.len() + 1);
499500
offsets.push(T::Offset::default());
500501

501502
let input_offsets = array.value_offsets();
502503
let mut capacity = 0;
503504
let nulls = take_nulls(array.nulls(), indices);
504505

505-
let (offsets, values) = if array.null_count() == 0 && indices.null_count() == 0 {
506-
offsets.reserve(indices.len());
507-
for index in indices.values() {
508-
let index = index.as_usize();
509-
capacity += input_offsets[index + 1].as_usize() - input_offsets[index].as_usize();
510-
offsets.push(
511-
T::Offset::from_usize(capacity)
512-
.ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?,
513-
);
514-
}
515-
let mut values = Vec::with_capacity(capacity);
516-
517-
for index in indices.values() {
518-
values.extend_from_slice(array.value(index.as_usize()).as_ref());
519-
}
520-
(offsets, values)
521-
} else if indices.null_count() == 0 {
522-
offsets.reserve(indices.len());
523-
for index in indices.values() {
524-
let index = index.as_usize();
525-
if array.is_valid(index) {
526-
capacity += input_offsets[index + 1].as_usize() - input_offsets[index].as_usize();
506+
// Branch on output nulls — `None` means every output slot is valid.
507+
match nulls.as_ref().filter(|n| n.null_count() > 0) {
508+
// Fast path: no nulls in output, every index is valid.
509+
None => {
510+
for index in indices.values() {
511+
let index = index.as_usize();
512+
let start = input_offsets[index].as_usize();
513+
let end = input_offsets[index + 1].as_usize();
514+
capacity += end - start;
515+
offsets.push(
516+
T::Offset::from_usize(capacity)
517+
.ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?,
518+
);
527519
}
528-
offsets.push(
529-
T::Offset::from_usize(capacity)
530-
.ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?,
531-
);
532-
}
533-
let mut values = Vec::with_capacity(capacity);
534520

535-
for index in indices.values() {
536-
let index = index.as_usize();
537-
if array.is_valid(index) {
538-
values.extend_from_slice(array.value(index).as_ref());
539-
}
540-
}
541-
(offsets, values)
542-
} else if array.null_count() == 0 {
543-
offsets.reserve(indices.len());
544-
for (i, index) in indices.values().iter().enumerate() {
545-
let index = index.as_usize();
546-
if indices.is_valid(i) {
547-
capacity += input_offsets[index + 1].as_usize() - input_offsets[index].as_usize();
521+
values.reserve(capacity);
522+
523+
let dst = values.spare_capacity_mut();
524+
debug_assert!(dst.len() >= capacity);
525+
let mut offset = 0;
526+
527+
for index in indices.values() {
528+
// SAFETY: in-bounds proven by the first loop's bounds-checked offset access.
529+
// dst asserted above to include the required capacity.
530+
unsafe {
531+
let data: &[u8] = array.value_unchecked(index.as_usize()).as_ref();
532+
std::ptr::copy_nonoverlapping(
533+
data.as_ptr(),
534+
dst.get_unchecked_mut(offset..).as_mut_ptr().cast::<u8>(),
535+
data.len(),
536+
);
537+
offset += data.len();
538+
}
548539
}
549-
offsets.push(
550-
T::Offset::from_usize(capacity)
551-
.ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?,
552-
);
553-
}
554-
let mut values = Vec::with_capacity(capacity);
555540

556-
for (i, index) in indices.values().iter().enumerate() {
557-
if indices.is_valid(i) {
558-
values.extend_from_slice(array.value(index.as_usize()).as_ref());
541+
// SAFETY: wrote exactly `capacity` bytes above; reserved on line above.
542+
unsafe {
543+
values.set_len(capacity);
559544
}
560545
}
561-
(offsets, values)
562-
} else {
563-
let nulls = nulls.as_ref().unwrap();
564-
offsets.reserve(indices.len());
565-
for (i, index) in indices.values().iter().enumerate() {
566-
let index = index.as_usize();
567-
if nulls.is_valid(i) {
568-
capacity += input_offsets[index + 1].as_usize() - input_offsets[index].as_usize();
546+
// Nullable path: only process valid (non-null) output positions.
547+
Some(output_nulls) => {
548+
let mut source_ranges = Vec::with_capacity(indices.len() - output_nulls.null_count());
549+
let mut last_filled = 0;
550+
551+
// Pre-fill offsets; we overwrite valid positions below.
552+
offsets.resize(indices.len() + 1, T::Offset::default());
553+
554+
// Pass 1: find all valid ranges that need to be copied.
555+
for i in output_nulls.valid_indices() {
556+
let current_offset = T::Offset::from_usize(capacity)
557+
.ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?;
558+
// Fill offsets for skipped null slots so they get zero-length ranges.
559+
if last_filled < i {
560+
offsets[last_filled + 1..=i].fill(current_offset);
561+
}
562+
563+
// SAFETY: `i` comes from a validity bitmap over `indices`, so it is in-bounds.
564+
let index = unsafe { indices.value_unchecked(i) }.as_usize();
565+
let start = input_offsets[index].as_usize();
566+
let end = input_offsets[index + 1].as_usize();
567+
capacity += end - start;
568+
offsets[i + 1] = T::Offset::from_usize(capacity)
569+
.ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?;
570+
571+
source_ranges.push((start, end));
572+
last_filled = i + 1;
569573
}
570-
offsets.push(
571-
T::Offset::from_usize(capacity)
572-
.ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?,
574+
575+
// Fill trailing null offsets after the last valid position.
576+
let final_offset = T::Offset::from_usize(capacity)
577+
.ok_or_else(|| ArrowError::OffsetOverflowError(capacity))?;
578+
offsets[last_filled + 1..].fill(final_offset);
579+
// Pass 2: copy byte data for all collected ranges.
580+
values.reserve(capacity);
581+
debug_assert_eq!(
582+
source_ranges.iter().map(|(s, e)| e - s).sum::<usize>(),
583+
capacity,
584+
"capacity must equal total bytes across all ranges"
573585
);
574-
}
575-
let mut values = Vec::with_capacity(capacity);
576-
577-
for (i, index) in indices.values().iter().enumerate() {
578-
// check index is valid before using index. The value in
579-
// NULL index slots may not be within bounds of array
580-
let index = index.as_usize();
581-
if nulls.is_valid(i) {
582-
values.extend_from_slice(array.value(index).as_ref());
586+
587+
let src = array.value_data();
588+
let src = src.as_ptr();
589+
let dst = values.spare_capacity_mut();
590+
debug_assert!(dst.len() >= capacity);
591+
592+
let mut offset = 0;
593+
594+
for (start, end) in source_ranges.into_iter() {
595+
let value_len = end - start;
596+
// SAFETY: caller guarantees each (start, end) is in-bounds of `src`.
597+
// `dst` asserted above to include the required capacity.
598+
// The regions don't overlap (src is input, dst is a fresh allocation).
599+
unsafe {
600+
std::ptr::copy_nonoverlapping(
601+
src.add(start),
602+
dst.get_unchecked_mut(offset..).as_mut_ptr().cast::<u8>(),
603+
value_len,
604+
);
605+
offset += value_len;
606+
}
583607
}
608+
// SAFETY: caller guarantees `capacity` == total bytes across all ranges,
609+
// so the loop above wrote exactly `capacity` bytes.
610+
unsafe { values.set_len(capacity) };
584611
}
585-
(offsets, values)
586612
};
587613

588-
T::Offset::from_usize(values.len())
589-
.ok_or_else(|| ArrowError::OffsetOverflowError(values.len()))?;
590-
614+
// SAFETY: offsets are monotonically increasing and in-bounds of `values`,
615+
// and `nulls` (if present) has length == `indices.len()`.
591616
let array = unsafe {
592617
let offsets = OffsetBuffer::new_unchecked(offsets.into());
593618
GenericByteArray::<T>::new_unchecked(offsets, values.into(), nulls)
@@ -1728,6 +1753,41 @@ mod tests {
17281753
assert_eq!(result.as_ref(), &expected);
17291754
}
17301755

1756+
/// Take from a *sliced* byte array, i.e. one whose value offsets do not
1757+
/// start at zero. This exercises copying byte data out of an array with a
1758+
/// non-zero base offset for both the no-null fast path and the nullable
1759+
/// path (null indices and selected null values).
1760+
#[test]
1761+
fn test_take_bytes_sliced_values() {
1762+
let values = StringArray::from(vec![
1763+
Some("aaa"),
1764+
Some("bbb"),
1765+
None,
1766+
Some("ccccc"),
1767+
Some("dd"),
1768+
None,
1769+
Some("eeee"),
1770+
]);
1771+
// Slice so the underlying value offsets no longer start at 0:
1772+
// sliced == [None, "ccccc", "dd", None, "eeee"]
1773+
let sliced = values.slice(2, 5);
1774+
1775+
// Fast path: every output slot is valid (no null indices, no null
1776+
// values selected).
1777+
let indices = Int32Array::from(vec![1, 2, 4, 1]);
1778+
let result = take(&sliced, &indices, None).unwrap();
1779+
let expected =
1780+
StringArray::from(vec![Some("ccccc"), Some("dd"), Some("eeee"), Some("ccccc")]);
1781+
assert_eq!(result.as_string::<i32>(), &expected);
1782+
1783+
// Nullable path: a null index (position 1) and selected null values
1784+
// (sliced indices 0 and 3 are null).
1785+
let indices = Int32Array::from(vec![Some(1), None, Some(0), Some(4), Some(3)]);
1786+
let result = take(&sliced, &indices, None).unwrap();
1787+
let expected = StringArray::from(vec![Some("ccccc"), None, None, Some("eeee"), None]);
1788+
assert_eq!(result.as_string::<i32>(), &expected);
1789+
}
1790+
17311791
fn _test_byte_view<T>()
17321792
where
17331793
T: ByteViewType,
@@ -2808,11 +2868,38 @@ mod tests {
28082868
assert_eq!(array.len(), 3);
28092869
}
28102870

2871+
/// Fixture for the offset-overflow tests: a single large value plus the
2872+
/// number of times it must be selected so the cumulative offset exceeds
2873+
/// `i32::MAX`. Using a large value keeps the index count (and the test
2874+
/// runtime) small.
2875+
fn offset_overflow_fixture() -> (StringArray, usize) {
2876+
let value_len = 1_000_000;
2877+
let values = StringArray::from(vec![Some("a".repeat(value_len))]);
2878+
let n = i32::MAX as usize / value_len + 1;
2879+
(values, n)
2880+
}
2881+
28112882
#[test]
28122883
fn test_take_bytes_offset_overflow() {
2813-
let indices = Int32Array::from(vec![0; (i32::MAX >> 4) as usize]);
2814-
let text = ('a'..='z').collect::<String>();
2815-
let values = StringArray::from(vec![Some(text.clone())]);
2884+
let (values, n) = offset_overflow_fixture();
2885+
let indices = Int32Array::from(vec![0; n]);
2886+
assert!(matches!(
2887+
take(&values, &indices, None),
2888+
Err(ArrowError::OffsetOverflowError(_))
2889+
));
2890+
}
2891+
2892+
/// The offset-overflow error must also be produced on the nullable code
2893+
/// path (when the output contains nulls), not only on the no-null fast path.
2894+
#[test]
2895+
fn test_take_bytes_offset_overflow_nullable() {
2896+
let (values, n) = offset_overflow_fixture();
2897+
// A null index forces the output to contain nulls, exercising the
2898+
// nullable code path.
2899+
let validity =
2900+
NullBuffer::from_iter(std::iter::once(false).chain(std::iter::repeat_n(true, n)));
2901+
let indices = Int32Array::new(vec![0i32; n + 1].into(), Some(validity));
2902+
28162903
assert!(matches!(
28172904
take(&values, &indices, None),
28182905
Err(ArrowError::OffsetOverflowError(_))

0 commit comments

Comments
 (0)