Skip to content

feat: make StatisticsRegistry the default operator-statistics path - #23651

Open
asolimando wants to merge 16 commits into
apache:mainfrom
asolimando:asolimando/statistics-registry-default
Open

feat: make StatisticsRegistry the default operator-statistics path#23651
asolimando wants to merge 16 commits into
apache:mainfrom
asolimando:asolimando/statistics-registry-default

Conversation

@asolimando

@asolimando asolimando commented Jul 16, 2026

Copy link
Copy Markdown
Member

Which issue does this PR close?

Rationale for this change

#23051 unified statistics into one StatisticsContext walk. This folds the
previously opt-in StatisticsRegistry (#21483) into that walk as the default, so
providers are consulted inline and use_statistics_registry is no longer needed.
Consumed today by join ordering and EXPLAIN; more CBO rules are follow-up.

What changes are included in this PR?

  • Consult the provider chain in the StatisticsContext walk (first Computed
    wins, else statistics_from_inputs); used by join_selection.
  • Deprecate use_statistics_registry (ignored, warns) and
    DefaultStatisticsProvider; add partition-aware
    StatisticsProvider::compute_statistics_with_args.
  • Propagate provider Extensions through the walk via a side cache (no cost
    without providers); add StatisticsContext::compute_extended, deprecate
    StatisticsRegistry::compute/compute_base.
  • EXPLAIN and EXPLAIN ANALYZE show_statistics now reflect the session registry.
  • Add the join_reorder example.

Are these changes tested?

Unit tests, statistics_registry.slt, the example, the full extended suite, and
compute_statistics at benchmark parity.

Are there any user-facing changes?

  • use_statistics_registry deprecated and ignored (default behavior unchanged).
  • StatisticsRegistry::compute/compute_base deprecated (55.0.0) for
    StatisticsContext; compute_extended added; DefaultStatisticsProvider
    deprecated. Nothing removed. See the 55.0.0 upgrade guide.

Disclaimer: I used AI to assist in the code generation, I have manually reviewed the output and it matches my intention and understanding.

@github-actions github-actions Bot added documentation Improvements or additions to documentation optimizer Optimizer rules sqllogictest SQL Logic Tests (.slt) common Related to common crate physical-plan Changes to the physical-plan crate labels Jul 16, 2026
@github-actions

github-actions Bot commented Jul 16, 2026

Copy link
Copy Markdown

Thank you for opening this pull request!

Reviewer note: cargo-semver-checks reported the current version number is not SemVer-compatible with the changes in this pull request (compared against the base branch).

Details
     Cloning apache/main
    Building datafusion v54.1.0 (current)
       Built [  99.961s] (current)
     Parsing datafusion v54.1.0 (current)
      Parsed [   0.032s] (current)
    Building datafusion v54.1.0 (baseline)
       Built [  99.597s] (baseline)
     Parsing datafusion v54.1.0 (baseline)
      Parsed [   0.032s] (baseline)
    Checking datafusion v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.726s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 202.864s] datafusion
    Building datafusion-common v54.1.0 (current)
       Built [  32.276s] (current)
     Parsing datafusion-common v54.1.0 (current)
      Parsed [   0.058s] (current)
    Building datafusion-common v54.1.0 (baseline)
       Built [  32.328s] (baseline)
     Parsing datafusion-common v54.1.0 (baseline)
      Parsed [   0.059s] (baseline)
    Checking datafusion-common v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.743s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [  66.675s] datafusion-common
    Building datafusion-physical-optimizer v54.1.0 (current)
       Built [  38.877s] (current)
     Parsing datafusion-physical-optimizer v54.1.0 (current)
      Parsed [   0.020s] (current)
    Building datafusion-physical-optimizer v54.1.0 (baseline)
       Built [  38.637s] (baseline)
     Parsing datafusion-physical-optimizer v54.1.0 (baseline)
      Parsed [   0.021s] (baseline)
    Checking datafusion-physical-optimizer v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.133s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [  78.934s] datafusion-physical-optimizer
    Building datafusion-physical-plan v54.1.0 (current)
       Built [  35.818s] (current)
     Parsing datafusion-physical-plan v54.1.0 (current)
      Parsed [   0.135s] (current)
    Building datafusion-physical-plan v54.1.0 (baseline)
       Built [  35.618s] (baseline)
     Parsing datafusion-physical-plan v54.1.0 (baseline)
      Parsed [   0.138s] (baseline)
    Checking datafusion-physical-plan v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.741s] 223 checks: 221 pass, 2 fail, 0 warn, 30 skip

--- failure type_marked_deprecated: #[deprecated] added on type ---

Description:
A type is now #[deprecated]. Downstream crates will get a compiler warning when using this type.
        ref: https://doc.rust-lang.org/reference/attributes/diagnostics.html#the-deprecated-attribute
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.49.0/src/lints/type_marked_deprecated.ron

Failed in:
  Struct DefaultStatisticsProvider in /home/runner/work/datafusion/datafusion/datafusion/physical-plan/src/operator_statistics/mod.rs:337

--- failure type_method_marked_deprecated: type method #[deprecated] added ---

Description:
A type method is now #[deprecated]. Downstream crates will get a compiler warning when using this method.
        ref: https://doc.rust-lang.org/reference/attributes/diagnostics.html#the-deprecated-attribute
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.49.0/src/lints/type_method_marked_deprecated.ron

Failed in:
  method datafusion_physical_plan::operator_statistics::StatisticsRegistry::compute in /home/runner/work/datafusion/datafusion/datafusion/physical-plan/src/operator_statistics/mod.rs:442
  method datafusion_physical_plan::operator_statistics::StatisticsRegistry::compute_base in /home/runner/work/datafusion/datafusion/datafusion/physical-plan/src/operator_statistics/mod.rs:456

     Summary semver requires new minor version: 0 major and 2 minor checks failed
    Finished [  73.884s] datafusion-physical-plan
    Building datafusion-sqllogictest v54.1.0 (current)
       Built [ 170.692s] (current)
     Parsing datafusion-sqllogictest v54.1.0 (current)
      Parsed [   0.020s] (current)
    Building datafusion-sqllogictest v54.1.0 (baseline)
       Built [ 171.741s] (baseline)
     Parsing datafusion-sqllogictest v54.1.0 (baseline)
      Parsed [   0.021s] (baseline)
    Checking datafusion-sqllogictest v54.1.0 -> v54.1.0 (no change; assume patch)
     Checked [   0.099s] 223 checks: 223 pass, 30 skip
     Summary no semver update required
    Finished [ 345.425s] datafusion-sqllogictest

@github-actions github-actions Bot added the auto detected api change Auto detected API change label Jul 16, 2026
@codecov-commenter

codecov-commenter commented Jul 16, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 80.33033% with 131 lines in your changes missing coverage. Please review.
✅ Project coverage is 80.85%. Comparing base (8fbbfdc) to head (08845ef).
⚠️ Report is 39 commits behind head on main.

Files with missing lines Patch % Lines
...usion/physical-plan/src/operator_statistics/mod.rs 71.14% 66 Missing and 52 partials ⚠️
datafusion/physical-plan/src/statistics.rs 93.78% 8 Missing and 3 partials ⚠️
datafusion/physical-plan/src/analyze.rs 83.33% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23651      +/-   ##
==========================================
+ Coverage   80.69%   80.85%   +0.15%     
==========================================
  Files        1095     1098       +3     
  Lines      372626   374693    +2067     
  Branches   372626   374693    +2067     
==========================================
+ Hits       300707   302956    +2249     
+ Misses      53968    53679     -289     
- Partials    17951    18058     +107     

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

@asolimando
asolimando force-pushed the asolimando/statistics-registry-default branch from 1f2d39d to 38d5bb1 Compare July 16, 2026 20:19
StatisticsContext (the single statistics walk, apache#23051) now consults the StatisticsRegistry (apache#21483) at each node before falling back to the operator's statistics_from_inputs, using the provider result's base Statistics. The registry is always-on: datafusion.optimizer.use_statistics_registry is deprecated and ignored (register providers on the SessionState instead). The now-redundant DefaultStatisticsProvider is deprecated, since the walk falls back natively.
The walk keeps core `Statistics` as its currency; a per-walk side cache
carries provider `Extensions` separately, so a walk with no providers has
no per-node overhead. A provider's `Computed` extensions are cached and
reach parent-node providers as their `child_stats`; the built-in
`statistics_from_inputs` fallback yields none, so extensions survive only
across an unbroken chain of provider-handled nodes.

Adds `StatisticsContext::compute_extended` (statistics + extensions)
alongside `compute` (core statistics only). `StatisticsRegistry::compute`
preserves extensions again; it and `compute_base` are deprecated in favor
of `StatisticsContext`.
EXPLAIN ... show_statistics computed statistics with an empty registry, so registered providers were not reflected in the statistics column. Thread the session's StatisticsRegistry into the displayable plan; the column now reflects provider-supplied estimates when providers are registered, and is unchanged when none are.
A runnable example showing a StatisticsProvider that supplies and refines column statistics (post-filter distinct count via the survival formula) to flip a join's build side, with a before/after EXPLAIN.
@asolimando
asolimando force-pushed the asolimando/statistics-registry-default branch from 38d5bb1 to ded4292 Compare July 17, 2026 18:38
@github-actions github-actions Bot added the core Core DataFusion crate label Jul 17, 2026
STORED AS PARQUET
LOCATION 'test_files/scratch/statistics_registry/dim_small.parquet';

# -- Without registry --------------------------------------------------------

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Having removed use_statistics_registry config knob means the registry can't be turned off within an SLT. The no-provider path is covered by existing tests indirectly, and the with/without contrast now lives in the join_reorder example

}
struct StatsCache {
statistics: HashMap<CacheKey, Arc<Statistics>>,
extensions: HashMap<CacheKey, Extensions>,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

I kept https://github.com/apache/datafusion/pull/23651/changes#diff-149914d58cc354ddd4d84e283de4f8f07f3295b39137d850b3344448d4f6dc7a separated on purpose in case reviewers prefer to drop it from this PR.

The reason to have two caches is that caching on ExtendedStatistics at every node regressed the compute_statistics benchmark ~6% to 28% (median ~13%) on the no-provider path (a wrapper allocation per node).

The committed version keeps core Statistics in the walk and holds Extensions in a side map, populated only when a provider returns some. Benchmark parity when no providers are registered, extensions still reach parent-node providers.

I think this commit is worth keeping for feature parity w.r.t. StatisticsRegistry as existing today.

metric_types: self.metric_types,
metric_categories: self.metric_categories,
format: self.format,
statistics_registry: self.statistics_registry,

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

The registry here is needed to keep EXPLAIN ANALYZE and EXPLAIN in sync when statistics providers are in use.

@asolimando
asolimando marked this pull request as ready for review July 17, 2026 19:30
@asolimando

Copy link
Copy Markdown
Member Author

As discussed in #21122 (comment), making StatisticsRegistry part of the default path was suggested by @alamb, who also suggested to showcase it via an example, which I have added, showing a better plan (join build/probe flip) over built-in defaults, via a statistics provider.

cc:

Note: the diff reads large (+1055 / -332), but ~620 of the added lines are examples and tests, ~38 are docs. The core change is ~400 lines, which goes down to a more reasonable ~300 if you exclude mechanical fixes.

@kosiew kosiew 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.

@asolimando
Thanks for working on this.

I left two small suggestions to reduce duplication and make future display-related changes easier to maintain.

Comment thread datafusion/core/src/physical_planner.rs Outdated
Comment thread datafusion/physical-plan/src/display.rs
@asolimando
asolimando requested a review from kosiew July 21, 2026 04:17
Comment thread datafusion/physical-plan/src/statistics.rs Outdated
Comment thread docs/source/library-user-guide/upgrading/55.0.0.md
@asolimando
asolimando requested a review from xudong963 July 22, 2026 14:27
@alamb

alamb commented Jul 22, 2026

Copy link
Copy Markdown
Contributor

run benchmark sql_planner

@adriangbot

Copy link
Copy Markdown

🤖 Benchmark running (GKE) | trigger
Instance: c4a-highmem-16 (12 vCPU / 65 GiB) | Linux bench-c5051832006-1238-xtd2x 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 asolimando/statistics-registry-default (5f2b7f1) to 7f6cc60 (merge-base) diff using: sql_planner
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

group                                                 HEAD                                   asolimando_statistics-registry-default
-----                                                 ----                                   --------------------------------------
logical_aggregate_with_join                           1.03    467.4±1.54µs        ? ?/sec    1.00    453.9±1.14µs        ? ?/sec
logical_correlated_subquery_exists                    1.01    289.1±1.11µs        ? ?/sec    1.00    285.5±0.91µs        ? ?/sec
logical_correlated_subquery_in                        1.01    290.0±1.15µs        ? ?/sec    1.00    286.1±0.90µs        ? ?/sec
logical_distinct_many_columns                         1.00    573.5±1.35µs        ? ?/sec    1.00    574.6±1.83µs        ? ?/sec
logical_join_4_with_agg_and_filter                    1.03    258.8±2.78µs        ? ?/sec    1.00    250.2±0.85µs        ? ?/sec
logical_join_8_with_agg_sort_limit                    1.02    429.0±2.24µs        ? ?/sec    1.00    419.6±1.68µs        ? ?/sec
logical_join_chain_16                                 1.00    679.3±4.13µs        ? ?/sec    1.00    677.2±4.46µs        ? ?/sec
logical_join_chain_4                                  1.00    122.5±0.62µs        ? ?/sec    1.00    122.4±0.34µs        ? ?/sec
logical_join_chain_8                                  1.02    255.5±1.32µs        ? ?/sec    1.00    251.0±1.04µs        ? ?/sec
logical_multiple_subqueries                           1.02    528.7±2.32µs        ? ?/sec    1.00    520.3±2.35µs        ? ?/sec
logical_nested_cte_4_levels                           1.02    265.5±1.18µs        ? ?/sec    1.00    260.9±2.53µs        ? ?/sec
logical_plan_struct_join_agg_sort                     1.02    183.2±0.81µs        ? ?/sec    1.00    178.8±0.95µs        ? ?/sec
logical_plan_tpcds_all                                1.00     94.1±0.22ms        ? ?/sec    1.00     94.0±0.23ms        ? ?/sec
logical_plan_tpch_all                                 1.00      6.6±0.02ms        ? ?/sec    1.01      6.6±0.01ms        ? ?/sec
logical_scalar_subquery                               1.03    313.1±0.91µs        ? ?/sec    1.00    304.4±1.31µs        ? ?/sec
logical_select_all_from_1000                          1.00    104.6±0.36ms        ? ?/sec    1.00    105.0±0.67ms        ? ?/sec
logical_select_one_from_700                           1.02    332.3±2.17µs        ? ?/sec    1.00    326.5±2.19µs        ? ?/sec
logical_trivial_join_high_numbered_columns            1.02    292.0±0.92µs        ? ?/sec    1.00    287.5±1.21µs        ? ?/sec
logical_trivial_join_low_numbered_columns             1.01    278.8±1.07µs        ? ?/sec    1.00    274.8±1.43µs        ? ?/sec
logical_union_4_branches                              1.01    427.0±1.48µs        ? ?/sec    1.00    424.3±3.01µs        ? ?/sec
logical_union_8_branches                              1.02    822.9±3.40µs        ? ?/sec    1.00    810.1±3.93µs        ? ?/sec
logical_wide_aggregate_100_exprs                      1.00      4.5±0.01ms        ? ?/sec    1.01      4.6±0.01ms        ? ?/sec
logical_wide_case_50_exprs                            1.00      2.4±0.00ms        ? ?/sec    1.00      2.4±0.01ms        ? ?/sec
logical_wide_filter_200_predicates                    1.00   1330.9±4.59µs        ? ?/sec    1.00   1336.6±5.84µs        ? ?/sec
logical_wide_filter_50_predicates                     1.01    395.2±1.64µs        ? ?/sec    1.00    392.9±1.59µs        ? ?/sec
optimizer_correlated_exists                           1.02    252.9±0.80µs        ? ?/sec    1.00    247.9±0.77µs        ? ?/sec
optimizer_join_4_with_agg_filter                      1.00    444.8±2.03µs        ? ?/sec    1.03    459.0±1.94µs        ? ?/sec
optimizer_join_chain_4                                1.02    175.9±0.37µs        ? ?/sec    1.00    173.1±0.35µs        ? ?/sec
optimizer_join_chain_8                                1.01    555.8±1.48µs        ? ?/sec    1.00    549.3±1.18µs        ? ?/sec
optimizer_select_all_from_1000                        1.01      7.0±0.02ms        ? ?/sec    1.00      6.9±0.01ms        ? ?/sec
optimizer_select_one_from_700                         1.01    259.6±0.68µs        ? ?/sec    1.00    257.6±0.63µs        ? ?/sec
optimizer_tpcds_all                                   1.00    307.3±1.91ms        ? ?/sec    1.00    306.9±0.42ms        ? ?/sec
optimizer_tpch_all                                    1.00     16.9±0.06ms        ? ?/sec    1.00     16.9±0.03ms        ? ?/sec
optimizer_wide_aggregate_100                          1.00      2.3±0.01ms        ? ?/sec    1.00      2.3±0.00ms        ? ?/sec
optimizer_wide_filter_200                             1.00      3.8±0.01ms        ? ?/sec    1.00      3.8±0.01ms        ? ?/sec
physical_intersection                                 1.00    597.4±2.86µs        ? ?/sec    1.00    600.2±1.52µs        ? ?/sec
physical_join_consider_sort                           1.00   1056.1±3.89µs        ? ?/sec    1.01   1065.1±2.36µs        ? ?/sec
physical_join_distinct                                1.01    272.2±1.17µs        ? ?/sec    1.00    268.2±1.23µs        ? ?/sec
physical_many_self_joins                              1.01      7.8±0.02ms        ? ?/sec    1.00      7.7±0.02ms        ? ?/sec
physical_plan_clickbench_all                          1.00    127.1±0.32ms        ? ?/sec    1.01    127.7±0.41ms        ? ?/sec
physical_plan_clickbench_q1                           1.03   1403.5±8.79µs        ? ?/sec    1.00   1367.9±7.30µs        ? ?/sec
physical_plan_clickbench_q10                          1.00      2.1±0.01ms        ? ?/sec    1.01      2.1±0.01ms        ? ?/sec
physical_plan_clickbench_q11                          1.00      2.2±0.01ms        ? ?/sec    1.01      2.2±0.01ms        ? ?/sec
physical_plan_clickbench_q12                          1.00      2.3±0.01ms        ? ?/sec    1.00      2.3±0.01ms        ? ?/sec
physical_plan_clickbench_q13                          1.00      2.0±0.01ms        ? ?/sec    1.01      2.0±0.01ms        ? ?/sec
physical_plan_clickbench_q14                          1.00      2.2±0.01ms        ? ?/sec    1.01      2.2±0.01ms        ? ?/sec
physical_plan_clickbench_q15                          1.00      2.1±0.01ms        ? ?/sec    1.00      2.1±0.01ms        ? ?/sec
physical_plan_clickbench_q16                          1.00   1779.1±6.48µs        ? ?/sec    1.01   1800.5±7.78µs        ? ?/sec
physical_plan_clickbench_q17                          1.00   1824.9±6.17µs        ? ?/sec    1.01   1848.8±7.80µs        ? ?/sec
physical_plan_clickbench_q18                          1.00   1666.3±6.25µs        ? ?/sec    1.02   1691.6±7.07µs        ? ?/sec
physical_plan_clickbench_q19                          1.00      2.1±0.01ms        ? ?/sec    1.01      2.1±0.01ms        ? ?/sec
physical_plan_clickbench_q2                           1.03   1796.8±6.69µs        ? ?/sec    1.00   1748.0±6.65µs        ? ?/sec
physical_plan_clickbench_q20                          1.00   1521.7±7.45µs        ? ?/sec    1.02   1549.3±7.87µs        ? ?/sec
physical_plan_clickbench_q21                          1.00   1757.6±5.99µs        ? ?/sec    1.02   1792.0±7.19µs        ? ?/sec
physical_plan_clickbench_q22                          1.00      2.1±0.01ms        ? ?/sec    1.02      2.2±0.01ms        ? ?/sec
physical_plan_clickbench_q23                          1.00      2.3±0.01ms        ? ?/sec    1.01      2.4±0.02ms        ? ?/sec
physical_plan_clickbench_q24                          1.00      6.7±0.01ms        ? ?/sec    1.01      6.7±0.01ms        ? ?/sec
physical_plan_clickbench_q25                          1.00   1886.9±6.77µs        ? ?/sec    1.01   1911.6±6.07µs        ? ?/sec
physical_plan_clickbench_q26                          1.00  1723.4±11.81µs        ? ?/sec    1.01   1748.6±6.07µs        ? ?/sec
physical_plan_clickbench_q27                          1.00  1923.9±17.16µs        ? ?/sec    1.01   1938.7±8.58µs        ? ?/sec
physical_plan_clickbench_q28                          1.00      2.3±0.01ms        ? ?/sec    1.01      2.3±0.01ms        ? ?/sec
physical_plan_clickbench_q29                          1.00      2.5±0.01ms        ? ?/sec    1.01      2.5±0.01ms        ? ?/sec
physical_plan_clickbench_q3                           1.02   1687.6±7.33µs        ? ?/sec    1.00   1650.6±6.97µs        ? ?/sec
physical_plan_clickbench_q30                          1.00     15.4±0.03ms        ? ?/sec    1.00     15.5±0.05ms        ? ?/sec
physical_plan_clickbench_q31                          1.00      2.5±0.01ms        ? ?/sec    1.01      2.5±0.01ms        ? ?/sec
physical_plan_clickbench_q32                          1.00      2.5±0.01ms        ? ?/sec    1.00      2.5±0.01ms        ? ?/sec
physical_plan_clickbench_q33                          1.00      2.1±0.01ms        ? ?/sec    1.01      2.1±0.01ms        ? ?/sec
physical_plan_clickbench_q34                          1.00   1802.2±6.31µs        ? ?/sec    1.01   1814.5±6.13µs        ? ?/sec
physical_plan_clickbench_q35                          1.00   1845.0±6.79µs        ? ?/sec    1.00  1853.9±13.19µs        ? ?/sec
physical_plan_clickbench_q36                          1.02      2.2±0.02ms        ? ?/sec    1.00      2.1±0.01ms        ? ?/sec
physical_plan_clickbench_q37                          1.02      2.6±0.02ms        ? ?/sec    1.00      2.5±0.02ms        ? ?/sec
physical_plan_clickbench_q38                          1.02      2.6±0.01ms        ? ?/sec    1.00      2.5±0.02ms        ? ?/sec
physical_plan_clickbench_q39                          1.01      2.6±0.01ms        ? ?/sec    1.00      2.6±0.01ms        ? ?/sec
physical_plan_clickbench_q4                           1.02   1500.0±6.72µs        ? ?/sec    1.00   1468.1±5.76µs        ? ?/sec
physical_plan_clickbench_q40                          1.02      3.4±0.01ms        ? ?/sec    1.00      3.3±0.01ms        ? ?/sec
physical_plan_clickbench_q41                          1.00      2.9±0.01ms        ? ?/sec    1.00      2.9±0.01ms        ? ?/sec
physical_plan_clickbench_q42                          1.00      3.0±0.01ms        ? ?/sec    1.00      3.0±0.01ms        ? ?/sec
physical_plan_clickbench_q43                          1.01      3.2±0.01ms        ? ?/sec    1.00      3.2±0.01ms        ? ?/sec
physical_plan_clickbench_q44                          1.01   1581.1±6.49µs        ? ?/sec    1.00   1566.7±6.33µs        ? ?/sec
physical_plan_clickbench_q45                          1.01   1584.3±5.49µs        ? ?/sec    1.00   1561.7±6.96µs        ? ?/sec
physical_plan_clickbench_q46                          1.00   1891.6±6.14µs        ? ?/sec    1.00   1885.8±7.16µs        ? ?/sec
physical_plan_clickbench_q47                          1.00      2.6±0.01ms        ? ?/sec    1.00      2.6±0.01ms        ? ?/sec
physical_plan_clickbench_q48                          1.01      2.8±0.01ms        ? ?/sec    1.00      2.8±0.01ms        ? ?/sec
physical_plan_clickbench_q49                          1.01      2.8±0.01ms        ? ?/sec    1.00      2.8±0.01ms        ? ?/sec
physical_plan_clickbench_q5                           1.02   1635.6±6.43µs        ? ?/sec    1.00   1596.1±7.13µs        ? ?/sec
physical_plan_clickbench_q50                          1.01      2.7±0.02ms        ? ?/sec    1.00      2.7±0.01ms        ? ?/sec
physical_plan_clickbench_q51                          1.00      2.0±0.01ms        ? ?/sec    1.03      2.1±0.01ms        ? ?/sec
physical_plan_clickbench_q6                           1.00   1613.9±8.62µs        ? ?/sec    1.00   1607.9±6.74µs        ? ?/sec
physical_plan_clickbench_q7                           1.01   1433.2±6.02µs        ? ?/sec    1.00   1422.2±5.51µs        ? ?/sec
physical_plan_clickbench_q8                           1.01   1924.1±7.37µs        ? ?/sec    1.00   1898.2±6.85µs        ? ?/sec
physical_plan_clickbench_q9                           1.00   1914.5±6.45µs        ? ?/sec    1.01   1934.2±7.51µs        ? ?/sec
physical_plan_struct_join_agg_sort                    1.00   1305.2±4.73µs        ? ?/sec    1.01   1316.7±2.76µs        ? ?/sec
physical_plan_tpcds_all                               1.02    743.5±6.02ms        ? ?/sec    1.00    727.4±3.46ms        ? ?/sec
physical_plan_tpch_all                                1.03     46.4±0.12ms        ? ?/sec    1.00     45.3±0.09ms        ? ?/sec
physical_plan_tpch_q1                                 1.02   1559.4±7.89µs        ? ?/sec    1.00   1536.0±3.08µs        ? ?/sec
physical_plan_tpch_q10                                1.01      3.0±0.00ms        ? ?/sec    1.00      2.9±0.00ms        ? ?/sec
physical_plan_tpch_q11                                1.01      2.4±0.00ms        ? ?/sec    1.00      2.3±0.00ms        ? ?/sec
physical_plan_tpch_q12                                1.02   1283.2±2.15µs        ? ?/sec    1.00   1260.4±3.22µs        ? ?/sec
physical_plan_tpch_q13                                1.02   1075.7±2.52µs        ? ?/sec    1.00   1057.8±3.02µs        ? ?/sec
physical_plan_tpch_q14                                1.02   1433.4±3.99µs        ? ?/sec    1.00   1411.7±2.36µs        ? ?/sec
physical_plan_tpch_q16                                1.01   1683.3±3.09µs        ? ?/sec    1.00   1667.1±3.03µs        ? ?/sec
physical_plan_tpch_q17                                1.00   1707.7±2.76µs        ? ?/sec    1.00   1701.5±3.66µs        ? ?/sec
physical_plan_tpch_q18                                1.00   1985.6±2.83µs        ? ?/sec    1.00   1985.9±3.39µs        ? ?/sec
physical_plan_tpch_q19                                1.01   1907.7±4.29µs        ? ?/sec    1.00   1896.8±3.12µs        ? ?/sec
physical_plan_tpch_q2                                 1.01      3.7±0.02ms        ? ?/sec    1.00      3.7±0.01ms        ? ?/sec
physical_plan_tpch_q20                                1.02      2.2±0.00ms        ? ?/sec    1.00      2.2±0.00ms        ? ?/sec
physical_plan_tpch_q21                                1.01      3.0±0.00ms        ? ?/sec    1.00      3.0±0.00ms        ? ?/sec
physical_plan_tpch_q22                                1.02   1587.5±3.83µs        ? ?/sec    1.00   1555.0±4.44µs        ? ?/sec
physical_plan_tpch_q3                                 1.00  1918.8±10.54µs        ? ?/sec    1.00   1919.4±3.90µs        ? ?/sec
physical_plan_tpch_q4                                 1.01   1256.4±2.67µs        ? ?/sec    1.00   1240.3±4.35µs        ? ?/sec
physical_plan_tpch_q5                                 1.01      2.8±0.01ms        ? ?/sec    1.00      2.8±0.00ms        ? ?/sec
physical_plan_tpch_q6                                 1.01    665.2±1.82µs        ? ?/sec    1.00    655.6±2.12µs        ? ?/sec
physical_plan_tpch_q7                                 1.01      3.0±0.01ms        ? ?/sec    1.00      3.0±0.01ms        ? ?/sec
physical_plan_tpch_q8                                 1.02      4.0±0.02ms        ? ?/sec    1.00      4.0±0.00ms        ? ?/sec
physical_plan_tpch_q9                                 1.02      2.7±0.01ms        ? ?/sec    1.00      2.7±0.00ms        ? ?/sec
physical_select_aggregates_from_200                   1.00     15.6±0.03ms        ? ?/sec    1.00     15.7±0.06ms        ? ?/sec
physical_select_all_from_1000                         1.00    115.6±0.16ms        ? ?/sec    1.00    116.0±0.43ms        ? ?/sec
physical_select_one_from_700                          1.00    779.7±5.30µs        ? ?/sec    1.00    783.0±4.14µs        ? ?/sec
physical_sorted_union_order_by_10_int64               1.01      4.3±0.01ms        ? ?/sec    1.00      4.3±0.00ms        ? ?/sec
physical_sorted_union_order_by_10_uint64              1.00      8.5±0.05ms        ? ?/sec    1.00      8.4±0.01ms        ? ?/sec
physical_sorted_union_order_by_50_int64               1.01    107.1±0.59ms        ? ?/sec    1.00    105.9±0.30ms        ? ?/sec
physical_sorted_union_order_by_50_uint64              1.01    366.6±2.85ms        ? ?/sec    1.00    363.6±0.69ms        ? ?/sec
physical_theta_join_consider_sort                     1.00   1087.6±9.00µs        ? ?/sec    1.00   1088.5±3.17µs        ? ?/sec
physical_unnest_to_join                               1.00    663.6±1.78µs        ? ?/sec    1.00    664.3±2.01µs        ? ?/sec
physical_window_function_partition_by_12_on_values    1.00    719.6±1.68µs        ? ?/sec    1.00    720.2±1.47µs        ? ?/sec
physical_window_function_partition_by_30_on_values    1.00   1423.5±1.91µs        ? ?/sec    1.00   1427.0±2.04µs        ? ?/sec
physical_window_function_partition_by_4_on_values     1.00    442.6±1.26µs        ? ?/sec    1.00    444.5±1.72µs        ? ?/sec
physical_window_function_partition_by_7_on_values     1.00    543.7±1.57µs        ? ?/sec    1.00    544.5±1.52µs        ? ?/sec
physical_window_function_partition_by_8_on_values     1.00    582.7±2.47µs        ? ?/sec    1.00    584.2±1.53µs        ? ?/sec
with_param_values_many_columns                        1.00    433.4±2.62µs        ? ?/sec    1.01    435.8±2.62µs        ? ?/sec

Resource Usage

sql_planner — base (merge-base)

Metric Value
Wall time 2560.6s
Peak memory 131.1 MiB
Avg memory 62.3 MiB
CPU user 1883.9s
CPU sys 1.3s
Peak spill 0 B

sql_planner — branch

Metric Value
Wall time 2525.6s
Peak memory 129.6 MiB
Avg memory 62.0 MiB
CPU user 1876.8s
CPU sys 1.3s
Peak spill 0 B

File an issue against this benchmark runner

@kosiew kosiew 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.

@asolimando
Thanks for the iteration. The provider-specific child statistics work is heading in the right direction, but there is still one behavior issue that should be addressed before merging. I also noticed a small documentation mismatch.

let children = plan.children();
let requests = plan.child_stats_requests(partition);
// Resolved for the built-in `statistics_from_inputs` fallback below.
let child_statistics = self.resolve_children(plan, &children, &requests)?;

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.

Could we try the provider path before resolving the operator's child statistics requests?

Right now, compute_base calls plan.child_stats_requests() before trying any provider. This means an operator's fallback-only child walk still runs even when a matching provider requests ChildStats::Skip and can compute the node's statistics itself. Besides doing unnecessary work, an error from that child walk prevents the provider from overriding the node.

The existing test_provider_opts_out_of_child_stats confirms that the provider receives an unknown placeholder, but it does not confirm that the child computation was actually skipped.

Please resolve each provider's own requests while trying that provider, then resolve the operator's requests only after every provider delegates, immediately before calling statistics_from_inputs. It would also be helpful to add a regression test where the operator requests At(...), the matching provider requests Skip, and the test asserts that the child is never computed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

It makes sense, it's true that children statistics computation can fail, and that would be unfortunate for providers that don't even need those stats, so it makes sense to test the providers first, I have implemented this in 0b6a021.

I have added also a test to cover against regressions in this area (child throws error for statistics, but the provider Skips statistics, so it succeeds with the updated code).

///
/// If no providers are registered, falls back to the plan's built-in
/// `partition_statistics(None)` with no overhead.
/// Children are resolved via [`ExecutionPlan::child_stats_requests`] (default

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.

Could we update or remove this paragraph? It still says child statistics are resolved through ExecutionPlan::child_stats_requests and that the operator must declare At.

The follow-up moved that responsibility to StatisticsProvider::child_stats_requests, which allows providers for existing operators to request child statistics without changing those operators. Since this deprecated wrapper now follows the provider-specific behavior, the rustdoc should describe that behavior instead.

@asolimando asolimando Jul 27, 2026

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for pointing that out, I have updated (in 0b6a021) the comment to reflect the newly introduced StatisticsProvider::child_stats_requests.

@asolimando
asolimando force-pushed the asolimando/statistics-registry-default branch from 26095c7 to b2548db Compare July 27, 2026 16:18
@asolimando
asolimando requested a review from kosiew July 27, 2026 18:24

@kosiew kosiew 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.

@asolimando
Thanks for iteration.
I found one remaining issue around provider delegation. A provider that does not handle the current node can still trigger child statistics computation before a later provider or the operator fallback gets a chance to run. This can introduce unexpected work or surface child errors that should have been avoided.

let partition = args.partition();
for provider in providers {
let requests = provider.child_stats_requests(plan, partition);
let child_statistics = self.resolve_children(plan, children, &requests)?;

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.

Could we defer resolving a provider's child_stats_requests until we know that the provider will handle the current node?

Right now, try_provider_stats resolves each provider's child statistics request before calling that provider. As a result, a provider that eventually returns Delegate can still force child statistics to be computed. If that child walk fails, the next provider or the operator fallback never gets a chance to run.

The current regression test covers one matching provider that requests ChildStats::Skip, but it does not cover a provider chain. For example, provider A could use the default child_stats_requests value of At(None) and then delegate, while provider B would handle the node using Skip. Provider A's child walk can fail before provider B is called. The same issue affects an operator fallback whose ExecutionPlan::child_stats_requests is Skip, since registering an unrelated provider can unexpectedly cause all child statistics to be computed.

An applicability hook such as supports(plan) could be checked before resolving child statistics. An equivalent lazy design would also work, as long as child statistics are only resolved when the provider actually computes the result.

Please also add a regression test with a delegating provider before a Skip provider and an erroring child, then assert that the later provider still succeeds.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks for the suggestion, I have implemented it and it doesn't only make performance better, it also allowed to move those "guards" into a dedicated place, while before they had to live in compute, now there is a better separation of concerns and better overall readability!

I have added in 83b5e8f2c a matches(&self, plan) -> bool guard to StatisticsProvider (default to true), which is checked in try_provider_stats, only providers returning true are invoked and they don't resolve children statistics unnecessarily. There was a name clash on supports so I went for matches, we can revisit this if needed.

I have taken advantage of this new capability and I have migrated existing providers, tests and examples (code looks cleaner), and I have added test_non_matching_provider_does_not_trigger_child_walk to cover providers, which is analogous to the previous test_provider_opts_out_of_child_stats, which was covering providers vs built-in.

@kosiew kosiew 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.

@asolimando
Thanks for continuing to refine the provider statistics flow. The explicit matches() hook improves the new provider path, but there is still a compatibility issue for existing providers that rely on returning Delegate from compute_statistics. I think this needs one more change.

}
let partition = args.partition();
for provider in providers {
if !provider.matches(plan) {

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.

Could we preserve the existing Delegate contract without requiring every provider to implement matches()?

The new matches() hook avoids child-stat resolution when a provider explicitly returns false. However, its default is true, so existing providers that inspect plan inside compute_statistics and return Delegate for unsupported nodes still resolve their child-stat requests first.

That means an unrelated provider can trigger the default At(None) child walk and fail before a later provider that requests ChildStats::Skip, or before the operator fallback, gets a chance to handle the node. ClosureStatisticsProvider::new appears to have the same issue because it uses an always-true matcher.

A lazy child-stat design may help here, so child statistics are only computed once the provider actually needs them. Please also add a regression test where a provider that does not override matches() returns Delegate, comes before a child-skipping provider, and has an erroring child. The later provider should still succeed.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks @kosiew, your review comments are really helpful.

Re. the lazy vs eager design: I have "inherited" the eager child statistics resolution from #20184 and #22958 (implemented in #23051): the external StatisticsContext resolves child statistics first, then hands them to statistics_from_inputs (and here, to providers), so a node expresses only local propagation logic and externally-computed statistics (external catalogs, past-run feedback, ML cardinality estimators) can be injected through the same path.

I have considered a lazy design as an alternative but I didn't pursue it since the eager design was already backed by multiple maintainers in the issues/PRs linked above, after extensive discussion, so I considered the decision hard to reconsider.

The fatal-error problem you raise is important, but there is a middle ground solution to handle that in the eager design: if a provider's child walk fails, skip that provider and let a later one or the operator fallback handle the node.

Concretely, it would read as something like this:

let child_statistics = match self.resolve_children(plan, children, &requests) {
    Ok(child_statistics) => child_statistics,
    Err(e) => {
        debug!("Statistics provider {provider:?} skipped for {}: ... {e}", ...);
        continue;
    }
};

Note that this isn't error-swallowing: if the child is genuinely needed even by the built-in fallback, the error resurfaces there. A matched provider's own compute error stays fatal (which seems fair, and matches similar patterns like physical rules). Today each provider is responsible for its own recoverable errors: it can return Delegate for transient failures (e.g., network calls to an external catalog) and surface Err only for hard cases. If you prefer, we could instead catch compute errors the same way as the child walk.

I have optimistically implemented this solution in f9355ca to ground the discussion (along with the regression test you asked for), happy to take a different route if you prefer.

I think this keeps the good sides of both designs: robust over errors, doesn't go against previously agreed eager evaluation for statistics.

In terms of performance I think we are safe too, there is enough machinery to prevent waste for providers:

  • matches() can easily express operator-level applicability which is probably the vast majority of cases
  • Delegate in compute() for finer-grained checks (e.g., I handle ProjectionExec but only a subset of expressions, and delegate on the rest)
  • ChildStats::Skip for avoiding computing unneeded stats for some children
  • caching via StatisticsContext so repeated stats computation across providers/built-in doesn't trigger multiple walks

What do you think?

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, this explanation is very helpful and I agree with keeping the eager model for now.

I also agree with your middle-ground goal (do not let an early delegating provider block later providers or fallback), and the new regression test for that scenario is great.

I still see one remaining contract risk in the current implementation: all provider child-resolution errors are treated as skippable. That can mask provider bugs or contract violations (for example, malformed child_stats_requests) and silently disable provider logic.

Could we narrow the skip behavior to only the delegation-related failure mode, while still surfacing hard/provider-contract failures? In other words:

  1. keep the resilience benefit for the delegate-chain case
  2. preserve fail-fast behavior for invalid provider requests and other hard errors

@kosiew kosiew 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.

@asolimando
Thanks for the follow-up. I think the overall direction looks good and the rationale for preserving eager child resolution makes sense. The EXPLAIN/ANALYZE registry propagation, display deduplication, constructor cleanup, contract restoration, and the new regression test all address the earlier feedback.

I do think there is one remaining issue before this is ready. The current child resolution error handling is now too broad and can end up masking provider bugs by treating every failure as skippable.

let partition = args.partition();
for provider in providers {
if !provider.matches(plan) {
continue;

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.

I like the delegate-chain fix, but I think we're now skipping too much.

At the moment, every resolve_children(...) error falls into the same continue path. That means provider contract violations and other hard failures are treated the same as delegation failures, which can silently disable provider behavior and make bugs much harder to spot.

Could we narrow the classification here so that only delegation-related failures are skipped? I'd keep the current eager resolution model, but continue to fail fast for provider contract violations, especially request-length mismatches, and other hard errors.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Thanks @kosiew, I agree that for invalid provider requests (I added out-of-bounds partitions to the malformed request length you mentioned) it’s good to fail fast.

To that effect, in 08845ef I've split validation from resolution: validating the provider's requests now fails fast, while a child-statistics computation error during resolution is passed on to the next provider (or the operator fallback).

I have added two regression tests to verify that both a wrong-length request set and an out-of-range partition request fail fast and don’t go to the next provider.

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

Labels

auto detected api change Auto detected API change common Related to common crate core Core DataFusion crate documentation Improvements or additions to documentation optimizer Optimizer rules physical-plan Changes to the physical-plan crate sqllogictest SQL Logic Tests (.slt)

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Pluggable operator-level statistics propagation (StatisticsRegistry)

6 participants