Commit bc6e058
perf: Optimize array_has() for array needle (#23337)
## Which issue does this PR close?
- Part of #23334.
> The numbers below come from the committed criterion benchmark added in
#23335 (`cargo bench --bench
array_has`) — **origin** = the per-row `eq` kernel (unoptimized `main` /
#23335), **now** = with this
optimization applied. Run the bench on `main` and on this branch to
reproduce.
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 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 #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`, groups `array_has_array_null_patterns` /
`array_has_array_by_size` / `array_has_array_by_rows`): the `array_has`
UDF evaluated in isolation with an array needle, **origin** (the per-row
`eq` kernel) 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)
| element | element len | null pattern | origin | now | speedup |
|-----------|----------------|----------------------|---------|---------|---------|
| i64 | - | no nulls, found | 1.10 ms | 73 µs | 15.1x |
| i64 | - | no nulls, not found | 1.07 ms | 72 µs. | 14.9x |
| i64 | - | 30% nulls, found | 1.17 ms | 315 µs | 3.7x |
| i64 | - | 30% nulls, not found | 1.10 ms | 274 µs | 4.0x |
| i64 | - | all null | 1.10 ms | 272 µs | 4.0x |
| i64 | - | collision | 1.10 ms | 270 µs | 4.1x |
| Utf8 | short (inline) | no nulls | 2.57 ms | 1.01 ms | 2.5x |
| Utf8 | short (inline) | 30% nulls | 3.37 ms | 1.52 ms | 2.2x |
| Utf8 | long (>12B) | no nulls | 2.61 ms | 1.04 ms | 2.5x |
| Utf8 | long (>12B) | 30% nulls | 3.31 ms | 1.52 ms | 2.2x |
| Utf8 | - | all null | 1.26 ms | 256 µs | 4.9x |
| LargeUtf8 | short (inline) | no nulls | 2.56 ms | 1.02 ms | 2.5x |
| LargeUtf8 | short (inline) | 30% nulls | 3.20 ms | 1.54 ms | 2.1x |
| LargeUtf8 | long (>12B) | no nulls | 2.67 ms | 1.05 ms | 2.6x |
| LargeUtf8 | long (>12B) | 30% nulls | 3.42 ms | 1.59 ms | 2.2x |
| LargeUtf8 | - | all null | 1.31 ms | 263 µs | 5.0x |
| Utf8View | short (inline) | no nulls | 1.18 ms | 239 µs | 4.9x |
| Utf8View | short (inline) | 30% nulls | 1.26 ms | 246 µs | 5.1x |
| Utf8View | long (>12B) | no nulls | 2.86 ms | 1.17 ms | 2.4x |
| Utf8View | long (>12B) | 30% nulls | 3.51 ms | 1.66 ms | 2.1x |
| Utf8View | - | all null | 1.20 ms | 267 µs | 4.5x |
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 `BooleanArray`
allocation. `Utf8View` additionally 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, so `Utf8View` falls 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)
| elems/row | origin | now | speedup |
|-----------|---------|---------|--------------------------------------|
| 8 | 1.03 ms | 111 µs | 9.3x |
| 32 | 1.07 ms | 197 µs | 5.5x |
| 128 | 1.18 ms | 446 µs | 2.6x |
| 256 | 1.28 ms | 780 µs | 1.6x |
| 512 | 1.54 ms | 1.44 ms | 1.1x |
| 1024 | 2.17 ms | 2.15 ms | 1.0x (falls back to per-row kernel) |
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)
| rows | origin | now | speedup |
|------|-----------|----------|---------|
| 10K | 1.04 ms | 111 µs | 9.4x |
| 100K | 10.42 ms | 1.09 ms | 9.6x |
| 1M | 102.68 ms | 10.91 ms | 9.4x |
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_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 #18070 /
#18161, which fixed the join's deep-copy but left the per-row
`array_has` cost), 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 Arrow `eq` kernel
semantics (total-order float equality; null elements never match):
- **All-valid elements:** each row is a single branchless OR-reduction
over the raw native value slice (auto-vectorizes; the common case).
- **Element nulls:** a null slot's backing value is arbitrary, so the
per-element equality bitmap is ANDed with the validity bitmap (one
word-parallel op, no per-element branch) before reducing each row to
"any bit set", a null slot can never match regardless of its value. This
branch is processed in row chunks so the scratch buffer stays bounded,
and past `NULL_FAST_PATH_MAX_LEN` average elements/row a length check
over the visible (sliced) region bails to the per-row kernel (see the
list-length table).
- **String elements:** each row is a single pass over the row's values
(compare, then consult validity only on a match). `Utf8View` compares
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.
- **Nested (and any other) element types** keep using the per-row `eq`
kernel.
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:
- New unit tests for the array-needle path covering element nulls, the
null-fill collision (needle equal to a null slot's backing value),
total-order float equality (`NaN` / `-0.0`), sliced arrays (including a
small visible window over a large backing child), `LargeList` offsets,
empty rows, a multi-chunk input, and a long-list input that exercises
the per-row fallback, each cross-checked against the original per-row
`eq` kernel as an oracle.
- Existing `array_has` / `array_contains` / `join_lists` sqllogictest
suites pass.
## Are there any user-facing changes?
No.
---------
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>1 parent 7f6cc60 commit bc6e058
2 files changed
Lines changed: 392 additions & 3 deletions
| Original file line number | Diff line number | Diff line change | |
|---|---|---|---|
| |||
18 | 18 | | |
19 | 19 | | |
20 | 20 | | |
21 | | - | |
22 | | - | |
| 21 | + | |
| 22 | + | |
| 23 | + | |
23 | 24 | | |
24 | | - | |
| 25 | + | |
25 | 26 | | |
| 27 | + | |
26 | 28 | | |
27 | 29 | | |
28 | 30 | | |
| |||
323 | 325 | | |
324 | 326 | | |
325 | 327 | | |
| 328 | + | |
| 329 | + | |
| 330 | + | |
| 331 | + | |
| 332 | + | |
326 | 333 | | |
327 | 334 | | |
328 | 335 | | |
329 | 336 | | |
330 | 337 | | |
| 338 | + | |
| 339 | + | |
| 340 | + | |
| 341 | + | |
| 342 | + | |
| 343 | + | |
| 344 | + | |
| 345 | + | |
| 346 | + | |
| 347 | + | |
| 348 | + | |
| 349 | + | |
| 350 | + | |
| 351 | + | |
| 352 | + | |
| 353 | + | |
| 354 | + | |
| 355 | + | |
| 356 | + | |
| 357 | + | |
| 358 | + | |
| 359 | + | |
| 360 | + | |
| 361 | + | |
| 362 | + | |
| 363 | + | |
| 364 | + | |
| 365 | + | |
| 366 | + | |
| 367 | + | |
| 368 | + | |
| 369 | + | |
| 370 | + | |
| 371 | + | |
| 372 | + | |
| 373 | + | |
| 374 | + | |
| 375 | + | |
| 376 | + | |
| 377 | + | |
| 378 | + | |
| 379 | + | |
| 380 | + | |
| 381 | + | |
| 382 | + | |
| 383 | + | |
| 384 | + | |
| 385 | + | |
| 386 | + | |
| 387 | + | |
| 388 | + | |
| 389 | + | |
| 390 | + | |
| 391 | + | |
| 392 | + | |
| 393 | + | |
| 394 | + | |
| 395 | + | |
| 396 | + | |
| 397 | + | |
| 398 | + | |
| 399 | + | |
| 400 | + | |
| 401 | + | |
| 402 | + | |
| 403 | + | |
| 404 | + | |
| 405 | + | |
| 406 | + | |
331 | 407 | | |
332 | 408 | | |
333 | 409 | | |
| |||
344 | 420 | | |
345 | 421 | | |
346 | 422 | | |
| 423 | + | |
| 424 | + | |
| 425 | + | |
| 426 | + | |
| 427 | + | |
| 428 | + | |
| 429 | + | |
| 430 | + | |
| 431 | + | |
| 432 | + | |
| 433 | + | |
| 434 | + | |
| 435 | + | |
| 436 | + | |
| 437 | + | |
| 438 | + | |
| 439 | + | |
| 440 | + | |
| 441 | + | |
| 442 | + | |
| 443 | + | |
| 444 | + | |
| 445 | + | |
| 446 | + | |
| 447 | + | |
| 448 | + | |
| 449 | + | |
| 450 | + | |
| 451 | + | |
| 452 | + | |
| 453 | + | |
| 454 | + | |
| 455 | + | |
| 456 | + | |
| 457 | + | |
| 458 | + | |
| 459 | + | |
| 460 | + | |
| 461 | + | |
| 462 | + | |
| 463 | + | |
| 464 | + | |
| 465 | + | |
| 466 | + | |
| 467 | + | |
| 468 | + | |
| 469 | + | |
| 470 | + | |
| 471 | + | |
| 472 | + | |
| 473 | + | |
| 474 | + | |
| 475 | + | |
| 476 | + | |
| 477 | + | |
| 478 | + | |
| 479 | + | |
| 480 | + | |
| 481 | + | |
| 482 | + | |
| 483 | + | |
| 484 | + | |
| 485 | + | |
| 486 | + | |
| 487 | + | |
| 488 | + | |
| 489 | + | |
| 490 | + | |
| 491 | + | |
| 492 | + | |
| 493 | + | |
| 494 | + | |
| 495 | + | |
| 496 | + | |
| 497 | + | |
| 498 | + | |
| 499 | + | |
| 500 | + | |
| 501 | + | |
| 502 | + | |
| 503 | + | |
| 504 | + | |
| 505 | + | |
| 506 | + | |
| 507 | + | |
| 508 | + | |
| 509 | + | |
| 510 | + | |
| 511 | + | |
| 512 | + | |
| 513 | + | |
| 514 | + | |
| 515 | + | |
| 516 | + | |
| 517 | + | |
| 518 | + | |
| 519 | + | |
| 520 | + | |
| 521 | + | |
| 522 | + | |
| 523 | + | |
| 524 | + | |
| 525 | + | |
| 526 | + | |
| 527 | + | |
| 528 | + | |
| 529 | + | |
| 530 | + | |
| 531 | + | |
| 532 | + | |
| 533 | + | |
| 534 | + | |
| 535 | + | |
| 536 | + | |
| 537 | + | |
| 538 | + | |
| 539 | + | |
| 540 | + | |
| 541 | + | |
| 542 | + | |
| 543 | + | |
| 544 | + | |
| 545 | + | |
| 546 | + | |
| 547 | + | |
| 548 | + | |
| 549 | + | |
| 550 | + | |
| 551 | + | |
| 552 | + | |
| 553 | + | |
| 554 | + | |
| 555 | + | |
| 556 | + | |
| 557 | + | |
| 558 | + | |
| 559 | + | |
| 560 | + | |
| 561 | + | |
| 562 | + | |
347 | 563 | | |
348 | 564 | | |
349 | 565 | | |
| |||
1311 | 1527 | | |
1312 | 1528 | | |
1313 | 1529 | | |
| 1530 | + | |
| 1531 | + | |
| 1532 | + | |
| 1533 | + | |
| 1534 | + | |
| 1535 | + | |
| 1536 | + | |
| 1537 | + | |
| 1538 | + | |
| 1539 | + | |
| 1540 | + | |
| 1541 | + | |
| 1542 | + | |
| 1543 | + | |
| 1544 | + | |
| 1545 | + | |
| 1546 | + | |
| 1547 | + | |
| 1548 | + | |
| 1549 | + | |
| 1550 | + | |
| 1551 | + | |
| 1552 | + | |
| 1553 | + | |
| 1554 | + | |
| 1555 | + | |
| 1556 | + | |
| 1557 | + | |
| 1558 | + | |
| 1559 | + | |
| 1560 | + | |
| 1561 | + | |
| 1562 | + | |
| 1563 | + | |
| 1564 | + | |
| 1565 | + | |
| 1566 | + | |
| 1567 | + | |
| 1568 | + | |
| 1569 | + | |
| 1570 | + | |
| 1571 | + | |
| 1572 | + | |
| 1573 | + | |
| 1574 | + | |
| 1575 | + | |
| 1576 | + | |
| 1577 | + | |
| 1578 | + | |
| 1579 | + | |
| 1580 | + | |
| 1581 | + | |
| 1582 | + | |
| 1583 | + | |
| 1584 | + | |
| 1585 | + | |
| 1586 | + | |
| 1587 | + | |
| 1588 | + | |
1314 | 1589 | | |
0 commit comments