comphead commented on code in PR #25607:
URL: https://github.com/apache/datafusion/pull/25607#discussion_r4084774285
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
+ .then(|| values.slice(start, visible_len));
+ let values = sliced_values.as_ref().unwrap_or(values);
// Use logical nulls so element types without a validity buffer
// (e.g. NullArray) are still treated as null.
- let Some(values_nulls) = values.logical_nulls() else {
- // Fast path: no validity buffer, no nulls to remove
- return Ok(Arc::new(list_array.clone()));
+ let Some(values_nulls) = values
+ .logical_nulls()
+ .filter(|nulls| nulls.null_count() != 0)
+ else {
+ return Ok(Arc::clone(input_array));
};
- if values_nulls.null_count() == 0 {
- // Fast path: validity buffer present but no nulls set
- return Ok(Arc::new(list_array.clone()));
+ if values_nulls.null_count() == values_nulls.len() {
+ return Ok(empty_list_values(list_array, Arc::clone(field)));
}
- let list_nulls = list_array.nulls();
+ compact_list_values(list_array, field, values.as_ref(), &values_nulls)
+}
+
+/// Copy primitive values and build list offsets in one pass. For other types,
+/// build offsets and a keep bitmap, then copy Utf8/LargeUtf8 strings in valid
+/// spans or use Arrow's filter kernel for the remaining types.
+fn compact_list_values<O: OffsetSizeTrait>(
+ list_array: &GenericListArray<O>,
+ field: &FieldRef,
+ values: &dyn Array,
+ values_nulls: &NullBuffer,
+) -> Result<ArrayRef> {
+ let (offsets, new_values) = downcast_primitive_array! {
+ values => Ok(compact_primitive(values, list_array, values_nulls)),
+ _ => compact_non_primitive(values, list_array, values_nulls),
+ }?;
+ Ok(Arc::new(GenericListArray::<O>::try_new(
+ Arc::clone(field),
+ offsets,
+ new_values,
+ list_array.nulls().cloned(),
+ )?))
+}
+
+fn compact_primitive<T: ArrowPrimitiveType, O: OffsetSizeTrait>(
+ values: &PrimitiveArray<T>,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> (OffsetBuffer<O>, ArrayRef) {
let list_offsets = list_array.offsets();
- let original_data = values.to_data();
- let (first_offset, visible_len) = offset_span(list_offsets);
- let capacity =
- visible_len - values_nulls.slice(first_offset,
visible_len).null_count();
- let mut offsets = Vec::<O>::with_capacity(list_array.len() + 1);
+ let first_offset = list_offsets[0].as_usize();
+ let mut output = Vec::with_capacity(values_nulls.len() -
values_nulls.null_count());
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
offsets.push(O::zero());
- let mut mutable = MutableArrayData::with_capacities(
- vec![&original_data],
- false,
- Capacities::Array(capacity),
- );
-
- for row_index in 0..list_array.len() {
- if list_nulls.is_some_and(|n| n.is_null(row_index)) {
- offsets.push(offsets[row_index]);
- continue;
+ for (row, window) in list_offsets.windows(2).enumerate() {
+ if list_array.is_valid(row) {
+ let start = window[0].as_usize() - first_offset;
+ let end = window[1].as_usize() - first_offset;
+ for i in start..end {
Review Comment:
Is the fused scalar loop actually beating `offsets + filter` for primitives?
Two independent out-of-tree harnesses both found the generic shape faster at
nearly every null density, by 1.15x to 4.5x (1M Int32, rows of 16, at 50% null:
~4.07 ms for this loop vs ~0.92 ms for per-row `count_set_bits_offset` plus
`filter` on a validity-stripped child). The per-element `if is_valid { push }`
is a branch per element and mispredicts badly at moderate density, whereas
arrow's `filter_native` picks between run copies and a branch-free gather via
`IterationStrategy::default_strategy`.
Important caveat that cuts the other way: with many **null parent rows**
this loop wins, because it skips whole rows. One harness measured 3.48 ms vs
6.97 ms at 50% null parents. So this is not a clear-cut delete.
Both measurements are out-of-tree, so please treat them as a prompt for an
in-tree number rather than a conclusion. If the fused loop does win on the
target shape, that data is worth putting in the PR body, along with the
row-length regime, because the three-path ladder is hard to justify by
inspection. If it does not, dropping `compact_primitive` also removes the
`downcast_primitive_array!` dispatch at line 168 and roughly 30 primitive types
x 2 offset widths of monomorphization.
Separately, line 187: `Vec::with_capacity(values_nulls.len() -
values_nulls.null_count())` counts valid children across the whole visible span
**including those under null parent rows**, which this loop never pushes.
`ScalarBuffer::from(Vec)` preserves the allocation, so the excess is retained
for the array's lifetime and shows up in `get_buffer_memory_size()`. Measured
100% over-allocation at 50% null parents. Slightly ironic given
`check_sliced_list_behavior` exists to catch exactly this class of
over-reservation.
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
+ .then(|| values.slice(start, visible_len));
+ let values = sliced_values.as_ref().unwrap_or(values);
// Use logical nulls so element types without a validity buffer
// (e.g. NullArray) are still treated as null.
- let Some(values_nulls) = values.logical_nulls() else {
- // Fast path: no validity buffer, no nulls to remove
- return Ok(Arc::new(list_array.clone()));
+ let Some(values_nulls) = values
+ .logical_nulls()
+ .filter(|nulls| nulls.null_count() != 0)
+ else {
+ return Ok(Arc::clone(input_array));
};
- if values_nulls.null_count() == 0 {
- // Fast path: validity buffer present but no nulls set
- return Ok(Arc::new(list_array.clone()));
+ if values_nulls.null_count() == values_nulls.len() {
+ return Ok(empty_list_values(list_array, Arc::clone(field)));
}
- let list_nulls = list_array.nulls();
+ compact_list_values(list_array, field, values.as_ref(), &values_nulls)
+}
+
+/// Copy primitive values and build list offsets in one pass. For other types,
+/// build offsets and a keep bitmap, then copy Utf8/LargeUtf8 strings in valid
+/// spans or use Arrow's filter kernel for the remaining types.
+fn compact_list_values<O: OffsetSizeTrait>(
+ list_array: &GenericListArray<O>,
+ field: &FieldRef,
+ values: &dyn Array,
+ values_nulls: &NullBuffer,
+) -> Result<ArrayRef> {
+ let (offsets, new_values) = downcast_primitive_array! {
+ values => Ok(compact_primitive(values, list_array, values_nulls)),
+ _ => compact_non_primitive(values, list_array, values_nulls),
+ }?;
+ Ok(Arc::new(GenericListArray::<O>::try_new(
+ Arc::clone(field),
+ offsets,
+ new_values,
+ list_array.nulls().cloned(),
+ )?))
+}
+
+fn compact_primitive<T: ArrowPrimitiveType, O: OffsetSizeTrait>(
+ values: &PrimitiveArray<T>,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> (OffsetBuffer<O>, ArrayRef) {
let list_offsets = list_array.offsets();
- let original_data = values.to_data();
- let (first_offset, visible_len) = offset_span(list_offsets);
- let capacity =
- visible_len - values_nulls.slice(first_offset,
visible_len).null_count();
- let mut offsets = Vec::<O>::with_capacity(list_array.len() + 1);
+ let first_offset = list_offsets[0].as_usize();
+ let mut output = Vec::with_capacity(values_nulls.len() -
values_nulls.null_count());
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
offsets.push(O::zero());
- let mut mutable = MutableArrayData::with_capacities(
- vec![&original_data],
- false,
- Capacities::Array(capacity),
- );
-
- for row_index in 0..list_array.len() {
- if list_nulls.is_some_and(|n| n.is_null(row_index)) {
- offsets.push(offsets[row_index]);
- continue;
+ for (row, window) in list_offsets.windows(2).enumerate() {
+ if list_array.is_valid(row) {
+ let start = window[0].as_usize() - first_offset;
+ let end = window[1].as_usize() - first_offset;
+ for i in start..end {
+ if values_nulls.is_valid(i) {
+ output.push(values.value(i));
+ }
+ }
}
+ offsets.push(O::usize_as(output.len()));
+ }
+ let output = PrimitiveArray::<T>::new(output.into(), None)
+ .with_data_type(values.data_type().clone());
+ (OffsetBuffer::new(offsets.into()), Arc::new(output))
+}
- let start = list_offsets[row_index].as_usize();
- let end = list_offsets[row_index + 1].as_usize();
- let row_null_count = values_nulls.slice(start, end -
start).null_count();
- let kept = (end - start) - row_null_count;
-
- // Batch consecutive non-null elements into single extend() calls
- // to reduce per-element overhead. For [1, 2, NULL, 3, 4] this
- // produces 2 extend calls (0..2, 3..5) instead of 4 individual ones.
- let mut batch_start: Option<usize> = None;
- for i in start..end {
- if values_nulls.is_null(i) {
- // Null breaks the current batch — flush it
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, i)?;
- batch_start = None;
+#[inline(never)]
+fn compact_non_primitive<O: OffsetSizeTrait>(
+ values: &dyn Array,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> Result<(OffsetBuffer<O>, ArrayRef)> {
+ let first_offset = list_array.offsets()[0].as_usize();
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
+ offsets.push(O::zero());
+ let mut count = 0;
+ // Child validity is already the keep mask unless null parents hide values.
+ let mut mask =
+ (list_array.null_count() != 0).then(||
BooleanBufferBuilder::new(values.len()));
+ for (row, window) in list_array.offsets().windows(2).enumerate() {
+ let start = window[0].as_usize() - first_offset;
+ let len = window[1].as_usize() - window[0].as_usize();
+ if list_array.is_valid(row) {
+ let bit_start = values_nulls.offset() + start;
+ count += values_nulls.buffer().count_set_bits_offset(bit_start,
len);
+ if let Some(mask) = &mut mask {
+ // Fill the gap left by any preceding null parent rows.
+ if start > mask.len() {
+ mask.append_n(start - mask.len(), false);
}
- } else if batch_start.is_none() {
- batch_start = Some(i);
+ mask.append_packed_range(
+ bit_start..bit_start + len,
+ values_nulls.validity(),
+ );
}
}
- // Flush any remaining batch after the loop
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, end)?;
- }
-
- offsets.push(offsets[row_index] + O::usize_as(kept));
+ offsets.push(O::usize_as(count));
}
+ let mask = mask
+ .map(|mut mask| {
+ mask.append_n(values.len() - mask.len(), false);
+ mask.finish()
+ })
+ .unwrap_or_else(|| values_nulls.inner().clone());
+ let output = match values.data_type() {
+ DataType::Utf8 => copy_string_spans(values.as_string::<i32>(), &mask,
count),
+ DataType::LargeUtf8 => copy_string_spans(values.as_string::<i64>(),
&mask, count),
+ _ => filter(values, &BooleanArray::new(mask, None))?,
Review Comment:
Worth stripping the child's validity buffer before calling `filter`.
`FilterPredicate::filter_nulls` (arrow-select 60.0.0 `filter.rs:511-532`)
early-returns only when `nulls.null_count() == 0`, which is never true here
since child nulls are the whole premise of `array_compact`. So it runs a full
`filter_bits` over the validity bitmap plus a popcount, discovers `null_count
== 0`, and throws the result away. That is guaranteed waste on every call,
because the mask is a subset of the validity.
Measured out-of-tree, 1M values: Utf8 goes 1.784 ms to 1.527 ms at 1% null
and 2.955 ms to 1.882 ms at 5% null. Int32 roughly doubles to triples.
The nice part is that this generalizes what `copy_string_spans` already does
by hand. Line 288 gets the win by passing `None` for nulls, but only for the
two types it reimplements. Boolean, Binary, StringView, Struct, List,
Dictionary, RunEndEncoded, and Map all still pay.
`values.to_data().into_builder().nulls(None)` is cheap (buffer `Arc` clones)
and is safe here since `mask` is a subset of `logical_nulls`.
Upstream follow-up worth filing: `filter_nulls` could pre-check with an
AND-NOT popcount (`count_set_bits(filter & !validity) == 0`, O(n/64) word ops)
before the O(n) `filter_bits`. That would benefit every arrow caller and remove
most of the motivation for `copy_string_spans` living in DataFusion.
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
+ .then(|| values.slice(start, visible_len));
+ let values = sliced_values.as_ref().unwrap_or(values);
// Use logical nulls so element types without a validity buffer
// (e.g. NullArray) are still treated as null.
- let Some(values_nulls) = values.logical_nulls() else {
- // Fast path: no validity buffer, no nulls to remove
- return Ok(Arc::new(list_array.clone()));
+ let Some(values_nulls) = values
+ .logical_nulls()
+ .filter(|nulls| nulls.null_count() != 0)
+ else {
+ return Ok(Arc::clone(input_array));
};
- if values_nulls.null_count() == 0 {
- // Fast path: validity buffer present but no nulls set
- return Ok(Arc::new(list_array.clone()));
+ if values_nulls.null_count() == values_nulls.len() {
+ return Ok(empty_list_values(list_array, Arc::clone(field)));
}
- let list_nulls = list_array.nulls();
+ compact_list_values(list_array, field, values.as_ref(), &values_nulls)
+}
+
+/// Copy primitive values and build list offsets in one pass. For other types,
+/// build offsets and a keep bitmap, then copy Utf8/LargeUtf8 strings in valid
+/// spans or use Arrow's filter kernel for the remaining types.
+fn compact_list_values<O: OffsetSizeTrait>(
+ list_array: &GenericListArray<O>,
+ field: &FieldRef,
+ values: &dyn Array,
+ values_nulls: &NullBuffer,
+) -> Result<ArrayRef> {
+ let (offsets, new_values) = downcast_primitive_array! {
+ values => Ok(compact_primitive(values, list_array, values_nulls)),
+ _ => compact_non_primitive(values, list_array, values_nulls),
+ }?;
+ Ok(Arc::new(GenericListArray::<O>::try_new(
+ Arc::clone(field),
+ offsets,
+ new_values,
+ list_array.nulls().cloned(),
+ )?))
+}
+
+fn compact_primitive<T: ArrowPrimitiveType, O: OffsetSizeTrait>(
+ values: &PrimitiveArray<T>,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> (OffsetBuffer<O>, ArrayRef) {
let list_offsets = list_array.offsets();
- let original_data = values.to_data();
- let (first_offset, visible_len) = offset_span(list_offsets);
- let capacity =
- visible_len - values_nulls.slice(first_offset,
visible_len).null_count();
- let mut offsets = Vec::<O>::with_capacity(list_array.len() + 1);
+ let first_offset = list_offsets[0].as_usize();
+ let mut output = Vec::with_capacity(values_nulls.len() -
values_nulls.null_count());
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
offsets.push(O::zero());
- let mut mutable = MutableArrayData::with_capacities(
- vec![&original_data],
- false,
- Capacities::Array(capacity),
- );
-
- for row_index in 0..list_array.len() {
- if list_nulls.is_some_and(|n| n.is_null(row_index)) {
- offsets.push(offsets[row_index]);
- continue;
+ for (row, window) in list_offsets.windows(2).enumerate() {
+ if list_array.is_valid(row) {
+ let start = window[0].as_usize() - first_offset;
+ let end = window[1].as_usize() - first_offset;
+ for i in start..end {
+ if values_nulls.is_valid(i) {
+ output.push(values.value(i));
+ }
+ }
}
+ offsets.push(O::usize_as(output.len()));
+ }
+ let output = PrimitiveArray::<T>::new(output.into(), None)
+ .with_data_type(values.data_type().clone());
+ (OffsetBuffer::new(offsets.into()), Arc::new(output))
+}
- let start = list_offsets[row_index].as_usize();
- let end = list_offsets[row_index + 1].as_usize();
- let row_null_count = values_nulls.slice(start, end -
start).null_count();
- let kept = (end - start) - row_null_count;
-
- // Batch consecutive non-null elements into single extend() calls
- // to reduce per-element overhead. For [1, 2, NULL, 3, 4] this
- // produces 2 extend calls (0..2, 3..5) instead of 4 individual ones.
- let mut batch_start: Option<usize> = None;
- for i in start..end {
- if values_nulls.is_null(i) {
- // Null breaks the current batch — flush it
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, i)?;
- batch_start = None;
+#[inline(never)]
+fn compact_non_primitive<O: OffsetSizeTrait>(
+ values: &dyn Array,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> Result<(OffsetBuffer<O>, ArrayRef)> {
+ let first_offset = list_array.offsets()[0].as_usize();
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
+ offsets.push(O::zero());
+ let mut count = 0;
+ // Child validity is already the keep mask unless null parents hide values.
+ let mut mask =
+ (list_array.null_count() != 0).then(||
BooleanBufferBuilder::new(values.len()));
+ for (row, window) in list_array.offsets().windows(2).enumerate() {
+ let start = window[0].as_usize() - first_offset;
+ let len = window[1].as_usize() - window[0].as_usize();
+ if list_array.is_valid(row) {
+ let bit_start = values_nulls.offset() + start;
+ count += values_nulls.buffer().count_set_bits_offset(bit_start,
len);
+ if let Some(mask) = &mut mask {
+ // Fill the gap left by any preceding null parent rows.
+ if start > mask.len() {
+ mask.append_n(start - mask.len(), false);
}
- } else if batch_start.is_none() {
- batch_start = Some(i);
+ mask.append_packed_range(
+ bit_start..bit_start + len,
+ values_nulls.validity(),
+ );
}
}
- // Flush any remaining batch after the loop
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, end)?;
- }
-
- offsets.push(offsets[row_index] + O::usize_as(kept));
+ offsets.push(O::usize_as(count));
}
+ let mask = mask
+ .map(|mut mask| {
+ mask.append_n(values.len() - mask.len(), false);
+ mask.finish()
+ })
+ .unwrap_or_else(|| values_nulls.inner().clone());
+ let output = match values.data_type() {
+ DataType::Utf8 => copy_string_spans(values.as_string::<i32>(), &mask,
count),
+ DataType::LargeUtf8 => copy_string_spans(values.as_string::<i64>(),
&mask, count),
+ _ => filter(values, &BooleanArray::new(mask, None))?,
+ };
+ Ok((OffsetBuffer::new(offsets.into()), output))
+}
- let new_values = make_array(mutable.freeze());
- Ok(Arc::new(GenericListArray::<O>::try_new(
- Arc::clone(field),
- OffsetBuffer::new(offsets.into()),
- new_values,
- list_nulls.cloned(),
- )?))
+/// Copy adjacent retained strings together. The mask selects only non-null
+/// strings, so the output does not need a validity bitmap.
+#[inline(always)]
Review Comment:
Neither inline attribute has a rationale comment, and they pull against each
other: this force-inlines a 35-line function into a function marked
`#[inline(never)]` at line 207.
Measured in-tree, Utf8 child, 1M values:
| variant | 10% null | 50% null |
| --- | --- | --- |
| as-is | 2.03-2.08 ms | 5.22-5.42 ms |
| minus `#[inline(always)]` | 1.73-1.83 ms | 5.10-5.27 ms |
| minus both | 1.73-1.83 ms | 5.10-5.21 ms |
So `#[inline(always)]` costs about 14% on the hot path, and
`#[inline(never)]` is measurement-neutral. I also checked the codegen theory
that would justify the latter: `downcast_primitive_array!` expands its fallback
arm once, not once per primitive type, so `compact_non_primitive` is
instantiated twice regardless of the attribute.
Suggest deleting both lines. If either is load-bearing, a one-line comment
with the measured effect would help, since otherwise the next person cannot
tell whether removing them regresses anything.
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
+ .then(|| values.slice(start, visible_len));
+ let values = sliced_values.as_ref().unwrap_or(values);
// Use logical nulls so element types without a validity buffer
// (e.g. NullArray) are still treated as null.
- let Some(values_nulls) = values.logical_nulls() else {
- // Fast path: no validity buffer, no nulls to remove
- return Ok(Arc::new(list_array.clone()));
+ let Some(values_nulls) = values
+ .logical_nulls()
+ .filter(|nulls| nulls.null_count() != 0)
+ else {
+ return Ok(Arc::clone(input_array));
};
- if values_nulls.null_count() == 0 {
- // Fast path: validity buffer present but no nulls set
- return Ok(Arc::new(list_array.clone()));
+ if values_nulls.null_count() == values_nulls.len() {
+ return Ok(empty_list_values(list_array, Arc::clone(field)));
}
- let list_nulls = list_array.nulls();
+ compact_list_values(list_array, field, values.as_ref(), &values_nulls)
+}
+
+/// Copy primitive values and build list offsets in one pass. For other types,
+/// build offsets and a keep bitmap, then copy Utf8/LargeUtf8 strings in valid
+/// spans or use Arrow's filter kernel for the remaining types.
+fn compact_list_values<O: OffsetSizeTrait>(
+ list_array: &GenericListArray<O>,
+ field: &FieldRef,
+ values: &dyn Array,
+ values_nulls: &NullBuffer,
+) -> Result<ArrayRef> {
+ let (offsets, new_values) = downcast_primitive_array! {
+ values => Ok(compact_primitive(values, list_array, values_nulls)),
+ _ => compact_non_primitive(values, list_array, values_nulls),
+ }?;
+ Ok(Arc::new(GenericListArray::<O>::try_new(
+ Arc::clone(field),
+ offsets,
+ new_values,
+ list_array.nulls().cloned(),
+ )?))
+}
+
+fn compact_primitive<T: ArrowPrimitiveType, O: OffsetSizeTrait>(
+ values: &PrimitiveArray<T>,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> (OffsetBuffer<O>, ArrayRef) {
let list_offsets = list_array.offsets();
- let original_data = values.to_data();
- let (first_offset, visible_len) = offset_span(list_offsets);
- let capacity =
- visible_len - values_nulls.slice(first_offset,
visible_len).null_count();
- let mut offsets = Vec::<O>::with_capacity(list_array.len() + 1);
+ let first_offset = list_offsets[0].as_usize();
+ let mut output = Vec::with_capacity(values_nulls.len() -
values_nulls.null_count());
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
offsets.push(O::zero());
- let mut mutable = MutableArrayData::with_capacities(
- vec![&original_data],
- false,
- Capacities::Array(capacity),
- );
-
- for row_index in 0..list_array.len() {
- if list_nulls.is_some_and(|n| n.is_null(row_index)) {
- offsets.push(offsets[row_index]);
- continue;
+ for (row, window) in list_offsets.windows(2).enumerate() {
+ if list_array.is_valid(row) {
+ let start = window[0].as_usize() - first_offset;
+ let end = window[1].as_usize() - first_offset;
+ for i in start..end {
+ if values_nulls.is_valid(i) {
+ output.push(values.value(i));
+ }
+ }
}
+ offsets.push(O::usize_as(output.len()));
+ }
+ let output = PrimitiveArray::<T>::new(output.into(), None)
+ .with_data_type(values.data_type().clone());
+ (OffsetBuffer::new(offsets.into()), Arc::new(output))
+}
- let start = list_offsets[row_index].as_usize();
- let end = list_offsets[row_index + 1].as_usize();
- let row_null_count = values_nulls.slice(start, end -
start).null_count();
- let kept = (end - start) - row_null_count;
-
- // Batch consecutive non-null elements into single extend() calls
- // to reduce per-element overhead. For [1, 2, NULL, 3, 4] this
- // produces 2 extend calls (0..2, 3..5) instead of 4 individual ones.
- let mut batch_start: Option<usize> = None;
- for i in start..end {
- if values_nulls.is_null(i) {
- // Null breaks the current batch — flush it
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, i)?;
- batch_start = None;
+#[inline(never)]
+fn compact_non_primitive<O: OffsetSizeTrait>(
+ values: &dyn Array,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> Result<(OffsetBuffer<O>, ArrayRef)> {
+ let first_offset = list_array.offsets()[0].as_usize();
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
+ offsets.push(O::zero());
+ let mut count = 0;
+ // Child validity is already the keep mask unless null parents hide values.
+ let mut mask =
+ (list_array.null_count() != 0).then(||
BooleanBufferBuilder::new(values.len()));
+ for (row, window) in list_array.offsets().windows(2).enumerate() {
+ let start = window[0].as_usize() - first_offset;
+ let len = window[1].as_usize() - window[0].as_usize();
+ if list_array.is_valid(row) {
+ let bit_start = values_nulls.offset() + start;
+ count += values_nulls.buffer().count_set_bits_offset(bit_start,
len);
+ if let Some(mask) = &mut mask {
+ // Fill the gap left by any preceding null parent rows.
+ if start > mask.len() {
+ mask.append_n(start - mask.len(), false);
}
- } else if batch_start.is_none() {
- batch_start = Some(i);
+ mask.append_packed_range(
+ bit_start..bit_start + len,
+ values_nulls.validity(),
+ );
}
}
- // Flush any remaining batch after the loop
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, end)?;
- }
-
- offsets.push(offsets[row_index] + O::usize_as(kept));
+ offsets.push(O::usize_as(count));
}
+ let mask = mask
+ .map(|mut mask| {
+ mask.append_n(values.len() - mask.len(), false);
+ mask.finish()
+ })
+ .unwrap_or_else(|| values_nulls.inner().clone());
+ let output = match values.data_type() {
+ DataType::Utf8 => copy_string_spans(values.as_string::<i32>(), &mask,
count),
+ DataType::LargeUtf8 => copy_string_spans(values.as_string::<i64>(),
&mask, count),
+ _ => filter(values, &BooleanArray::new(mask, None))?,
+ };
+ Ok((OffsetBuffer::new(offsets.into()), output))
+}
- let new_values = make_array(mutable.freeze());
- Ok(Arc::new(GenericListArray::<O>::try_new(
- Arc::clone(field),
- OffsetBuffer::new(offsets.into()),
- new_values,
- list_nulls.cloned(),
- )?))
+/// Copy adjacent retained strings together. The mask selects only non-null
+/// strings, so the output does not need a validity bitmap.
+#[inline(always)]
+fn copy_string_spans<S: OffsetSizeTrait>(
+ values: &GenericStringArray<S>,
+ mask: &BooleanBuffer,
+ count: usize,
+) -> ArrayRef {
+ let source_offsets = values.value_offsets();
+ let mut offsets = Vec::with_capacity(count + 1);
+ offsets.push(S::zero());
+ let mut length = S::zero();
+ for (start, end) in mask.set_slices() {
Review Comment:
Test coverage gap, and it is on the branch the optimization depends on.
`source_offsets[start + 1..=end]` is never exercised with more than one element
anywhere in the suite, so the multi-string span copy is untested.
I derived the mask for each test rather than guessing:
- `test_compact_child_types`, string child: after `child.slice(3, 12)` and
the outer `.slice(1, 5)`, the visible child validity is `[T,F,T,T,T,F,F,T,F,T]`
with row 2 a null parent, giving `mask = [T,F,T,F,F,F,F,T,F,T]` and
`set_slices() = [(0,1),(2,3),(7,8),(9,10)]`. Every span has length 1.
- `test_compact_nested_rows` uses a `List<Int32>` child, so it routes to
`filter`, not here.
- `test_compact_all_null_children` early-returns at the all-null check
before any dispatch.
- `test_compact_hidden_nulls_zero_copy` returns the input by `Arc::clone`.
- `check_sliced_list_behavior` uses a Float64 child.
- The SLT case `array_compact(['a', NULL, 'b', NULL, 'c'])` alternates, so
also max span 1.
A case with two or more adjacent non-null strings would cover both the
slice-wide offset `extend` and the multi-string `extend_from_slice`.
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
+ .then(|| values.slice(start, visible_len));
+ let values = sliced_values.as_ref().unwrap_or(values);
// Use logical nulls so element types without a validity buffer
// (e.g. NullArray) are still treated as null.
- let Some(values_nulls) = values.logical_nulls() else {
- // Fast path: no validity buffer, no nulls to remove
- return Ok(Arc::new(list_array.clone()));
+ let Some(values_nulls) = values
+ .logical_nulls()
+ .filter(|nulls| nulls.null_count() != 0)
+ else {
+ return Ok(Arc::clone(input_array));
};
- if values_nulls.null_count() == 0 {
- // Fast path: validity buffer present but no nulls set
- return Ok(Arc::new(list_array.clone()));
+ if values_nulls.null_count() == values_nulls.len() {
+ return Ok(empty_list_values(list_array, Arc::clone(field)));
}
- let list_nulls = list_array.nulls();
+ compact_list_values(list_array, field, values.as_ref(), &values_nulls)
+}
+
+/// Copy primitive values and build list offsets in one pass. For other types,
+/// build offsets and a keep bitmap, then copy Utf8/LargeUtf8 strings in valid
+/// spans or use Arrow's filter kernel for the remaining types.
+fn compact_list_values<O: OffsetSizeTrait>(
Review Comment:
Name collision: `compact_list_values` already exists at
`datafusion/common/src/nested_struct.rs:384`, where it means "densify a sliced
list via `take`", which is a different operation from "remove null elements".
Same workspace, contradictory semantics, so grepping the name finds the wrong
one. `compact_list` vs `compact_list_values` also differ only by a suffix while
meaning "whole pipeline" vs "build the array".
This is also a single-call-site four-parameter wrapper whose only
contribution over the `downcast_primitive_array!` dispatch is the `try_new`
call. Inlining it into `compact_list` at line 156 would resolve both points at
once. If you prefer to keep it, `build_compacted_list` reads unambiguously.
Its doc comment at lines 159-161 describes `compact_primitive` and
`compact_non_primitive`, which have no doc comments of their own. Worth moving
each sentence onto the function it describes. The invariant that actually
licenses the `- first_offset` arithmetic in both is undocumented anywhere and
is the thing a future reader will need: **`values` is the child already
restricted to the visible span, so `values.len() == offsets.last() -
offsets.first()`, `values_nulls` is `values.logical_nulls()`, and child indices
are `offset - first_offset`.**
Naming nits in the same area:
- `count` (lines 216, 225, 237, 246) counts retained child elements and
doubles as the output length. The pre-PR code already had the right word: `let
kept = ...`.
- `length` (lines 264, 272) is a byte offset accumulator, not an element
count. Arrow calls the same thing `cur_offset`. `byte_len` would be clearer.
- `output` is used for both the `Vec<T::Native>` at line 187 and the
resulting `PrimitiveArray` at line 202. `kept_values` for the vec would avoid
the shadow.
- `mask`: this crate says `predicate` (`array_filter.rs:220`) or
`remove_mask` (`remove.rs:590`). `keep_mask` reconciles both and matches the
comment on line 217.
The `_primitive` / `_non_primitive` split itself is good and matches
`sort.rs:220` / `sort.rs:378`. No objection there.
One more, line 281: the SAFETY comment correctly discharges
`new_unchecked`'s obligations but does not say why the checked constructor is
not used, which is the part a reader wants. Something like "the checked
constructor would re-scan all output bytes for UTF-8 validity the input already
guarantees" would complete it.
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
+ .then(|| values.slice(start, visible_len));
+ let values = sliced_values.as_ref().unwrap_or(values);
// Use logical nulls so element types without a validity buffer
// (e.g. NullArray) are still treated as null.
- let Some(values_nulls) = values.logical_nulls() else {
- // Fast path: no validity buffer, no nulls to remove
- return Ok(Arc::new(list_array.clone()));
+ let Some(values_nulls) = values
+ .logical_nulls()
+ .filter(|nulls| nulls.null_count() != 0)
+ else {
+ return Ok(Arc::clone(input_array));
};
- if values_nulls.null_count() == 0 {
- // Fast path: validity buffer present but no nulls set
- return Ok(Arc::new(list_array.clone()));
+ if values_nulls.null_count() == values_nulls.len() {
+ return Ok(empty_list_values(list_array, Arc::clone(field)));
}
- let list_nulls = list_array.nulls();
+ compact_list_values(list_array, field, values.as_ref(), &values_nulls)
+}
+
+/// Copy primitive values and build list offsets in one pass. For other types,
+/// build offsets and a keep bitmap, then copy Utf8/LargeUtf8 strings in valid
+/// spans or use Arrow's filter kernel for the remaining types.
+fn compact_list_values<O: OffsetSizeTrait>(
+ list_array: &GenericListArray<O>,
+ field: &FieldRef,
+ values: &dyn Array,
+ values_nulls: &NullBuffer,
+) -> Result<ArrayRef> {
+ let (offsets, new_values) = downcast_primitive_array! {
+ values => Ok(compact_primitive(values, list_array, values_nulls)),
+ _ => compact_non_primitive(values, list_array, values_nulls),
+ }?;
+ Ok(Arc::new(GenericListArray::<O>::try_new(
+ Arc::clone(field),
+ offsets,
+ new_values,
+ list_array.nulls().cloned(),
+ )?))
+}
+
+fn compact_primitive<T: ArrowPrimitiveType, O: OffsetSizeTrait>(
+ values: &PrimitiveArray<T>,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> (OffsetBuffer<O>, ArrayRef) {
let list_offsets = list_array.offsets();
- let original_data = values.to_data();
- let (first_offset, visible_len) = offset_span(list_offsets);
- let capacity =
- visible_len - values_nulls.slice(first_offset,
visible_len).null_count();
- let mut offsets = Vec::<O>::with_capacity(list_array.len() + 1);
+ let first_offset = list_offsets[0].as_usize();
+ let mut output = Vec::with_capacity(values_nulls.len() -
values_nulls.null_count());
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
offsets.push(O::zero());
- let mut mutable = MutableArrayData::with_capacities(
- vec![&original_data],
- false,
- Capacities::Array(capacity),
- );
-
- for row_index in 0..list_array.len() {
- if list_nulls.is_some_and(|n| n.is_null(row_index)) {
- offsets.push(offsets[row_index]);
- continue;
+ for (row, window) in list_offsets.windows(2).enumerate() {
+ if list_array.is_valid(row) {
+ let start = window[0].as_usize() - first_offset;
+ let end = window[1].as_usize() - first_offset;
+ for i in start..end {
+ if values_nulls.is_valid(i) {
+ output.push(values.value(i));
+ }
+ }
}
+ offsets.push(O::usize_as(output.len()));
+ }
+ let output = PrimitiveArray::<T>::new(output.into(), None)
+ .with_data_type(values.data_type().clone());
+ (OffsetBuffer::new(offsets.into()), Arc::new(output))
+}
- let start = list_offsets[row_index].as_usize();
- let end = list_offsets[row_index + 1].as_usize();
- let row_null_count = values_nulls.slice(start, end -
start).null_count();
- let kept = (end - start) - row_null_count;
-
- // Batch consecutive non-null elements into single extend() calls
- // to reduce per-element overhead. For [1, 2, NULL, 3, 4] this
- // produces 2 extend calls (0..2, 3..5) instead of 4 individual ones.
- let mut batch_start: Option<usize> = None;
- for i in start..end {
- if values_nulls.is_null(i) {
- // Null breaks the current batch — flush it
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, i)?;
- batch_start = None;
+#[inline(never)]
+fn compact_non_primitive<O: OffsetSizeTrait>(
+ values: &dyn Array,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> Result<(OffsetBuffer<O>, ArrayRef)> {
+ let first_offset = list_array.offsets()[0].as_usize();
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
+ offsets.push(O::zero());
+ let mut count = 0;
+ // Child validity is already the keep mask unless null parents hide values.
+ let mut mask =
+ (list_array.null_count() != 0).then(||
BooleanBufferBuilder::new(values.len()));
+ for (row, window) in list_array.offsets().windows(2).enumerate() {
+ let start = window[0].as_usize() - first_offset;
+ let len = window[1].as_usize() - window[0].as_usize();
+ if list_array.is_valid(row) {
+ let bit_start = values_nulls.offset() + start;
+ count += values_nulls.buffer().count_set_bits_offset(bit_start,
len);
+ if let Some(mask) = &mut mask {
+ // Fill the gap left by any preceding null parent rows.
+ if start > mask.len() {
+ mask.append_n(start - mask.len(), false);
}
- } else if batch_start.is_none() {
- batch_start = Some(i);
+ mask.append_packed_range(
+ bit_start..bit_start + len,
+ values_nulls.validity(),
+ );
}
}
- // Flush any remaining batch after the loop
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, end)?;
- }
-
- offsets.push(offsets[row_index] + O::usize_as(kept));
+ offsets.push(O::usize_as(count));
}
+ let mask = mask
+ .map(|mut mask| {
+ mask.append_n(values.len() - mask.len(), false);
+ mask.finish()
+ })
+ .unwrap_or_else(|| values_nulls.inner().clone());
+ let output = match values.data_type() {
+ DataType::Utf8 => copy_string_spans(values.as_string::<i32>(), &mask,
count),
+ DataType::LargeUtf8 => copy_string_spans(values.as_string::<i64>(),
&mask, count),
+ _ => filter(values, &BooleanArray::new(mask, None))?,
+ };
+ Ok((OffsetBuffer::new(offsets.into()), output))
+}
- let new_values = make_array(mutable.freeze());
- Ok(Arc::new(GenericListArray::<O>::try_new(
- Arc::clone(field),
- OffsetBuffer::new(offsets.into()),
- new_values,
- list_nulls.cloned(),
- )?))
+/// Copy adjacent retained strings together. The mask selects only non-null
+/// strings, so the output does not need a validity bitmap.
+#[inline(always)]
+fn copy_string_spans<S: OffsetSizeTrait>(
+ values: &GenericStringArray<S>,
+ mask: &BooleanBuffer,
+ count: usize,
+) -> ArrayRef {
+ let source_offsets = values.value_offsets();
+ let mut offsets = Vec::with_capacity(count + 1);
+ offsets.push(S::zero());
+ let mut length = S::zero();
+ for (start, end) in mask.set_slices() {
+ let adjustment = length - source_offsets[start];
+ offsets.extend(
+ source_offsets[start + 1..=end]
+ .iter()
+ .map(|offset| *offset + adjustment),
+ );
+ length = source_offsets[end] + adjustment;
+ }
+ let mut bytes = Vec::with_capacity(length.as_usize());
+ for (start, end) in mask.set_slices() {
Review Comment:
This second walk of `mask.set_slices()` exists only to get an exact `bytes`
capacity. Folding the copy into the first pass removes a full
`BitSliceIterator` traversal and seven lines:
```rust
let mut bytes: Vec<u8> =
Vec::with_capacity(offset_span_len(values.offsets()));
for (start, end) in mask.set_slices() {
let adjustment = S::usize_as(bytes.len()) - source_offsets[start];
offsets.extend(
source_offsets[start + 1..=end]
.iter()
.map(|offset| *offset + adjustment),
);
bytes.extend_from_slice(
&values.value_data()
[source_offsets[start].as_usize()..source_offsets[end].as_usize()],
);
}
```
The `length` accumulator goes away, and this keeps `offset_span_len` in use,
which the suggestion on line 141 otherwise drops from the imports. Note
`values.value_data().len()` would be the wrong bound here, since it is the
entire backing buffer and can be arbitrarily larger than the visible span for a
sliced child.
Measured in-tree, combined with dropping the inline attributes: 2.03 to
1.55-1.68 ms at 10% null, 5.22 to 4.04-4.18 ms at 50% null.
Why this is worth doing rather than a nit. Routing Utf8/LargeUtf8 straight
through `filter` instead measures 3.75 ms / 4.79 ms. So at 10% null the
specialization earns its ~40 lines and its `unsafe` (1.85x), but **at 50% null
the current two-pass form is slower than just calling `filter`** (5.22 vs
4.79), because arrow drops to per-index copying below 0.8 selectivity
(`FILTER_SLICES_SELECTIVITY_THRESHOLD`) while this always uses spans. The
single-pass form fixes that (4.04 vs 4.79).
Two honest caveats. Capacity becomes an upper bound rather than exact, so
the output byte buffer retains capacity for dropped strings' bytes when null
slots carry payload. And a separate out-of-tree run saw a ~12% regression for
256-byte strings, where the copy is bandwidth-bound. Worth confirming on your
own workload.
Also worth saying: the two-pass shape matches arrow's own `filter_bytes`, so
it is idiomatic rather than sloppy, and this offset pass is actually better
than arrow's, because the slice-wide `extend` vectorizes where arrow's
`extend_offsets_slices` pushes one at a time.
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
+ .then(|| values.slice(start, visible_len));
+ let values = sliced_values.as_ref().unwrap_or(values);
// Use logical nulls so element types without a validity buffer
// (e.g. NullArray) are still treated as null.
- let Some(values_nulls) = values.logical_nulls() else {
- // Fast path: no validity buffer, no nulls to remove
- return Ok(Arc::new(list_array.clone()));
+ let Some(values_nulls) = values
+ .logical_nulls()
+ .filter(|nulls| nulls.null_count() != 0)
+ else {
+ return Ok(Arc::clone(input_array));
};
- if values_nulls.null_count() == 0 {
- // Fast path: validity buffer present but no nulls set
- return Ok(Arc::new(list_array.clone()));
+ if values_nulls.null_count() == values_nulls.len() {
+ return Ok(empty_list_values(list_array, Arc::clone(field)));
}
- let list_nulls = list_array.nulls();
+ compact_list_values(list_array, field, values.as_ref(), &values_nulls)
+}
+
+/// Copy primitive values and build list offsets in one pass. For other types,
+/// build offsets and a keep bitmap, then copy Utf8/LargeUtf8 strings in valid
+/// spans or use Arrow's filter kernel for the remaining types.
+fn compact_list_values<O: OffsetSizeTrait>(
+ list_array: &GenericListArray<O>,
+ field: &FieldRef,
+ values: &dyn Array,
+ values_nulls: &NullBuffer,
+) -> Result<ArrayRef> {
+ let (offsets, new_values) = downcast_primitive_array! {
+ values => Ok(compact_primitive(values, list_array, values_nulls)),
+ _ => compact_non_primitive(values, list_array, values_nulls),
+ }?;
+ Ok(Arc::new(GenericListArray::<O>::try_new(
+ Arc::clone(field),
+ offsets,
+ new_values,
+ list_array.nulls().cloned(),
+ )?))
+}
+
+fn compact_primitive<T: ArrowPrimitiveType, O: OffsetSizeTrait>(
+ values: &PrimitiveArray<T>,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> (OffsetBuffer<O>, ArrayRef) {
let list_offsets = list_array.offsets();
- let original_data = values.to_data();
- let (first_offset, visible_len) = offset_span(list_offsets);
- let capacity =
- visible_len - values_nulls.slice(first_offset,
visible_len).null_count();
- let mut offsets = Vec::<O>::with_capacity(list_array.len() + 1);
+ let first_offset = list_offsets[0].as_usize();
+ let mut output = Vec::with_capacity(values_nulls.len() -
values_nulls.null_count());
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
offsets.push(O::zero());
- let mut mutable = MutableArrayData::with_capacities(
- vec![&original_data],
- false,
- Capacities::Array(capacity),
- );
-
- for row_index in 0..list_array.len() {
- if list_nulls.is_some_and(|n| n.is_null(row_index)) {
- offsets.push(offsets[row_index]);
- continue;
+ for (row, window) in list_offsets.windows(2).enumerate() {
+ if list_array.is_valid(row) {
+ let start = window[0].as_usize() - first_offset;
+ let end = window[1].as_usize() - first_offset;
+ for i in start..end {
+ if values_nulls.is_valid(i) {
+ output.push(values.value(i));
+ }
+ }
}
+ offsets.push(O::usize_as(output.len()));
+ }
+ let output = PrimitiveArray::<T>::new(output.into(), None)
+ .with_data_type(values.data_type().clone());
+ (OffsetBuffer::new(offsets.into()), Arc::new(output))
+}
- let start = list_offsets[row_index].as_usize();
- let end = list_offsets[row_index + 1].as_usize();
- let row_null_count = values_nulls.slice(start, end -
start).null_count();
- let kept = (end - start) - row_null_count;
-
- // Batch consecutive non-null elements into single extend() calls
- // to reduce per-element overhead. For [1, 2, NULL, 3, 4] this
- // produces 2 extend calls (0..2, 3..5) instead of 4 individual ones.
- let mut batch_start: Option<usize> = None;
- for i in start..end {
- if values_nulls.is_null(i) {
- // Null breaks the current batch — flush it
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, i)?;
- batch_start = None;
+#[inline(never)]
+fn compact_non_primitive<O: OffsetSizeTrait>(
+ values: &dyn Array,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> Result<(OffsetBuffer<O>, ArrayRef)> {
+ let first_offset = list_array.offsets()[0].as_usize();
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
+ offsets.push(O::zero());
+ let mut count = 0;
+ // Child validity is already the keep mask unless null parents hide values.
+ let mut mask =
+ (list_array.null_count() != 0).then(||
BooleanBufferBuilder::new(values.len()));
+ for (row, window) in list_array.offsets().windows(2).enumerate() {
+ let start = window[0].as_usize() - first_offset;
+ let len = window[1].as_usize() - window[0].as_usize();
+ if list_array.is_valid(row) {
+ let bit_start = values_nulls.offset() + start;
+ count += values_nulls.buffer().count_set_bits_offset(bit_start,
len);
+ if let Some(mask) = &mut mask {
+ // Fill the gap left by any preceding null parent rows.
+ if start > mask.len() {
+ mask.append_n(start - mask.len(), false);
}
- } else if batch_start.is_none() {
- batch_start = Some(i);
+ mask.append_packed_range(
+ bit_start..bit_start + len,
+ values_nulls.validity(),
+ );
}
}
- // Flush any remaining batch after the loop
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, end)?;
- }
-
- offsets.push(offsets[row_index] + O::usize_as(kept));
+ offsets.push(O::usize_as(count));
}
+ let mask = mask
+ .map(|mut mask| {
+ mask.append_n(values.len() - mask.len(), false);
+ mask.finish()
+ })
+ .unwrap_or_else(|| values_nulls.inner().clone());
+ let output = match values.data_type() {
+ DataType::Utf8 => copy_string_spans(values.as_string::<i32>(), &mask,
count),
+ DataType::LargeUtf8 => copy_string_spans(values.as_string::<i64>(),
&mask, count),
+ _ => filter(values, &BooleanArray::new(mask, None))?,
+ };
+ Ok((OffsetBuffer::new(offsets.into()), output))
+}
- let new_values = make_array(mutable.freeze());
- Ok(Arc::new(GenericListArray::<O>::try_new(
- Arc::clone(field),
- OffsetBuffer::new(offsets.into()),
- new_values,
- list_nulls.cloned(),
- )?))
+/// Copy adjacent retained strings together. The mask selects only non-null
+/// strings, so the output does not need a validity bitmap.
+#[inline(always)]
+fn copy_string_spans<S: OffsetSizeTrait>(
+ values: &GenericStringArray<S>,
+ mask: &BooleanBuffer,
+ count: usize,
+) -> ArrayRef {
+ let source_offsets = values.value_offsets();
+ let mut offsets = Vec::with_capacity(count + 1);
+ offsets.push(S::zero());
+ let mut length = S::zero();
+ for (start, end) in mask.set_slices() {
+ let adjustment = length - source_offsets[start];
+ offsets.extend(
+ source_offsets[start + 1..=end]
+ .iter()
+ .map(|offset| *offset + adjustment),
+ );
+ length = source_offsets[end] + adjustment;
+ }
+ let mut bytes = Vec::with_capacity(length.as_usize());
+ for (start, end) in mask.set_slices() {
+ bytes.extend_from_slice(
+ &values.value_data()
+
[source_offsets[start].as_usize()..source_offsets[end].as_usize()],
+ );
+ }
+ // SAFETY: the mask selects only non-null strings. Each output string is
+ // copied unchanged from the input, so its UTF-8 remains valid. Rebasing
+ // offsets preserves their order, and the last offset equals bytes.len().
+ let output = unsafe {
+ GenericStringArray::<S>::new_unchecked(
+ OffsetBuffer::new(offsets.into()),
+ bytes.into(),
+ None,
+ )
+ };
+ Arc::new(output)
}
#[cfg(test)]
mod tests {
use super::*;
+ use arrow::array::{
+ AsArray, BooleanArray, DictionaryArray, FixedSizeListArray, Int32Array,
+ ListArray, MapArray, NullArray, RunArray, StringArray, StringViewArray,
+ StructArray, UInt32Array, new_empty_array,
+ };
+ use arrow::compute::take;
+ use arrow::datatypes::{Field, Int32Type, TimeUnit};
+ use datafusion_common::utils::list_values;
+
+ #[test]
+ fn test_compact_child_types() -> Result<()> {
+ let input = Int32Array::from(vec![
+ Some(1),
+ None,
+ Some(2), // Padding before the child slice.
+ Some(90), // Padding before the outer slice.
+ Some(10),
+ None,
+ Some(20),
+ Some(100),
+ Some(110), // Values belonging to a null list row.
+ None,
+ None, // A valid list with only null elements.
+ Some(30),
+ None,
+ Some(40),
+ Some(99), // Padding after the outer slice.
+ ]);
+ let mut children = vec![];
Review Comment:
Test dedup. The coverage here is genuinely good, so this is about runtime
and reading cost rather than lost coverage. Three spots:
**This matrix (lines 326-343).** 13 of the 14 listed types resolve to the
same `compact_primitive<T>` instantiation. Checked against arrow 60's
`downcast_primitive!` arms: Int8, UInt64, Int32, Int64, Float32, Float64,
Decimal32/64/128/256, Date32, Duration, and Timestamp all match primitive arms,
and only `Dictionary` falls through to `compact_non_primitive`.
`compact_primitive` has no width- or type-specific branch, so the only distinct
property exercised is `.with_data_type()` preservation. Four entries cover it:
`Int32` (plain), `Decimal256` (widest native plus precision/scale),
`Timestamp(Micro, tz)` (tz payload), `Dictionary` (different path).
**`test_compact_nested_rows` (line 451).** Mostly overlaps this test, which
already covers a `ListArray` child, an empty row, a null parent with a
non-empty hidden child range, and sliced outer and child. The one scenario
unique to it is an empty row sitting between two rows that both keep values,
where in `test_compact_child_types` the empty row abuts the null row. Worth
keeping that scenario and dropping the 2x2 `child_type` / `data_type` cast
matrix, since offset width is already swept by every other test and there is no
width-specific code on this path. Roughly 40 lines and 4 iterations down to ~15
lines and 1.
**`test_compact_all_null_children` line 498.** All five children hit the
same `values_nulls.null_count() == values_nulls.len()` early return before any
type dispatch, so the only per-type difference is the `logical_nulls()`
implementation. `Int32Array::new_null` and the all-null `StringArray` are both
"validity buffer, all bits unset". `NullArray`, `Dictionary`, and `RunArray`
each exercise a distinct `logical_nulls()`, so those three plus one plain array
suffice.
All three are optional. The one that is not optional is the missing
multi-element span case flagged on line 265.
##########
datafusion/functions-nested/src/array_filter.rs:
##########
@@ -204,27 +206,9 @@ impl HigherOrderUDFImpl for ArrayFilter {
/// Returns a list array with every non-null sublist emptied, preserving the
null buffer.
/// Used for the `x -> false` / `x -> null` scalar predicate short-circuit.
fn empty_filtered_list(list_array: &ArrayRef, field: FieldRef) ->
Result<ArrayRef> {
Review Comment:
With the extraction done, this is a seven-line single-call-site dispatch
wrapper that only downcasts and delegates. Giving `empty_list_values` the
`(&dyn Array, FieldRef) -> Result<ArrayRef>` signature with the match inside
would let this go away, and `array_compact.rs:153` could then call it with
`input_array`, which is already in scope. Net about ten lines, one helper
instead of two.
The `empty_list_values` extraction itself is a real win, three call sites
across two files. `pub(crate)` in `functions-nested/src/utils.rs` is also the
right home. I grepped for other hand-rolled "empty every valid list row" sites
(`new_zeroed`, `vec![0i32; n + 1]`, `from_repeated_length(0`) and found none,
so moving it to `datafusion-common` would widen the public API for no current
consumer.
Separate, and I would treat it as a follow-up issue rather than a change
request here. `filter_list_values` just below (line 218) and
`compact_non_primitive` in `array_compact.rs:208` are now the same primitive
one file apart, with the tuple order mirrored:
```rust
filter_list_values(...) -> Result<(ArrayRef, OffsetBuffer<O>)>
compact_non_primitive(...) -> Result<(OffsetBuffer<O>, ArrayRef)>
```
Both compute per-row kept counts by popcount over a keep bitmap, rebuild an
`OffsetBuffer`, call `arrow::compute::filter`, and carry a no-op short circuit.
They have already drifted: `array_compact` excludes children of null parent
rows, `array_filter` copies them into the values buffer where they are
invisible but retained. A shared `compact_list_with_mask(values, offsets,
parent_nulls, keep)` in `utils.rs` next to `empty_list_values` would remove the
drift by construction and give both the validity-strip and span-copy wins.
`general_array_distinct` (`set_ops.rs:555`), `array_except` (`except.rs:210`),
and `general_remove_with_scalar` (`remove.rs:560`) all build monotone `indices`
plus `take` and could use it too, so it plausibly serves five or six call sites.
Unifying is clearly out of scope for a perf PR on one function. Swapping one
tuple order so the two agree is not, and a linked issue would keep the
duplication tracked.
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
+ .then(|| values.slice(start, visible_len));
+ let values = sliced_values.as_ref().unwrap_or(values);
// Use logical nulls so element types without a validity buffer
// (e.g. NullArray) are still treated as null.
- let Some(values_nulls) = values.logical_nulls() else {
- // Fast path: no validity buffer, no nulls to remove
- return Ok(Arc::new(list_array.clone()));
+ let Some(values_nulls) = values
+ .logical_nulls()
+ .filter(|nulls| nulls.null_count() != 0)
+ else {
+ return Ok(Arc::clone(input_array));
};
- if values_nulls.null_count() == 0 {
- // Fast path: validity buffer present but no nulls set
- return Ok(Arc::new(list_array.clone()));
+ if values_nulls.null_count() == values_nulls.len() {
+ return Ok(empty_list_values(list_array, Arc::clone(field)));
}
- let list_nulls = list_array.nulls();
+ compact_list_values(list_array, field, values.as_ref(), &values_nulls)
+}
+
+/// Copy primitive values and build list offsets in one pass. For other types,
+/// build offsets and a keep bitmap, then copy Utf8/LargeUtf8 strings in valid
+/// spans or use Arrow's filter kernel for the remaining types.
+fn compact_list_values<O: OffsetSizeTrait>(
+ list_array: &GenericListArray<O>,
+ field: &FieldRef,
+ values: &dyn Array,
+ values_nulls: &NullBuffer,
+) -> Result<ArrayRef> {
+ let (offsets, new_values) = downcast_primitive_array! {
+ values => Ok(compact_primitive(values, list_array, values_nulls)),
+ _ => compact_non_primitive(values, list_array, values_nulls),
+ }?;
+ Ok(Arc::new(GenericListArray::<O>::try_new(
+ Arc::clone(field),
+ offsets,
+ new_values,
+ list_array.nulls().cloned(),
+ )?))
+}
+
+fn compact_primitive<T: ArrowPrimitiveType, O: OffsetSizeTrait>(
+ values: &PrimitiveArray<T>,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> (OffsetBuffer<O>, ArrayRef) {
let list_offsets = list_array.offsets();
- let original_data = values.to_data();
- let (first_offset, visible_len) = offset_span(list_offsets);
- let capacity =
- visible_len - values_nulls.slice(first_offset,
visible_len).null_count();
- let mut offsets = Vec::<O>::with_capacity(list_array.len() + 1);
+ let first_offset = list_offsets[0].as_usize();
+ let mut output = Vec::with_capacity(values_nulls.len() -
values_nulls.null_count());
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
offsets.push(O::zero());
- let mut mutable = MutableArrayData::with_capacities(
- vec![&original_data],
- false,
- Capacities::Array(capacity),
- );
-
- for row_index in 0..list_array.len() {
- if list_nulls.is_some_and(|n| n.is_null(row_index)) {
- offsets.push(offsets[row_index]);
- continue;
+ for (row, window) in list_offsets.windows(2).enumerate() {
+ if list_array.is_valid(row) {
+ let start = window[0].as_usize() - first_offset;
+ let end = window[1].as_usize() - first_offset;
+ for i in start..end {
+ if values_nulls.is_valid(i) {
+ output.push(values.value(i));
+ }
+ }
}
+ offsets.push(O::usize_as(output.len()));
+ }
+ let output = PrimitiveArray::<T>::new(output.into(), None)
+ .with_data_type(values.data_type().clone());
+ (OffsetBuffer::new(offsets.into()), Arc::new(output))
+}
- let start = list_offsets[row_index].as_usize();
- let end = list_offsets[row_index + 1].as_usize();
- let row_null_count = values_nulls.slice(start, end -
start).null_count();
- let kept = (end - start) - row_null_count;
-
- // Batch consecutive non-null elements into single extend() calls
- // to reduce per-element overhead. For [1, 2, NULL, 3, 4] this
- // produces 2 extend calls (0..2, 3..5) instead of 4 individual ones.
- let mut batch_start: Option<usize> = None;
- for i in start..end {
- if values_nulls.is_null(i) {
- // Null breaks the current batch — flush it
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, i)?;
- batch_start = None;
+#[inline(never)]
+fn compact_non_primitive<O: OffsetSizeTrait>(
+ values: &dyn Array,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> Result<(OffsetBuffer<O>, ArrayRef)> {
+ let first_offset = list_array.offsets()[0].as_usize();
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
+ offsets.push(O::zero());
+ let mut count = 0;
+ // Child validity is already the keep mask unless null parents hide values.
+ let mut mask =
+ (list_array.null_count() != 0).then(||
BooleanBufferBuilder::new(values.len()));
+ for (row, window) in list_array.offsets().windows(2).enumerate() {
+ let start = window[0].as_usize() - first_offset;
+ let len = window[1].as_usize() - window[0].as_usize();
+ if list_array.is_valid(row) {
+ let bit_start = values_nulls.offset() + start;
+ count += values_nulls.buffer().count_set_bits_offset(bit_start,
len);
+ if let Some(mask) = &mut mask {
+ // Fill the gap left by any preceding null parent rows.
+ if start > mask.len() {
+ mask.append_n(start - mask.len(), false);
}
- } else if batch_start.is_none() {
- batch_start = Some(i);
+ mask.append_packed_range(
+ bit_start..bit_start + len,
+ values_nulls.validity(),
+ );
}
}
- // Flush any remaining batch after the loop
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, end)?;
- }
-
- offsets.push(offsets[row_index] + O::usize_as(kept));
+ offsets.push(O::usize_as(count));
}
+ let mask = mask
+ .map(|mut mask| {
+ mask.append_n(values.len() - mask.len(), false);
+ mask.finish()
+ })
+ .unwrap_or_else(|| values_nulls.inner().clone());
+ let output = match values.data_type() {
+ DataType::Utf8 => copy_string_spans(values.as_string::<i32>(), &mask,
count),
Review Comment:
`Binary` and `LargeBinary` look excluded by accident rather than by design.
The body of `copy_string_spans` only touches `value_offsets()`, `value_data()`,
and `new_unchecked`, all of which exist on `GenericByteArray<T:
ByteArrayType>`. Generalizing the signature and adding two match arms here is
about three lines, and the SAFETY comment gets strictly weaker, since binary
has no encoding invariant to preserve.
Excluding `StringView` and `BinaryView` **is** principled, since those go
through `filter_byte_view` and have no offset chain to rebase, so there is
nothing to gain beyond the validity-strip on line 248.
Supporting evidence that it is an oversight: the type matrix at lines
325-409 includes `StringViewArray` but no `BinaryArray`, `LargeBinaryArray`,
`BinaryViewArray`, or `FixedSizeBinary`, so the gap is not exercised either way.
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
+ .then(|| values.slice(start, visible_len));
+ let values = sliced_values.as_ref().unwrap_or(values);
// Use logical nulls so element types without a validity buffer
// (e.g. NullArray) are still treated as null.
- let Some(values_nulls) = values.logical_nulls() else {
- // Fast path: no validity buffer, no nulls to remove
- return Ok(Arc::new(list_array.clone()));
+ let Some(values_nulls) = values
+ .logical_nulls()
+ .filter(|nulls| nulls.null_count() != 0)
+ else {
+ return Ok(Arc::clone(input_array));
};
- if values_nulls.null_count() == 0 {
- // Fast path: validity buffer present but no nulls set
- return Ok(Arc::new(list_array.clone()));
+ if values_nulls.null_count() == values_nulls.len() {
+ return Ok(empty_list_values(list_array, Arc::clone(field)));
}
- let list_nulls = list_array.nulls();
+ compact_list_values(list_array, field, values.as_ref(), &values_nulls)
+}
+
+/// Copy primitive values and build list offsets in one pass. For other types,
+/// build offsets and a keep bitmap, then copy Utf8/LargeUtf8 strings in valid
+/// spans or use Arrow's filter kernel for the remaining types.
+fn compact_list_values<O: OffsetSizeTrait>(
+ list_array: &GenericListArray<O>,
+ field: &FieldRef,
+ values: &dyn Array,
+ values_nulls: &NullBuffer,
+) -> Result<ArrayRef> {
+ let (offsets, new_values) = downcast_primitive_array! {
+ values => Ok(compact_primitive(values, list_array, values_nulls)),
+ _ => compact_non_primitive(values, list_array, values_nulls),
+ }?;
+ Ok(Arc::new(GenericListArray::<O>::try_new(
+ Arc::clone(field),
+ offsets,
+ new_values,
+ list_array.nulls().cloned(),
+ )?))
+}
+
+fn compact_primitive<T: ArrowPrimitiveType, O: OffsetSizeTrait>(
+ values: &PrimitiveArray<T>,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> (OffsetBuffer<O>, ArrayRef) {
let list_offsets = list_array.offsets();
- let original_data = values.to_data();
- let (first_offset, visible_len) = offset_span(list_offsets);
- let capacity =
- visible_len - values_nulls.slice(first_offset,
visible_len).null_count();
- let mut offsets = Vec::<O>::with_capacity(list_array.len() + 1);
+ let first_offset = list_offsets[0].as_usize();
+ let mut output = Vec::with_capacity(values_nulls.len() -
values_nulls.null_count());
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
offsets.push(O::zero());
- let mut mutable = MutableArrayData::with_capacities(
- vec![&original_data],
- false,
- Capacities::Array(capacity),
- );
-
- for row_index in 0..list_array.len() {
- if list_nulls.is_some_and(|n| n.is_null(row_index)) {
- offsets.push(offsets[row_index]);
- continue;
+ for (row, window) in list_offsets.windows(2).enumerate() {
+ if list_array.is_valid(row) {
+ let start = window[0].as_usize() - first_offset;
+ let end = window[1].as_usize() - first_offset;
+ for i in start..end {
+ if values_nulls.is_valid(i) {
+ output.push(values.value(i));
+ }
+ }
}
+ offsets.push(O::usize_as(output.len()));
+ }
+ let output = PrimitiveArray::<T>::new(output.into(), None)
+ .with_data_type(values.data_type().clone());
+ (OffsetBuffer::new(offsets.into()), Arc::new(output))
+}
- let start = list_offsets[row_index].as_usize();
- let end = list_offsets[row_index + 1].as_usize();
- let row_null_count = values_nulls.slice(start, end -
start).null_count();
- let kept = (end - start) - row_null_count;
-
- // Batch consecutive non-null elements into single extend() calls
- // to reduce per-element overhead. For [1, 2, NULL, 3, 4] this
- // produces 2 extend calls (0..2, 3..5) instead of 4 individual ones.
- let mut batch_start: Option<usize> = None;
- for i in start..end {
- if values_nulls.is_null(i) {
- // Null breaks the current batch — flush it
- if let Some(bs) = batch_start {
- mutable.try_extend(0, bs, i)?;
- batch_start = None;
+#[inline(never)]
+fn compact_non_primitive<O: OffsetSizeTrait>(
+ values: &dyn Array,
+ list_array: &GenericListArray<O>,
+ values_nulls: &NullBuffer,
+) -> Result<(OffsetBuffer<O>, ArrayRef)> {
+ let first_offset = list_array.offsets()[0].as_usize();
+ let mut offsets = Vec::with_capacity(list_array.len() + 1);
+ offsets.push(O::zero());
+ let mut count = 0;
+ // Child validity is already the keep mask unless null parents hide values.
+ let mut mask =
+ (list_array.null_count() != 0).then(||
BooleanBufferBuilder::new(values.len()));
+ for (row, window) in list_array.offsets().windows(2).enumerate() {
+ let start = window[0].as_usize() - first_offset;
+ let len = window[1].as_usize() - window[0].as_usize();
+ if list_array.is_valid(row) {
+ let bit_start = values_nulls.offset() + start;
+ count += values_nulls.buffer().count_set_bits_offset(bit_start,
len);
+ if let Some(mask) = &mut mask {
+ // Fill the gap left by any preceding null parent rows.
+ if start > mask.len() {
Review Comment:
The builder only advances on valid rows, so its length silently falls behind
the child position, and two pieces of catch-up repair it: this leading backfill
and the trailing pad on line 241. List offsets are contiguous by construction,
so the gap a null row leaves is exactly `len`. Appending it when you see it
keeps the invariant `mask.len() == current child position` and removes both
fixups plus a branch from the hot valid-row path:
```rust
if list_array.is_valid(row) {
let bit_start = values_nulls.offset() + start;
count += values_nulls.buffer().count_set_bits_offset(bit_start, len);
if let Some(mask) = &mut mask {
mask.append_packed_range(bit_start..bit_start + len,
values_nulls.validity());
}
} else if let Some(mask) = &mut mask {
// Values under a null row are never selected.
mask.append_n(len, false);
}
```
Then line 239 collapses to `mask.map(|mut m| m.finish()).unwrap_or_else(||
values_nulls.inner().clone())`. Six lines fewer. Verified in-tree: all five
tests pass.
While here, line 220 re-fetches `list_array.offsets()` in the loop header
where `compact_primitive` binds it once at line 185. Free either way, but
inconsistent between two functions that are otherwise parallel.
##########
datafusion/functions-nested/src/utils.rs:
##########
@@ -640,11 +655,13 @@ pub(crate) mod tests {
let compact = arrow::compute::cast(&compact, &data_type)?;
// Middle slice, empty slice, empty row, and null row with child
data.
for (offset, len) in [(0, 3), (0, 0), (1, 1), (2, 1)] {
- let result = run(&input.slice(1 + offset, len))?;
+ let sliced = input.slice(1 + offset, len);
+ let result = run(&sliced)?;
let expected = run(&compact.slice(offset, len))?;
assert_eq!(result.as_ref(), expected.as_ref());
assert!(
- result.get_buffer_memory_size() < 1024,
+ Arc::ptr_eq(&result, &sliced)
Review Comment:
This loosens a shared assertion for all 11 callers (`array_add.rs:140`,
`array_compact.rs:537`, `array_normalize.rs:235`, `array_scale.rs:229`,
`array_subtract.rs:139`, `concat.rs:657` and `:664`, `extract.rs:1403` and
`:1413`, `remove.rs:1173`, `replace.rs:830`) to accommodate one of them.
The helper's contract is "the output must not reserve capacity based on the
full backing child". `array_compact` introduces a second legitimate outcome,
"the output is the input", and the escape hatch is now invisible at the other
ten call sites. Making it explicit, for example
`check_sliced_list_behavior(may_return_input: bool, run: ...)` with
`array_compact` the only `true`, keeps the invariant honest for everyone else.
It is a mechanical 11-line edit.
Worth noting the PR already has a dedicated positive test for the zero-copy
contract in `test_compact_hidden_nulls_zero_copy`, which is the right place for
it. The shared helper does not need to assert it loosely as well.
In practice the loosening is narrow, since `Arc::ptr_eq` needs the exact
same allocation and things like `remove.rs:566`'s
`Arc::new(list_array.clone())` would not satisfy it. So this is a clarity
point, not a hole.
##########
datafusion/functions-nested/src/array_compact.rs:
##########
@@ -112,101 +114,462 @@ fn array_compact_inner(arg: &[ArrayRef]) ->
Result<ArrayRef> {
let [input_array] = take_function_args("array_compact", arg)?;
match &input_array.data_type() {
- List(field) => {
- let array = as_list_array(input_array)?;
- compact_list::<i32>(array, field)
- }
- LargeList(field) => {
- let array = as_large_list_array(input_array)?;
- compact_list::<i64>(array, field)
- }
+ List(field) => compact_list::<i32>(input_array, field),
+ LargeList(field) => compact_list::<i64>(input_array, field),
Null => Ok(Arc::clone(input_array)),
array_type => exec_err!("array_compact does not support type
'{array_type}'."),
}
}
/// Remove null elements from each row of a list array.
+///
+/// Each row is a range in a shared child array. Compaction removes null child
+/// values and rebuilds the row offsets, preserving null list rows.
fn compact_list<O: OffsetSizeTrait>(
- list_array: &GenericListArray<O>,
- field: &Arc<arrow::datatypes::Field>,
+ input_array: &ArrayRef,
+ field: &FieldRef,
) -> Result<ArrayRef> {
+ let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
+ let visible_len = offset_span_len(list_array.offsets());
+ if visible_len == 0 || list_array.null_count() == list_array.len() {
+ return Ok(Arc::clone(input_array));
+ }
+ // Restrict the child to the visible values before computing logical nulls,
+ // which can be expensive.
let values = list_array.values();
+ let start = list_array.offsets()[0].as_usize();
+ let sliced_values = (start != 0 || visible_len != values.len())
Review Comment:
This is `datafusion_common::utils::list_values` reimplemented.
`sliced_list_values` (`common/src/utils/mod.rs:1275-1284`) has the identical
`start != 0 || len != values.len()` guard and the same `values.slice(first,
last - first)`. The test module in this very file imports `list_values` at line
304.
```rust
let list_array = as_generic_list_array::<O>(input_array.as_ref())?;
if list_array.null_count() == list_array.len() {
return Ok(Arc::clone(input_array));
}
// Restrict the child to the visible values before computing logical nulls,
// which can be expensive.
let values = list_values(input_array.as_ref())?;
```
Nine lines to six, and `visible_len`, `start`, and `sliced_values` all
disappear. Verified in-tree: all five tests pass, including the `(0, 0)`
empty-slice case in `test_sliced_capacity` and all of
`test_compact_hidden_nulls_zero_copy`. The `visible_len == 0` disjunct is
subsumed by the `nulls.null_count() != 0` filter three lines below, since an
empty child has null count 0.
Minor, line 133: the pre-PR code used `let (first_offset, visible_len) =
offset_span(list_offsets)`, one call returning both. This replaces it with
`offset_span_len` plus a hand-rolled `offsets()[0]`, and `offset_span_len` is
literally `offset_span(offsets).1`. `first_offset` is then re-derived a third
and fourth time at lines 186 and 213. No measurable cost, but it walks back an
existing helper use.
(Not for this PR: `common/src/nested_struct.rs:328-331` is a third copy of
the same predicate. Promoting `sliced_list_values` to `pub` and adopting it at
the hand-rolled sites in `position.rs`, `replace.rs`, `remove.rs`, and
`set_ops.rs` would be a decent follow-up.)
--
This is an automated message from the Apache Git Service.
To respond to the message, please log on to GitHub and use the
URL above to go to the specific comment.
To unsubscribe, e-mail: [email protected]
For queries about this service, please contact Infrastructure at:
[email protected]
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]