Skip to content

Commit 4e90755

Browse files
authored
perf: Unify, optimize map[k] and map_extract(key) (#25201)
## Which issue does this PR close? - Closes #25083. - Closes #25210. ## Rationale for this change `map[key]` (aka `get_field`) and `map_extract(map, key)` collectively had three ways to lookup keys in a map: (1): `get_field` used a per-batch `eq` kernel for scalar keys. This is efficient for maps with many entries where `key` is not found, but slower for maps in which the key can be found quickly (because `eq` does not allow early-stopping). On my local machine, `eq` only beats a comparator-based approach if the latter required touching more than 60% of the keys in a row. (2): `get_field` used a comparator-based approach for nested keys. (3): `map_extract` always used a comparator-based approach. Conceptually, these two functions only differ in how the result is represented, so it makes sense to consolidate them. We can also adopt a hybrid strategy that gets the best of the previous approaches for most inputs: * Start with a comparator-based approach. * After the first row, remember the index at which the matching key was found, and check that index first for subsequent rows. This takes advantage of the observation that most map rows have their keys in the same order. * After 32 rows, check whether the comparator looked at more than 50% of the entries in those rows. If it did, the early stopping that the comparator approach allows is not useful and we switch to an `eq` kernel for the remainder of the batch (as long as the map key is not a nested type). We can also use `take` to construct the results, which is faster than the previous approach based on `MutableArrayData::extend`. Along the way, fix `get_field` to behave correctly for NULL map rows with non-empty offset ranges (#25210). Benchmarks: (M4 Max) get_field (map[key]): - get_field_map/int32/last/1024x4: 8.13 µs -> 3.52 µs, -56.7% - get_field_map/int32/shuffled/1024x4: 7.81 µs -> 4.34 µs, -44.5% - get_field_map/int32/missing/1024x4: 7.91 µs -> 4.86 µs, -38.5% - get_field_map/int32/first/1024x32: 9.73 µs -> 3.56 µs, -63.4% - get_field_map/int32/last/1024x32: 20.19 µs -> 3.64 µs, -82.0% - get_field_map/int32/shuffled/1024x32: 15.03 µs -> 13.09 µs, -12.9% - get_field_map/int32/missing/1024x32: 18.70 µs -> 19.91 µs, +6.5% - get_field_map/utf8_view/last/1024x4: 15.34 µs -> 5.74 µs, -62.6% - get_field_map/utf8_view/shuffled/1024x4: 15.61 µs -> 12.47 µs, -20.1% - get_field_map/utf8_view/missing/1024x4: 14.97 µs -> 11.93 µs, -20.3% - get_field_map/utf8_view/first/1024x32: 68.91 µs -> 6.38 µs, -90.7% - get_field_map/utf8_view/last/1024x32: 83.72 µs -> 6.63 µs, -92.1% - get_field_map/utf8_view/shuffled/1024x32: 79.27 µs -> 76.77 µs, -3.2% - get_field_map/utf8_view/missing/1024x32: 71.72 µs -> 72.85 µs, +1.6% - get_field_map/struct/last/1024x4: 13.52 µs -> 4.06 µs, -70.0% - get_field_map/struct/shuffled/1024x4: 10.75 µs -> 9.01 µs, -16.2% - get_field_map/struct/missing/1024x4: 12.88 µs -> 10.71 µs, -16.9% - get_field_map/struct/first/1024x32: 8.41 µs -> 4.13 µs, -50.9% - get_field_map/struct/last/1024x32: 64.71 µs -> 4.24 µs, -93.5% - get_field_map/struct/shuffled/1024x32: 38.84 µs -> 36.01 µs, -7.3% - get_field_map/struct/missing/1024x32: 59.14 µs -> 58.04 µs, -1.9% map_extract: - map_extract/int32/last/1x0: 323 ns -> 420 ns, +30.0% - map_extract/int32/last/1x1: 382 ns -> 372 ns, -2.6% - map_extract/int32/last/1024x4: 9.40 µs -> 3.99 µs, -57.5% - map_extract/int32/shuffled/1024x4: 8.27 µs -> 4.56 µs, -44.8% - map_extract/int32/missing/1024x4: 5.54 µs -> 5.58 µs, +0.7% - map_extract/int32/varying/1024x4: 7.61 µs -> 7.22 µs, -5.1% - map_extract/utf8_view/last/1024x4: 20.07 µs -> 6.46 µs, -67.8% - map_extract/utf8_view/shuffled/1024x4: 14.91 µs -> 12.46 µs, -16.4% - map_extract/utf8_view/missing/1024x4: 16.15 µs -> 13.30 µs, -17.6% - map_extract/utf8_view/varying/1024x4: 14.69 µs -> 16.21 µs, +10.4% - map_extract/struct/last/1024x4: 14.07 µs -> 4.49 µs, -68.1% - map_extract/struct/shuffled/1024x4: 11.58 µs -> 9.43 µs, -18.6% - map_extract/struct/missing/1024x4: 10.30 µs -> 11.44 µs, +11.0% - map_extract/struct/varying/1024x4: 9.96 µs -> 10.69 µs, +7.3% - map_extract/int32/first/1024x32: 6.26 µs -> 4.26 µs, -31.9% - map_extract/int32/last/1024x32: 38.53 µs -> 4.44 µs, -88.5% - map_extract/int32/shuffled/1024x32: 25.13 µs -> 13.95 µs, -44.5% - map_extract/int32/missing/1024x32: 34.91 µs -> 21.73 µs, -37.7% - map_extract/int32/varying/1024x32: 25.49 µs -> 24.45 µs, -4.1% - map_extract/utf8_view/first/1024x32: 10.15 µs -> 6.86 µs, -32.4% - map_extract/utf8_view/last/1024x32: 122.54 µs -> 7.03 µs, -94.3% - map_extract/utf8_view/shuffled/1024x32: 71.11 µs -> 85.15 µs, +19.8% - map_extract/utf8_view/missing/1024x32: 113.40 µs -> 81.12 µs, -28.5% - map_extract/utf8_view/varying/1024x32: 71.74 µs -> 66.87 µs, -6.8% - map_extract/struct/first/1024x32: 8.62 µs -> 4.79 µs, -44.4% - map_extract/struct/last/1024x32: 70.64 µs -> 4.97 µs, -93.0% - map_extract/struct/shuffled/1024x32: 41.94 µs -> 41.52 µs, -1.0% - map_extract/struct/missing/1024x32: 61.08 µs -> 63.58 µs, +4.1% - map_extract/struct/varying/1024x32: 40.54 µs -> 38.60 µs, -4.8% ## What changes are included in this PR? * Add `datafusion_functions::utils::map_lookup`; for each map row, this returns the index of a matching entry or null. * Implement `get_field` and `map_extract` on top of the shared `map_lookup` helper, constructing the results with `take` * In `map_extract`, optimize for the single-scalar-key case by passing it through as a scalar value instead of expanding it to the batch size * `map[NULL]` now returns `NULL` instead of producing an error * Fix #25210 * Overhaul benchmarks ## What is the testing strategy for this PR? Existing tests pass; new tests added. ## Are there any user-facing changes? No, aside from the bugfix and some corner-case changes like how error messages are formatted.
1 parent 28c0dec commit 4e90755

6 files changed

Lines changed: 869 additions & 270 deletions

File tree

‎datafusion/functions-nested/benches/map.rs‎

Lines changed: 62 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -210,15 +210,28 @@ fn criterion_benchmark(c: &mut Criterion) {
210210
}
211211

212212
fn bench_map_extract(c: &mut Criterion) {
213+
// DataFusion's default execution batch size.
214+
const ROWS: usize = 8192;
215+
213216
let udf = map_extract_udf();
214217
let config_options = Arc::new(ConfigOptions::default());
215218
let mut group = c.benchmark_group("map_extract");
216219

217-
for (rows, width) in [(1, 0), (1, 1), (1024, 1), (1024, 32)] {
218-
for key_type in ["int32", "utf8_view", "struct"] {
220+
// Cases are named `{key type}/{lookup}/{rows}x{entries}`. `shuffled` looks
221+
// up a key that every row holds at a different position, and `varying`
222+
// looks up a different key per row, mixing matches and misses.
223+
let shapes: &[(usize, &[&str])] = &[
224+
(4, &["last", "shuffled", "missing", "varying"]),
225+
(32, &["first", "last", "shuffled", "missing", "varying"]),
226+
];
227+
for &(width, lookups) in shapes {
228+
for key_type in ["int32", "utf8", "utf8_view", "struct"] {
219229
let make_keys = |keys: Vec<i32>| -> ArrayRef {
220230
match key_type {
221231
"int32" => Arc::new(Int32Array::from(keys)),
232+
"utf8" => Arc::new(StringArray::from_iter_values(
233+
keys.iter().map(|key| format!("key_{key:016}")),
234+
)),
222235
"utf8_view" => Arc::new(StringViewArray::from_iter_values(
223236
keys.iter().map(|key| format!("key_{key:016}")),
224237
)),
@@ -229,39 +242,52 @@ fn bench_map_extract(c: &mut Criterion) {
229242
_ => unreachable!(),
230243
}
231244
};
232-
let keys = make_keys((0..rows).flat_map(|_| 0..width as i32).collect());
233-
let entries = StructArray::from(vec![
234-
(
235-
Arc::new(Field::new("key", keys.data_type().clone(), false)),
236-
keys,
237-
),
238-
(
239-
Arc::new(Field::new("value", DataType::Int32, false)),
240-
Arc::new(Int32Array::from_iter_values(0..(rows * width) as i32))
241-
as ArrayRef,
242-
),
243-
]);
244-
let map: ArrayRef = Arc::new(MapArray::new(
245-
Arc::new(Field::new("entries", entries.data_type().clone(), false)),
246-
OffsetBuffer::from_lengths(std::iter::repeat_n(width, rows)),
247-
entries,
248-
None,
249-
false,
250-
));
251-
let lookups: &[&str] = if width <= 1 {
252-
&["last"]
253-
} else {
254-
&["first", "last", "missing", "varying"]
245+
// Every row holds the keys `0..width`. With `shuffled`, each
246+
// row's entries are rotated by the row number.
247+
let make_map = |shuffled: bool| -> ArrayRef {
248+
let keys = (0..ROWS)
249+
.flat_map(|row| {
250+
(0..width).map(move |position| {
251+
if shuffled {
252+
((position + row) % width) as i32
253+
} else {
254+
position as i32
255+
}
256+
})
257+
})
258+
.collect();
259+
let keys = make_keys(keys);
260+
let entries = StructArray::from(vec![
261+
(
262+
Arc::new(Field::new("key", keys.data_type().clone(), false)),
263+
keys,
264+
),
265+
(
266+
Arc::new(Field::new("value", DataType::Int32, false)),
267+
Arc::new(Int32Array::from_iter_values(0..(ROWS * width) as i32))
268+
as ArrayRef,
269+
),
270+
]);
271+
Arc::new(MapArray::new(
272+
Arc::new(Field::new("entries", entries.data_type().clone(), false)),
273+
OffsetBuffer::from_lengths(std::iter::repeat_n(width, ROWS)),
274+
entries,
275+
None,
276+
false,
277+
))
255278
};
279+
let map = make_map(false);
280+
let shuffled_map = make_map(true);
256281
for &lookup in lookups {
257-
let query_keys = match lookup {
258-
"first" => vec![0],
259-
"last" => vec![width.saturating_sub(1) as i32],
260-
"missing" => vec![width as i32],
261-
// Mix matches and misses with a different lookup key per row.
262-
"varying" => {
263-
(0..rows).map(|row| (row % (width + 1)) as i32).collect()
264-
}
282+
let (map, query_keys) = match lookup {
283+
"first" => (&map, vec![0]),
284+
"last" => (&map, vec![width.saturating_sub(1) as i32]),
285+
"shuffled" => (&shuffled_map, vec![0]),
286+
"missing" => (&map, vec![width as i32]),
287+
"varying" => (
288+
&map,
289+
(0..ROWS).map(|row| (row % (width + 1)) as i32).collect(),
290+
),
265291
_ => unreachable!(),
266292
};
267293
let query_keys = make_keys(query_keys);
@@ -272,7 +298,7 @@ fn bench_map_extract(c: &mut Criterion) {
272298
ScalarValue::try_from_array(&query_keys, 0).unwrap(),
273299
)
274300
};
275-
let args = vec![ColumnarValue::Array(Arc::clone(&map)), query_keys];
301+
let args = vec![ColumnarValue::Array(Arc::clone(map)), query_keys];
276302
let arg_fields = args
277303
.iter()
278304
.map(|arg| Field::new("arg", arg.data_type(), true).into())
@@ -289,15 +315,15 @@ fn bench_map_extract(c: &mut Criterion) {
289315
group.bench_function(
290316
BenchmarkId::new(
291317
format!("{key_type}/{lookup}"),
292-
format!("{rows}x{width}"),
318+
format!("{ROWS}x{width}"),
293319
),
294320
|b| {
295321
b.iter(|| {
296322
black_box(
297323
udf.invoke_with_args(ScalarFunctionArgs {
298324
args: args.clone(),
299325
arg_fields: arg_fields.clone(),
300-
number_rows: rows,
326+
number_rows: ROWS,
301327
return_field: Arc::clone(&return_field),
302328
config_options: Arc::clone(&config_options),
303329
})

‎datafusion/functions-nested/src/map_extract.rs‎

Lines changed: 49 additions & 73 deletions
Original file line numberDiff line numberDiff line change
@@ -17,20 +17,19 @@
1717

1818
//! [`ScalarUDFImpl`] definitions for map_extract functions.
1919
20-
use crate::utils::{get_map_entry_field, make_scalar_function};
21-
use arrow::array::{
22-
Array, ArrayRef, ListArray, MapArray, MutableArrayData, make_array, new_empty_array,
23-
};
20+
use crate::utils::get_map_entry_field;
21+
use arrow::array::{Array, ArrayRef, ListArray, MapArray, UInt32Array};
2422
use arrow::buffer::OffsetBuffer;
25-
use arrow::compute::SortOptions;
23+
use arrow::compute::take;
2624
use arrow::datatypes::{DataType, Field};
27-
use arrow_ord::ord::make_comparator;
2825
use datafusion_common::utils::take_function_args;
2926
use datafusion_common::{Result, cast::as_map_array, exec_err};
27+
use datafusion_expr::function::Hint;
3028
use datafusion_expr::{
3129
ColumnarValue, Documentation, ScalarFunctionArgs, ScalarUDFImpl, Signature,
3230
Volatility,
3331
};
32+
use datafusion_functions::utils::{make_scalar_function, map_lookup};
3433
use datafusion_macros::user_doc;
3534
use std::sync::Arc;
3635

@@ -119,7 +118,11 @@ impl ScalarUDFImpl for MapExtract {
119118
}
120119

121120
fn invoke_with_args(&self, args: ScalarFunctionArgs) -> Result<ColumnarValue> {
122-
make_scalar_function(map_extract_inner)(&args.args)
121+
// A scalar key is passed through as a single row rather than expanded
122+
// to the batch size; the lookup applies it to every map row.
123+
make_scalar_function(map_extract_inner, vec![Hint::Pad, Hint::AcceptsSingular])(
124+
&args.args,
125+
)
123126
}
124127

125128
fn aliases(&self) -> &[String] {
@@ -149,85 +152,40 @@ fn general_map_extract_inner(
149152
map_array: &MapArray,
150153
query_keys_array: &dyn Array,
151154
) -> Result<ArrayRef> {
152-
let keys = map_array.keys();
153-
let values = map_array.values();
154-
let field = Arc::new(Field::new_list_field(map_array.value_type().clone(), true));
155-
let map_offsets = map_array.value_offsets();
156-
if map_offsets.first() == map_offsets.last() {
157-
return Ok(Arc::new(ListArray::new(
158-
field,
159-
OffsetBuffer::new_zeroed(map_array.len()),
160-
new_empty_array(values.data_type()),
161-
map_array.nulls().cloned(),
162-
)));
163-
}
164-
165-
// Compare keys by index using a single comparator for the batch.
166-
let compare =
167-
make_comparator(keys.as_ref(), query_keys_array, SortOptions::default())?;
168-
let mut offsets = Vec::with_capacity(map_array.len() + 1);
169-
offsets.push(0_i32);
170-
171-
let original_data = values.to_data();
172-
// There is at most one output value per map row.
173-
let mut mutable = MutableArrayData::new(
174-
vec![&original_data],
175-
false,
176-
map_array.len().min(values.len()),
177-
);
178-
179-
for (row_index, offset_window) in map_offsets.windows(2).enumerate() {
180-
let start = offset_window[0] as usize;
181-
let end = offset_window[1] as usize;
182-
let mut offset = offsets[row_index];
183-
184-
if map_array.is_valid(row_index)
185-
&& let Some(index) = (start..end).find(|&i| compare(i, row_index).is_eq())
186-
{
187-
mutable.try_extend(0, index, index + 1)?;
188-
offset += 1;
189-
}
190-
191-
// A missing key results in an empty list.
192-
offsets.push(offset);
193-
}
194-
195-
let data = mutable.freeze();
196-
155+
let indices = map_lookup(map_array, query_keys_array)?;
156+
// Each matched row contributes one list element. Every other row is an
157+
// empty list, or NULL when the map itself is NULL.
158+
let lengths = indices.iter().map(|index| usize::from(index.is_some()));
159+
let mut matched = Vec::with_capacity(indices.len() - indices.null_count());
160+
matched.extend(indices.iter().flatten());
161+
let values = take(
162+
map_array.values().as_ref(),
163+
&UInt32Array::from(matched),
164+
None,
165+
)?;
197166
Ok(Arc::new(ListArray::new(
198-
field,
199-
OffsetBuffer::<i32>::new(offsets.into()),
200-
make_array(data),
167+
Arc::new(Field::new_list_field(map_array.value_type().clone(), true)),
168+
OffsetBuffer::from_lengths(lengths),
169+
values,
201170
map_array.nulls().cloned(),
202171
)))
203172
}
204173

205174
fn map_extract_inner(args: &[ArrayRef]) -> Result<ArrayRef> {
206175
let [map_arg, key_arg] = take_function_args("map_extract", args)?;
207-
208-
let map_array = match map_arg.data_type() {
209-
DataType::Map(_, _) => as_map_array(&map_arg)?,
210-
DataType::Null => return Ok(Arc::clone(map_arg)),
211-
_ => return exec_err!("The first argument in map_extract must be a map"),
212-
};
213-
214-
let key_type = map_array.key_type();
215-
216-
if key_type != key_arg.data_type() {
217-
return exec_err!(
218-
"The key type {} does not match the map key type {}",
219-
key_arg.data_type(),
220-
key_type
221-
);
176+
match map_arg.data_type() {
177+
DataType::Map(_, _) => {
178+
general_map_extract_inner(as_map_array(map_arg.as_ref())?, key_arg.as_ref())
179+
}
180+
DataType::Null => Ok(Arc::clone(map_arg)),
181+
_ => exec_err!("The first argument in map_extract must be a map"),
222182
}
223-
224-
general_map_extract_inner(map_array, key_arg)
225183
}
226184

227185
#[cfg(test)]
228186
mod tests {
229187
use super::*;
230-
use arrow::array::{Float64Array, Int32Array, StructArray};
188+
use arrow::array::{Float64Array, Int32Array, NullArray, StructArray};
231189
use arrow::buffer::NullBuffer;
232190
use arrow::datatypes::Int32Type;
233191

@@ -299,6 +257,24 @@ mod tests {
299257
Ok(())
300258
}
301259

260+
#[test]
261+
fn map_extract_untyped_null_keys() -> Result<()> {
262+
let map = make_map(
263+
Arc::new(Int32Array::from(vec![1, 2])),
264+
vec![10, 20],
265+
vec![0, 1, 2],
266+
Some(NullBuffer::from(vec![true, false])),
267+
);
268+
let expected =
269+
ListArray::from_iter_primitive::<Int32Type, _, _>([Some(vec![]), None]);
270+
// Direct callers can pass untyped NULLs without SQL's key coercion.
271+
for len in [1, map.len()] {
272+
let result = general_map_extract_inner(&map, &NullArray::new(len))?;
273+
assert_eq!(result.as_ref(), &expected);
274+
}
275+
Ok(())
276+
}
277+
302278
#[test]
303279
fn map_extract_float_keys() -> Result<()> {
304280
let nan = f64::NAN;

0 commit comments

Comments
 (0)