Skip to content

Add dfbench statistics command - #23975

Open
gabotechs wants to merge 9 commits into
apache:mainfrom
gabotechs:gabotechs/add-q-error-stats-benchmarks
Open

Add dfbench statistics command#23975
gabotechs wants to merge 9 commits into
apache:mainfrom
gabotechs:gabotechs/add-q-error-stats-benchmarks

Conversation

@gabotechs

@gabotechs gabotechs commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Rationale for this change

Provide a repeatable way to measure how closely planning cardinality statistics match runtime output for benchmark query suites.

The goal is not to have an automated benchmark suite, and not something to enforce in the CI. Instead, this is meant to be a development tool for developers to locally iterate over stats estimation improvements.

What changes are included in this PR?

dfbench statistics accepts any Parquet data directory and SQL file or directory, so it can be used with TPC-DS, TPC-H, or another compatible query suite. It:

  • prints an indented, per-operator estimate-versus-runtime report as each query completes;
  • reports q-error per operator and finite q-error p50/p75/p95/p99 across the run;
  • stores results by branch, comparing with the previous run by default or a named branch using --compare.

About q-error calculation: https://vldb.org/pvldb/vol9/p204-leis.pdf

Example: verify a TPC-DS Q21 improvement

  1. Run TPC-DS Q21 for storing the baseline
cargo run --bin dfbench statistics \
  --path benchmarks/data/tpcds_sf1 \
  --query_path datafusion/core/tests/tpc-ds \
  --query 21
  1. Perform this small change in https://github.com/apache/datafusion/blob/main/datafusion/physical-plan/src/sorts/sort.rs#L1427-L1427:
-        Ok(Arc::new(stats.with_fetch(self.fetch, 0, 1)?))
+        Ok(Arc::new(stats.with_fetch(
+            self.fetch,
+            0,
+            self.topk_emitter_count(),
+        )?))
  1. Run again the command from step 1.
=== 21 ===
SortPreservingMergeExec: rows=Inexact(100) vs 100, q-error: previous=1.00x, current=1.00x, change=0.0%
  SortExec(TopK): rows=Inexact(1600) vs 1417, q-error: previous=14.40x, current=1.13x, change=✅ 92.2%
    ....

Compare against a baseline from main

# Run on main first; the result is stored under main
cargo run --bin dfbench statistics \
  --path benchmarks/data/tpcds_sf1 \
  --query_path datafusion/core/tests/tpc-ds \
  --query 21

# Then, on a feature branch
cargo run --bin dfbench statistics \
  --path benchmarks/data/tpcds_sf1 \
  --query_path datafusion/core/tests/tpc-ds \
  --query 21 \
  --compare main

TPC-H works with its partitioned data layout as well:

cargo run --bin dfbench statistics \
  --path benchmarks/data/tpch_sf1 \
  --query_path benchmarks/queries \
  --query q1

Are these changes tested?

No unit tests were added: this is a reporting CLI over planner statistics and runtime metrics, exercised by the benchmark-suite smoke runs above.

If reviewers think this type of code needs to be covered by unit tests let me know.

Are there any user-facing changes?

No, this is just for extending the dfbench command with a new dfbench statistics subcommand

@codecov-commenter

codecov-commenter commented Jul 29, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 35.42435% with 350 lines in your changes missing coverage. Please review.
✅ Project coverage is 81.22%. Comparing base (70c26a0) to head (a5085cd).
⚠️ Report is 86 commits behind head on main.

Files with missing lines Patch % Lines
benchmarks/src/statistics.rs 35.48% 321 Missing and 28 partials ⚠️
benchmarks/src/bin/dfbench.rs 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #23975      +/-   ##
==========================================
+ Coverage   81.04%   81.22%   +0.18%     
==========================================
  Files        1105     1111       +6     
  Lines      380163   385878    +5715     
  Branches   380163   385878    +5715     
==========================================
+ Hits       308111   313444    +5333     
- Misses      53834    53916      +82     
- Partials    18218    18518     +300     

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

@gabotechs
gabotechs force-pushed the gabotechs/add-q-error-stats-benchmarks branch 4 times, most recently from 6f2a30b to 8b7b5ac Compare July 29, 2026 13:25
@gabotechs
gabotechs marked this pull request as ready for review July 29, 2026 13:28
@gabotechs gabotechs mentioned this pull request Jul 29, 2026
22 tasks

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

@gabotechs
Thanks for adding this new statistics command.
I found a few issues that could make benchmark reports incomplete, stale, or nondeterministic.
I also left a testing suggestion to help protect the command's repeatability guarantees.

Comment thread benchmarks/src/statistics.rs Outdated
Comment thread benchmarks/src/statistics.rs Outdated
Comment thread benchmarks/src/statistics.rs Outdated
.map_err(|error| DataFusionError::External(Box::new(error)))
}

fn query_files(path: &Path, query: Option<&str>) -> Result<Vec<PathBuf>> {

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.

It would be helpful to add focused unit tests for SQL-aware multi-statement parsing, partial-run persistence and exit status, and duplicate table-name rejection. These cases define the repeatability contract for the new standalone CLI and would help prevent regressions.

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.

Added some tests here 6366a30.

I want to be careful in not introducing too many tests, as those also require maintenance, and this is just a benchmarking tool that is not in a production path.

Let me know if you think we should be covering some more.

@alamb

alamb commented Aug 3, 2026

Copy link
Copy Markdown
Contributor

I think @Omega359 is in the process of porting dfbench code -- maybe we should use the new format

@gabotechs
gabotechs force-pushed the gabotechs/add-q-error-stats-benchmarks branch from 8b7b5ac to 13ccf04 Compare August 4, 2026 14:43
@github-actions github-actions Bot added the physical-plan Changes to the physical-plan crate label Aug 4, 2026
@gabotechs gabotechs removed the physical-plan Changes to the physical-plan crate label Aug 4, 2026
@gabotechs

gabotechs commented Aug 4, 2026

Copy link
Copy Markdown
Contributor Author

@alamb I imagine you are referring to #23772. Unless I misinterpreted the intentions there, I see that's focusing on performance benchmarks, while this PR focuses on planning-time statistics vs execution-time metrics statistical divergence.

I've tried several things already for shipping a tool that can qualify improvements to DataFusion stats system:

  • Modeling stats estimation as integration tests, attempted in Add statistics integration tests #20292 without success because of non-determinism.
  • Enhancing existing benchmarks so that they can additionally output the planning stats VS execution metrics as another output. However, this required shoehorning some logic into the existing benchmarks, and the benefit is not very big, as performance-based benchmarks have a different set of requirements that are not needed for planning stats VS execution metrics benchmarks, like executing the same queries a certain amount of times, measuring timing, etc...

As there's really not any precedence about what I'm trying to do here, and it does not quite fit neither in the existing benchmark infrastructure or the current integration tests, I preferred to ship something as isolated as possible, so that:

  • It does not get in the way of normal benchmarks with additional (potentially unrelated) code
  • We can easily nuke it if we don't find it useful without touching any other pre-existing files.

Also @Omega359, if you have ideas about how to integrate this with your work, they are more than welcome, but my impression is that integrating this with the existing or future (#23772) is going to require some shoehorning that is likely to get in the way of other efforts rather than being helpful.

@alamb

alamb commented Aug 4, 2026

Copy link
Copy Markdown
Contributor

@alamb I imagine you are referring to #23772. Unless I misinterpreted the intentions there, I see that's focusing on performance benchmarks, while this PR focuses on planning-time statistics vs execution-time metrics statistical divergence.

I've tried several things already for shipping a tool that can qualify improvements to DataFusion stats system:

As there's really not any precedence about what I'm trying to do here, and it does not quite fit neither in the existing benchmark infrastructure or the current integration tests, I preferred to ship something as isolated as possible, so that:

  • It does not get in the way of normal benchmarks with additional (potentially unrelated) code
  • We can easily nuke it if we don't find it useful without touching any other pre-existing files.

Fair enough.

Another thing we could do potentially is to add some sort of mode to the benchmark runner ("stats verification mode" perhaps?) that runs the query and then verifies that the actual metrics match the statistics 🤔

@Omega359

Omega359 commented Aug 5, 2026

Copy link
Copy Markdown
Contributor

Also @Omega359, if you have ideas about how to integrate this with your work, they are more than welcome, but my impression is that integrating this with the existing or future (#23772) is going to require some shoehorning that is likely to get in the way of other efforts rather than being helpful.

Most of the benchmarks in dfbench will be redirected to the SQL benchmark suite, and their corresponding Rust-based implementations will be removed in the somewhat near future. However, dfbench itself will remain because a few benchmarks are not well suited for SQL benchmarking. From a quick look at the code for this PR this looks like it'll be one of those. I don't think it's a concern at this point.

@gabotechs

gabotechs commented Aug 5, 2026

Copy link
Copy Markdown
Contributor Author

Another thing we could do potentially is to add some sort of mode to the benchmark runner ("stats verification mode" perhaps?)

What I found challenging is how to integrate this with existing benchmark runner without it getting in the way of the classical "performance benchmarks" path.

For example, typical performance benchmarks have the option to specify the number of iterations, which does not make much sense with planing stats vs execution metrics benchmarks, and planing stats vs execution metrics benchmarks need to render the output in a very certain way for them to be useful (plans displayed with q-error per operator), which does not make sense for classical benchmarks.

What I found is that integrating it in existing benchmarks would require some "if normal_path; do this; else if stats_vs_metrics_path; do this other different thing" conditional logic that could end up getting in the way of other people contributing to the classical benchmarks.

Most of the code added in this PR is kind of very specific to this planning stats vs execution metrics benchmarks, but there are some common bits that could potentially be reused with other types of benchmarks: fn query_files(), fn register_parquet_files() and fn collect_parquet_files(), making a total of 80 LOC that could potentially be reused from the ~650 LOC these new benchmarks have.

Even if 80 LOC out of 650 is something, it's still not a lot to be reused with existing benchmarks, the other 570 LOC are very specific to this new type of benchmark.

@gabotechs
gabotechs force-pushed the gabotechs/add-q-error-stats-benchmarks branch from 6366a30 to e2f79d3 Compare August 6, 2026 10:42
@asolimando

Copy link
Copy Markdown
Member

Thanks @gabotechs for this PR, the q-error report is a useful addition that will help improving our supports for statistics!

One issue I hit while trying it out: for plans containing a fetch, the reported q-error is not reproducible between runs, which affects the --compare mode.

Running the same query (SELECT * FROM t ORDER BY a LIMIT 10) four times against unchanged code and unchanged data:

run 1 SortPreservingMergeExec estimate=Inexact(10) runtime=10 q-error=1.00x
run 1 SortExec(TopK) estimate=Inexact(10) runtime=90 q-error=9.00x
run 1 DataSourceExec estimate=Inexact(2000000) runtime=320000 q-error=6.25x
run 2 SortExec(TopK) estimate=Inexact(10) runtime=70 q-error=7.00x change=✅ 22.2%
run 3 SortExec(TopK) estimate=Inexact(10) runtime=80 q-error=8.00x change=-14.3%
run 4 SortExec(TopK) estimate=Inexact(10) runtime=80 q-error=8.00x change=0.0%

An earlier set of four runs on the same machine produced -50.0% and -166.7%, so the tool reports both improvements and regressions where nothing changed (it failed AA testing, basically).

In my example query, the estimate column is stable at Inexact(10), only the runtime column moves. The cause seems to be that that SortExec has preserve_partitioning=[true] and fetch=10, where each partition that gets fully consumed contributes up to 10 rows to MetricsSet::output_rows, which sums across partitions, while the estimate is capped at a single fetch (which is expected). How many partitions get drained before the SortPreservingMergeExec is satisfied, depends on scheduling, and it is not stable across runs.

Some options to address the problem:

  1. Exclude problematic operators for now (I think the benchmark still brings lots of values even with partial support, we need to document the limitations and we can improve later)
  2. Change the way we report per-partition runtime rows for operators with preserve_partitioning, so both sides of the comparison use the same unit (I didn't really thought of all implications for this tbh)
  3. Run each query multiple times and report the median (not a huge fan of this, but if this proves to be enough for now, we can always go for 2. or better alternatives later)

Claude helped creating the attached repro script, qerr_repro.sh, which runs from the root of a datafusion checkout, builds dfbench itself, and generates its own dataset. It covers just the query shared above, as that's a minimal reproducer for the only problem that could be detected using the existing benchmark.

Note the spread depends on core count and load, so your numbers will differ from mine; the claim is that the runtime column varies, not the specific values. I suggest to use RUNS=8 if the first four runs happen to agree, I couldn't test on more machines.

@gabotechs

Copy link
Copy Markdown
Contributor Author

An earlier set of four runs on the same machine produced -50.0% and -166.7%, so the tool reports both improvements and regressions where nothing changed (it failed AA testing, basically).

Is that too bad though? it's not like we are enforcing some specific benchmark numbers in the CI, users of the dfbench statistics cli can always apply some critical judgement when interpreting the stdout, same as with normal benchmarks, where there's always some small variability in two identical runs.

Do you foresee other places where reporting might be non-deterministic?

If It was just me using the tool, I think I could live with it being non-deterministic for certain operators, but in case other people don't, something that comes to mind is to add a new cli argument for skipping certain operators from the comparison. Something like: --skip SortExec(TopK).

@gabotechs

Copy link
Copy Markdown
Contributor Author

Also, @alamb, @kosiew and @Omega359, just to be upfront with the intentions of this cli. The idea would be to follow up immediately by opening a bunch of easily reproducible Github issues with improvement opportunities in the stats propagation logic and link them to #8227.

I'm expecting to create a bunch of issues that follow the same template:

  • Title: Improve stats estimation accuracy in X operator
  • Description:
    • The operator that is reporting high q-error on planing stats vs runtime metrics, and by how much it misses on estimations
    • The dfbench statistics ... command that reproduces the issue
    • The success criteria that qualifies an improvement in stats estimation there (e.g., lower q-error + no regressions in other operators)

@asolimando

Copy link
Copy Markdown
Member

An earlier set of four runs on the same machine produced -50.0% and -166.7%, so the tool reports both improvements and regressions where nothing changed (it failed AA testing, basically).

Is that too bad though? it's not like we are enforcing some specific benchmark numbers in the CI, users of the dfbench statistics cli can always apply some critical judgement when interpreting the stdout, same as with normal benchmarks, where there's always some small variability in two identical runs.

Do you foresee other places where reporting might be non-deterministic?

If It was just me using the tool, I think I could live with it being non-deterministic for certain operators, but in case other people don't, something that comes to mind is to add a new cli argument for skipping certain operators from the comparison. Something like: --skip SortExec(TopK).

To me it's still valuable as-is, as at the moment we don't have any easy way to assess improvements over statistics, as we have only a handful of CBO rules (JoinSelection notably) that can't move the needle much for benchmarks. If that's of any help, we could mark the benchmark as experimental for now, and follow-up on non-determinism (I couldn't think or find other similar problems apart from Sort+TopK I reported).

Re. #23975 (comment), during my investigation for the review, AI stumbled upon several long hanging fruits, and I could genuinely verify some after a cursory look, so I agree landing this benchmark would already have a tangible beneficial effect.

@kosiew

kosiew commented Aug 11, 2026

Copy link
Copy Markdown
Contributor

I think it is worth moving forward with this while working out how dfbench should work with benchmark_runner framework introduced in #23772.

A few reasons I think this is valuable:

  • It fills an important gap in the statistics work by providing a systematic way to compare estimated and actual cardinalities across realistic workloads.
  • It creates a useful feedback loop for [#8227](Epic: Statistics improvements #8227), making it easier to identify problematic operators and measure whether statistics changes actually improve estimation quality.
  • Statistics analysis has different execution and reporting requirements from conventional performance benchmarking, so keeping the analysis itself specialized seems reasonable.
  • The nondeterminism identified in some operators limits its usefulness as a strict regression test, but does not prevent it from being useful as an exploratory/diagnostic tool. We can document this limitation and improve repeatability over time.
  • dfbench already hosts specialized workloads and diagnostics that don't map cleanly to generic SQL performance benchmarking, so this seems like a reasonable use case while we determine the longer-term boundary between dfbench and the new SQL benchmark framework.

Longer term, I think the ideal direction is to share suite discovery, query selection, data/configuration handling, etc. with #23772 where practical, while allowing statistics analysis to retain its own execution and reporting model.

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

@gabotechs,

Thanks for the follow-up. The SQL-aware statement parsing, failed-report persistence, duplicate table-name handling, and focused tests address the main points from my previous review.

I found one remaining issue with SQL dialect handling. The new pre-parsing step always uses the generic dialect, while the session can be configured with a different dialect through datafusion.sql_parser.dialect. This means SQL that the configured session would otherwise accept can now fail before planning or potentially be parsed differently.

I think this should be fixed before merging. Once the parser uses the session's configured dialect and there is a regression test covering a non-generic dialect, the concerns from my previous review should be addressed.

Comment thread benchmarks/src/statistics.rs Outdated

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

@gabotechs,

Thanks for the follow-up changes. The earlier issues around SQL-aware parsing, configured dialect handling, failed report persistence, and duplicate table names look addressed.

I found one remaining issue with multi-statement suites that contain session-changing statements. The current execution path plans and collects each statement directly from SessionState, so statements such as DDL do not get applied to the SessionContext before subsequent statements run.

I reproduced this with CREATE TABLE x AS VALUES (1); SELECT * FROM x;. The CREATE TABLE statement fails with Unsupported logical plan: CreateMemoryTable, and the following query then cannot find x.

I think this should be fixed before merging. One option is to route session-changing statements through the context execution path while keeping the existing metric-collection path for query plans. Alternatively, if dfbench statistics is only intended to support query statements, it would be better to reject non-query statements explicitly and document that restriction. A regression test covering a session-changing statement followed by a query would also be useful.

statement: Statement,
) -> Result<Vec<OperatorReport>> {
let state = ctx.state();
let logical_plan = state.statement_to_plan(statement).await?;

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 think this introduces a regression for multi-statement suites that contain session-changing statements. report_statement calls SessionState::statement_to_plan, optimizes the result, and then directly creates and collects the physical plan. That bypasses the SessionContext execution path that applies session-affecting logical plans such as DDL.

For example, CREATE TABLE x AS VALUES (1); SELECT * FROM x; currently reports the first statement as Unsupported logical plan: CreateMemoryTable, and the second statement then cannot find x. The earlier ctx.sql path applied the session change before continuing.

Could we route session-changing statements through the context execution path while preserving the metric-collection path for query plans? Alternatively, if this command is intended to support query statements only, we should reject non-query statements explicitly and document that restriction. It would also be good to add a regression test with a session-changing statement followed by a query.

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 in a5085cd

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.

If possible, I'd try to avoid introducing more complexity for the sake of getting a perfect solution, specially given that the usefulness of this command is not yet proven.

As this is just a development tool, if people find the need of extending it for supporting more esoteric SQL setups, they can just contribute a patch in future PRs as needed (there's a high chance these additions are never even needed).

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

@gabotechs, thanks for working through the earlier feedback. The SQL-aware statement handling, parser configuration, duplicate table-name checks, failure persistence, and context execution for session-changing statements all look good now.

I have two small follow-up suggestions below. Neither is a merge blocker given that this is intentionally scoped as an experimental local development tool.


impl RunOpt {
pub async fn run(self) -> Result<()> {
let mut config = SessionConfig::from_env()?.with_collect_statistics(true);

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.

One small limitation to keep in mind: sql_parser_options is cloned once before the suite statements run. That means something like SET datafusion.sql_parser.dialect = 'MySQL' in one file will not affect how a later file is pre-parsed. The same applies to parser recursion-limit changes.

Supporting this would require more incremental parsing and state handling, so I think deferring it is reasonable for the current scope. It would be good to document the limitation for now. If support is added later, we could fetch the parser options from ctx.state() for each file and add a test where a SET ... dialect statement is followed by dialect-specific SQL.

success: true,
error: None,
},
Err(error) => QueryReport {

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.

Another small edge case: because the report is replaced after every statement, an interrupted run or a failure while reading a later query file can leave the successful prefix behind as statistics.json. A later --compare would then treat that partial report as a complete baseline.

Publishing the report only after the full run succeeds, or recording a completion status and rejecting incomplete reports as comparison baselines, would avoid that. This does add some persistence machinery, so I think it is reasonable to defer unless incomplete comparisons become a practical problem.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

6 participants