Skip to content

Commit 26fbf4d

Browse files
authored
bench: add nested-type (List/Struct/Map) cases to first_value/last_value benchmark (#24075)
## Which issue does this PR close? Groundwork for benchmarking #23628 (native `GroupsAccumulator` for nested value types in `first_value` / `last_value`). ## Rationale for this change The `first_last` benchmark only covers primitive value types today. #23628 adds a native `GroupsAccumulator` for **nested** value types (`List`, `Struct`, `Map`), which previously fell back to one per-group `Accumulator` via `GroupsAccumulatorAdapter`. To measure that work we need nested-type cases in the benchmark. Landing this first (with the fallback path on current `main`) means that once #23628 is in flight, a `run benchmark first_last` diff shows the fallback → native speedup per type automatically. ## What changes are included in this PR? - Adds `List<Int64>`, `Struct<i64,utf8,f64>`, `Map<utf8,i64>` and a composite `List<Struct<i64,utf8>>` value column, each with the same coverage as the primitive cases: `first_value`/`last_value` update + merge, plus `first_value` evaluate, at 0% and 90% nulls. - `prepare_typed_groups_accumulator` now mirrors the planner: it uses the native `GroupsAccumulator` when the value type is supported and otherwise falls back to a `GroupsAccumulatorAdapter` around one per-group `Accumulator`. The same benchmark case therefore runs the fallback on a build without native nested support and the native path on one that has it. ## Are these changes tested? Benchmark-only. Runs locally on `main` (all nested cases exercise the fallback path). As a preview of the intended signal, `first_value struct update` goes ~112ms (fallback) → ~24ms (native, with #23628) at 1024 groups. ## Are there any user-facing changes? No — benchmark only.
1 parent 3ef9a8c commit 26fbf4d

1 file changed

Lines changed: 257 additions & 15 deletions

File tree

datafusion/functions-aggregate/benches/first_last.rs

Lines changed: 257 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -15,10 +15,16 @@
1515
// specific language governing permissions and limitations
1616
// under the License.
1717

18-
use arrow::array::{ArrayRef, BooleanArray, Int64Array};
18+
use arrow::array::{
19+
Array, ArrayRef, BooleanArray, Int64Array, ListArray, MapArray, StringArray,
20+
StructArray,
21+
};
22+
use arrow::buffer::{NullBuffer, OffsetBuffer};
1923
use arrow::compute::SortOptions;
20-
use arrow::datatypes::{DataType, Field, Int64Type, Schema};
21-
use arrow::util::bench_util::{create_boolean_array, create_primitive_array};
24+
use arrow::datatypes::{DataType, Field, Fields, Float64Type, Int64Type, Schema};
25+
use arrow::util::bench_util::{
26+
create_boolean_array, create_primitive_array, create_string_array_with_len,
27+
};
2228
use datafusion_common::instant::Instant;
2329
use std::hint::black_box;
2430
use std::sync::Arc;
@@ -29,14 +35,21 @@ use datafusion_expr::{
2935
use datafusion_functions_aggregate::first_last::{
3036
FirstValue, LastValue, TrivialFirstValueAccumulator, TrivialLastValueAccumulator,
3137
};
38+
use datafusion_functions_aggregate_common::aggregate::groups_accumulator::GroupsAccumulatorAdapter;
3239
use datafusion_physical_expr::PhysicalSortExpr;
3340
use datafusion_physical_expr::expressions::col;
3441

3542
use criterion::{BatchSize, Criterion, criterion_group, criterion_main};
3643

37-
fn prepare_groups_accumulator(is_first: bool) -> Box<dyn GroupsAccumulator> {
44+
/// Build a `GroupsAccumulator` for an arbitrary value type, so the nested-type
45+
/// (`Struct` / `List`) fast paths added for `first_value` / `last_value` can be
46+
/// exercised with the same harness as the primitive ones.
47+
fn prepare_typed_groups_accumulator(
48+
is_first: bool,
49+
value_type: DataType,
50+
) -> Box<dyn GroupsAccumulator> {
3851
let schema = Arc::new(Schema::new(vec![
39-
Field::new("value", DataType::Int64, true),
52+
Field::new("value", value_type.clone(), true),
4053
Field::new("ord", DataType::Int64, true),
4154
]));
4255

@@ -46,11 +59,12 @@ fn prepare_groups_accumulator(is_first: bool) -> Box<dyn GroupsAccumulator> {
4659
options: SortOptions::default(),
4760
};
4861

49-
let value_field: Arc<Field> = Field::new("value", DataType::Int64, true).into();
50-
let accumulator_args = AccumulatorArgs {
62+
let value_field: Arc<Field> = Field::new("value", value_type.clone(), true).into();
63+
let value_expr = col("value", &schema).unwrap();
64+
let make_args = || AccumulatorArgs {
5165
return_field: Arc::clone(&value_field),
5266
schema: &schema,
53-
expr_fields: &[value_field],
67+
expr_fields: std::slice::from_ref(&value_field),
5468
ignore_nulls: false,
5569
order_bys: std::slice::from_ref(&sort_expr),
5670
is_reversed: false,
@@ -60,20 +74,81 @@ fn prepare_groups_accumulator(is_first: bool) -> Box<dyn GroupsAccumulator> {
6074
"LAST_VALUE(value ORDER BY ord)"
6175
},
6276
is_distinct: false,
63-
exprs: &[col("value", &schema).unwrap()],
77+
exprs: std::slice::from_ref(&value_expr),
6478
};
6579

80+
// Mirror the planner: use the native GroupsAccumulator when this value type
81+
// is supported and otherwise fall back to a GroupsAccumulatorAdapter around
82+
// one per-group Accumulator. Deciding with `groups_accumulator_supported`
83+
// (rather than catching `create_groups_accumulator` errors) keeps genuine
84+
// construction failures loud. The same case then runs the fallback on a
85+
// build without native nested support and the native path on one with it,
86+
// so a before/after benchmark run surfaces the win directly.
87+
let supported = if is_first {
88+
FirstValue::new().groups_accumulator_supported(make_args())
89+
} else {
90+
LastValue::new().groups_accumulator_supported(make_args())
91+
};
92+
if !supported {
93+
return build_fallback_adapter(is_first, value_type);
94+
}
6695
if is_first {
6796
FirstValue::new()
68-
.create_groups_accumulator(accumulator_args)
97+
.create_groups_accumulator(make_args())
6998
.unwrap()
7099
} else {
71100
LastValue::new()
72-
.create_groups_accumulator(accumulator_args)
101+
.create_groups_accumulator(make_args())
73102
.unwrap()
74103
}
75104
}
76105

106+
/// Build the *fallback* grouped accumulator for a value type: a
107+
/// `GroupsAccumulatorAdapter` wrapping one per-group `Accumulator`. This is
108+
/// exactly what nested value types (`List` / `Struct` / `Map`) used before
109+
/// they gained a native `GroupsAccumulator`, and it is what the planner still
110+
/// selects when `groups_accumulator_supported` returns `false`. Benching this
111+
/// side by side with `prepare_typed_groups_accumulator` (the native path)
112+
/// shows the win from the native `GroupsAccumulator`.
113+
fn build_fallback_adapter(
114+
is_first: bool,
115+
value_type: DataType,
116+
) -> Box<dyn GroupsAccumulator> {
117+
Box::new(GroupsAccumulatorAdapter::new(move || {
118+
let schema = Arc::new(Schema::new(vec![
119+
Field::new("value", value_type.clone(), true),
120+
Field::new("ord", DataType::Int64, true),
121+
]));
122+
let sort_expr = PhysicalSortExpr {
123+
expr: col("ord", &schema)?,
124+
options: SortOptions::default(),
125+
};
126+
let value_field: Arc<Field> =
127+
Field::new("value", value_type.clone(), true).into();
128+
let value_expr = col("value", &schema)?;
129+
let accumulator_args = AccumulatorArgs {
130+
return_field: Arc::clone(&value_field),
131+
schema: &schema,
132+
expr_fields: std::slice::from_ref(&value_field),
133+
ignore_nulls: false,
134+
order_bys: std::slice::from_ref(&sort_expr),
135+
is_reversed: false,
136+
name: if is_first {
137+
"FIRST_VALUE(value ORDER BY ord)"
138+
} else {
139+
"LAST_VALUE(value ORDER BY ord)"
140+
},
141+
is_distinct: false,
142+
exprs: std::slice::from_ref(&value_expr),
143+
};
144+
if is_first {
145+
FirstValue::new().accumulator(accumulator_args)
146+
} else {
147+
LastValue::new().accumulator(accumulator_args)
148+
}
149+
}))
150+
}
151+
77152
fn create_trivial_accumulator(
78153
is_first: bool,
79154
ignore_nulls: bool,
@@ -104,11 +179,13 @@ fn evaluate_bench(
104179
) {
105180
let n = values.len();
106181
let group_indices: Vec<usize> = (0..n).map(|i| i % num_groups).collect();
182+
let value_type = values.data_type().clone();
107183

108184
c.bench_function(name, |b| {
109185
b.iter_batched(
110186
|| {
111-
let mut accumulator = prepare_groups_accumulator(is_first);
187+
let mut accumulator =
188+
prepare_typed_groups_accumulator(is_first, value_type.clone());
112189
accumulator
113190
.update_batch(
114191
&[Arc::clone(&values), Arc::clone(&ord)],
@@ -139,6 +216,7 @@ fn update_bench(
139216
) {
140217
let n = values.len();
141218
let group_indices: Vec<usize> = (0..n).map(|i| i % num_groups).collect();
219+
let value_type = values.data_type().clone();
142220

143221
// Initialize with worst-case ordering so update_batch forces rows comparison for all groups.
144222
let worst_ord: ArrayRef = Arc::new(Int64Array::from(vec![
@@ -153,7 +231,8 @@ fn update_bench(
153231
c.bench_function(name, |b| {
154232
b.iter_batched(
155233
|| {
156-
let mut accumulator = prepare_groups_accumulator(is_first);
234+
let mut accumulator =
235+
prepare_typed_groups_accumulator(is_first, value_type.clone());
157236
accumulator
158237
.update_batch(
159238
&[Arc::clone(&values), Arc::clone(&worst_ord)],
@@ -197,6 +276,7 @@ fn merge_bench(
197276
let n = values.len();
198277
let group_indices: Vec<usize> = (0..n).map(|i| i % num_groups).collect();
199278
let is_set: ArrayRef = Arc::new(BooleanArray::from(vec![true; n]));
279+
let value_type = values.data_type().clone();
200280

201281
// Initialize with worst-case ordering so update_batch forces rows comparison for all groups.
202282
let worst_ord: ArrayRef = Arc::new(Int64Array::from(vec![
@@ -212,7 +292,8 @@ fn merge_bench(
212292
b.iter_batched(
213293
|| {
214294
// Prebuild accumulator
215-
let mut accumulator = prepare_groups_accumulator(is_first);
295+
let mut accumulator =
296+
prepare_typed_groups_accumulator(is_first, value_type.clone());
216297
accumulator
217298
.update_batch(
218299
&[Arc::clone(&values), Arc::clone(&worst_ord)],
@@ -270,6 +351,167 @@ fn trivial_update_bench(
270351
});
271352
}
272353

354+
/// A top-level validity buffer with roughly `null_density` nulls, so the
355+
/// generated nested arrays have null *values* (not just null inner
356+
/// fields/elements) — matching the `nulls={pct}%` semantics of the primitive
357+
/// benchmarks, where the value itself is null. Returns `None` at 0% so the
358+
/// arrays stay fully valid. Derived from arrow's own null generator for a
359+
/// deterministic, density-accurate pattern.
360+
fn top_level_nulls(n: usize, null_density: f32) -> Option<NullBuffer> {
361+
create_primitive_array::<Int64Type>(n, null_density)
362+
.nulls()
363+
.cloned()
364+
}
365+
366+
/// A 3-field struct value column `Struct<Int64, Utf8, Float64>`. `null_density`
367+
/// controls both the struct-level null values and the inner field nulls.
368+
fn create_struct_array(n: usize, null_density: f32) -> ArrayRef {
369+
let a = Arc::new(create_primitive_array::<Int64Type>(n, null_density)) as ArrayRef;
370+
let b =
371+
Arc::new(create_string_array_with_len::<i32>(n, null_density, 16)) as ArrayRef;
372+
let d = Arc::new(create_primitive_array::<Float64Type>(n, null_density)) as ArrayRef;
373+
let fields = Fields::from(vec![
374+
Field::new("c0", DataType::Int64, true),
375+
Field::new("c1", DataType::Utf8, true),
376+
Field::new("c2", DataType::Float64, true),
377+
]);
378+
Arc::new(StructArray::new(
379+
fields,
380+
vec![a, b, d],
381+
top_level_nulls(n, null_density),
382+
))
383+
}
384+
385+
/// A `List<Int64>` value column with fixed-size lists of `list_len` elements.
386+
fn create_list_array(n: usize, list_len: usize, null_density: f32) -> ArrayRef {
387+
let child = Arc::new(create_primitive_array::<Int64Type>(
388+
n * list_len,
389+
null_density,
390+
)) as ArrayRef;
391+
let offsets = OffsetBuffer::from_lengths(std::iter::repeat_n(list_len, n));
392+
let field = Arc::new(Field::new_list_field(DataType::Int64, true));
393+
Arc::new(ListArray::new(
394+
field,
395+
offsets,
396+
child,
397+
top_level_nulls(n, null_density),
398+
))
399+
}
400+
401+
/// A `Map<Utf8, Int64>` value column with `entries_per_row` entries per row.
402+
/// Values carry `null_density` nulls (keys are never null), matching the null
403+
/// treatment of the struct / list generators.
404+
fn create_map_array(n: usize, entries_per_row: usize, null_density: f32) -> ArrayRef {
405+
let total = n * entries_per_row;
406+
let values =
407+
Arc::new(create_primitive_array::<Int64Type>(total, null_density)) as ArrayRef;
408+
let keys = Arc::new(StringArray::from_iter_values(
409+
(0..total).map(|idx| format!("k{}", idx % entries_per_row)),
410+
)) as ArrayRef;
411+
let entry_fields = Fields::from(vec![
412+
Field::new("keys", DataType::Utf8, false),
413+
Field::new("values", DataType::Int64, true),
414+
]);
415+
let entries = StructArray::new(entry_fields.clone(), vec![keys, values], None);
416+
let offsets = OffsetBuffer::from_lengths(std::iter::repeat_n(entries_per_row, n));
417+
let map_field =
418+
Arc::new(Field::new("entries", DataType::Struct(entry_fields), false));
419+
Arc::new(MapArray::new(
420+
map_field,
421+
offsets,
422+
entries,
423+
top_level_nulls(n, null_density),
424+
false,
425+
))
426+
}
427+
428+
/// A composite `List<Struct<a: Int64, b: Utf8>>` column — a list whose
429+
/// elements are structs (the "array of records" shape). Exercises the
430+
/// nested-within-nested case, which the generic value-state path must also
431+
/// handle.
432+
fn create_list_of_struct_array(n: usize, list_len: usize, null_density: f32) -> ArrayRef {
433+
let total = n * list_len;
434+
let a =
435+
Arc::new(create_primitive_array::<Int64Type>(total, null_density)) as ArrayRef;
436+
let b =
437+
Arc::new(create_string_array_with_len::<i32>(total, null_density, 8)) as ArrayRef;
438+
let struct_fields = Fields::from(vec![
439+
Field::new("a", DataType::Int64, true),
440+
Field::new("b", DataType::Utf8, true),
441+
]);
442+
let child =
443+
Arc::new(StructArray::new(struct_fields.clone(), vec![a, b], None)) as ArrayRef;
444+
let offsets = OffsetBuffer::from_lengths(std::iter::repeat_n(list_len, n));
445+
let list_field =
446+
Arc::new(Field::new_list_field(DataType::Struct(struct_fields), true));
447+
Arc::new(ListArray::new(
448+
list_field,
449+
offsets,
450+
child,
451+
top_level_nulls(n, null_density),
452+
))
453+
}
454+
455+
fn first_last_nested_benchmark(c: &mut Criterion) {
456+
const N: usize = 65536;
457+
const NUM_GROUPS: usize = 1024;
458+
459+
let ord = Arc::new(create_primitive_array::<Int64Type>(N, 0.0)) as ArrayRef;
460+
461+
for pct in [0, 90] {
462+
let null_density = (pct as f32) / 100.0;
463+
464+
// One column per nested value type. Each type gets the same treatment
465+
// as the primitive first_value / last_value benchmarks: update and
466+
// merge (both first and last) plus evaluate, at 0% and 90% nulls. On a
467+
// build without native nested support these run the fallback adapter;
468+
// with this PR they run the native GroupsAccumulator, so the benchmark
469+
// bot's before/after diff shows the win per type.
470+
let columns: [(&str, ArrayRef); 4] = [
471+
("struct(i64,utf8,f64)", create_struct_array(N, null_density)),
472+
("list<i64>[4]", create_list_array(N, 4, null_density)),
473+
("map<utf8,i64>", create_map_array(N, 4, null_density)),
474+
(
475+
"list<struct(i64,utf8)>[4]",
476+
create_list_of_struct_array(N, 4, null_density),
477+
),
478+
];
479+
480+
for (type_label, values) in columns {
481+
for (fn_label, is_first) in [("first_value", true), ("last_value", false)] {
482+
update_bench(
483+
c,
484+
is_first,
485+
&format!("{fn_label} update_bench {type_label} nulls={pct}%"),
486+
values.clone(),
487+
ord.clone(),
488+
None,
489+
NUM_GROUPS,
490+
);
491+
merge_bench(
492+
c,
493+
is_first,
494+
&format!("{fn_label} merge_bench {type_label} nulls={pct}%"),
495+
values.clone(),
496+
ord.clone(),
497+
None,
498+
NUM_GROUPS,
499+
);
500+
}
501+
evaluate_bench(
502+
c,
503+
true,
504+
EmitTo::All,
505+
&format!("first_value evaluate_bench {type_label} nulls={pct}%, all"),
506+
values.clone(),
507+
ord.clone(),
508+
None,
509+
NUM_GROUPS,
510+
);
511+
}
512+
}
513+
}
514+
273515
fn first_last_benchmark(c: &mut Criterion) {
274516
const N: usize = 65536;
275517
const NUM_GROUPS: usize = 1024;
@@ -354,5 +596,5 @@ fn first_last_benchmark(c: &mut Criterion) {
354596
}
355597
}
356598

357-
criterion_group!(benches, first_last_benchmark);
599+
criterion_group!(benches, first_last_benchmark, first_last_nested_benchmark);
358600
criterion_main!(benches);

0 commit comments

Comments
 (0)