feat(parquet): support page-level limit pruning - #25000
Conversation
Codecov Report❌ Patch coverage is Additional details and impacted files@@ Coverage Diff @@
## main #25000 +/- ##
========================================
Coverage 81.67% 81.68%
========================================
Files 1126 1127 +1
Lines 414842 415125 +283
Branches 414842 415125 +283
========================================
+ Hits 338841 339101 +260
- Misses 56070 56085 +15
- Partials 19931 19939 +8 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
jayzhan211
left a comment
There was a problem hiding this comment.
Thanks @peterxcli, here is a suggestion:
The inverted-predicate pass at page_filter.rs:384-414 never short-circuits, at either level.
Per row group. The forward loop 30 lines above breaks as soon as the running selection stops selecting anything:
if !selects_any {
break;
}The inverted loop has no equivalent. update_selection is an intersection, so once fully_matched_selection is empty it can never become non-empty again — but every remaining conjunct is still fully evaluated.
Per file. The loop keeps computing inverted selections for every remaining row group even after enough guaranteed rows exist to satisfy limit. limit_pruned_plan stops greedily at candidate_rows >= limit, but only after all of the work has already been paid for.
Each prune_pages_for_predicate call builds a StatisticsConverter, materializes page min/max/null-count arrays, evaluates the pruning expression and builds a RowSelection — that's the bulk of page-index pruning cost. So this roughly doubles page_index_eval_time for every WHERE … LIMIT parquet query (page index is on by default), scaling with row-group count even when the first row group already proves the limit, or when nothing in the file is ever fully matched.
The per-row-group half is a two-line fix and mirrors the existing pattern exactly. complete stays true, so an empty selection is stored and limit_pruned_plan skips the row group via its existing rows == 0 check — same outcome, less work:
fully_matched_selection = update_selection(
fully_matched_selection,
complement_selection(selection),
);
+
+ // Intersection is monotone: once nothing is selected, no
+ // later conjunct can add rows back.
+ if !fully_matched_selection
+ .as_ref()
+ .is_some_and(|s| s.selects_any())
+ {
+ break;
+ }
}For the per-file half, track the guaranteed rows as you go and stop once the limit is covere shape limit_pruned_plan does, so the two agree:
let mut guaranteed_rows = 0usize;
// in the `is_fully_matched(row_group_index)` branch, before `continue`:
guaranteed_rows += selected_rows_in_access(
&access_plan.inner()[row_group_index],
&groups[row_group_index],
);
// gate the inverted block:
if guaranteed_rows < limit.unwrap_or(0)
&& access_plan.should_scan(row_group_index)
&& let Some(inverted_predicates) = &inverted_predicates
{
...
// once `complete`, add the rows this row group contributes, using the
// same `Scan` / `Selection` intersection `limit_pruned_plan` applies
}Given the effort already spent avoiding exactly this class of work elsewhere in the opener page_index_load_skipped, the lazily-registered counters), it'd be good to have this inbefore merge.
Which issue does this PR close?
Rationale for this change
Row-group LIMIT pruning cannot skip partially matching row groups even when a few pages within them contain enough guaranteed matches. For example, with pages
[0, 10, 0],[5, 6, 7], and[0, 8, 0],WHERE a >= 5 LIMIT 3can read only the middle page.This extends the row-group optimization introduced in #18868 to page ranges for scans that do not need to preserve order.
What changes are included in this PR?
limit_pruned_rowsmetric.What is the testing strategy for this PR?
The Parquet integration tests cover selecting a fully matched page, combining pages and row groups, NULLs, unsupported conjuncts, insufficient guaranteed rows, missing page indexes, and an eliminated
ORDER BYsort. Unit tests cover existing row selections, full-match flags, and the opener's order-preservation gate.The full-match assertion was verified to fail before its fix. Temporarily removing the page-level order guard also makes the SQL ordering regression fail with different row IDs.
Local validation:
cargo fmt --all.cargo clippy --all-targets --all-features -- -D warnings../dev/rust_lint.sh.Performance benchmarks are not yet available. The inverse-statistics pass adds work when it cannot prove enough matches, so both successful-pruning and no-benefit cases need measurement; the tests demonstrate pruning behavior, not a measured speedup.
Are there any user-facing changes?
Eligible unordered Parquet LIMIT queries can skip additional row ranges. EXPLAIN ANALYZE reports
limit_pruned_rowswhen this optimization removes rows. No new configuration or public API is introduced.