Skip to content

perf: prune ListingTable files before grouping - #26441

Merged
jayzhan211 merged 3 commits into
apache:mainfrom
rgbuilds:codex/26130-listing-file-statistics-pruning
Nov 9, 2026
Merged

jayzhan211 merged 3 commits into
apache:mainfrom
rgbuilds:codex/26130-listing-file-statistics-pruning

Conversation

@rgbuilds

@rgbuilds rgbuilds commented Oct 17, 2026 •

Copy link
Copy Markdown
Contributor

Which issue does this PR close?

Closes #26130.

Rationale for this change

When files contain disjoint ranges of a filtered column, ListingTable groups all files before file-statistics pruning. The matching files can therefore land in a single scan partition, and scan statistics still include files that cannot match.

In the eight-file example from the issue, d >= 20 AND d < 40 currently places both matching files in the second of four groups and estimates 80 rows. Pruning before grouping retains only f2 and f3, puts them in separate groups, and aggregates 20 survivor rows.

What changes are included in this PR?

  • Pass non-partition filters through a private listing path and reuse PruningPredicateBuilder with already collected file statistics before splitting regular scan file groups.
  • Keep files with missing/inexact statistics or evaluation errors, retain normal row filtering, and avoid evaluating volatile filters during planning.
  • Bypass the optimization for custom expression adapters and declared output partitioning, preserving partition identities and empty trailing groups.
  • Recompute aggregate statistics from survivors without additional object-store requests.
  • Add regression coverage and update physical-plan/runtime-metric expectations for files eliminated during planning. No public API change.

What is the testing strategy for this PR?

On the investigated baseline (a4b8198cf), local validation passed 12,579 distinct Rust tests and 533 SQL logic files. This includes 23 listing unit tests, 10 focused pruning integration tests, all 246 Parquet integration tests, and 126 extended fuzz tests. The no-extra-I/O test compares object-store requests during planning.

Coverage includes the eight-file grouping example, partially matching files, missing and inexact bounds, NULL semantics, Hash/Range partition layouts, Hive/data predicates, LIMIT/OFFSET, custom adapters, volatile UDF invocation counts, survivor statistics, and conservative filtered-statistics precision. Nine SQL snapshot files change physical-plan expectations only.

On current upstream main (5f2158269), all 1,459 core integration tests (including all 10 pruning regressions and the no-extra-I/O test), all 247 Parquet tests, and all 535 SQL logic files pass. The full extended workspace run completed successfully: 12,742 Rust tests passed, including all 126 fuzz tests, with eight tests ignored and no failures. All 535 SQL logic files also passed. Formatting, diff whitespace, TOML formatting, and license headers on all 2,331 tracked project source files pass.

The earlier full workspace Clippy run passed with explicit allowances for pre-existing upstream/toolchain diagnostics. The mandatory strict Clippy invocation also fails on the updated main branch with Rust 1.99, on three pre-existing clippy::mutable_key_type diagnostics in untouched datafusion/optimizer/src/push_down_filter.rs. The CI-style Clippy invocation (--workspace --all-targets --features avro,integration-tests,extended_tests -- -D warnings) passes. The full lint wrapper stops at its license walk because locally exported third-party SQLite fixture snapshots lack project headers; the separate tracked-project-source license check passes. Subsequent wrapper steps have not been run. No lint suppression or unrelated optimizer change is included in this PR. No performance speedup is claimed: the prior Hive-only planning benchmark is not representative of data-column pruning and ran under differing concurrent load.

Are there any user-facing changes?

With statistics collection enabled, regular listing scans can contain fewer files and report tighter statistics in EXPLAIN. Query results and row filtering are unchanged. Unsupported or inconclusive statistics keep files, and declared partition layouts are preserved.

This contribution was developed with AI assistance. The design and safeguards are described above; performance under distributed execution remains unmeasured.

@github-actions github-actions Bot added core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) catalog Related to the catalog crate labels Oct 17, 2026
@codecov-commenter

codecov-commenter commented Oct 17, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 98.75776% with 2 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.90%. Comparing base (5f21582) to head (bebaf2e).
⚠️ Report is 161 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/catalog-listing/src/table.rs 98.75% 2 Missing ⚠️
Additional details and impacted files
@@           Coverage Diff            @@
##             main   #26441    +/-   ##
========================================
  Coverage   82.89%   82.90%            
========================================
  Files        1154     1154            
  Lines      457620   457778   +158     
  Branches   457620   457778   +158     
========================================
+ Hits       379352   379520   +168     
+ Misses      55262    55249    -13     
- Partials    23006    23009     +3     

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

@rgbuilds
rgbuilds marked this pull request as ready for review October 17, 2026 07:04
@rgbuilds

Copy link
Copy Markdown
Contributor Author

@andygrove This is ready for review and addresses #26130 by pruning files before grouping, using already collected statistics. Would appreciate your feedback when you have time.

@rgbuilds

Copy link
Copy Markdown
Contributor Author

Hi @andygrove, gentle follow-up on this PR addressing #26130. When you have a chance, I’d appreciate your feedback—particularly on whether the pruning-before-grouping design aligns with what you had in mind for the issue. Thanks!

@andygrove andygrove left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

Heads up: this is an AI-assisted review pass. I checked each point below locally against bebaf2e, and against current main with your catalog-listing changes applied.

Thanks for working on this, @rgbuilds. To answer your question: yes, pruning before split_files is what I had in mind in #26130. On the issue's example the scan now gets [[f2.parquet], [f3.parquet]] and Rows=Inexact(20), instead of 8 files in 4 groups and Rows=Inexact(80). Reusing the statistics that were already collected, keeping files with unknown bounds, and leaving declared output_partitioning alone all match the issue. For Parquet, the planner only drops files that the opener's FilePruner would already skip at open time using the same statistics. That keeps the risk to single-scan results low.

One request before I approve: keep partition-value groups intact

When datafusion.optimizer.preserve_file_partitions is enabled, list_files_for_regular_scan prunes before group_by_partition_values. If the predicate removes every file of one Hive value on one side of a join, that side's groups stop lining up with the other side's.

Two Hive tables, each with values A–D and one file per value. Only dim's d_dkey=B file lacks service = 'log', and only fact's f_dkey=B file has value = 20:

SET datafusion.execution.target_partitions = 3;
SET datafusion.optimizer.preserve_file_partitions = 1;
SET datafusion.optimizer.hash_join_single_partition_threshold = 0;
SET datafusion.optimizer.hash_join_single_partition_threshold_rows = 0;

-- fact/f_dkey={A,B,C,D}/data.parquet hold value 10, 20, 30, 40
-- dim/d_dkey={A,B,C,D}/data.parquet hold service 'log', 'trace', 'log', 'log'
CREATE EXTERNAL TABLE fact (value BIGINT) STORED AS PARQUET PARTITIONED BY (f_dkey STRING) LOCATION '.../fact/';
CREATE EXTERNAL TABLE dim (service STRING) STORED AS PARQUET PARTITIONED BY (d_dkey STRING) LOCATION '.../dim/';

-- Q1 prunes dim's B file; Q2 prunes fact's B file
SELECT f.f_dkey, f.value FROM fact f JOIN dim d ON f.f_dkey = d.d_dkey WHERE d.service = 'log' ORDER BY f.f_dkey;
SELECT f.f_dkey, f.value FROM fact f JOIN dim d ON f.f_dkey = d.d_dkey WHERE f.value <> 20 ORDER BY f.f_dkey;

Both queries should return A, C and D.

  • On this PR's base (5f21582), both return only A. The pruned side is grouped as [A], [C], [D] while the other side stays [A, D], [B], [C]. Both sides still declare Hash([key], 3), so the partitioned join runs without repartitioning. This is the mismatch from #23436, now reachable through a data predicate.
  • After rebasing onto main, the results are correct but the plan gets an extra exchange. #25279 (merged 2026-10-22) declares these scans as Range with split points. Pruning changes the pruned side's split points, so the planner adds RepartitionExec: partitioning=Range([f_dkey@1 ASC], [(B), (C)], 3) on that side. In Q2 that means repartitioning the fact table. main needs no exchange there: it keeps the B file in its group, and the opener skips it without reading data. In Ballista that is an extra shuffle stage.

I think the value-grouped path should follow the same rule you already apply to declared layouts. Build the groups and split points from all listed files, then prune inside each group and keep the empty groups. I tried that on main with your patch applied (diff below). Both queries return A, C and D, there is no RepartitionExec, and the pruned side becomes [[A], [], [C, D]]. The full SLT suite gives the same results as your patch.

Prototype (on top of this PR applied to main)
@@ -1072,7 +1072,6 @@ impl ListingTable {
         let (file_group, inexact_stats) = self
             .collect_files_for_scan(ctx, &store, filters, limit)
             .await?;
-        let file_group = self.prune_files_by_statistics(ctx, data_filters, file_group);
 
         // Threshold: 0 = disabled, N > 0 = enabled when distinct_keys >= N
         //
@@ -1086,17 +1085,26 @@ impl ListingTable {
                 let (grouped, split_points) = file_group
                     .group_by_partition_values_with_split_points(file_group_count);
                 if grouped.len() >= threshold {
+                    // Prune inside each value group so the split points (and the
+                    // declared Range partitioning) do not change.
+                    let grouped = grouped
+                        .into_iter()
+                        .map(|g| self.prune_files_by_statistics(ctx, data_filters, g))
+                        .collect();
                     (grouped, true, split_points)
                 } else {
                     let all_files: Vec<_> =
                         grouped.into_iter().flat_map(|g| g.into_inner()).collect();
-                    (
-                        FileGroup::new(all_files).split_files(file_group_count),
-                        false,
-                        vec![],
-                    )
+                    let file_group = self.prune_files_by_statistics(
+                        ctx,
+                        data_filters,
+                        FileGroup::new(all_files),
+                    );
+                    (file_group.split_files(file_group_count), false, vec![])
                 }
             } else {
+                let file_group =
+                    self.prune_files_by_statistics(ctx, data_filters, file_group);
                 (file_group.split_files(file_group_count), false, vec![])
             };

The prototype rebuilds the PruningPredicate for every group, so a real version should build it once and reuse it. A test in preserve_file_partitioning.slt would lock this in: a partitioned join plus a data filter that prunes one value on one side, checking both the rows and that no RepartitionExec is added.

Rebase

The PR now conflicts with main in repartition_subset_satisfaction.slt, and #25279 rewrote the grouping code in list_files_for_regular_scan that the request above touches. With your catalog-listing changes applied to current main, the full SLT suite fails only on EXPLAIN blocks in the 9 files you already update. No query result changes.

Smaller things

  • prunes_before_grouping_and_aggregates_survivor_statistics still has three debug println!s ("AFTER raw scan", ...).
  • file_statistics_pruning.rs and the new unit tests in table.rs have no blank lines between functions.
  • In clickbench.slt, 8 of the 9 updated EXPLAIN blocks now plan FilterExec over EmptyExec, and the COUNT(*) ... WHERE "AdvEngineID" <> 0 block becomes 0 as count(*) over PlaceholderRowExec. Those blocks no longer show the scan and aggregate shape these queries get on the real data. If others want to keep that coverage, running those blocks with collect_statistics = false would do it.

Follow-ups (non-blocking)

  • Files dropped at planning no longer show up in files_ranges_pruned_statistics. On the issue's example, EXPLAIN ANALYZE goes from 8 total → 2 matched to 2 total → 2 matched, and nothing records the 6 skipped files. A count on the DataSourceExec line, or a planning metric, would keep that visible.
  • prune_collected_files builds one PrunableStatistics per file. That is needed because PrunableStatistics fills non-exact bounds with an untyped ScalarValue::Null, so iter_to_array fails for the whole column once files are mixed. The cost is small. With 10k files and a predicate that prunes nothing, planning goes from 0.276 s to 0.296 s (median of 10 runs, three rounds), about 2 µs per file. Typed nulls taken from the schema field in PrunableStatistics would allow a single prune call over all files, and would fix the same problem for other callers that pass more than one container.

What I checked

  • With 10k files of which 10 can match, planning is no slower (0.34–0.42 s vs 0.37–0.54 s on a loaded machine), and the query drops from about 0.45 s to 0.30 s.
  • A volatile conjunct is skipped while the rest still prunes. An OR with a volatile branch turns pruning off. An uncorrelated scalar-subquery conjunct doesn't block it.
  • The full SLT suite passes on main + this PR apart from the EXPLAIN updates above. CI is green on bebaf2e.

@rgbuilds
rgbuilds force-pushed the codex/26130-listing-file-statistics-pruning branch from bebaf2e to 698154c Compare October 26, 2026 06:44
@rgbuilds

Copy link
Copy Markdown
Contributor Author

@andygrove Thanks for the detailed review and prototype. I’ve rebased onto current main and addressed the blocking issue.

The value-grouped path now:

  • builds the pruning predicate once;
  • forms groups and split points from all listed files before pruning;
  • prunes independently within each group; and
  • retains empty groups so the declared range partitioning remains stable.

I added the suggested A–D regression coverage for pruning B from both the dimension and fact sides. Both queries return A, C, and D, preserve [A], [], [C, D], and avoid an additional RepartitionExec.

I also removed the debug output, fixed the function spacing, resolved the repartition_subset_satisfaction.slt expectations after the rebase, and disabled statistics collection around the affected ClickBench EXPLAIN cases to retain their scan/aggregate plan-shape coverage.

The focused tests, full SQL logic suite, formatting, and strict all-target/all-feature Clippy check pass.

I left the two explicitly non-blocking follow-ups—planning-time pruning metrics and batching files into one PrunableStatistics call—for separate work, since they require broader metrics/shared-pruning changes.

@rgbuilds

rgbuilds commented Nov 7, 2026

Copy link
Copy Markdown
Contributor Author

Hi @andygrove, gentle follow-up on the changes addressing your review feedback. When you have a chance, could you take another look and let me know if anything else is needed? Thanks!

@jayzhan211 jayzhan211 left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

@rgbuilds thanks for this! Approving. I checked that planning-time pruning gives the same results as the Parquet opener on schema-evolution, missing-column and partition-column predicates. The three inline comments are optional.

Comment on lines +1657 to +1666
use datafusion_expr::{col, lit};
// A malformed statistics type cannot establish that the file is empty.
assert_eq!(
prune_test_files(
col("d").gt(lit(10_i64)),
vec![Some(bounds(
Precision::Exact("invalid".into()),
Precision::Exact("invalid".into())
))]
),

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.

Optional cleanup: this test doesn't reach the error branch yet. A Utf8 bound doesn't make prune fail, so the test still passes if .unwrap_or(true) becomes .unwrap_or(false). Binary bounds can't be cast to Int64, so they do error, and the test then fails with unwrap_or(false):

Suggested change
use datafusion_expr::{col, lit};
// A malformed statistics type cannot establish that the file is empty.
assert_eq!(
prune_test_files(
col("d").gt(lit(10_i64)),
vec![Some(bounds(
Precision::Exact("invalid".into()),
Precision::Exact("invalid".into())
))]
),
use datafusion_common::ScalarValue;
use datafusion_expr::{col, lit};
// Binary bounds cannot be cast to Int64, so `prune` returns an error.
// A Utf8 bound does not error, so it would not reach that branch.
let binary = || Precision::Exact(ScalarValue::Binary(Some(vec![1])));
assert_eq!(
prune_test_files(
col("d").gt(lit(10_i64)),
vec![Some(bounds(binary(), binary()))]
),

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.

@jayzhan211 Addressed. This test now uses Binary bounds, so it exercises the pruning error path and verifies that the file is retained on evaluation failure. Thanks for catching this.

Comment on lines +1108 to +1111
let file_group = self.prune_files_by_statistics(
pruning_predicate.as_deref(),
FileGroup::new(all_files),
);

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.

Optional: no test covers this branch, where preserve_file_partitions is above the number of distinct partition values. If the prune here is removed, every test still passes. With two p values and a threshold of 3, the test below expects [[f2], [f3]]. It passes on this PR and fails when the prune is removed.

Test for file_statistics_pruning.rs
#[tokio::test]
async fn preserve_partitions_below_threshold_still_prunes() -> Result<()> {
    let dir = fixture(
        (0..8)
            .map(|i| (i * 10..i * 10 + 10).map(Some).collect())
            .collect(),
        true,
        true,
    );
    let mut config = SessionConfig::new()
        .with_target_partitions(4)
        .with_collect_statistics(true);
    // Two distinct `p` values are below the threshold, so files are split normally.
    config.options_mut().optimizer.preserve_file_partitions = 3;
    let ctx = SessionContext::new_with_config(config);
    let table = table(&dir, None, true, false);
    let plan = table.scan(&ctx.state(), None, &[predicate()], None).await?;
    assert_eq!(
        names(scan_config(&plan)),
        vec![vec!["f2.parquet"], vec!["f3.parquet"]]
    );
    Ok(())
}

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.

@jayzhan211 Added preserve_partitions_below_threshold_still_prunes with two partition values and a threshold of 3. It verifies that pruning still occurs before normal file splitting and produces [[f2.parquet], [f3.parquet]].

Comment on lines +1359 to +1361
let Some(statistics) = &file.statistics else {
return 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.

Optional: files whose statistics are all Absent can't be pruned, but each one still gets a PrunableStatistics and a prune call. That covers every file of a CSV or JSON table, whose infer_stats returns unknown statistics. FilePruner::try_new skips these files with the same check:

Suggested change
let Some(statistics) = &file.statistics else {
return true;
};
// Same check as `FilePruner::try_new`: all-`Absent` statistics
// cannot prune, so skip building `PrunableStatistics` for them.
if !file.has_statistics() {
return true;
}
let Some(statistics) = &file.statistics else {
return true;
};

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.

@jayzhan211 Addressed. Files without usable statistics now return early via file.has_statistics(), avoiding unnecessary PrunableStatistics construction and pruning calls.

@rgbuilds
rgbuilds force-pushed the codex/26130-listing-file-statistics-pruning branch from 66c7925 to 9941416 Compare November 8, 2026 20:00
@rgbuilds
rgbuilds force-pushed the codex/26130-listing-file-statistics-pruning branch from 9941416 to a2413fd Compare November 8, 2026 20:01
@rgbuilds

rgbuilds commented Nov 8, 2026

Copy link
Copy Markdown
Contributor Author

@rgbuilds thanks for this! Approving. I checked that planning-time pruning gives the same results as the Parquet opener on schema-evolution, missing-column and partition-column predicates. The three inline comments are optional.

@jayzhan211 Thanks for the thorough review and approval. I’ve addressed all three optional suggestions and rebased onto current main. The focused tests and formatting checks pass.

@jayzhan211
jayzhan211 added this pull request to the merge queue Nov 9, 2026
@jayzhan211

Copy link
Copy Markdown
Contributor

Thanks @rgbuilds @andygrove @Omega359 !

Merged via the queue into apache:main with commit fd101e9 Nov 9, 2026
45 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

catalog Related to the catalog crate core Core DataFusion crate sqllogictest SQL Logic Tests (.slt) v57.0.0

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Prune files by statistics in ListingTable before building file groups

5 participants