Latest Results
Prepare the random-access Vortex scan once and reuse it across takes
`VortexRandomAccessor::take` built a fresh `ScanBuilder` on every call, so each
timed iteration re-paid `prepare()`: dtype resolution, projection binding, the
`RowIdx` reader-enrichment check, and split computation. Only the file handle and
its layout reader cache were being reused.
The benchmark loop takes the same indices for the whole measurement, so the scan
can be prepared once. `take` now goes through `prepared_scan`, which prepares on
first use and hands back the cached `RepeatedScan` afterwards. The first timed run
still pays preparation; the rest measure the take alone.
The cache is keyed on the indices because a prepared scan pins its selection, and
with row indices the split ranges are derived from that selection
(`ScanBuilder::prepare` takes the `Splits::Ranges` branch). A different index set
therefore has to prepare again, and reusing the previous scan would silently
return the wrong rows.
This shifts what the Vortex rows measure: preparation leaves every iteration after
the first. Measurement names are unchanged, so the rows still line up with the S3
baseline, but a step change in the Vortex numbers at this commit is expected and
is not a runtime improvement.
Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145v7rv6d87FGLPvNxSfUAHclaude/rnanodm-access-benchmark-wwgo7q Prepare the random-access Vortex scan once and reuse it across takes
`VortexRandomAccessor::take` built a fresh `ScanBuilder` on every call, so each
timed iteration re-paid `prepare()`: dtype resolution, projection binding, the
`RowIdx` reader-enrichment check, and split computation. Only the file handle and
its layout reader cache were being reused.
The benchmark loop takes the same indices for the whole measurement, so the scan
can be prepared once. `take` now goes through `prepared_scan`, which prepares on
first use and hands back the cached `RepeatedScan` afterwards. The first timed run
still pays preparation; the rest measure the take alone.
The cache is keyed on the indices because a prepared scan pins its selection, and
with row indices the split ranges are derived from that selection
(`ScanBuilder::prepare` takes the `Splits::Ranges` branch). A different index set
therefore has to prepare again, and reusing the previous scan would silently
return the wrong rows.
This shifts what the Vortex rows measure: preparation leaves every iteration after
the first. Measurement names are unchanged, so the rows still line up with the S3
baseline, but a step change in the Vortex numbers at this commit is expected and
is not a runtime improvement.
Signed-off-by: "Joe Isaacs" <joe.isaacs@live.co.uk>
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0145v7rv6d87FGLPvNxSfUAHclaude/rnanodm-access-benchmark-wwgo7q 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 Latest Branches
-12%
rk/deprecate-arrow-arrays -19%
claude/rnanodm-access-benchmark-wwgo7q -6%
claude/layoutreader-v1-scan-perf-efkqk3 © 2026 CodSpeed Technology