Skip to content

perf: Unify, optimize map[k] and map_extract(key) - #25201

Open
neilconway wants to merge 4 commits into
apache:mainfrom
neilconway:neilc/map-lookup-kernel
Open

perf: Unify, optimize map[k] and map_extract(key)#25201
neilconway wants to merge 4 commits into
apache:mainfrom
neilconway:neilc/map-lookup-kernel

Conversation

@neilconway

@neilconway neilconway commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

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 the first 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
  • Overhaul benchmarks
  • Fix map[key] misbehaves for NULL map rows that have entries #25210

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.

@github-actions github-actions Bot added sqllogictest SQL Logic Tests (.slt) functions Changes to functions implementation labels Sep 11, 2026
@neilconway

Copy link
Copy Markdown
Contributor Author

@comphead FYI, this follows up on the idea you raised in the review for #24999

Both suites cover Int32, Utf8View, and struct keys on maps of 4 and 32
entries, named `{key type}/{lookup}/{rows}x{entries}`. The lookups are the
first and last entry of every row, a key that every row holds at a
different position (`shuffled`), and a key present in no row, plus one key
per row for `map_extract`. Cases that only rescaled another case, such as
8192 rows or one-entry maps, are dropped; `map_extract` keeps two
single-row cases that measure per-batch fixed cost.
@codecov-commenter

codecov-commenter commented Sep 11, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 90.56604% with 25 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.93%. Comparing base (bb21f51) to head (76d7c62).

Files with missing lines Patch % Lines
datafusion/functions/src/utils.rs 92.56% 0 Missing and 18 partials ⚠️
datafusion/functions-nested/src/map_extract.rs 78.94% 2 Missing and 2 partials ⚠️
datafusion/functions/src/core/getfield.rs 25.00% 0 Missing and 3 partials ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #25201    +/-   ##
========================================
  Coverage   81.93%   81.93%            
========================================
  Files        1133     1133            
  Lines      423529   423667   +138     
  Branches   423529   423667   +138     
========================================
+ Hits       347032   347150   +118     
- Misses      55907    55918    +11     
- Partials    20590    20599     +9     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

`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` used a comparator-based approach for all keys.

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 75% 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).

Add `datafusion_functions::utils::map_lookup`, which returns for each map
row the index of the first matching entry or null, and build both
functions on it with `take`. The lookup scans each row with a comparator,
which stops at the first match, and tries the position where the previous
row matched first, since rows in a batch usually share key order; a hit
then usually costs one comparison wherever the key sits. A miss has no
match to stop at, so every entry of the row is compared at the
comparator's higher per-comparison cost. After sampling 32 rows, if most
of them missed, the remaining entries are compared with one vectorized
`eq` instead, which does the same full comparison more cheaply. A lookup
key that differs from the map key type only in dictionary encoding is cast
rather than rejected.

`map_extract` now passes a scalar key through as a single row instead of
expanding it to the batch size. `map[key]` reports a mismatched key type
as an execution error instead of an Arrow comparison error, and a null map
row that still carries entries yields NULL, as it already did from
`map_extract`.

Closes apache#25083
@neilconway
neilconway force-pushed the neilc/map-lookup-kernel branch from 56f7828 to b33a1c0 Compare September 11, 2026 21:33
@neilconway neilconway changed the title perf: Consolidate and optimize map[k] and map_extract(key) perf: Unify, optimize map[k] and map_extract(key) Sep 11, 2026
@comphead

Copy link
Copy Markdown
Contributor

Nice, I'll check it tomorrow, and might be related to apache/datafusion-comet#5806

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

functions Changes to functions implementation sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

map[key] misbehaves for NULL map rows that have entries Consolidate map lookup code between get_field and map_extract

3 participants