Skip to content

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

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

mohitgurav20 wants to merge 14 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 .

…d short-circuit test

Ensure Bloom filter pruning only records matches when filters are evaluated, respects row group should_scan checks, and removes unrelated join changes.
@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.

…valuation and extend tests

The previous incremental pruning loop accumulated all loaded Bloom filter
columns into a single BloomFilterStatistics object and re-ran
predicate.prune() after every column load.  For N predicate columns where
all values survive pruning, this causes N*(N+1)/2 membership checks instead
of N (e.g. 528 vs 32 for N=32 columns), as the reviewer correctly identified.

Fix: evaluate the predicate against a *single-column* BloomFilterStatistics
containing only the newly loaded column.  Because PruningStatistics::contained
returns None for columns absent from the statistics object, evaluating a
single-column filter is semantically equivalent for both conjunctive and
disjunctive predicates -- only the newly presented column can drive a
pruning decision in that round.  This keeps the short-circuit behaviour
intact while reducing complexity from O(N^2) to O(N).

Also replace the any_filter_loaded sentinel (previously row_group_filters
.is_empty()) with a plain bool to remove the now-unnecessary accumulator
allocation, and drop the stray _timer_guard that was placed outside the loop.

Tests updated/added:
- incremental_bloom_filter_pruning_short_circuits_remaining_columns: extended
  to two row groups; Query 3 (a=10) verifies the (pruned=1, matched=1) split
  and that the actual matching row is returned through decoder filtering.
- bloom_filter_pruning_with_partial_column_availability: new test verifying
  that a Bloom filter available for only one predicate column (b, not a) can
  still prune a row group, and that matched/pruned metrics are correct when
  filters are partially absent.
@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

…ectly and add benchmarks

- Directly check Guarantee::In literal guarantees on loaded column bloom filters via BloomFilterStatistics::is_known_not_present, preserving early rejection while removing repeated full-expression evaluation overhead
- Factor column guarantees into BloomColumnGuarantee struct
- Include Criterion benchmarks for reject_first, reject_last, and survive_all queries across memory and slow storage configurations
- Add test coverage for all-filters-absent queries
@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
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