This is an automated email from the ASF dual-hosted git repository.
sunchao 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 2b35fb8eec perf: vectorize Map in spark_size via offset buffer (#5395)
2b35fb8eec is described below
commit 2b35fb8eec0fccff56f705073de572b34f0fe3dc
Author: ChenChen Lai <[email protected]>
AuthorDate: Fri Aug 21 00:23:04 2026 +0800
perf: vectorize Map in spark_size via offset buffer (#5395)
* perf: vectorize Map in spark_size via offset buffer
* check style
* fix Prettier
---
.../contributor-guide/optimizing_expressions.md | 2 +-
native/spark-expr/benches/array_size.rs | 63 ++++++-
native/spark-expr/src/array_funcs/size.rs | 186 +++++++++++++++++----
3 files changed, 218 insertions(+), 33 deletions(-)
diff --git a/docs/source/contributor-guide/optimizing_expressions.md
b/docs/source/contributor-guide/optimizing_expressions.md
index ecdb8a7263..4b11dd0487 100644
--- a/docs/source/contributor-guide/optimizing_expressions.md
+++ b/docs/source/contributor-guide/optimizing_expressions.md
@@ -149,7 +149,7 @@ 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` Map path and scalar path: `value_length` /
offsets instead of materializing a sliced array
|
+| **Read from the offset buffer directly** |
`list_array.value(i)` allocating a sliced `ArrayRef` per row
| `spark_size` scalar path: `value_length` instead of
materializing a sliced array; Map array path (~6-13x):
`MapArray::offsets().windows(2)` diff, `length` kernel doesn't support Map |
| **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
|
diff --git a/native/spark-expr/benches/array_size.rs
b/native/spark-expr/benches/array_size.rs
index afb340c06e..c82468f462 100644
--- a/native/spark-expr/benches/array_size.rs
+++ b/native/spark-expr/benches/array_size.rs
@@ -15,9 +15,9 @@
// specific language governing permissions and limitations
// under the License.
-use arrow::array::{ArrayRef, Int32Array, LargeListArray, ListArray};
+use arrow::array::{ArrayRef, Int32Array, LargeListArray, ListArray, MapArray,
StructArray};
use arrow::buffer::{NullBuffer, OffsetBuffer};
-use arrow::datatypes::{DataType, Field};
+use arrow::datatypes::{DataType, Field, Fields};
use criterion::{criterion_group, criterion_main, Criterion};
use datafusion::physical_plan::ColumnarValue;
use datafusion_comet_spark_expr::spark_size;
@@ -70,6 +70,52 @@ fn create_large_list_array(rows: usize, elems_per_row:
usize) -> ArrayRef {
))
}
+/// Build a `MapArray` of `rows` maps, each with `entries_per_row` Int32βInt32
+/// entries. When `with_nulls` is true every 10th row is null. Map entry counts
+/// come from the offset buffer, so per-row diff is O(1) already; this bench
+/// isolates per-row dispatch + builder overhead vs. one-shot offset
differencing.
+fn create_map_array(rows: usize, entries_per_row: usize, with_nulls: bool) ->
ArrayRef {
+ let total = rows * entries_per_row;
+ let keys = Int32Array::from((0..total as i32).collect::<Vec<i32>>());
+ let values = Int32Array::from((0..total as i32).collect::<Vec<i32>>());
+
+ let mut offsets = Vec::with_capacity(rows + 1);
+ offsets.push(0i32);
+ for i in 1..=rows {
+ offsets.push((i * entries_per_row) as i32);
+ }
+
+ let key_field = Arc::new(Field::new("key", DataType::Int32, false));
+ let value_field = Arc::new(Field::new("value", DataType::Int32, true));
+ let entries = StructArray::new(
+ Fields::from(vec![key_field, value_field]),
+ vec![Arc::new(keys), Arc::new(values)],
+ None,
+ );
+
+ let map_field = Arc::new(Field::new(
+ "entries",
+ DataType::Struct(Fields::from(vec![
+ Field::new("key", DataType::Int32, false),
+ Field::new("value", DataType::Int32, true),
+ ])),
+ false,
+ ));
+
+ let nulls =
+ with_nulls.then(|| NullBuffer::from((0..rows).map(|i| i % 10 !=
0).collect::<Vec<bool>>()));
+ Arc::new(
+ MapArray::try_new(
+ map_field,
+ OffsetBuffer::new(offsets.into()),
+ entries,
+ nulls,
+ false,
+ )
+ .unwrap(),
+ )
+}
+
fn criterion_benchmark(c: &mut Criterion) {
let rows = 8192;
@@ -105,6 +151,19 @@ fn criterion_benchmark(c: &mut Criterion) {
"spark_size: LargeList (10% null)",
&create_large_list_array(rows, 5),
);
+
+ // Map shapes: no length-kernel support for MapArray, so this path
differences
+ // offsets directly. Cover the two shapes the issue calls for β with and
without
+ // nulls β since the null rewrite is the only branch that touches the
values
+ // buffer.
+ bench(
+ "spark_size: map (10% null)",
+ &create_map_array(rows, 5, true),
+ );
+ bench(
+ "spark_size: map, no nulls",
+ &create_map_array(rows, 5, false),
+ );
}
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 0d555806b7..2311da5fa9 100644
--- a/native/spark-expr/src/array_funcs/size.rs
+++ b/native/spark-expr/src/array_funcs/size.rs
@@ -97,23 +97,9 @@ fn spark_size_array(array: &ArrayRef) -> Result<ArrayRef,
DataFusionError> {
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 {
- builder.append_value(map_array.value_length(i));
- }
- }
- Ok(Arc::new(builder.finish()))
- }
+ // Map is not supported by the length kernel; compute row lengths from
+ // the entry-offset buffer directly.
+ DataType::Map(_, _) => spark_size_map(array),
_ => {
exec_err!(
"size function only supports arrays and maps, got: {:?}",
@@ -126,10 +112,6 @@ fn spark_size_array(array: &ArrayRef) -> Result<ArrayRef,
DataFusionError> {
/// 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() {
@@ -151,7 +133,8 @@ fn spark_size_list_like(array: &ArrayRef) ->
Result<ArrayRef, DataFusionError> {
// 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.
+ // Comet plan. Return the length kernel output as-is; skip the downcast and
+ // `Int32Array::clone` that `rewrite_nulls_to_minus_one` would otherwise
pay.
if array.null_count() == 0 {
return Ok(lengths);
}
@@ -159,21 +142,48 @@ fn spark_size_list_like(array: &ArrayRef) ->
Result<ArrayRef, DataFusionError> {
let int_lengths = lengths
.as_any()
.downcast_ref::<Int32Array>()
- .ok_or_else(|| DataFusionError::Internal("Expected Int32Array from
length".to_string()))?;
+ .ok_or_else(|| DataFusionError::Internal("Expected Int32Array from
length".to_string()))?
+ .clone();
+ Ok(rewrite_nulls_to_minus_one(int_lengths))
+}
- // `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();
+/// Compute Spark `size()` for `MapArray` by differencing the entry-offset
+/// buffer. `MapArray::offsets()` returns the sliced offsets, so `windows(2)`
+/// stays correct on sliced inputs. Applies the same null β `-1` rewrite as the
+/// list path.
+fn spark_size_map(array: &ArrayRef) -> Result<ArrayRef, DataFusionError> {
+ let map_array = array
+ .as_any()
+ .downcast_ref::<arrow::array::MapArray>()
+ .ok_or_else(|| DataFusionError::Internal("Expected
MapArray".to_string()))?;
+
+ let offsets = map_array.offsets();
+ let values: Vec<i32> = offsets.windows(2).map(|w| w[1] - w[0]).collect();
+ let lengths = Int32Array::new(values.into(), map_array.nulls().cloned());
+ Ok(rewrite_nulls_to_minus_one(lengths))
+}
+
+/// Rewrite null slots to `-1` (Spark size-of-null semantics) and drop the null
+/// buffer. Returns the input untouched when there are no nulls.
+///
+/// Patches the values buffer in place rather than using `zip`; `zip` goes
+/// through `MutableArrayData` and roughly doubled runtime on the `array_size`
+/// criterion shapes. `set_indices()` on the inverted validity visits only null
+/// slots (O(null_count)); `into_parts` avoids an extra values-buffer clone
+/// beyond the `to_vec()` copy needed to write into the null slots.
+fn rewrite_nulls_to_minus_one(lengths: Int32Array) -> ArrayRef {
+ if lengths.null_count() == 0 {
+ return Arc::new(lengths);
+ }
+ let (_, values, nulls) = lengths.into_parts();
let Some(nulls) = nulls else {
- return Ok(Arc::new(Int32Array::new(values, None)));
+ return 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)))
+ Arc::new(Int32Array::from(values))
}
fn spark_size_scalar(scalar: &ScalarValue) -> Result<ScalarValue,
DataFusionError> {
@@ -375,6 +385,122 @@ mod tests {
assert_eq!(result.value(3), -1); // null map returns -1
}
+ #[test]
+ fn test_spark_size_map_no_nulls() {
+ use arrow::array::{Int32Array, MapArray, StringArray};
+
+ // Fast path for Map: no null buffer, so rewrite_nulls_to_minus_one
returns
+ // the offset-diff Int32Array as-is without touching the values buffer.
+ let keys = StringArray::from(vec![Some("a"), Some("b"), Some("c"),
Some("d")]);
+ let values = Int32Array::from(vec![Some(1), Some(2), Some(3),
Some(4)]);
+ let entry_offsets = arrow::buffer::OffsetBuffer::new(vec![0i32, 2, 3,
3, 4].into());
+
+ let key_field = Arc::new(Field::new("key", DataType::Utf8, false));
+ let value_field = Arc::new(Field::new("value", DataType::Int32, true));
+
+ let entries = arrow::array::StructArray::new(
+ arrow::datatypes::Fields::from(vec![key_field, value_field]),
+ vec![Arc::new(keys), Arc::new(values)],
+ None,
+ );
+
+ let map_field = Arc::new(Field::new(
+ "entries",
+ DataType::Struct(arrow::datatypes::Fields::from(vec![
+ Field::new("key", DataType::Utf8, false),
+ Field::new("value", DataType::Int32, true),
+ ])),
+ false,
+ ));
+
+ let map_array = MapArray::try_new(map_field, entry_offsets, entries,
None, false).unwrap();
+
+ let array_ref: ArrayRef = Arc::new(map_array);
+ let result = spark_size_array(&array_ref).unwrap();
+ let result = result.as_any().downcast_ref::<Int32Array>().unwrap();
+
+ // No nulls on input; no null buffer on output.
+ assert_eq!(result.null_count(), 0);
+ assert_eq!(result.value(0), 2);
+ assert_eq!(result.value(1), 1);
+ assert_eq!(result.value(2), 0);
+ assert_eq!(result.value(3), 1);
+ }
+
+ #[test]
+ fn test_spark_size_sliced_map_array() {
+ use arrow::array::{Int32Array, MapArray, StringArray};
+
+ // Slicing pins that `MapArray::offsets()` returns sliced-aware
offsets so
+ // `windows(2)` differences the correct rows. Same trap as the
sliced-List
+ // test above.
+ //
+ // Full map: [{"a":1,"b":2}, {"c":3}, {"d":4,"e":5,"f":6}, null,
{"g":7}]
+ let keys = StringArray::from(vec![
+ Some("a"),
+ Some("b"),
+ Some("c"),
+ Some("d"),
+ Some("e"),
+ Some("f"),
+ Some("g"),
+ ]);
+ let values = Int32Array::from(vec![
+ Some(1),
+ Some(2),
+ Some(3),
+ Some(4),
+ Some(5),
+ Some(6),
+ Some(7),
+ ]);
+ let entry_offsets = arrow::buffer::OffsetBuffer::new(vec![0i32, 2, 3,
6, 6, 7].into());
+
+ let key_field = Arc::new(Field::new("key", DataType::Utf8, false));
+ let value_field = Arc::new(Field::new("value", DataType::Int32, true));
+
+ let entries = arrow::array::StructArray::new(
+ arrow::datatypes::Fields::from(vec![key_field, value_field]),
+ vec![Arc::new(keys), Arc::new(values)],
+ None,
+ );
+
+ 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 map_field = Arc::new(Field::new(
+ "entries",
+ DataType::Struct(arrow::datatypes::Fields::from(vec![
+ Field::new("key", DataType::Utf8, false),
+ Field::new("value", DataType::Int32, true),
+ ])),
+ false,
+ ));
+
+ let map_array = MapArray::try_new(
+ map_field,
+ entry_offsets,
+ entries,
+ null_buffer.finish(),
+ false,
+ )
+ .unwrap();
+
+ // Skip the first two rows: sliced view is [{d,e,f}, null, {g}].
+ let sliced: ArrayRef = Arc::new(map_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_map() {
use arrow::array::{Int32Array, MapArray, StringArray};
---------------------------------------------------------------------
To unsubscribe, e-mail: [email protected]
For additional commands, e-mail: [email protected]