Skip to content

perf(physical-expr): cache remapped expression in DynamicFilterPhysicalExpr::current()#23532

Open
zhuqi-lucas wants to merge 4 commits into
apache:mainfrom
zhuqi-lucas:perf/cache-dynamic-filter-current
Open

perf(physical-expr): cache remapped expression in DynamicFilterPhysicalExpr::current()#23532
zhuqi-lucas wants to merge 4 commits into
apache:mainfrom
zhuqi-lucas:perf/cache-dynamic-filter-current

Conversation

@zhuqi-lucas

@zhuqi-lucas zhuqi-lucas commented Jul 13, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

DynamicFilterPhysicalExpr::current() is invoked per batch on the RowFilter path (via PhysicalExpr::evaluate). It calls remap_children, which does a transform_up tree walk. For dynamic filters that carry a large InListExpr — e.g. HashJoinExec pushing down a col IN build_keys list of ~150+ values — that walk is dominated by InListExpr::with_new_children cloning the whole list on every call.

The inner.generation only changes when the filter is updated — once per HashJoin build, or once per TopK threshold refresh. Between updates every current() call recomputes an identical remapped tree. Caching per generation lets per-batch calls short-circuit the walk.

Measured impact (TPCH SF1, 10 iterations)

HEAD pushdown=true This PR pushdown=true Baseline pushdown=false
Q17 128 ms 53 ms (2.4× faster) 55 ms
Q18 69 ms 56 ms 49 ms
Total (22 queries) 743 ms 644 ms (-13%) 531 ms

Q17 (Nation-in-orders subquery) matches the pushdown=false baseline after the fix — the DynamicFilter tax is fully eliminated on that shape. Row counts identical across all 22 queries pre/post fix.

Profile before the fix: InListExpr::with_new_children and its drop_glue sat at the top of Q17's samply flamegraph.

Part of #20324 (parquet filter-pushdown regression EPIC).

What changes are included in this PR?

  • Add current_cache: Arc<RwLock<Option<(u64, Arc<dyn PhysicalExpr>)>>> on DynamicFilterPhysicalExpr (aliased as CurrentExprCache to keep the type readable).
  • current(): read the current (expr, generation) from inner, return the cached Arc if the cached generation matches; otherwise recompute via remap_children and store the new pair under a write lock.
  • Reset semantics:
    • update() bumps inner.generation, so the next current() misses the cache and recomputes.
    • with_new_children clones the outer struct with new remapped_children; the derived filter gets its own fresh cache slot.
    • from_parts (proto deserialization) starts with an empty cache.
  • No public API changes.

Are these changes tested?

Yes — three new regression tests in expressions::dynamic_filters::test:

  • test_current_cache_hits_within_generation — three consecutive current() calls return the same Arc (pointer-equal) at the same generation, proving the remap walk did not re-run.
  • test_current_cache_invalidates_on_update — after update() bumps the generation, current() returns a fresh Arc (not pointer-equal, and stringly distinct).
  • test_current_cache_is_per_derived_filter — two filters derived via with_new_children with different remapped_children produce distinct cached Arcs and each hits its own cache on subsequent calls.

All 21 tests in expressions::dynamic_filters (18 existing + 3 new) pass.

Are there any user-facing changes?

No. Internal caching only. No public API changes; behavior is identical modulo the generation-tied fast path.

…alExpr::current()

`DynamicFilterPhysicalExpr::current()` is called per batch on the
RowFilter path (via `PhysicalExpr::evaluate`). It internally calls
`remap_children`, which does a `transform_up` tree walk that, for
filters carrying a large `InListExpr` (HashJoin's `col IN build_keys`,
~150+ values), is dominated by `InListExpr::with_new_children` cloning
the whole list on every call.

The inner generation only changes when the filter is `update`d — once
per HashJoin build or once per TopK threshold refresh — while
`current()` fires per batch. Cache the remapped expression per
generation so per-batch calls short-circuit the walk.

Reset semantics:
* `update()` bumps `inner.generation`, invalidating the cache lookup
  on the next `current()`.
* `with_new_children` clones the outer struct with its own remapped
  children; each derived filter gets its own fresh cache slot.
* `from_parts` (proto deserialization) starts with an empty cache.

TPCH SF1 (10 iterations):
* Q17: 128ms -> 53ms (2.4x)
* Total (22 queries): 743ms -> 644ms (-13%)

TPCH row counts identical across all 22 queries pre/post fix.

Adds three regression tests:
* cache hits within a generation (same Arc returned)
* update() invalidates (new Arc returned)
* derived filters via with_new_children have independent caches

Copilot AI 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.

Pull request overview

This PR optimizes DynamicFilterPhysicalExpr::current() on the hot RowFilter per-batch evaluation path by caching the remapped expression per inner-generation, avoiding repeated transform_up remap walks for unchanged dynamic filters.

Changes:

  • Add a per-generation (generation, Arc<dyn PhysicalExpr>) cache for DynamicFilterPhysicalExpr::current().
  • Update current() to return the cached remapped expression when the generation matches, otherwise recompute and store.
  • Add regression tests covering cache hits within a generation, invalidation on update(), and per-derived-filter cache isolation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread datafusion/physical-expr/src/expressions/dynamic_filters/mod.rs Outdated
…obber

A slow `current()` call that observed an older `inner` generation could
finish after a concurrent caller has published a newer entry, and its
unconditional write would replace that newer entry with a stale one.
Semantics were still correct (later readers see the mismatch and
recompute), but subsequent generation-matched calls would miss the cache
and redo the remap unnecessarily.

Guard the write: only publish if the observed generation is at least
as fresh as whatever is currently cached.
@github-actions github-actions Bot added the physical-expr Changes to the physical-expr crates label Jul 13, 2026
…s stable

`datafusion-proto` roundtrip tests compare `format!("{:?}", expr)` on
freshly-deserialized vs post-`current()`-called filters. Deriving
`Debug` for `DynamicFilterPhysicalExpr` prints the cache slot, which
differs across the assertion because one side has populated the cache
and the other hasn't. The cache is a pure optimization artifact —
exclude it from Debug output so equality by Debug format is preserved.

Fixes `test_dynamic_filter_plan_roundtrip_dedupe` and
`test_custom_node_with_dynamic_filter_dedup_roundtrip` regressions
observed on the "cargo test hash collisions (amd64)" CI job.
@adriangb

Copy link
Copy Markdown
Contributor

run benchmark tcph10

env:
  DATAFUSION_EXECUTION_PARQUET_PUSHDOWN_FILTERS: true

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c4958709746-1029-6jb6k 6.12.85+ #1 SMP Mon May 11 08:17:35 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 perf/cache-dynamic-filter-current (ac24b01) to 65f9726 (merge-base) diff using: tcph10
Results will be posted here when complete


File an issue against this benchmark runner

@adriangb

Copy link
Copy Markdown
Contributor

Note that this seems to be attacking the same thing as #20353, I don't remember why I gave up on that but I probably just missed something.

@zhuqi-lucas

Copy link
Copy Markdown
Contributor Author

Note that this seems to be attacking the same thing as #20353, I don't remember why I gave up on that but I probably just missed something.

Thanks @adriangb , just saw it, it should be similar.

@adriangbot

Copy link
Copy Markdown

Benchmark for this request failed.

Last 20 lines of output:

Click to expand
# Micro-Benchmarks (specific operators and features)
cancellation:           How long cancelling a query takes
nlj:                    Benchmark for simple nested loop joins, testing various join scenarios
hj:                     Benchmark for simple hash joins, testing various join scenarios
smj:                    Benchmark for simple sort merge joins, testing various join scenarios
dict:                   Benchmark for dictionary-encoded group-by scenarios
compile_profile:        Compile and execute TPC-H across selected Cargo profiles, reporting timing and binary size


━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
Supported Configuration (Environment Variables)
━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━
DATA_DIR            directory to store datasets
CARGO_COMMAND       command that runs the benchmark binary
DATAFUSION_DIR      directory to use (default /workspace/datafusion-base)
RESULTS_NAME        folder where the benchmark files are stored
PREFER_HASH_JOIN    Prefer hash join algorithm (default true)
SIMULATE_LATENCY    Simulate object store latency to mimic S3 (default false)
DATAFUSION_*        Set the given datafusion configuration


File an issue against this benchmark runner

@zhuqi-lucas

Copy link
Copy Markdown
Contributor Author

run benchmark tpch10

env:
  DATAFUSION_EXECUTION_PARQUET_PUSHDOWN_FILTERS: true

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c4958910188-1030-hdc7m 6.12.85+ #1 SMP Mon May 11 08:17:35 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 perf/cache-dynamic-filter-current (12ee030) to 21986cf (merge-base) diff using: tpch10
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)

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

Comparing HEAD and perf_cache-dynamic-filter-current
--------------------
Benchmark tpch_sf10.json
--------------------
┏━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━━━━━┓
┃ Query     ┃                                  HEAD ┃     perf_cache-dynamic-filter-current ┃        Change ┃
┡━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━━━━━┩
│ QQuery 1  │     312.05 / 312.90 ±0.58 / 313.79 ms │     310.83 / 312.46 ±1.11 / 313.79 ms │     no change │
│ QQuery 2  │     157.33 / 159.48 ±2.80 / 164.92 ms │     147.73 / 149.75 ±1.95 / 153.46 ms │ +1.06x faster │
│ QQuery 3  │     354.98 / 357.62 ±1.59 / 359.15 ms │     340.17 / 342.38 ±2.32 / 346.44 ms │     no change │
│ QQuery 4  │     302.94 / 306.61 ±3.27 / 312.73 ms │     288.63 / 292.30 ±3.96 / 299.46 ms │     no change │
│ QQuery 5  │     580.79 / 583.96 ±2.73 / 587.55 ms │     556.95 / 566.99 ±8.68 / 581.34 ms │     no change │
│ QQuery 6  │     182.93 / 184.06 ±1.09 / 185.66 ms │     183.66 / 184.28 ±0.47 / 184.93 ms │     no change │
│ QQuery 7  │     447.61 / 449.98 ±1.87 / 452.62 ms │     432.26 / 435.23 ±2.88 / 439.48 ms │     no change │
│ QQuery 8  │     628.10 / 631.16 ±2.20 / 634.48 ms │     600.06 / 602.89 ±2.24 / 606.47 ms │     no change │
│ QQuery 9  │     863.57 / 867.01 ±2.93 / 872.31 ms │     842.79 / 853.07 ±7.49 / 864.39 ms │     no change │
│ QQuery 10 │     385.59 / 387.69 ±1.93 / 390.98 ms │     375.92 / 380.47 ±4.98 / 389.62 ms │     no change │
│ QQuery 11 │     136.18 / 137.83 ±1.06 / 139.35 ms │     130.75 / 135.19 ±6.07 / 147.05 ms │     no change │
│ QQuery 12 │    342.57 / 350.62 ±11.03 / 371.17 ms │     335.89 / 340.63 ±3.70 / 345.11 ms │     no change │
│ QQuery 13 │     386.87 / 395.95 ±7.14 / 405.61 ms │     375.83 / 381.43 ±5.40 / 391.29 ms │     no change │
│ QQuery 14 │     192.77 / 194.11 ±1.19 / 196.16 ms │     190.87 / 193.49 ±2.83 / 198.85 ms │     no change │
│ QQuery 15 │     379.96 / 383.75 ±3.87 / 390.40 ms │     379.66 / 384.69 ±5.52 / 395.45 ms │     no change │
│ QQuery 16 │        89.60 / 90.94 ±1.41 / 93.33 ms │        86.77 / 89.72 ±2.53 / 92.61 ms │     no change │
│ QQuery 17 │     775.66 / 785.57 ±9.43 / 802.68 ms │     728.27 / 738.35 ±5.17 / 741.74 ms │ +1.06x faster │
│ QQuery 18 │     749.74 / 753.41 ±3.06 / 759.05 ms │     618.41 / 631.83 ±9.55 / 641.11 ms │ +1.19x faster │
│ QQuery 19 │    258.88 / 268.87 ±16.32 / 301.45 ms │     255.16 / 260.68 ±5.67 / 269.14 ms │     no change │
│ QQuery 20 │     310.31 / 316.29 ±9.38 / 334.88 ms │     299.85 / 302.19 ±2.02 / 304.71 ms │     no change │
│ QQuery 21 │ 1256.66 / 1277.12 ±15.89 / 1298.16 ms │ 1204.11 / 1217.85 ±11.76 / 1232.01 ms │     no change │
│ QQuery 22 │     131.95 / 135.57 ±2.75 / 138.75 ms │     139.70 / 144.90 ±4.36 / 152.08 ms │  1.07x slower │
└───────────┴───────────────────────────────────────┴───────────────────────────────────────┴───────────────┘
┏━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━┳━━━━━━━━━━━┓
┃ Benchmark Summary                                ┃           ┃
┡━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━╇━━━━━━━━━━━┩
│ Total Time (HEAD)                                │ 9330.52ms │
│ Total Time (perf_cache-dynamic-filter-current)   │ 8940.78ms │
│ Average Time (HEAD)                              │  424.11ms │
│ Average Time (perf_cache-dynamic-filter-current) │  406.40ms │
│ Queries Faster                                   │         3 │
│ Queries Slower                                   │         1 │
│ Queries with No Change                           │        18 │
│ Queries with Failure                             │         0 │
└──────────────────────────────────────────────────┴───────────┘

Resource Usage

tpch10 — base (merge-base)

Metric Value
Wall time 50.0s
Peak memory 2.8 GiB
Avg memory 1.0 GiB
CPU user 504.3s
CPU sys 16.3s
Peak spill 0 B

tpch10 — branch

Metric Value
Wall time 45.0s
Peak memory 2.6 GiB
Avg memory 1.0 GiB
CPU user 474.0s
CPU sys 16.0s
Peak spill 0 B

File an issue against this benchmark runner

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

Labels

physical-expr Changes to the physical-expr crates

Projects

None yet

Development

Successfully merging this pull request may close these issues.

perf: DynamicFilterPhysicalExpr::current() re-runs the remap tree walk on every batch

4 participants