Avatar for the vortex-data user
vortex-data
vortex
BlogDocsChangelog

Performance History

Latest Results

fixes Signed-off-by: Robert Kruszewski <github@robertk.io>
rk/deprecate-arrow-arrays
8 minutes ago
Chain filter evaluations for every conjunct (#9282) ## 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: ```rust 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. Co-authored-by: Claude <noreply@anthropic.com>
claude/layoutreader-v1-scan-perf-efkqk3
1 hour ago

Latest Branches

CodSpeed Performance Gauge
-12%
Deprecate FromArrowArray in favour of ArrowSession array imports#8927
10 minutes ago
f16dfe4
rk/deprecate-arrow-arrays
CodSpeed Performance Gauge
-19%
59 minutes ago
a0e9abd
claude/rnanodm-access-benchmark-wwgo7q
CodSpeed Performance Gauge
-6%
1 hour ago
8cef37d
claude/layoutreader-v1-scan-perf-efkqk3
© 2026 CodSpeed Technology
Home Terms Privacy Docs