Skip to content

perf: hold the filter on a scan as its residual predicate - #398

Merged
KKould merged 3 commits into
mainfrom
perf/scan-residual-predicate
Nov 2, 2026
Merged

KKould merged 3 commits into
mainfrom
perf/scan-residual-predicate

Conversation

@KKould

@KKould KKould commented Nov 2, 2026 •

Copy link
Copy Markdown
Member

What problem does this PR solve?

A filter on a table scan was a separate Filter operator, so a scan had no notion of which filter was its residual predicate. When an index range eliminated the filter, the scan still decoded the columns only that filter had read.

Issue link:

What is changed and how it works?

  • perf(scan): hold the filter on a scan as its residual predicate.
    • PushPredicateIntoScan turns the filter right on a scan into the scan's predicate, which the scan evaluates itself. Other filters stay separate operators.
    • EliminateIndexFilter replaces a scan's predicate with the residual of the chosen static index range.
    • A new rule, SkipResidualFilterColumns, leaves NULL in a covered secondary index read the scan columns nothing references anymore (no operator above, and not the remaining predicate), so their index fields are skipped instead of decoded. Column pruning marks the scans read by position above (DML, the plan root, ...) as pinned; those are never skipped.
    • A SeqScan with a predicate takes the predicate's row estimate, and PhysicalOption::filtered_rows keeps its work at the rows it reads, so plan costs match the previous Filter -> SeqScan.
    • PushLimitIntoScan no longer pushes a limit into a scan holding a predicate.
    • EXPLAIN shows the predicate on the scan: TableScan t -> [...], Filter: (...).
  • perf(hash-agg): reuse the group table across resets. HashAggExecutor keeps its group table as a field cleared on reset, keeping its capacity, and emits groups in insertion order.

Code changes

  • Has Rust code change
  • Has CI related scripts change

Check List

Tests

  • Unit test
  • Integration test
  • Manual test (add detailed scripts or steps below)
  • No code

Side effects

  • Performance regression: Consumes more CPU
  • Performance regression: Consumes more Memory
  • Breaking backward compatibility

Note for reviewer

  • skip_residual_filter_columns_in_covered_index_reads checks the cover mapping and the rows when the range eliminates the filter, when a residual still reads a column, when the output reads every column, and that an UPDATE through the index keeps its rows intact.
  • EXPLAIN expectations in slt, ORM and db tests only move the filter into the scan.
  • make clippy, make test, make test-slt and make test-slt-sqlite pass. TPC-C shows no regression against perf: cover scans with primary keys and estimate key prefixes for TPC-C #397.

A filter right on a table scan becomes the scan's predicate. A chosen
static index range leaves it just the residual, and a covered secondary
index read skips the scan columns nothing references anymore, leaving
them NULL. Column pruning marks the scans read by position above as
pinned, so their columns are never skipped.
@codecov

codecov Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.87234% with 4 lines in your changes missing coverage. Please review.
✅ Project coverage is 93.07%. Comparing base (6a2b30a) to head (08e47cf).

Files with missing lines Patch % Lines
src/optimizer/rule/normalization/column_pruning.rs 98.27% 1 Missing ⚠️
src/optimizer/rule/normalization/mod.rs 66.66% 1 Missing ⚠️
...ptimizer/rule/normalization/pushdown_predicates.rs 91.66% 1 Missing ⚠️
src/planner/operator/visitor.rs 66.66% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #398      +/-   ##
==========================================
+ Coverage   93.01%   93.07%   +0.05%     
==========================================
  Files         273      273              
  Lines       39012    39175     +163     
==========================================
+ Hits        36286    36461     +175     
+ Misses       2726     2714      -12     
Flag Coverage Δ
rust 93.07% <97.87%> (+0.05%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
src/db.rs 95.08% <100.00%> (+0.01%) ⬆️
src/db/program.rs 92.59% <ø> (ø)
src/execution/dql/aggregate/hash_agg.rs 97.80% <100.00%> (+0.02%) ⬆️
src/execution/dql/index_scan.rs 97.27% <100.00%> (+0.02%) ⬆️
src/execution/dql/seq_scan.rs 100.00% <100.00%> (ø)
src/optimizer/plan_utils.rs 90.75% <100.00%> (+0.05%) ⬆️
src/optimizer/rule/implementation/dql/filter.rs 94.44% <100.00%> (-2.14%) ⬇️
...rc/optimizer/rule/implementation/dql/table_scan.rs 97.76% <100.00%> (+0.20%) ⬆️
src/optimizer/rule/normalization/elimination.rs 95.37% <100.00%> (+0.09%) ⬆️
src/optimizer/rule/normalization/join_reorder.rs 98.00% <ø> (ø)
... and 13 more

... and 1 file with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@KKould KKould self-assigned this Nov 2, 2026
@KKould KKould added the enhancement New feature or request label Nov 2, 2026
SeqScan and IndexScan check the scan's own predicate as they read rows,
sharing one loop over their storage iterator, instead of wrapping the
scan in a Filter executor.
@KKould
KKould merged commit 4861378 into main Nov 2, 2026
14 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

enhancement New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant