Skip to content

perf: reuse cached dictionary value slots in vectorized_equal_to - #25652

Merged
jayzhan211 merged 3 commits into
apache:mainfrom
CuteChuanChuan:issue-25219/reuse-cache-dic
Sep 30, 2026
Merged

jayzhan211 merged 3 commits into
apache:mainfrom
CuteChuanChuan:issue-25219/reuse-cache-dic

Conversation

@CuteChuanChuan

@CuteChuanChuan CuteChuanChuan commented Sep 23, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Follow-up to #25185. vectorized_equal_to was left untouched and still rebuilt the same val_idx -> inner_slot map from scratch on every batch.

What changes are included in this PR?

  • Added lookup_inner_slot, which resolves a single val_idx to its slot in inner and memorizes the result in val_to_inner.

  • Changed vectorized_equal_to to call sync_value_cache and resolve slots lazily per row.

  • Removed build_lookup_table, the rhs_rows.len() < num_distinct guard and equal_to_per_row.

What is the testing strategy for this PR?

Covered by the existing dictionary.rs unit tests.

Are there any user-facing changes?

No. This is an internal performance change with no public API or behaviour change.

- Add `lookup_inner_slot`, resolving one `val_idx` against `value_dedup` and memoising the result in `val_to_inner`.
- Call `sync_value_cache` from `vectorized_equal_to`.
- Drop `build_lookup_table`, the `rhs_rows.len() < num_distinct` guard and `equal_to_per_row`.
@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Sep 23, 2026
@codecov-commenter

codecov-commenter commented Sep 23, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 82.55%. Comparing base (6a792c6) to head (40bfa2b).
⚠️ Report is 22 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25652      +/-   ##
==========================================
+ Coverage   82.49%   82.55%   +0.05%     
==========================================
  Files        1140     1141       +1     
  Lines      438555   440284    +1729     
  Branches   438555   440284    +1729     
==========================================
+ Hits       361803   363469    +1666     
+ Misses      54880    54839      -41     
- Partials    21872    21976     +104     

☔ 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.

@CuteChuanChuan

Copy link
Copy Markdown
Contributor Author

Hi @jayzhan211 ,

could you PTAL when you have chances? Thanks! 🙏

@jayzhan211 jayzhan211 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @CuteChuanChuan , here are some suggestions

// so overwriting an already-false bit is a no-op.
let raw_keys = dict_keys.values();
for (idx, (&lhs_row, &rhs_row)) in
lhs_rows.iter().zip(rhs_rows.iter()).enumerate()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Calling lookup_inner_slot(&mut self, ..) on every row makes the hot loop slower than the old lookup[..] slice index. The existing dictionary_group_values bench regresses: dict_intern_emit 1.21–1.71x at card ≤ 1000, dict_repeated_intern_emit 1.08–1.52x. In intern_emit the values Arc is already cached by vectorized_append, so this is the loop itself, not the removed fallback. Reading the cache inline and taking the resolver only on a miss brought intern_emit back to 0.96–1.05x in my run:

-                let val_idx = raw_keys[rhs_row].as_usize();
-                let rhs_slot = self.lookup_inner_slot(dict_values, val_idx);
+                let val_idx = raw_keys[rhs_row].as_usize();
+                let rhs_slot = match self.val_to_inner[val_idx] {
+                    usize::MAX => self.lookup_inner_slot(dict_values, val_idx),
+                    slot => slot,
+                };
+    #[cold]
+    #[inline(never)]
     fn lookup_inner_slot(&mut self, dict_values: &ArrayRef, val_idx: usize) -> usize {

Apply the same change to the nullable-key loop and keep its get_bit guard. A merged single loop still left repeated_intern_emit (10% null keys) at 1.06–1.19x.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the numbers. I applied this and then ran the whole dictionary_group_values bench against main.

let lookup = self.build_lookup_table(dict_values, &val_hashes);

let group_to_inner = self.group_to_inner.as_slice();
self.sync_value_cache(dict_values);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dropping the rhs_rows.len() < num_distinct fallback regresses the steady-state case with a new values array per batch: every row hits an existing group, so vectorized_append isn't called for the column. sync_value_cache now hashes all D values per batch where base did per-row equal_to. That happens with concatenated/coalesced dictionaries and at parquet row-group boundaries. With 8192 rows/batch and D = 100000, I measured 1.19–1.48x vs base. Keep the fallback, but only when the Arc isn't already cached (0.89–0.95x in my run):

let cached = self
    .cached_values
    .as_ref()
    .is_some_and(|c| Arc::ptr_eq(c, dict_values));
if !cached && rhs_rows.len() < dict_values.len() {
    self.equal_to_per_row(lhs_rows, dict_values, dict, rhs_rows, equal_to_results);
    return;
}
self.sync_value_cache(dict_values);

Please also add before/after bench numbers to the PR description, since this is a perf PR.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done, using your condition. The fallback now only runs when the values Arc isn't cached and rhs_rows.len() < dict_values.len().

- Restore the per-row fallback for uncached values arrays with fewer rows than values
- On a cache miss, compare against the group's slot and cache only equal results
- Borrow val_to_inner / group_to_inner as slices outside the hot loops
- Add tests for cache fill, null value vs null key, and the fallback path
@CuteChuanChuan

Copy link
Copy Markdown
Contributor Author

Benchmark: dictionary_group_values, main (7570366) vs this PR, Apple M2 Pro.
intern_emit (1 batch)

rows / cardinality main PR change
8192 / 20 44.9 µs 44.7 µs -0.5%
8192 / 75 50.5 µs 49.5 µs -1.9%
8192 / 300 61.7 µs 58.1 µs -5.7%
8192 / 1000 98.7 µs 84.7 µs -14.3%
8192 / 8192 384.8 µs 375.7 µs -2.4%
65536 / 20 329.2 µs 330.4 µs +0.4%
65536 / 75 346.9 µs 347.2 µs +0.1%
65536 / 300 358.7 µs 352.6 µs -1.7%
65536 / 1000 392.7 µs 373.7 µs -4.8%
65536 / 65536 3.27 ms 3.22 ms -1.6%

repeated_intern_emit (4 batches, 10% null keys)

rows / cardinality main PR change
8192 / 20 231.4 µs 230.2 µs -0.5%
8192 / 75 245.8 µs 245.0 µs -0.3%
8192 / 300 269.5 µs 273.7 µs +1.5%
8192 / 1000 336.9 µs 343.3 µs +1.9%
8192 / 8192 931.5 µs 926.1 µs -0.6%
65536 / 20 1.93 ms 1.94 ms +0.3%
65536 / 75 1.98 ms 1.99 ms +0.6%
65536 / 300 2.01 ms 1.99 ms -1.1%
65536 / 1000 2.04 ms 2.04 ms +0.1%
65536 / 65536 7.85 ms 7.72 ms -1.6%

shared_values_arc (32 batches of 8192 rows sharing one values array)

cardinality main PR change
8192 4.76 ms 2.05 ms -57.1%
32768 6.43 ms 4.38 ms -31.9%
65536 9.08 ms 7.29 ms -19.8%
100000 11.03 ms 9.31 ms -15.7%
500000 38.14 ms 32.49 ms -14.8%

@Rich-T-kid Rich-T-kid mentioned this pull request Sep 28, 2026
14 of 19 tasks
@Rich-T-kid

Copy link
Copy Markdown
Contributor

run benchmark dictionary_group_values

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c5874256024-2892-fjvfq 6.12.94+ #1 SMP Wed Aug 19 07:47:20 UTC 2026 aarch64 GNU/Linux

CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected

Comparing issue-25219/reuse-cache-dic (40bfa2b) to 6a792c6 (merge-base) diff

Run configuration
run benchmark dictionary_group_values

Results will be posted here when complete


File an issue against this benchmark runner

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark completed (GKE) | trigger

Instance: c4a-highmem-16 (12 vCPU / 65 GiB)

Comparing issue-25219/reuse-cache-dic (40bfa2b) to 6a792c6 (merge-base) diff

Run configuration
run benchmark dictionary_group_values
CPU Details (lscpu)
Architecture:                            aarch64
CPU op-mode(s):                          64-bit
Byte Order:                              Little Endian
CPU(s):                                  16
On-line CPU(s) list:                     0-15
Vendor ID:                               ARM
Model name:                              Neoverse-V2
Model:                                   1
Thread(s) per core:                      1
Core(s) per cluster:                     16
Socket(s):                               -
Cluster(s):                              1
Stepping:                                r0p1
BogoMIPS:                                2000.00
Flags:                                   fp asimd evtstrm aes pmull sha1 sha2 crc32 atomics fphp asimdhp cpuid asimdrdm jscvt fcma lrcpc dcpop sha3 sm3 sm4 asimddp sha512 sve asimdfhm dit uscat ilrcpc flagm sb paca pacg dcpodp sve2 sveaes svepmull svebitperm svesha3 svesm4 flagm2 frint svei8mm svebf16 i8mm bf16 dgh rng bti
L1d cache:                               1 MiB (16 instances)
L1i cache:                               1 MiB (16 instances)
L2 cache:                                32 MiB (16 instances)
L3 cache:                                80 MiB (1 instance)
NUMA node(s):                            1
NUMA node0 CPU(s):                       0-15
Vulnerability Gather data sampling:      Not affected
Vulnerability Indirect target selection: Not affected
Vulnerability Itlb multihit:             Not affected
Vulnerability L1tf:                      Not affected
Vulnerability Mds:                       Not affected
Vulnerability Meltdown:                  Not affected
Vulnerability Mmio stale data:           Not affected
Vulnerability Reg file data sampling:    Not affected
Vulnerability Retbleed:                  Not affected
Vulnerability Spec rstack overflow:      Not affected
Vulnerability Spec store bypass:         Mitigation; Speculative Store Bypass disabled via prctl
Vulnerability Spectre v1:                Mitigation; __user pointer sanitization
Vulnerability Spectre v2:                Mitigation; CSV2, BHB
Vulnerability Srbds:                     Not affected
Vulnerability Tsa:                       Not affected
Vulnerability Tsx async abort:           Not affected
Vulnerability Vmscape:                   Not affected
Details

group                                                                             HEAD                                    issue-25219_reuse-cache-dic
-----                                                                             ----                                    ---------------------------
dict_intern_emit/intern_emit/size_65536_card_1000_null_0.00                       1.02    509.8±8.40µs 122.6 MElem/sec    1.00    501.0±7.15µs 124.8 MElem/sec
dict_intern_emit/intern_emit/size_65536_card_20_null_0.00                         1.00    430.3±9.35µs 145.3 MElem/sec    1.01    436.4±9.04µs 143.2 MElem/sec
dict_intern_emit/intern_emit/size_65536_card_300_null_0.00                        1.01    465.9±9.92µs 134.2 MElem/sec    1.00    461.1±8.44µs 135.5 MElem/sec
dict_intern_emit/intern_emit/size_65536_card_65536_null_0.00                      1.00      6.2±0.12ms 10.1 MElem/sec     1.00      6.2±0.16ms 10.1 MElem/sec
dict_intern_emit/intern_emit/size_65536_card_75_null_0.00                         1.01    452.0±8.99µs 138.3 MElem/sec    1.00    449.3±6.74µs 139.1 MElem/sec
dict_intern_emit/intern_emit/size_8192_card_1000_null_0.00                        1.11    128.3±1.98µs 60.9 MElem/sec     1.00    115.3±2.22µs 67.7 MElem/sec
dict_intern_emit/intern_emit/size_8192_card_20_null_0.00                          1.01     58.6±1.54µs 133.3 MElem/sec    1.00     57.8±1.28µs 135.3 MElem/sec
dict_intern_emit/intern_emit/size_8192_card_300_null_0.00                         1.06     79.0±1.66µs 98.8 MElem/sec     1.00     74.5±1.56µs 104.9 MElem/sec
dict_intern_emit/intern_emit/size_8192_card_75_null_0.00                          1.01     63.9±1.74µs 122.3 MElem/sec    1.00     63.3±1.67µs 123.4 MElem/sec
dict_intern_emit/intern_emit/size_8192_card_8192_null_0.00                        1.00    522.5±2.15µs 15.0 MElem/sec     1.01    527.4±3.73µs 14.8 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_65536_card_1000_null_0.10     1.00      2.4±0.01ms 103.8 MElem/sec    1.00      2.4±0.02ms 103.9 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_65536_card_20_null_0.10       1.00      2.2±0.01ms 111.8 MElem/sec    1.00      2.2±0.01ms 112.1 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_65536_card_300_null_0.10      1.00      2.3±0.01ms 107.5 MElem/sec    1.00      2.3±0.02ms 107.4 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_65536_card_65536_null_0.10    1.00     20.4±1.27ms 12.3 MElem/sec     1.12     22.9±1.66ms 10.9 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_65536_card_75_null_0.10       1.00      2.3±0.01ms 108.6 MElem/sec    1.00      2.3±0.01ms 108.6 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_8192_card_1000_null_0.10      1.02    409.2±2.07µs 76.4 MElem/sec     1.00    403.1±2.64µs 77.5 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_8192_card_20_null_0.10        1.05    272.4±2.44µs 114.7 MElem/sec    1.00    259.1±2.33µs 120.6 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_8192_card_300_null_0.10       1.04    315.2±1.96µs 99.2 MElem/sec     1.00    302.6±2.50µs 103.3 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_8192_card_75_null_0.10        1.00    286.0±2.08µs 109.3 MElem/sec    1.06  304.1±309.72µs 102.8 MElem/sec
dict_repeated_intern_emit/repeated_intern_emit/size_8192_card_8192_null_0.10      1.00   1283.9±6.59µs 24.3 MElem/sec     1.03   1323.2±9.58µs 23.6 MElem/sec
dict_scalar_append_equal/scalar_append_equal/size_65536_card_1000_null_0.10       1.00   1375.3±4.76µs 45.4 MElem/sec     1.00   1370.8±5.08µs 45.6 MElem/sec
dict_scalar_append_equal/scalar_append_equal/size_65536_card_20_null_0.10         1.00   1240.6±3.06µs 50.4 MElem/sec     1.00   1240.3±4.24µs 50.4 MElem/sec
dict_scalar_append_equal/scalar_append_equal/size_65536_card_300_null_0.10        1.00   1278.1±2.81µs 48.9 MElem/sec     1.00   1279.1±9.72µs 48.9 MElem/sec
dict_scalar_append_equal/scalar_append_equal/size_65536_card_65536_null_0.10      1.01      7.9±0.20ms  7.9 MElem/sec     1.00      7.8±0.18ms  8.1 MElem/sec
dict_scalar_append_equal/scalar_append_equal/size_65536_card_75_null_0.10         1.00   1259.8±2.87µs 49.6 MElem/sec     1.00   1262.3±4.95µs 49.5 MElem/sec
dict_shared_values_arc/shared_values_arc/size_8192_card_100000                    1.46     30.6±3.24ms  8.2 MElem/sec     1.00     20.9±1.88ms 11.9 MElem/sec
dict_shared_values_arc/shared_values_arc/size_8192_card_32768                     1.70     13.7±0.29ms 18.3 MElem/sec     1.00      8.0±0.10ms 31.1 MElem/sec
dict_shared_values_arc/shared_values_arc/size_8192_card_500000                    1.02     83.0±7.56ms  3.0 MElem/sec     1.00     81.2±6.64ms  3.1 MElem/sec
dict_shared_values_arc/shared_values_arc/size_8192_card_65536                     1.55     23.7±1.67ms 10.5 MElem/sec     1.00     15.3±1.02ms 16.3 MElem/sec
dict_shared_values_arc/shared_values_arc/size_8192_card_8192                      2.33      6.7±0.05ms 37.4 MElem/sec     1.00      2.9±0.04ms 87.1 MElem/sec
dict_take_n/take_n/size_65536_card_1000_null_0.10                                 1.03      2.6±0.01ms 96.8 MElem/sec     1.00      2.5±0.02ms 100.1 MElem/sec
dict_take_n/take_n/size_65536_card_20_null_0.10                                   1.03      2.2±0.01ms 113.2 MElem/sec    1.00      2.1±0.02ms 116.9 MElem/sec
dict_take_n/take_n/size_65536_card_300_null_0.10                                  1.03      2.4±0.01ms 106.1 MElem/sec    1.00      2.3±0.02ms 109.4 MElem/sec
dict_take_n/take_n/size_65536_card_65536_null_0.10                                1.00     26.5±0.48ms  9.4 MElem/sec     1.02     27.1±0.49ms  9.2 MElem/sec
dict_take_n/take_n/size_65536_card_75_null_0.10                                   1.03      2.3±0.01ms 109.8 MElem/sec    1.00      2.2±0.01ms 113.0 MElem/sec

Resource Usage

dictionary_group_values — base (merge-base)

Metric Value
Wall time 585.1s
Peak memory 900.6 MiB
Avg memory 199.5 MiB
CPU user 441.4s
CPU sys 10.7s
Peak spill 0 B

dictionary_group_values — branch

Metric Value
Wall time 545.1s
Peak memory 901.2 MiB
Avg memory 75.3 MiB
CPU user 415.8s
CPU sys 24.5s
Peak spill 0 B

File an issue against this benchmark runner

@jayzhan211 jayzhan211 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @CuteChuanChuan !

@jayzhan211
jayzhan211 added this pull request to the merge queue Sep 30, 2026
Merged via the queue into apache:main with commit 622354f Sep 30, 2026
42 checks passed
@CuteChuanChuan

CuteChuanChuan commented Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Hi @jayzhan211,

About commit 40bfa2b, I went through three steps:

  1. Applied your two suggestions (keep the guard/fallback, read the cache inline). repeated_intern_emit with 65536 rows / card 65536 still regressed, +25.7% and +44.9% over two runs. In that bench every batch has a new values array: vectorized_append syncs the cache first, so cached is true, but val_to_inner only has the values that were just appended. Most rows in vectorized_equal_to then miss and go through value_dedup.

  2. Changed the miss path (lookup_inner_slot -> equal_to_uncached). Since we already have lhs_slot, it compares the value directly to inner[lhs_slot]. If they are equal, that mapping is correct (each distinct value has one slot in inner), so it goes into val_to_inner. If not, nothing is cached. That fixed the case above, but intern_emit with low cardinality was still +5% to +11%.

  3. Borrowed val_to_inner / group_to_inner as slices before the loop, so the loop no longer calls a &mut self method on a miss. With that, everything is back to main or faster; the table is in the PR description.

@CuteChuanChuan
CuteChuanChuan deleted the issue-25219/reuse-cache-dic branch September 30, 2026 16:04
kosiew added a commit to kosiew/datafusion that referenced this pull request Oct 5, 2026
Upstream apache#25652 added Arc::ptr_eq checks against cached_values, which
this branch changed to Option<Weak<dyn Array>>. Use the existing
is_cached helper instead.
Omega359 pushed a commit to Omega359/arrow-datafusion that referenced this pull request Oct 11, 2026
…che#25652)

## Which issue does this PR close?

- Closes apache#25219.

## Rationale for this change

Follow-up to apache#25185. `vectorized_equal_to` was left untouched and still
rebuilt the same `val_idx -> inner_slot` map from scratch on every
batch.

## What changes are included in this PR?
- Added `lookup_inner_slot`, which resolves a single `val_idx` to its
slot in `inner` and memorizes the result in `val_to_inner`.

- Changed `vectorized_equal_to` to call `sync_value_cache` and resolve
slots lazily per row.

- Removed `build_lookup_table`, the `rhs_rows.len() < num_distinct`
guard and `equal_to_per_row`.

## What is the testing strategy for this PR?

Covered by the existing `dictionary.rs` unit tests.

## Are there any user-facing changes?

No. This is an internal performance change with no public API or
behaviour change.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

physical-plan Changes to the physical-plan crate v56.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Reuse the cached dictionary value→slot map in vectorized_equal_to instead of rebuilding it per batch

5 participants