perf: Optimize array_has() for array needle#23337
Conversation
|
run benchmarks array_has |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing perf/array-has-array (ab0504c) to a0e9887 (merge-base) diff using: array_has File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: CPU Details (lscpu)Details
Resource Usagearray_has — base (merge-base)
array_has — branch
File an issue against this benchmark runner |
ab0504c to
7ed6f01
Compare
|
run benchmarks array_has |
|
🤖 Benchmark running (GKE) | trigger CPU Details (lscpu)Comparing perf/array-has-array (7ed6f01) to 0365d3c (merge-base) diff using: array_has File an issue against this benchmark runner |
|
🤖 Benchmark completed (GKE) | trigger Instance: CPU Details (lscpu)Details
Resource Usagearray_has — base (merge-base)
array_has — branch
File an issue against this benchmark runner |
|
Those are some pretty good results! |
`array_has(array, element)` returns, for each row, whether the array contains the element. When the `element` (needle) is an array rather than a scalar -- the needle argument is a column with one value per row, e.g. `array_has(t1.tags, t2.key)` in a join filter -- execution goes through `array_has_dispatch_for_array` (the `ColumnarValue::Array` needle branch), which compared each row by invoking the Arrow `eq` kernel once per row. That kernel allocates a `BooleanArray` and pays downcast and dispatch overhead on every row. (The scalar-needle branch was optimized separately in apache#20374.) Add a fast path for primitive and string element types, preserving the Arrow `eq` kernel semantics (total-order float equality; null elements never match). With all-valid elements each row is a single branchless OR-reduction over the native values. When primitive elements contain nulls -- whose backing values are arbitrary -- the per-element equality bitmap is ANDed with the validity bitmap (one word-parallel op, no per-element branch) before the per-row reduction, so null slots never match regardless of their value. Past a moderate average list length (`NULL_FAST_PATH_MAX_LEN`) this bitmap's extra passes lose to the per-row kernel, so the element-null branch bails to it there; that length is measured over the visible (sliced) region, so a sliced array's hidden child elements don't route a small window to the slow path. The all-valid fold has no such crossover. For string elements each row is a single pass over the row's values; for `Utf8View` this is view-aware -- the byte length and 4-byte prefix packed in the 128-bit view reject non-matches before touching the data buffer (what the `eq` kernel does, but without its per-row allocation), and an inline needle (<= 12 bytes) is matched by full-view equality with no materialization at all. Nested and other element types keep using the per-row `eq` kernel. What this removes is the fixed per-row kernel overhead, not the element comparison itself, so the gain is largest for short lists and shrinks as lists grow. Criterion microbenchmark, array needle on a 100k-row batch vs the per-row `eq` kernel (NOT end-to-end query time): i64, all-valid elements: ~15x (64-elem lists), ~1.9x at 1024-elem i64, ~30% null elements: ~9x (8-elem) ... ~1x (512-elem); falls back beyond Utf8 / LargeUtf8: ~2.5x Utf8View (view-aware): ~5x on short/inline strings Every shape improves with no regression. End-to-end impact depends on how much of a query `array_has` accounts for. For a query dominated by an array-needle `array_has` join filter (a `NestedLoopJoinExec` with `filter=array_has(tags, key)` over 3000x3000 rows of 8-element lists) total time drops from 0.95s to 0.059s (~16x, identical results). For a workload where `array_has` is a smaller fraction -- e.g. the ~6% of profile that motivated this (see apache#18070 / apache#18161, which fixed the join's deep-copy but left the per-row `array_has` cost) -- the overall speedup is single-digit percent. The array-needle criterion benchmark used for these numbers is added separately (so it can be run against `main` to capture the before baseline). Part of apache#18727. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
7ed6f01 to
7598605
Compare
Move the behavioral coverage for the array (column) needle path from Rust unit tests into datafusion/sqllogictest/test_files/array/array_has.slt (element and needle nulls, null/empty rows, the null-fill collision, found/not-found, over i64, Utf8, Utf8View, LargeList and LargeUtf8, plus a >512-row case that exercises the chunked element-null path), following the maintainer preference for covering UDFs with SLT. Keep a single unit test for sliced List / FixedSizeList offset handling, whose sliced-array shape SQL can't produce. Drops the reference oracle and the string-list test helper, no longer needed. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Which issue does this PR close?
array_haswhen the search element is a column #23334.Full disclosure - this was heavily assisted by AI, and I did my best to understand and justify every change here before submitting.
Rationale for this change
array_has(array, element)returns, for each row, whether the array contains the element.When the
element(needle) is an array rather than a scalar, the needle argument is a column with one value per row, e.g.array_has(t1.tags, t2.key)in a join filter, execution goes througharray_has_dispatch_for_array(theColumnarValue::Arrayneedle branch), which compared each row by invoking the Arroweqkernel once per row.That kernel allocates a
BooleanArrayand pays downcast and dispatch overhead on every row. (The scalar-needle branch was optimized separately in #20374.)What this removes is the fixed per-row kernel overhead, not the element comparison itself, so the gain is largest for short lists and shrinks as lists grow.
All numbers below are from the committed criterion benchmark (
cargo bench --bench array_has, groupsarray_has_array_null_patterns/array_has_array_by_size/array_has_array_by_rows): thearray_hasUDF evaluated in isolation with an array needle, origin (the per-roweqkernel) vs now. "list length" is the number of elements in each row's array (not the row count). Not end-to-end query time.By data type and null pattern (list length 64, 10K rows)
The i64 null cases are uniform (~4x) whether the match is present, absent, the whole list is null, or the needle collides with a null slot's backing fill value — validity is folded in with one word-parallel op, so there is no per-row rescan and no null slot can match.
Strings win ~2.1–2.5x mainly by dropping the per-row
BooleanArrayallocation.Utf8Viewadditionally uses a view-aware compare: the byte length and 4-byte prefix packed into the 128-bit view reject non-matches before touching the data buffer, and an inline value (≤ 12 bytes) is matched by whole-view equality with no materialization at all — hence ~5x on short/inline strings. When long strings share a prefix (e.g. ARNs) the prefix can't reject, soUtf8Viewfalls in line with the other string types (~2.1–2.4x). No string case regresses.By list length (i64, 30% element nulls, not found, 10K rows)
The element-null branch makes a few passes over the values; past a moderate average list length (
NULL_FAST_PATH_MAX_LEN) the per-row kernel wins, so it bails to it there — no meaningful regression. That average is measured over the visible (sliced) region, so a sliced array's hidden child elements can't route a small window to the slow path. The all-valid fold has no such crossover.By row count (i64, 8 elems/row, 30% nulls, not found)
Invariant to the number of rows — the per-row overhead removed is a fixed cost, so absolute savings scale linearly with the column height.
The remaining benchmarks in the suite (scalar
array_has,array_has_all,array_has_any— paths this PR does not touch) are unchanged (median 0.99x, within measurement noise), confirming no regression outside the array-needle path.End-to-end (context)
For a query dominated by an array-needle
array_hasjoin filter (aNestedLoopJoinExecwithfilter=array_has(tags, key)over 3000x3000 rows of 8-element lists) total time drops from 0.95s to 0.059s (~16x, identical results). For a workload wherearray_hasis a smaller fraction, e.g. the ~6% of profile that motivated this (see #18070 / #18161, which fixed the join's deep-copy but left the per-rowarray_hascost), the overall speedup is single-digit percent.What changes are included in this PR?
A fast path for primitive and string element types in
array_has_dispatch_for_array, preserving the Arroweqkernel semantics (total-order float equality; null elements never match):NULL_FAST_PATH_MAX_LENaverage elements/row a length check over the visible (sliced) region bails to the per-row kernel (see the list-length table).Utf8Viewcompares the packed 128-bit views directly — length + 4-byte prefix reject non-matches before any data-buffer access, and an inline value (≤ 12 bytes) matches by whole-view equality with no materialization.eqkernel.The array-needle benchmarks used for the numbers above are added in #3 (null patterns, list length, and row count).
Are these changes tested?
Yes:
NaN/-0.0), sliced arrays (including a small visible window over a large backing child),LargeListoffsets, empty rows, a multi-chunk input, and a long-list input that exercises the per-row fallback, each cross-checked against the original per-roweqkernel as an oracle.array_has/array_contains/join_listssqllogictest suites pass.Are there any user-facing changes?
No.