Repository navigation
perf: prune ListingTable files before grouping - #26441
jayzhan211 merged 3 commits into
Conversation
Codecov Report❌ Patch coverage is
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. 🚀 New features to boost your workflow:
|
|
@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. |
|
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
left a comment
There was a problem hiding this comment.
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 declareHash([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 asRangewith split points. Pruning changes the pruned side's split points, so the planner addsRepartitionExec: partitioning=Range([f_dkey@1 ASC], [(B), (C)], 3)on that side. In Q2 that means repartitioning the fact table.mainneeds 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_statisticsstill has three debugprintln!s ("AFTER raw scan", ...).file_statistics_pruning.rsand the new unit tests intable.rshave no blank lines between functions.- In
clickbench.slt, 8 of the 9 updatedEXPLAINblocks now planFilterExecoverEmptyExec, and theCOUNT(*) ... WHERE "AdvEngineID" <> 0block becomes0 as count(*)overPlaceholderRowExec. 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 withcollect_statistics = falsewould 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 from8 total → 2 matchedto2 total → 2 matched, and nothing records the 6 skipped files. A count on theDataSourceExecline, or a planning metric, would keep that visible. prune_collected_filesbuilds onePrunableStatisticsper file. That is needed becausePrunableStatisticsfills non-exact bounds with an untypedScalarValue::Null, soiter_to_arrayfails 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 inPrunableStatisticswould allow a singleprunecall 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
ORwith 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 theEXPLAINupdates above. CI is green on bebaf2e.
bebaf2e to
698154c
Compare
|
@andygrove Thanks for the detailed review and prototype. I’ve rebased onto current The value-grouped path now:
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 I also removed the debug output, fixed the function spacing, resolved the 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 |
|
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
left a comment
There was a problem hiding this comment.
@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.
| 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()) | ||
| ))] | ||
| ), |
There was a problem hiding this comment.
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):
| 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()))] | |
| ), |
There was a problem hiding this comment.
@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.
| let file_group = self.prune_files_by_statistics( | ||
| pruning_predicate.as_deref(), | ||
| FileGroup::new(all_files), | ||
| ); |
There was a problem hiding this comment.
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(())
}There was a problem hiding this comment.
@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]].
| let Some(statistics) = &file.statistics else { | ||
| return true; | ||
| }; |
There was a problem hiding this comment.
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:
| 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; | |
| }; |
There was a problem hiding this comment.
@jayzhan211 Addressed. Files without usable statistics now return early via file.has_statistics(), avoiding unnecessary PrunableStatistics construction and pruning calls.
66c7925 to
9941416
Compare
9941416 to
a2413fd
Compare
@jayzhan211 Thanks for the thorough review and approval. I’ve addressed all three optional suggestions and rebased onto current |
|
Thanks @rgbuilds @andygrove @Omega359 ! |
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 < 40currently places both matching files in the second of four groups and estimates 80 rows. Pruning before grouping retains onlyf2andf3, puts them in separate groups, and aggregates 20 survivor rows.What changes are included in this PR?
PruningPredicateBuilderwith already collected file statistics before splitting regular scan file groups.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_typediagnostics in untoucheddatafusion/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.