Skip to content

Chain filter evaluations for every conjunct - #9282

Draft
joseph-isaacs wants to merge 1 commit into
claude/layoutreader-v1-scan-perf-efkqk3from
claude/layoutreader-v1-chain-conjuncts-efkqk3
Draft

Chain filter evaluations for every conjunct#9282
joseph-isaacs wants to merge 1 commit into
claude/layoutreader-v1-scan-perf-efkqk3from
claude/layoutreader-v1-chain-conjuncts-efkqk3

Conversation

@joseph-isaacs

@joseph-isaacs joseph-isaacs commented Aug 7, 2026

Copy link
Copy Markdown
Contributor

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_evaluation registers 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:

It is recommended to defer awaiting the input mask for as long as possible (ideally, after all I/O is complete). This allows other conjuncts the opportunity to refine the mask as much as possible before it is used.

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_mask replaces both the single-conjunct helper from #9279 and the multi-conjunct loop. Net −31 lines.

Each conjunct's output MaskFuture is 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 the EXPR_EVAL_THRESHOLD low-density path still applies.

The evaluation order is drained from FilterExpr::next_conjunct up front rather than re-queried between conjuncts. That is safe: next_conjunct (scan/filter.rs:93-97) reads a precomputed ordering vector that is only recomputed inside report_selectivity, from histograms accumulated across splits. Within a single split the order was already fixed. Ordering still adapts across splits.

The all_false short 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.

pread64 counts:

Query sf=1 base sf=1 #9279 sf=1 chain sf=10 base sf=10 #9279 sf=10 chain
lineitem_filter_only 30 20 20 302 177 177
lineitem 14 14 14 109 109 109
lineitem_and (2 conjuncts) 13 13 14 14 14 109
lineitem_prune 8 8 14 9 9 91
lineitem_wide 114 113 117 1165 1172 1163

Execution time at sf=10, median of 7 interleaved rounds, each round the median of 5 executions:

Query base (ms) chain (ms) change
lineitem_filter_only 140.3 151.8 +8.2%
lineitem 91.5 109.7 +20.0%
lineitem_and 27.7 81.1 +192.8%
lineitem_prune 12.2 55.4 +354.1%

Both regressions reproduce exactly across repeated runs (preads 14/14 vs 109/109 and 9/9 vs 91/91; timing distributions fully separated — lineitem_prune 12–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's filter_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 on lineitem_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_evaluation rather than at the call site. Currently flat::filter_evaluation does:

let mut array = array.clone().await?;   // decodes unconditionally
let mask = mask.await?;

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_mask is 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 passed
  • cargo clippy -p vortex-layout --all-targets --all-features — clean
  • cargo +nightly fmt --all — clean

Not run: full workspace tests, Python bindings, docs — this touches one Rust file with no API or documentation surface.

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
@joseph-isaacs joseph-isaacs added the changelog/performance A performance improvement label Aug 7, 2026 — with Claude
@codspeed-hq

codspeed-hq Bot commented Aug 7, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 1.72%

⚡ 2 improved benchmarks
❌ 2 regressed benchmarks
✅ 1925 untouched benchmarks
⏩ 56 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation decode_varbin[(1000, 2)] 62.3 µs 77.7 µs -19.85%
Simulation compress_fsst[(10000, 64, 8)] 9.5 ms 10.7 ms -10.73%
Simulation decompress[u64, (10000, 4)] 361 µs 311.1 µs +16.04%
Simulation decompress[u64, (1000, 16)] 72.6 µs 64.6 µs +12.35%

Tip

Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.


Comparing claude/layoutreader-v1-chain-conjuncts-efkqk3 (5e9d4b4) with claude/layoutreader-v1-scan-perf-efkqk3 (0cb8b3f)

Open in CodSpeed

Footnotes

  1. 56 benchmarks were skipped, so the baseline results were used instead. If they were deleted from the codebase, click here and archive them to remove them from the performance reports.

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

Labels

changelog/performance A performance improvement

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants