Skip to content

perf: reuse one StatisticsContext across ensure_distribution - #25098

Draft
zhuqi-lucas wants to merge 1 commit into
apache:mainfrom
zhuqi-lucas:qizhu/ensure-distribution-stats-memoization
Draft

perf: reuse one StatisticsContext across ensure_distribution#25098
zhuqi-lucas wants to merge 1 commit into
apache:mainfrom
zhuqi-lucas:qizhu/ensure-distribution-stats-memoization

Conversation

@zhuqi-lucas

@zhuqi-lucas zhuqi-lucas commented Sep 9, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

None filed; small self-contained perf fix. Rationale below.

Rationale for this change

get_repartition_requirement_status creates a fresh StatisticsContext::new() once per child. StatisticsContext::compute recurses the child's whole subtree and carries a pointer-keyed memoization cache its own docstring describes as a "per-call memoization cache" meant to be reused across a walk. Allocating a new context per child discards that cache every time, so a single ensure_distribution pass recomputes shared subtree statistics O(depth) times.

On a deep/wide plan this is measurable. In our deployment (EnsureRequirements runs several times over a ~200-node plan) sharing the cache cut physical planning by ~10% with no plan change.

What changes are included in this PR?

  • Thread one StatisticsContext through the ensure_distribution transform_up (pass &StatisticsContext into get_repartition_requirement_status) so each subtree's statistics are computed once per pass.
  • StatsCache is keyed by raw node pointer, and ensure_distribution returns Transformed::yes unconditionally, so the cache reset is keyed on whether the node's plan pointer actually changed (Arc::ptr_eq before/after). A node that changed may have freed a cached child (which would make a stale pointer key unsafe); a node that made no change cannot, so the cache safely persists across the no-op nodes that dominate a deep plan.

(A second per-child StatisticsContext::new() in PlanSize::from_plan / enforce_distribution_relationships can get the same treatment; left as a follow-up to keep this PR focused.)

Are these changes tested?

Yes. New test ensure_distribution_shares_statistics_cache puts a leaf that counts its own statistics computations under a stack of pass-through operators, runs the distribution pass with a shared context vs a fresh-per-node context, and asserts the shared cache saves progressively more as the stack deepens. A cache that is not actually shared (e.g. reset on every node) saves nothing and fails the test — which a plan-output assertion cannot catch, since the optimized plan is identical either way.

Existing suites remain green and unchanged: datafusion --test core_integration physical_optimizer (569 passed) and datafusion-physical-plan statistics tests (96 passed).

Are there any user-facing changes?

No. Internal physical-optimizer performance only; planner output is identical.

Copilot AI lite review requested due to automatic review settings September 9, 2026 05:52
@github-actions github-actions Bot added optimizer Optimizer rules core Core DataFusion crate labels Sep 9, 2026
@zhuqi-lucas
zhuqi-lucas marked this pull request as draft September 9, 2026 05:55

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.

🟡 Changes recommended

It introduces a breaking change to the public ensure_distribution function signature, which is re-exported and may be used by downstream crates.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR improves physical-optimizer planning performance by reusing a single StatisticsContext (and its memoization cache) across an ensure_distribution traversal, avoiding repeated recursive statistics computation on deep/wide plans.

Changes:

  • Create one StatisticsContext for the distribution-enforcement pass and reset its cache only when a node’s plan pointer actually changes.
  • Thread &StatisticsContext through ensure_distribution and into get_repartition_requirement_status so child stats are memoized across the pass.
  • Add a deep operator-stack regression test asserting the repartition decisions remain unchanged.
File summaries
File Description
datafusion/physical-optimizer/src/ensure_requirements/mod.rs Reuses a single StatisticsContext during the bottom-up distribution pass and resets cache on actual plan-pointer rewrites.
datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs Threads &StatisticsContext into distribution enforcement logic to share memoized statistics across children/subtrees.
datafusion/core/tests/physical_optimizer/enforce_distribution.rs Updates call sites for the new ensure_distribution signature and adds a deep-stack test for unchanged repartition behavior.
Review details
  • Files reviewed: 3/3 changed files
  • Comments generated: 1
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment on lines 1356 to 1360
pub fn ensure_distribution(
dist_context: DistributionContext,
config: &ConfigOptions,
stats_ctx: &StatisticsContext,
) -> Result<Transformed<DistributionContext>> {
@github-actions

github-actions Bot commented Sep 9, 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 v55.0.0 (current)
       Built [  61.677s] (current)
     Parsing datafusion v55.0.0 (current)
      Parsed [   0.033s] (current)
    Building datafusion v55.0.0 (baseline)
       Built [  59.118s] (baseline)
     Parsing datafusion v55.0.0 (baseline)
      Parsed [   0.033s] (baseline)
    Checking datafusion v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.736s] 223 checks: 223 pass, 31 skip
     Summary no semver update required
    Finished [ 123.263s] datafusion
    Building datafusion-physical-optimizer v55.0.0 (current)
       Built [  40.958s] (current)
     Parsing datafusion-physical-optimizer v55.0.0 (current)
      Parsed [   0.022s] (current)
    Building datafusion-physical-optimizer v55.0.0 (baseline)
       Built [  40.826s] (baseline)
     Parsing datafusion-physical-optimizer v55.0.0 (baseline)
      Parsed [   0.022s] (baseline)
    Checking datafusion-physical-optimizer v55.0.0 -> v55.0.0 (no change; assume patch)
     Checked [   0.132s] 223 checks: 222 pass, 1 fail, 0 warn, 31 skip

--- failure function_parameter_count_changed: pub fn parameter count changed ---

Description:
A publicly-visible function now takes a different number of parameters.
        ref: https://doc.rust-lang.org/cargo/reference/semver.html#fn-change-arity
       impl: https://github.com/obi1kenobi/cargo-semver-checks/tree/v0.50.0/src/lints/function_parameter_count_changed.ron

Failed in:
  datafusion_physical_optimizer::ensure_requirements::enforce_distribution::ensure_distribution now takes 3 parameters instead of 2, in /home/runner/work/datafusion/datafusion/datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs:1356
  datafusion_physical_optimizer::enforce_distribution::ensure_distribution now takes 3 parameters instead of 2, in /home/runner/work/datafusion/datafusion/datafusion/physical-optimizer/src/ensure_requirements/enforce_distribution.rs:1356

     Summary semver requires new major version: 1 major and 0 minor checks failed
    Finished [  83.141s] datafusion-physical-optimizer

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

codecov-commenter commented Sep 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.88889% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.80%. Comparing base (35c56b0) to head (a2cb2c8).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
...er/src/ensure_requirements/enforce_distribution.rs 88.88% 1 Missing ⚠️
.../physical-optimizer/src/ensure_requirements/mod.rs 88.88% 0 Missing and 1 partial ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25098      +/-   ##
==========================================
- Coverage   81.80%   81.80%   -0.01%     
==========================================
  Files        1130     1130              
  Lines      417708   417722      +14     
  Branches   417708   417722      +14     
==========================================
+ Hits       341701   341708       +7     
- Misses      55878    55881       +3     
- Partials    20129    20133       +4     

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

@zhuqi-lucas
zhuqi-lucas force-pushed the qizhu/ensure-distribution-stats-memoization branch 2 times, most recently from b23e2ce to a6f3f81 Compare September 9, 2026 07:15
get_repartition_requirement_status created a fresh StatisticsContext per
child. StatisticsContext::compute recurses the child's whole subtree and
carries a pointer-keyed memoization cache its docstring describes as a
per-call cache meant to be reused across a walk, so allocating a new
context per child discards it every time. A single ensure_distribution
pass then recomputes shared subtree statistics O(depth) times.

Share one StatisticsContext across the whole ensure_distribution
transform_up. The cache is keyed by raw node pointer; ensure_distribution
reports Transformed::yes unconditionally, so the reset is keyed on whether
the node's plan pointer actually changed (a rewrite can free a cached node
and a later allocation could reuse its address). Nodes that make no change
cannot free anything, so the cache safely persists across no-op nodes,
which dominate a deep plan.

Adds ensure_distribution_shares_statistics_cache, which counts a leaf's
statistics computations under a stack of pass-through operators and
asserts the shared cache saves progressively more as the stack deepens
(a cache that is not actually shared saves nothing). No plan changes.
@zhuqi-lucas
zhuqi-lucas force-pushed the qizhu/ensure-distribution-stats-memoization branch from a6f3f81 to a2cb2c8 Compare September 9, 2026 07:35
@asolimando

Copy link
Copy Markdown
Member

Hi @zhuqi-lucas, I can confirm that your use-case fits what the cache in StatisticsContext is made for, so it sounds like a great idea.

Glad to read that you are seeing a drop in planning time of ~10% due to caching!

We can probably push this a little further: instead of just passing StatisticsContext through ensure_distribution to share the memoization cache across the pass, you could use EnsureRequirements::optimize_with_context (instead of EnsureRequirements::optimize), so that you receive a PhysicalOptimizerContext, including the StatisticsRegistry and any other statistics context useful for CBO.

StatisticsContext::new() builds an empty StatisticsRegistry, so today this rule never consults any registered provider, even when the session has one configured. I think it's worth doing in this PR, rather than a follow-up, because we can avoid changing the public ensure_distribution twice.

I have implemented something similar in #24716 for JoinSelection to showcase how I think this should be done for physical optimizer rules.

So, concretely, my suggestion is to:

  • override optimize_with_context on EnsureRequirements
  • pass context to ensure_distribution and get_repartition_requirement_status instead of stats_ctx
  • build the shared StatisticsContext from context.statistics_registry() once per pass

This would keep the caching fix from this PR, and also allow the rule to benefit from the present and future statistics context (I will be working on #21120 as my next task).

Note that there is no behavior change by default: a session without registered providers is unaffected, but it gives room for improvement without further breaking changes.

I am off this week with very limited access to a computer, but I can surely offer a review or help with a PR from next week!

Regarding the cache reset: your proposed solution seems safe to me, but in the future I'd like to make the cache more robust so consumers don't have to think about it. Either by introducing a unique id per constructed ExecutionPlan and using that as cache key, or by preventing the freeing of the deleted nodes for the lifetime of the cache (Arc clone of the node). This is only tangential to this PR, but I'd appreciate your opinion on this matter since you have the needed context already.

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 core Core DataFusion crate optimizer Optimizer rules

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants