Skip to content

perf: evaluate Parquet Bloom filters incrementally during row group pruning - #25811

Open
mohitgurav20 wants to merge 3 commits into
apache:mainfrom
mohitgurav20:fix/incremental-bloom-filter-pruning
Open

mohitgurav20 wants to merge 3 commits into
apache:mainfrom
mohitgurav20:fix/incremental-bloom-filter-pruning

Conversation

@mohitgurav20

Copy link
Copy Markdown
Contributor

Closes #25797

Rationale for this change
Parquet Bloom filter pruning currently loads filters for all relevant predicate columns before evaluating whether a row group can be excluded. For queries with multiple filtering conditions, this results in unnecessary I/O reads if an earlier filter already proves the group cannot match.

By incrementally evaluating Bloom filters and stopping reading additional filters for a row group as soon as a condition proves it cannot match, we can skip unnecessary reads and release memory eagerly. This brings a solid performance bump for observability workloads filtering across several columns over remote storage.

What changes are included in this PR?
Incremental Evaluation: Folded the Bloom filter evaluation directly into the load_bloom_filters async loop to evaluate immediately as each column is fetched.
Short-circuiting: As soon as predicate.prune(&row_group_filters) rules out a row group, it breaks out of the loop and skips reading any remaining columns for that group.
State Machine Optimization: Dropped the BloomFiltersLoadedParquetOpen and PruneWithBloomFilters states entirely. Because we evaluate eagerly during I/O, we no longer need to retain all of those filters in memory for a secondary pass. The state machine now transitions cleaner directly to BuildStream.
Access Plan Updates: Added a mutable accessor access_plan_mut to RowGroupAccessPlanFilter to support skipping row groups directly from within the loader.
Are these changes tested?
Yes, this relies on existing Parquet pruning test suites which will validate that correctness is maintained while avoiding unnecessary reads.

@github-actions github-actions Bot added datasource Changes to the datasource crate physical-plan Changes to the physical-plan crate labels Sep 27, 2026
@kosiew

kosiew commented Oct 20, 2026

Copy link
Copy Markdown
Contributor

hi @mohitgurav20
I would love to review this after the merge conflicts are resolved.

@mohitgurav20

Copy link
Copy Markdown
Contributor Author

@kosiew ohkk sir will work on it .

@github-actions github-actions Bot removed the physical-plan Changes to the physical-plan crate label Oct 20, 2026
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.57798% with 7 lines in your changes missing coverage. Please review.
✅ Project coverage is 82.94%. Comparing base (6131a98) to head (336de15).
⚠️ Report is 9 commits behind head on main.

Files with missing lines Patch % Lines
datafusion/datasource-parquet/src/opener/mod.rs 93.39% 5 Missing and 2 partials ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main   #25811      +/-   ##
==========================================
- Coverage   82.94%   82.94%   -0.01%     
==========================================
  Files        1155     1155              
  Lines      458401   458462      +61     
  Branches   458401   458462      +61     
==========================================
+ Hits       380222   380264      +42     
- Misses      55132    55146      +14     
- Partials    23047    23052       +5     

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

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

@mohitgurav20,

Thanks for improving Parquet Bloom filter pruning. Incremental evaluation avoids unnecessary column reads, and the new regression test demonstrates the intended I/O reduction.

Moving pruning into the asynchronous loading phase is appropriate because evaluation results determine whether further reads are needed. Removing the separate pruning state simplifies the pipeline while preserving conservative pruning behavior and the existing access-plan ownership.

I'm requesting changes because repeatedly evaluating the full predicate introduces substantial redundant CPU work when row groups survive pruning. This is a demonstrated operation-count regression, not a measured latency regression, and the I/O savings may still outweigh it. Please address the repeated evaluations and add regression coverage and benchmarks for both rejecting and surviving workloads. The additional multi-row-group and missing-filter tests are non-blocking suggestions.

);

let _timer_guard = bloom_filter_eval_time.timer();
match predicate.prune(&row_group_filters) {

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.

Calling the full predicate.prune() after every column reloads previously checked guarantees. For N Bloom-filtered columns whose queried values are all present, membership checks grow from N to N(N+1)/2 (528 versus 32 for N=32), with additional statistics evaluation. Please evaluate only guarantees for the newly loaded column while preserving conservative missing-filter handling and early rejection. Add an operation-count regression for surviving groups and benchmark both rejecting and surviving workloads.

}

#[tokio::test]
async fn incremental_bloom_filter_pruning_short_circuits_remaining_columns() {

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.

Could we extend this test with an early-pruned row group followed by one containing a matching row? With statistics pruning disabled and decoder filtering enabled, verify the matching row is returned and Bloom metrics report (pruned=1, matched=1). Here matched means Bloom pruning could not exclude the group.

}

#[tokio::test]
async fn incremental_bloom_filter_pruning_short_circuits_remaining_columns() {

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.

Could we also test partial Bloom-filter availability through the opener? Enable filters only for the second predicate column and verify it can still prune despite the first being unavailable, while surviving rows remain readable. Check that a surviving group with an available filter increments matched once, and an all-filters-absent group increments neither outcome. This can share the multi-row-group test setup.

@kosiew kosiew mentioned this pull request Oct 22, 2026
@kosiew

kosiew commented Oct 22, 2026

Copy link
Copy Markdown
Contributor

@mohitgurav20

I created a benchmark to measure SQL queries against one Parquet file where Bloom filters are the only thing that can skip row groups.

Benchmark run results:

group                                                          HEAD                                   bench-25811
-----                                                          ----                                   -----------
parquet_bloom_filter_pruning/memory/reject_first/columns/2     1.10    740.5±1.58µs        ? ?/sec    1.00    676.2±1.47µs        ? ?/sec
parquet_bloom_filter_pruning/memory/reject_first/columns/32    1.49      6.1±0.05ms        ? ?/sec    1.00      4.1±0.05ms        ? ?/sec
parquet_bloom_filter_pruning/memory/reject_first/columns/8     1.30  1595.5±14.12µs        ? ?/sec    1.00   1226.9±4.34µs        ? ?/sec
parquet_bloom_filter_pruning/memory/reject_last/columns/2      1.00    788.6±1.57µs        ? ?/sec    1.89  1489.5±10.88µs        ? ?/sec
parquet_bloom_filter_pruning/memory/reject_last/columns/32     1.00      7.2±0.25ms        ? ?/sec    43.31   312.4±1.08ms        ? ?/sec
parquet_bloom_filter_pruning/memory/reject_last/columns/8      1.00  1880.5±16.14µs        ? ?/sec    10.87    20.4±0.05ms        ? ?/sec
parquet_bloom_filter_pruning/memory/survive_all/columns/2      1.00      2.3±0.03ms        ? ?/sec    1.26      2.9±0.02ms        ? ?/sec
parquet_bloom_filter_pruning/memory/survive_all/columns/32     1.00     27.6±0.10ms        ? ?/sec    12.08   332.9±0.70ms        ? ?/sec
parquet_bloom_filter_pruning/memory/survive_all/columns/8      1.00      7.5±0.03ms        ? ?/sec    3.39     25.5±0.13ms        ? ?/sec
parquet_bloom_filter_pruning/slow/reject_first/columns/2       1.99    162.2±0.49ms        ? ?/sec    1.00     81.4±0.30ms        ? ?/sec
parquet_bloom_filter_pruning/slow/reject_first/columns/32      30.22      2.6±0.00s        ? ?/sec    1.00     85.5±0.67ms        ? ?/sec
parquet_bloom_filter_pruning/slow/reject_first/columns/8       7.89    646.8±2.91ms        ? ?/sec    1.00     82.0±0.94ms        ? ?/sec
parquet_bloom_filter_pruning/slow/reject_last/columns/2        1.00    162.4±0.82ms        ? ?/sec    1.00    162.9±1.78ms        ? ?/sec
parquet_bloom_filter_pruning/slow/reject_last/columns/32       1.00       2.6±0.01s        ? ?/sec    1.12       2.9±0.01s        ? ?/sec
parquet_bloom_filter_pruning/slow/reject_last/columns/8        1.00    646.6±2.00ms        ? ?/sec    1.04    671.4±3.97ms        ? ?/sec
parquet_bloom_filter_pruning/slow/survive_all/columns/2        1.00    244.9±0.83ms        ? ?/sec    1.00    244.1±1.29ms        ? ?/sec
parquet_bloom_filter_pruning/slow/survive_all/columns/32       1.00       2.7±0.00s        ? ?/sec    1.12       3.0±0.01s        ? ?/sec
parquet_bloom_filter_pruning/slow/survive_all/columns/8        1.00    732.1±2.26ms        ? ?/sec    1.03    753.1±2.78ms        ? ?/sec

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

@mohitgurav20,

Thanks for the follow-up. The single-column Bloom filter approach eliminates repeated membership probes, and the additional tests improve coverage of short-circuiting, surviving row groups, and partially available filters.

Evaluating filters during asynchronous loading is the right architectural direction because pruning decisions must prevent unnecessary I/O. However, the incremental path also needs to avoid repeatedly performing expensive full-predicate evaluation when filters cannot reject early.

Requesting changes because comparative benchmarks now demonstrate a substantial CPU regression for non-rejecting workloads, reaching 43x at 32 columns with in-memory storage. The early-rejection gains are significant, reaching 30x with simulated storage latency, and should be preserved. Please eliminate the repeated full-evaluation overhead and include rejecting and surviving benchmark results. No additional correctness issues were identified in the follow-up.

col_filter.insert(column_name, bf, *physical_type, *type_length);

let _timer_guard = bloom_filter_eval_time.timer();
match predicate.prune(&col_filter) {

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.

Each loaded column still runs the full predicate.prune(&col_filter) path, rebuilding statistics and evaluating the entire expression. My Criterion comparison against main shows up to 43x slower reject_last and 12x slower survive_all at 32 columns on in-memory storage, despite up to 30x faster early rejection with simulated latency. Please remove the repeated full-evaluation overhead while preserving early rejection, and include rejecting and surviving benchmarks in the PR.

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.

thanks for ur patience sir will work on it and follow ur steps

@mohitgurav20

Copy link
Copy Markdown
Contributor Author

@kosiew Thanks for the clear feedback and the reproducible Criterion benchmark.

I have updated the implementation in commit d7abce7 to eliminate the repeated full-expression evaluation overhead while preserving early rejection:

  1. Direct Guarantee Evaluation: Instead of calling predicate.prune() on each loaded column (which re-evaluates the entire expression tree and propagates None across unloaded columns), we now evaluate column-level Guarantee::In literals directly against the loaded Sbbf via BloomFilterStatistics::is_known_not_present. This reduces surviving-column evaluation complexity from $O(N^2)$ to $O(N)$ with no redundant expression rebuilding.
  2. I/O Filtering: Pre-filters columns so that any predicate column lacking Guarantee::In guarantees does not trigger unnecessary asynchronous Bloom filter loading.
  3. Benchmarks Added: Added the Criterion benchmark suite in datafusion/core/benches/parquet_bloom_filter_pruning.rs covering reject_first, reject_last, and survive_all across 2, 8, and 32 columns on both memory and throttled storage. All 18 combinations pass and verify expected pruning metrics.
  4. Tests & Coverage: Added unit tests covering all branch outcomes of is_known_not_present and verified all 296 tests in datafusion-datasource-parquet pass cleanly.

@github-actions github-actions Bot added the core Core DataFusion crate label Oct 22, 2026
@kosiew

kosiew commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

@mohitgurav20,
There's merge conflict.

Can you

  • isolate the benchmark into its own commit
  • rebase the commits onto main, resolve conflict and place the benchmark commit at the beginning, making it easy to run benchmark like this
    ?

…y during row group pruning

- Evaluate Bloom filters column-by-column incrementally to short-circuit multi-column pruning before loading remaining column filters.
- Re-evaluate only newly loaded column guarantees to avoid redundant full-predicate tree evaluations.
- Preserve upstream early-exit Done state when all row groups are pruned.
- Add unit tests covering full matching, stats pruning, and I/O error fallback.
@mohitgurav20
mohitgurav20 force-pushed the fix/incremental-bloom-filter-pruning branch from 9e00026 to 6b927e5 Compare October 23, 2026 18:06
@mohitgurav20

mohitgurav20 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor Author

@kosiew done, here is what is landed on the branch now:

Commit 1 (benchmark baseline): 4c3dc5f — ench: add parquet bloom filter pruning benchmarks
This is placed directly on top of main so you can checkout this commit on main-equivalent code and run the benchmark to get a clean baseline, then compare against commit 2.

Commit 2 (implementation): 6b927e5 — perf(datasource-parquet): evaluate Parquet Bloom filters incrementally during row group pruning

  • Resolves the merge conflict from upstream perf: don't build a parquet stream for a file range with no row group left #26476 (c0ec512) — preserved is_fully_pruned() early-exit in both PruneWithStatistics and the LoadBloomFilters handler in ParquetMorselPlanner
  • Evaluation is now O(N) per row group — directly checks Guarantee::In literals against the loaded Sbbf via is_known_not_present, no full predicate tree re-evaluation
  • Added count_morsels helper and est_no_morsel_when_every_row_group_is_pruned from upstream
  • Added unit tests covering: !should_scan branch, is_fully_matched branch, and Bloom filter I/O error fallback (predicate_evaluation_errors metric)

Commit 3 (security): �7c176f — chore: bump ctutils to 0.4.5 to fix RUSTSEC-2026-0385
The security_audit CI failed because of a new advisory on ctutils v0.4.2 that landed after our rebase. Bumped it in Cargo.lock to match what PR #26666 (�damg/fix-sec-audit) does upstream — can drop this commit once that merges into main.

All 300 unit tests pass, cargo clippy -D warnings clean, cargo fmt applied.

@mohitgurav20

Copy link
Copy Markdown
Contributor Author

@kosiew CI is re-running on the latest push — the security_audit failure was an unrelated advisory on ctutils v0.4.2 (RUSTSEC-2026-0385) that came in after our rebase; bumped it in Cargo.lock to clear it (same fix as upstream PR #26666).

All 300 unit tests pass locally, Clippy clean, fmt applied. Happy to address any additional feedback you have once you get a chance to re-review. Thanks for the patience!

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

Labels

core Core DataFusion crate datasource Changes to the datasource crate

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Avoid unnecessary Parquet Bloom filter reads through incremental pruning

3 participants