This is an automated email from the ASF dual-hosted git repository.
andygrove pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/datafusion-comet.git
The following commit(s) were added to refs/heads/main by this push:
new 1feef7aba8 perf: compute spark_size list lengths with Arrow length
kernel (#5233)
1feef7aba8 is described below
commit 1feef7aba891385349234b4fb8d204c3f3f371ac
Author: ChenChen Lai <[email protected]>
AuthorDate: Thu Aug 6 02:01:50 2026 +0800
perf: compute spark_size list lengths with Arrow length kernel (#5233)
---
.../contributor-guide/optimizing_expressions.md | 3 +-
native/spark-expr/benches/array_size.rs | 75 ++++--
native/spark-expr/src/array_funcs/size.rs | 256 ++++++++++++++++-----
3 files changed, 257 insertions(+), 77 deletions(-)
diff --git a/docs/source/contributor-guide/optimizing_expressions.md
b/docs/source/contributor-guide/optimizing_expressions.md
index a000f91398..ecdb8a7263 100644
--- a/docs/source/contributor-guide/optimizing_expressions.md
+++ b/docs/source/contributor-guide/optimizing_expressions.md
@@ -149,7 +149,8 @@ for the lightest one that fits.
| **Preallocate builders to known size** |
Repeated buffer growth/reallocation
| `spark_unhex`: preallocate `BinaryBuilder` to the known
output length
|
| **Compile-time lookup tables** |
Per-element range matches / branching
| `spark_unhex`: 256-entry hex table instead of per-digit
range match
|
| **Cache compiled regex** (thread-local, keyed by the constant arg) |
`Regex::new()` per row
| `parse_url` QUERY-with-key (50x): the key is constant
across a batch
|
-| **Read from the offset buffer directly** |
`list_array.value(i)` allocating a sliced `ArrayRef` per row
| `spark_size`: compute list lengths from offsets, zero
allocation
|
+| **Read from the offset buffer directly** |
`list_array.value(i)` allocating a sliced `ArrayRef` per row
| `spark_size` Map path and scalar path: `value_length` /
offsets instead of materializing a sliced array
|
+| **Reuse Arrow `length` kernel + cheap null rewrite** | Per-row
`value_length` / builder loop, or `zip` via `MutableArrayData`
| `spark_size` (~12x on no-null List): `length` for
List/LargeList/FixedSizeList, then `into_parts` + `set_indices` to patch null
slots to `-1` |
| **Typed scans over flat values buffers + hash probe** | A
per-element Arrow `eq`/compute kernel that allocates per call
| `spark_arrays_overlap` (up to 18x): scan buffers
directly, hash probe for large lists
|
| **ASCII / byte-offset fast path** |
`chars().count()` and per-char UTF-8 decoding
| `substring` (up to 10x), `spark_lpad` (2x): slice by
byte offset when input is ASCII
|
| **`memcpy` from a precomputed buffer** |
Char-by-char `push` into a scratch `String`
| `spark_lpad`: pad from a precomputed repeating pad
buffer, write directly into the builder
|
diff --git a/native/spark-expr/benches/array_size.rs
b/native/spark-expr/benches/array_size.rs
index f6d3559da7..afb340c06e 100644
--- a/native/spark-expr/benches/array_size.rs
+++ b/native/spark-expr/benches/array_size.rs
@@ -15,7 +15,7 @@
// specific language governing permissions and limitations
// under the License.
-use arrow::array::{ArrayRef, Int32Array, ListArray};
+use arrow::array::{ArrayRef, Int32Array, LargeListArray, ListArray};
use arrow::buffer::{NullBuffer, OffsetBuffer};
use arrow::datatypes::{DataType, Field};
use criterion::{criterion_group, criterion_main, Criterion};
@@ -24,9 +24,9 @@ use datafusion_comet_spark_expr::spark_size;
use std::hint::black_box;
use std::sync::Arc;
-/// Build a `ListArray` of `rows` lists, each with `elems_per_row` Int32
elements,
-/// with every 10th row null.
-fn create_list_array(rows: usize, elems_per_row: usize) -> ArrayRef {
+/// Build a `ListArray` of `rows` lists, each with `elems_per_row` Int32
elements.
+/// When `with_nulls` is true every 10th row is null.
+fn create_list_array(rows: usize, elems_per_row: usize, with_nulls: bool) ->
ArrayRef {
let total = rows * elems_per_row;
let values = Int32Array::from((0..total as i32).collect::<Vec<i32>>());
@@ -36,9 +36,33 @@ fn create_list_array(rows: usize, elems_per_row: usize) ->
ArrayRef {
offsets.push((i * elems_per_row) as i32);
}
- let nulls = NullBuffer::from((0..rows).map(|i| i % 10 !=
0).collect::<Vec<bool>>());
+ let nulls =
+ with_nulls.then(|| NullBuffer::from((0..rows).map(|i| i % 10 !=
0).collect::<Vec<bool>>()));
let field = Arc::new(Field::new("item", DataType::Int32, true));
Arc::new(ListArray::new(
+ field,
+ OffsetBuffer::new(offsets.into()),
+ Arc::new(values),
+ nulls,
+ ))
+}
+
+/// Build a `LargeListArray` (i64 offsets) of `rows` lists with `elems_per_row`
+/// Int32 elements. Every 10th row is null. LargeList exercises the extra
+/// Int64->Int32 cast on top of the length kernel.
+fn create_large_list_array(rows: usize, elems_per_row: usize) -> ArrayRef {
+ let total = rows * elems_per_row;
+ let values = Int32Array::from((0..total as i32).collect::<Vec<i32>>());
+
+ let mut offsets = Vec::with_capacity(rows + 1);
+ offsets.push(0i64);
+ for i in 1..=rows {
+ offsets.push((i * elems_per_row) as i64);
+ }
+
+ let nulls = NullBuffer::from((0..rows).map(|i| i % 10 !=
0).collect::<Vec<bool>>());
+ let field = Arc::new(Field::new("item", DataType::Int32, true));
+ Arc::new(LargeListArray::new(
field,
OffsetBuffer::new(offsets.into()),
Arc::new(values),
@@ -49,17 +73,38 @@ fn create_list_array(rows: usize, elems_per_row: usize) ->
ArrayRef {
fn criterion_benchmark(c: &mut Criterion) {
let rows = 8192;
- let short_lists = create_list_array(rows, 5);
- c.bench_function("spark_size: list of short arrays", |b| {
- let args = vec![ColumnarValue::Array(Arc::clone(&short_lists))];
- b.iter(|| black_box(spark_size(black_box(&args)).unwrap()))
- });
+ let mut bench = |name: &str, arr: &ArrayRef| {
+ let args = vec![ColumnarValue::Array(Arc::clone(arr))];
+ c.bench_function(name, |b| {
+ b.iter(|| black_box(spark_size(black_box(&args)).unwrap()))
+ });
+ };
+
+ // 10%-null shapes: match the pre-existing coverage.
+ bench(
+ "spark_size: list of short arrays",
+ &create_list_array(rows, 5, true),
+ );
+ bench(
+ "spark_size: list of long arrays",
+ &create_list_array(rows, 64, true),
+ );
+
+ // No-null shape: `CometSize.convert` wraps size() in a `CASE WHEN
isnotnull(child)`
+ // that filters null rows out before the THEN branch runs, so in a real
Comet plan
+ // spark_size_list_like only ever sees a null-free array. This shape
measures that
+ // path.
+ bench(
+ "spark_size: list, no nulls",
+ &create_list_array(rows, 5, false),
+ );
- let long_lists = create_list_array(rows, 64);
- c.bench_function("spark_size: list of long arrays", |b| {
- let args = vec![ColumnarValue::Array(Arc::clone(&long_lists))];
- b.iter(|| black_box(spark_size(black_box(&args)).unwrap()))
- });
+ // LargeList: exposes the Int64 length -> Int32 cast (extra allocation) on
top of
+ // the length kernel.
+ bench(
+ "spark_size: LargeList (10% null)",
+ &create_large_list_array(rows, 5),
+ );
}
criterion_group!(benches, criterion_benchmark);
diff --git a/native/spark-expr/src/array_funcs/size.rs
b/native/spark-expr/src/array_funcs/size.rs
index 5ad4f1670a..0d555806b7 100644
--- a/native/spark-expr/src/array_funcs/size.rs
+++ b/native/spark-expr/src/array_funcs/size.rs
@@ -15,8 +15,9 @@
// specific language governing permissions and limitations
// under the License.
-use arrow::array::builder::Int32Builder;
-use arrow::array::{Array, ArrayRef, GenericListArray, Int32Array,
OffsetSizeTrait};
+use arrow::array::{Array, ArrayRef, Int32Array};
+use arrow::compute::kernels::length::length;
+use arrow::compute::{cast_with_options, CastOptions};
use arrow::datatypes::{DataType, Field};
use datafusion::common::{exec_err, DataFusionError, Result as
DataFusionResult, ScalarValue};
use datafusion::logical_expr::{
@@ -91,99 +92,111 @@ impl ScalarUDFImpl for SparkSizeFunc {
}
fn spark_size_array(array: &ArrayRef) -> Result<ArrayRef, DataFusionError> {
- let mut builder = Int32Array::builder(array.len());
-
match array.data_type() {
- DataType::List(_) => {
- let list_array = array
- .as_any()
- .downcast_ref::<arrow::array::ListArray>()
- .ok_or_else(|| DataFusionError::Internal("Expected
ListArray".to_string()))?;
- append_list_sizes(&mut builder, list_array);
- }
- DataType::LargeList(_) => {
- let list_array = array
- .as_any()
- .downcast_ref::<arrow::array::LargeListArray>()
- .ok_or_else(|| DataFusionError::Internal("Expected
LargeListArray".to_string()))?;
- append_list_sizes(&mut builder, list_array);
- }
- DataType::FixedSizeList(_, size) => {
- let fixed_list_array = array
- .as_any()
- .downcast_ref::<arrow::array::FixedSizeListArray>()
- .ok_or_else(|| {
- DataFusionError::Internal("Expected
FixedSizeListArray".to_string())
- })?;
-
- for i in 0..fixed_list_array.len() {
- if fixed_list_array.is_null(i) {
- builder.append_value(-1); // Spark behavior: return -1 for
null
- } else {
- builder.append_value(*size);
- }
- }
+ // List / LargeList / FixedSizeList: reuse Arrow's vectorized length
kernel.
+ DataType::List(_) | DataType::LargeList(_) |
DataType::FixedSizeList(..) => {
+ spark_size_list_like(array)
}
+ // Map is not supported by the length kernel; keep the offset-based
path.
DataType::Map(_, _) => {
let map_array = array
.as_any()
.downcast_ref::<arrow::array::MapArray>()
.ok_or_else(|| DataFusionError::Internal("Expected
MapArray".to_string()))?;
+ let mut builder = Int32Array::builder(map_array.len());
for i in 0..map_array.len() {
if map_array.is_null(i) {
builder.append_value(-1); // Spark behavior: return -1 for
null
} else {
- let map_len = map_array.value_length(i);
- builder.append_value(map_len);
+ builder.append_value(map_array.value_length(i));
}
}
+ Ok(Arc::new(builder.finish()))
}
_ => {
- return exec_err!(
+ exec_err!(
"size function only supports arrays and maps, got: {:?}",
array.data_type()
- );
+ )
}
}
-
- Ok(Arc::new(builder.finish()))
}
-/// Append the element count of each list row to `builder`, using `-1` for null
-/// rows (Spark's behavior). `value_length` reads the row's element count from
the
-/// offset buffer, avoiding the per-row allocation that `value(i).len()` would
incur
-/// from materializing a sliced array.
-fn append_list_sizes<O: OffsetSizeTrait>(
- builder: &mut Int32Builder,
- list_array: &GenericListArray<O>,
-) {
- for i in 0..list_array.len() {
- if list_array.is_null(i) {
- builder.append_value(-1); // Spark behavior: return -1 for null
- } else {
- builder.append_value(list_array.value_length(i).as_usize() as i32);
+/// Compute Spark `size()` for list-like arrays via Arrow's `length` kernel,
then
+/// rewrite null inputs to `-1` (Spark's legacy/compatible size-of-null
behavior
+/// for this UDF). LargeList lengths are Int64 and are cast to Int32.
+///
+/// Patches the values buffer in place rather than using `zip`; `zip` goes
+/// through `MutableArrayData` and roughly doubled runtime on the `array_size`
+/// criterion shapes.
+fn spark_size_list_like(array: &ArrayRef) -> Result<ArrayRef, DataFusionError>
{
+ let lengths = length(array.as_ref())?;
+ let lengths = match lengths.data_type() {
+ DataType::Int32 => lengths,
+ // Unsafe cast: overflow must error, not become null then get
rewritten to -1.
+ DataType::Int64 => cast_with_options(
+ lengths.as_ref(),
+ &DataType::Int32,
+ &CastOptions {
+ safe: false,
+ ..Default::default()
+ },
+ )?,
+ other => {
+ return exec_err!("unexpected type from length kernel: {other:?}");
}
+ };
+
+ // Fast path for the production shape: `CometSize.convert` wraps size() in
a
+ // `CASE WHEN isnotnull(child)` that filters null rows out before the THEN
+ // branch runs, so this function only ever sees a null-free array in a real
+ // Comet plan. Return the length kernel output as-is.
+ if array.null_count() == 0 {
+ return Ok(lengths);
+ }
+
+ let int_lengths = lengths
+ .as_any()
+ .downcast_ref::<Int32Array>()
+ .ok_or_else(|| DataFusionError::Internal("Expected Int32Array from
length".to_string()))?;
+
+ // `set_indices()` on the inverted validity visits only null slots
+ // (O(null_count)). We still `to_vec()` the values buffer (O(n)) so we can
+ // write `-1` into those slots; `into_parts` avoids an extra values-buffer
+ // clone beyond that copy. Prefer this over scanning every validity bit.
+ let (_, values, nulls) = int_lengths.clone().into_parts();
+ let Some(nulls) = nulls else {
+ return Ok(Arc::new(Int32Array::new(values, None)));
+ };
+ let mut values = values.to_vec();
+ for i in (!nulls.inner()).set_indices() {
+ values[i] = -1;
}
+ Ok(Arc::new(Int32Array::from(values)))
}
fn spark_size_scalar(scalar: &ScalarValue) -> Result<ScalarValue,
DataFusionError> {
match scalar {
+ // ScalarValue::{List,LargeList,FixedSizeList,Map} each wrap an array
with
+ // exactly one row; read the row's element count from the offset buffer
+ // (matches the array path, avoids `value(0)` slicing).
ScalarValue::List(array) => {
- // ScalarValue::List contains a ListArray with exactly one row.
- // We need the length of that row's contents, not the row count.
if array.is_null(0) {
Ok(ScalarValue::Int32(Some(-1))) // Spark behavior: return -1
for null
} else {
- let len = array.value(0).len() as i32;
- Ok(ScalarValue::Int32(Some(len)))
+ Ok(ScalarValue::Int32(Some(array.value_length(0))))
}
}
ScalarValue::LargeList(array) => {
if array.is_null(0) {
Ok(ScalarValue::Int32(Some(-1)))
} else {
- let len = array.value(0).len() as i32;
+ // Spark arrays are capped near Integer.MAX_VALUE; overflow
shouldn't
+ // happen in practice but must error rather than silently wrap.
+ let len = i32::try_from(array.value_length(0)).map_err(|_| {
+ DataFusionError::Execution("size(): list length exceeds
i32::MAX".to_string())
+ })?;
Ok(ScalarValue::Int32(Some(len)))
}
}
@@ -191,16 +204,14 @@ fn spark_size_scalar(scalar: &ScalarValue) ->
Result<ScalarValue, DataFusionErro
if array.is_null(0) {
Ok(ScalarValue::Int32(Some(-1)))
} else {
- let len = array.value(0).len() as i32;
- Ok(ScalarValue::Int32(Some(len)))
+ Ok(ScalarValue::Int32(Some(array.value_length())))
}
}
ScalarValue::Map(array) => {
if array.is_null(0) {
Ok(ScalarValue::Int32(Some(-1)))
} else {
- let len = array.value_length(0);
- Ok(ScalarValue::Int32(Some(len)))
+ Ok(ScalarValue::Int32(Some(array.value_length(0))))
}
}
ScalarValue::Null => {
@@ -254,6 +265,29 @@ mod tests {
assert_eq!(result.value(3), 0); // [] has 0 elements
}
+ #[test]
+ fn test_spark_size_array_no_nulls() {
+ // Fast path (the shape Comet actually runs after the CASE-WHEN
filter):
+ // input has no nulls, so spark_size_list_like returns the length
kernel
+ // output directly without touching the values buffer.
+ let value_data = Int32Array::from(vec![1, 2, 3, 4, 5, 6]);
+ let value_offsets = arrow::buffer::OffsetBuffer::new(vec![0, 3, 5, 5,
6].into());
+ let field = Arc::new(Field::new("item", DataType::Int32, true));
+ let list_array =
+ ListArray::try_new(field, value_offsets, Arc::new(value_data),
None).unwrap();
+
+ let array_ref: ArrayRef = Arc::new(list_array);
+ let result = spark_size_array(&array_ref).unwrap();
+ let result = result.as_any().downcast_ref::<Int32Array>().unwrap();
+
+ // Expected: [3, 2, 0, 1]; no null buffer on the output.
+ assert_eq!(result.null_count(), 0);
+ assert_eq!(result.value(0), 3);
+ assert_eq!(result.value(1), 2);
+ assert_eq!(result.value(2), 0);
+ assert_eq!(result.value(3), 1);
+ }
+
#[test]
fn test_spark_size_scalar() {
// Test non-null list with 3 elements
@@ -484,4 +518,104 @@ mod tests {
assert_eq!(result.value(2), -1); // null returns -1
assert_eq!(result.value(3), 0); // [] has 0 elements
}
+
+ #[test]
+ fn test_spark_size_sliced_list_array() {
+ // Slicing is the classic trap for buffer-level ops: the null buffer,
values
+ // buffer, and offsets all logically start at `array.offset()`, not 0.
Pin the
+ // behavior so a future edit that touches buffers directly cannot
regress it.
+ //
+ // Full array: [[1, 2], [3], [4, 5, 6], null, [7]]
+ let value_data = Int32Array::from(vec![1, 2, 3, 4, 5, 6, 7]);
+ let value_offsets = arrow::buffer::OffsetBuffer::new(vec![0, 2, 3, 6,
6, 7].into());
+ let field = Arc::new(Field::new("item", DataType::Int32, true));
+
+ let mut null_buffer = NullBufferBuilder::new(5);
+ null_buffer.append(true);
+ null_buffer.append(true);
+ null_buffer.append(true);
+ null_buffer.append(false);
+ null_buffer.append(true);
+
+ let list_array = ListArray::try_new(
+ field,
+ value_offsets,
+ Arc::new(value_data),
+ null_buffer.finish(),
+ )
+ .unwrap();
+
+ // Skip the first two rows: sliced view is [[4, 5, 6], null, [7]].
+ let sliced: ArrayRef = Arc::new(list_array.slice(2, 3));
+ let result = spark_size_array(&sliced).unwrap();
+ let result = result.as_any().downcast_ref::<Int32Array>().unwrap();
+
+ assert_eq!(result.len(), 3);
+ assert_eq!(result.value(0), 3);
+ assert_eq!(result.value(1), -1);
+ assert_eq!(result.value(2), 1);
+ }
+
+ #[test]
+ fn test_spark_size_scalar_large_list() {
+ use arrow::array::LargeListArray;
+
+ // Non-null LargeList: [10, 20, 30, 40] → 4
+ let values = Int32Array::from(vec![10, 20, 30, 40]);
+ let offsets = arrow::buffer::OffsetBuffer::new(vec![0i64, 4].into());
+ let field = Arc::new(Field::new("item", DataType::Int32, true));
+ let large_list_array =
+ LargeListArray::try_new(Arc::clone(&field), offsets,
Arc::new(values), None).unwrap();
+ let scalar = ScalarValue::LargeList(Arc::new(large_list_array));
+ assert_eq!(
+ spark_size_scalar(&scalar).unwrap(),
+ ScalarValue::Int32(Some(4))
+ );
+
+ // Null LargeList row → -1
+ let empty_values = Int32Array::from(Vec::<i32>::new());
+ let null_offsets = arrow::buffer::OffsetBuffer::new(vec![0i64,
0].into());
+ let mut null_buffer = NullBufferBuilder::new(1);
+ null_buffer.append(false);
+ let null_large_list = LargeListArray::try_new(
+ field,
+ null_offsets,
+ Arc::new(empty_values),
+ null_buffer.finish(),
+ )
+ .unwrap();
+ let scalar = ScalarValue::LargeList(Arc::new(null_large_list));
+ assert_eq!(
+ spark_size_scalar(&scalar).unwrap(),
+ ScalarValue::Int32(Some(-1))
+ );
+ }
+
+ #[test]
+ fn test_spark_size_scalar_fixed_size_list() {
+ use arrow::array::FixedSizeListArray;
+
+ // Non-null FixedSizeList of size 4 → 4. `value_length()` on FSL takes
no index,
+ // unlike `value_length(i)` on List/LargeList — pin the correct API is
called.
+ let values = Int32Array::from(vec![10, 20, 30, 40]);
+ let field = Arc::new(Field::new("item", DataType::Int32, true));
+ let fsl_array = FixedSizeListArray::new(Arc::clone(&field), 4,
Arc::new(values), None);
+ let scalar = ScalarValue::FixedSizeList(Arc::new(fsl_array));
+ assert_eq!(
+ spark_size_scalar(&scalar).unwrap(),
+ ScalarValue::Int32(Some(4))
+ );
+
+ // Null FSL row → -1
+ let null_values = Int32Array::from(vec![0, 0, 0, 0]);
+ let mut null_buffer = NullBufferBuilder::new(1);
+ null_buffer.append(false);
+ let null_fsl_array =
+ FixedSizeListArray::new(field, 4, Arc::new(null_values),
null_buffer.finish());
+ let scalar = ScalarValue::FixedSizeList(Arc::new(null_fsl_array));
+ assert_eq!(
+ spark_size_scalar(&scalar).unwrap(),
+ ScalarValue::Int32(Some(-1))
+ );
+ }
}
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]