Chain filter evaluations for every conjunct - #9282
Draft
joseph-isaacs wants to merge 1 commit into
Draft
Conversation
Generalises the previous commit from a single conjunct to any number. `LayoutReader::filter_evaluation` registers its segment reads when it is called, but only awaits its input mask when it is polled. Building the evaluations one at a time -- awaiting each before constructing the next -- therefore trickles reads in one conjunct at a time, per split, and nothing can be coalesced. Feeding each conjunct's output `MaskFuture` straight into the next registers the reads for the whole chain up front while each conjunct still receives the mask its predecessor refined, so no extra work is done. This is the usage the trait documentation already recommends: "defer awaiting the input mask for as long as possible ... this allows other conjuncts the opportunity to refine the mask as much as possible before it is used." The evaluation order is drained from `FilterExpr::next_conjunct` up front rather than re-queried between conjuncts. That ordering is recomputed only when a completed conjunct reports its selectivity, so within a single split it was already fixed; draining it gives up nothing, and ordering still adapts across splits. The `all_false` short circuit between filter evaluations is deliberately not added back. Awaiting the array before the mask is what lets a conjunct's decode overlap its predecessor's compute, so short circuiting would trade that overlap for skipped work -- a trade that depends on selectivity and needs measuring separately. Signed-off-by: Claude <noreply@anthropic.com> Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01D6qV3R62EBNgkd2Leq5YqZ
Merging this PR will degrade performance by 1.72%
Warning Please fix the performance issues or acknowledge them on CodSpeed. Performance Changes
Tip Investigate this regression by commenting Comparing Footnotes
|
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Rationale for this change
Stacked on #9279 — that PR handles a single conjunct as a special case, this one generalises it to any number and deletes the special case.
Draft, and not mergeable as it stands: it is a large regression on prunable queries (
lineitem_prune+354%,lineitem_and+193%). Opening it because the mechanism works exactly as intended for the case it targets, and the regression isolates a specific, fixable blocker. Detail below.LayoutReader::filter_evaluationregisters its segment reads when it is called, but only awaits its input mask when it is polled. The existing loop builds one evaluation, awaits it fully, then builds the next — so reads trickle out one conjunct at a time, per split, and nothing can be coalesced. The trait documentation already describes the intended alternative:That only makes sense if several conjuncts' evaluations are built and in flight at once, which the caller never did.
What changes are included in this PR?
chained_filter_maskreplaces both the single-conjunct helper from #9279 and the multi-conjunct loop. Net −31 lines.Each conjunct's output
MaskFutureis fed straight into the next at construction time, so the reads for the whole chain are registered up front while each conjunct still receives the mask its predecessor refined — no extra compute, and theEXPR_EVAL_THRESHOLDlow-density path still applies.The evaluation order is drained from
FilterExpr::next_conjunctup front rather than re-queried between conjuncts. That is safe:next_conjunct(scan/filter.rs:93-97) reads a precomputedorderingvector that is only recomputed insidereport_selectivity, from histograms accumulated across splits. Within a single split the order was already fixed. Ordering still adapts across splits.The
all_falseshort circuit between filter evaluations is deliberately not carried over — see below.Results
TPC-H
lineitem, warm page cache, local NVMe. Row counts identical across all three variants on every shape.pread64counts:lineitem_filter_onlylineitemlineitem_and(2 conjuncts)lineitem_prunelineitem_wideExecution time at sf=10, median of 7 interleaved rounds, each round the median of 5 executions:
lineitem_filter_onlylineitemlineitem_andlineitem_pruneBoth regressions reproduce exactly across repeated runs (preads 14/14 vs 109/109 and 9/9 vs 91/91; timing distributions fully separated —
lineitem_prune12–14ms vs 45–57ms). This is deterministic, not noise.Why it regresses, and what would fix it
The old loop checked
mask.all_false()before constructing each conjunct'sfilter_evaluation. On a heavily-pruned query most splits never reached that line, so their filter reads were never registered at all — which is why the baseline is 9 preads onlineitem_prune. Chaining necessarily registers every conjunct's reads before pruning has run, because getting the I/O in flight early is the entire point. On prunable queries that is 10× wasted reads.Eager registration and pruning-driven skipping are therefore in direct tension, and the resolution has to live inside
filter_evaluationrather than at the call site. Currentlyflat::filter_evaluationdoes:It awaits the array before the mask, so an all-false input mask still pays for the read and the decode. Polling both concurrently and returning early when the mask resolves all-false would let cancellation propagate back up the chain and make eager registration close to free.
That change is a genuine trade rather than a pure win — awaiting the array first is also what lets a conjunct's decode overlap its predecessor's compute, and short-circuiting gives that overlap up. Which effect dominates depends on selectivity, so it wants measuring on its own. These numbers say the waste dominates for prunable queries by a wide margin, so it is worth measuring next.
What APIs are changed? Are there any user-facing changes?
None. No public API changes;
chained_filter_maskis a private helper. Results are unchanged — same masks, same arrays, same row counts.Checks run
cargo nextest run -p vortex-layout -p vortex-file -p vortex-scan— 350 passedcargo clippy -p vortex-layout --all-targets --all-features— cleancargo +nightly fmt --all— cleanNot run: full workspace tests, Python bindings, docs — this touches one Rust file with no API or documentation surface.