Skip to content

Avoid boxing already-resolved masks in MaskFuture - #9667

Closed
joseph-isaacs wants to merge 1 commit into
developfrom
claude/random-access-perf-3su1yz
Closed

Avoid boxing already-resolved masks in MaskFuture#9667
joseph-isaacs wants to merge 1 commit into
developfrom
claude/random-access-perf-3su1yz

Conversation

@joseph-isaacs

Copy link
Copy Markdown
Contributor

Summary

Reduces per-split overhead on the sparse random-access path.

MaskFuture always stored its mask behind a Shared<BoxFuture<..>>, even when the mask was
already known. Random access hits this hardest: it runs without a filter, so split_exec builds
every split's mask up front via MaskFuture::ready(..), and StructReader::projection_evaluation
then clones that mask once per leaf field. Each of those clones, slices, and awaits went through
boxed-future and waker machinery to hand back a value that was already sitting in memory.

Changes

Splits the inner state into Ready and Pending. Ready masks resolve inline, slice eagerly, and
clone without allocating; pending masks keep the previous shared-future behaviour. inspect on an
already-resolved mask now fires immediately rather than deferring to the first poll, which is
documented on the method — it currently has no production callers.

Tests cover that ready and pending variants agree through slice and await, and that inspect
fires for a resolved mask.

Local measurements

nested-structs (1M rows, 6 leaf fields, cached file handle), interleaved A/B of separately built
baseline and patched binaries, median of 5 runs x 300 takes:

indices build-futures stage end-to-end take
uniform 99 275.5us -> 237.5us (-14%) 4958us -> 4886us (-1.5%)
correlated 100 150.2us -> 123.8us (-18%) 3425us -> 3265us (-4.7%)
fixed 6 77.5us -> 71.2us (-8%) 1238us -> 1161us (-6.2%)

The saving is concentrated in future construction, which is only ~5% of a take, so the end-to-end
effect is small and may not clear benchmark noise in CI.

Why the end-to-end win is small

Instrumenting bytes actually read per take() shows the dominant cost is read amplification rather
than scan machinery: a 99-row point lookup reads 33.97 MiB, against 33.98 MiB of segments in the
whole file. 100 correlated rows read 26.66 MiB; even 6 rows read 11.72 MiB. The file has 54 segments
of roughly 904 KiB-1 MiB, because the default data_block_target_bytes of 1 MiB coalesces the
8192-row blocks into megabyte data blocks, so reading one row fetches its whole segment. Sampling
agrees: ~76% of CPU samples sit on the tokio blocking pool servicing those reads.

That is a write-side granularity question (WriteStrategyBuilder::with_data_block_target_bytes)
with a scan-throughput trade-off, so it is deliberately left out of this PR.


Generated by Claude Code

`MaskFuture` always stored its mask behind a `Shared<BoxFuture<..>>`, so a
mask that is known up front still allocated a boxed, shared future, and every
clone, slice, and await went through the waker machinery to hand back a value
that was sitting in memory. Sparse random-access scans hit this hardest: they
run without a filter, so every split's mask is ready at construction, and the
struct layout reader clones it once per leaf field.

Split the inner state into `Ready` and `Pending`. Ready masks resolve inline,
slice eagerly, and clone without allocating; pending masks keep the previous
shared-future behaviour.

Measured on the nested-structs random-access benchmark (1M rows, 6 leaf
fields, cached file handle), median of 5 runs x 300 takes:

  indices          build-futures stage    end-to-end take
  uniform 99       275.5us -> 237.5us      4958us -> 4886us
  correlated 100   150.2us -> 123.8us      3425us -> 3265us
  fixed 6           77.5us ->  71.2us      1238us -> 1161us

Signed-off-by: Joe Isaacs <joe.isaacs@live.co.uk>
@joseph-isaacs joseph-isaacs added the action/bench-random-access Run only the random-access benchmark on this PR label Aug 27, 2026 — with Claude
@github-actions github-actions Bot removed the action/bench-random-access Run only the random-access benchmark on this PR label Aug 27, 2026
@joseph-isaacs joseph-isaacs added the changelog/performance A performance improvement label Aug 27, 2026 — with Claude
@joseph-isaacs joseph-isaacs added the action/bench-random-access Run only the random-access benchmark on this PR label Aug 27, 2026
@github-actions github-actions Bot removed the action/bench-random-access Run only the random-access benchmark on this PR label Aug 27, 2026

Copy link
Copy Markdown
Contributor Author

Two red checks on 45a41b7 are runner loss, not this PR.

Compat check / compat-test (job) died at 16:57:45 while still compiling vortex-layout / vortex-file / vortex-compat — before any test body ran:

##[error]The runner has received a shutdown signal. This can happen when the runner
service is stopped, or a manually started runner is canceled.
##[error]The operation was canceled.

Benchmark with Codspeed (Shard #4) (job) measured every benchmark successfully (Done running alp_compress / Finished running 1 benchmark suite(s)) and then died at 16:56:04 in the Uploading results step with outcome=cancelled. Because the shard matrix in codspeed.yml does not set fail-fast: false, that cancelled shards #9, #3 and #7 at 16:56:23/27/32 as collateral. Shard #6 had already finished green.

Both are the same infrastructure event at ~16:56–16:58. Nothing to port: there is no fix to carry, since neither failure is a defect. Supporting evidence that the diff is not implicated — it only touches vortex-array/src/mask_future.rs, whereas shard #4 benches vortex-alp vortex-bytebool vortex-datetime-parts and compat-test never reached a test; meanwhile every check that does exercise the change is green: Rust (lint), Rust (lint, no default), Rust (docs), Rust tests (miri), Rust (MSRV), Check build with minimal dependencies, Java, Python (test), WASM integration smoke test, and both codecov checks.

I attempted the one re-run for each and GitHub refused with 403 This workflow is already running, as both parent runs still have jobs in flight. I will re-run the failed jobs once those runs settle, and if either fails a second time I will treat it as real and root-cause it rather than re-running again.


Generated by Claude Code

@codspeed-hq

codspeed-hq Bot commented Aug 27, 2026

Copy link
Copy Markdown

Merging this PR will regress 2 benchmarks

⚠️ Unknown Walltime execution environment detected

Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data.

For the most accurate results, we recommend using CodSpeed Macro Runners: bare-metal machines fine-tuned for performance measurement consistency.

⚡ 1 improved benchmark
❌ 2 regressed benchmarks
✅ 2136 untouched benchmarks
⏩ 106 skipped benchmarks1
🗄️ 4 archived benchmarks run2

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
WallTime words_gather_scalar_avx2[65536] 8.3 µs 9.4 µs -11.96%
Simulation cold_misaligned[(16, 64)] 345.6 µs 391.1 µs -11.64%
WallTime arrow_checked_add_u32_neon[16384] 20.4 µs 13.4 µs +52.2%

Tip

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


Comparing claude/random-access-perf-3su1yz (45a41b7) with develop (1fe8dda)

Open in CodSpeed

Footnotes

  1. 106 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.

  2. 4 benchmarks were run, but are now archived. If they were deleted in another branch, consider rebasing to remove them from the report. Instead if they were added back, click here to restore them.

Copy link
Copy Markdown
Contributor Author

On the CodSpeed report: I checked all three flagged benchmarks and none of them can be attributed to this diff, which touches only vortex-array/src/mask_future.rs.

Two of the three are compile-time impossible.

Benchmark Crate Depends on vortex-array?
words_gather_scalar_avx2[65536] ❌ -11.96% vortex-buffer No — vortex-array depends on it
arrow_checked_add_u32_neon[16384] ⚡ +52.2% vortex-compute No — vortex-array depends on it
cold_misaligned[(16, 64)] ❌ -11.64% vortex-file Yes

vortex-buffer and vortex-compute are dependencies of vortex-array (see vortex-array/Cargo.toml lines 66–67). Neither can observe a change to vortex-array, so neither benchmark's generated code differs between BASE and HEAD.

The third is not on a MaskFuture code path. cold_misaligned times collect_splits, which is:

SplitBy::Layout.splits(reader.as_ref(), &(0..file.row_count()), &[FieldMask::All])

That walks the layout tree via register_splits, pushing row boundaries. It constructs no MaskFuture — that type is only reached through split_exec / projection_evaluation / filter_evaluation, none of which this benchmark calls.

The +52.2% "improvement" is the tell. It is in vortex-compute, where the compiled code is provably identical between BASE and HEAD. A harness that reports a 52% swing on unchanged code is not resolving ±12% reliably.

Two further points of context, both visible in the report itself: CodSpeed warns Unknown Walltime execution environment detected — Using the Walltime instrument on standard Hosted Runners will lead to inconsistent data, and 106 benchmarks were skipped with baseline results substituted, which is consistent with the runner shutdown that cancelled shards #3/#4/#7/#9 (see my previous comment). All three flagged benchmarks come from shard #1 (vortex-buffer vortex-error vortex-mask vortex-compute vortex-file); shard #2 is vortex-array — the only crate this PR modifies — and it reported no changes at all.

I am not making a code change for these. The pending re-run of the cancelled Codspeed jobs should also produce a complete report; if cold_misaligned reproduces a regression on a clean run, that is a real signal and I will root-cause it rather than dismiss it.


Generated by Claude Code

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Polar Signals Profiling Results

Latest Run

Status Commit Job Attempt Link
🟢 Done 45a41b7 random-access-bench 1 Explore Profiling Data

Powered by Polar Signals Cloud

Copy link
Copy Markdown
Contributor Author

Correction to my previous comment, and a status update.

Both red checks are now green, and the platform retried them itself. Codspeed Benchmarking (33095519462) and Publish Dry Runs (33095519777, which contains Compat check / compat-test) both show run_attempt: 2 with conclusion: success, triggered by runson-vortex-data-2[bot]. RunsOn auto-retrying both runs is independent confirmation that these were reclaimed runners rather than defects. No re-run from me was needed; my earlier attempts were refused first with 403 This workflow is already running and then with cannot be retried, because the retry had already happened.

I was wrong about the skipped benchmarks. I wrote that the 106 skipped benchmarks were consistent with the runner shutdown. That is not right. Codspeed attempt 2 finished at 17:05:25 and the performance report was posted at 17:05:57, so the report came from the complete, successful run — not from the degraded one. Please disregard that part of my reasoning; the skipped count has some other cause.

What that does and does not change. The dependency-direction argument is untouched and still rules out two of the three flagged benchmarks outright: vortex-buffer and vortex-compute do not depend on vortex-array, so their compiled code is identical between BASE and HEAD regardless of which run measured it. A benchmark in vortex-compute moving 52.2% in the favourable direction on provably identical code remains the clearest evidence of harness variance.

For cold_misaligned in vortex-file I said I would treat a clean-run reproduction as real, and by that standard it now qualifies. So, stating it precisely rather than dismissing it: this PR changes exactly one file, vortex-array/src/mask_future.rs (git diff --name-only over the commit confirms it). cold_misaligned times SplitBy::Layout::splits to register_splits, which constructs no MaskFuture. There is therefore no executed-logic path by which this diff can change that benchmark's work; any genuine delta would have to be indirect codegen or inlining drift from MaskFuture's changed size rippling through vortex-array. I do not think an 11.64% instruction-count swing is plausibly explained that way, but I cannot prove it from the code alone, so I am flagging it as unresolved rather than closed. Happy to measure that benchmark directly on both revisions if a reviewer wants it settled.


Generated by Claude Code

@github-actions

github-actions Bot commented Aug 27, 2026

Copy link
Copy Markdown
Contributor

Benchmarks: Random Access 📖

Commits: PR 45a41b78 vs base 1fe8dda0
Verdict: No clear signal (low confidence)
Attributed Vortex impact: -0.6%
Engines: random-access No clear signal (-0.6%, low confidence)
Vortex (geomean): hot 0.991x ➖
Parquet (geomean): hot 0.997x ➖
Shifts: Parquet (control) -0.3% · Median polish -0.8%

How to read Verdict and Engines
  • Verdict: Overall PR-level signal after subtracting baseline drift estimated from Parquet control rows. It can be Likely improvement, Likely regression, or No clear signal.
  • Engines: Per-engine attribution. DataFusion is compared against DataFusion/Parquet controls; DuckDB is compared against DuckDB/Parquet controls. This answers whether each engine improved or regressed independently.
  • Confidence: Based on directional consistency, share of rows above the noise floor, and control-run noise.
  • Hot vs cold: Every measurement is run several times. The first run is reported as the cold run, and the median of the runs after it is reported as the hot run. The verdict and significance use hot runs; each target's geomean reports hot and cold beside each other where the individual runs were recorded and hot alone where they were not, the cold column shows first-run cost per row, and hot/cold is how much of each run the warm path saves. Rows whose results predate per-run reporting show only one value, taken from the value the runner reported.
  • Table cells: Each cell reads PR / base / %diff. The hot and cold columns mark a change as 🔴 slower or 🟢 faster once it clears this suite's threshold; anything smaller is noise here and is left unmarked, as is hot/cold, because a shift in the warm-up ratio is not a win or a loss by itself.

vortex / arrow-ipc / ns (0.992x ➖, 0↑ 0↓)
name ns (PR / base / %diff)
random-access/arrow-tokio-local-disk 69978 / 70270 / -0.4%
random-access/arrow-tokio-local-disk-footer 80696 / 81872 / -1.4%
random-access/feature-vectors/correlated/arrow-tokio-local-disk 4383158 / 4359710 / +0.5%
random-access/feature-vectors/correlated/arrow-tokio-local-disk-footer 2908187 / 2948429 / -1.4%
random-access/feature-vectors/uniform/arrow-tokio-local-disk 71738856 / 69849167 / +2.7%
random-access/feature-vectors/uniform/arrow-tokio-local-disk-footer 69022047 / 71243025 / -3.1%
random-access/nested-lists/correlated/arrow-tokio-local-disk 65407 / 66614 / -1.8%
random-access/nested-lists/correlated/arrow-tokio-local-disk-footer 72770 / 75989 / -4.2%
random-access/nested-lists/uniform/arrow-tokio-local-disk 1254305 / 1331369 / -5.8%
random-access/nested-lists/uniform/arrow-tokio-local-disk-footer 1292692 / 1304369 / -0.9%
random-access/nested-structs/correlated/arrow-tokio-local-disk 54469 / 54713 / -0.4%
random-access/nested-structs/correlated/arrow-tokio-local-disk-footer 55682 / 56167 / -0.9%
random-access/nested-structs/uniform/arrow-tokio-local-disk 962616 / 957849 / +0.5%
random-access/nested-structs/uniform/arrow-tokio-local-disk-footer 976083 / 957115 / +2.0%
random-access/taxi/correlated/arrow-tokio-local-disk 121121 / 121965 / -0.7%
random-access/taxi/correlated/arrow-tokio-local-disk-footer 143301 / 143883 / -0.4%
random-access/taxi/uniform/arrow-tokio-local-disk 2749922 / 2738889 / +0.4%
random-access/taxi/uniform/arrow-tokio-local-disk-footer 2773270 / 2745895 / +1.0%
random-access / vortex-file-compressed / ns (0.991x ➖, 0↑ 0↓)
name ns (PR / base / %diff)
random-access/feature-vectors/correlated/vortex-tokio-local-disk 325652 / 335266 / -2.9%
random-access/feature-vectors/correlated/vortex-tokio-local-disk-footer 588264 / 612525 / -4.0%
random-access/feature-vectors/uniform/vortex-tokio-local-disk 2582203 / 2596777 / -0.6%
random-access/feature-vectors/uniform/vortex-tokio-local-disk-footer 3040012 / 3077966 / -1.2%
random-access/nested-lists/correlated/vortex-tokio-local-disk 290038 / 284401 / +2.0%
random-access/nested-lists/correlated/vortex-tokio-local-disk-footer 356911 / 357417 / -0.1%
random-access/nested-lists/uniform/vortex-tokio-local-disk 1660666 / 1611152 / +3.1%
random-access/nested-lists/uniform/vortex-tokio-local-disk-footer 1758812 / 1760401 / -0.1%
random-access/nested-structs/correlated/vortex-tokio-local-disk 322426 / 330880 / -2.6%
random-access/nested-structs/correlated/vortex-tokio-local-disk-footer 459987 / 475272 / -3.2%
random-access/nested-structs/uniform/vortex-tokio-local-disk 1016158 / 1002921 / +1.3%
random-access/nested-structs/uniform/vortex-tokio-local-disk-footer 1199614 / 1224366 / -2.0%
random-access/taxi/correlated/vortex-tokio-local-disk 912549 / 892592 / +2.2%
random-access/taxi/correlated/vortex-tokio-local-disk-footer 1359259 / 1408498 / -3.5%
random-access/taxi/uniform/vortex-tokio-local-disk 4021667 / 3974841 / +1.2%
random-access/taxi/uniform/vortex-tokio-local-disk-footer 4822276 / 4818133 / +0.1%
random-access/vortex-tokio-local-disk 594097 / 608799 / -2.4%
random-access/vortex-tokio-local-disk-footer 981575 / 1012918 / -3.1%
random-access / parquet / ns (0.997x ➖, 0↑ 0↓)
name ns (PR / base / %diff)
random-access/feature-vectors/correlated/parquet-tokio-local-disk 351146280684 / 351321163809 / -0.0%
random-access/feature-vectors/correlated/parquet-tokio-local-disk-footer 351762736244 / 352232911741 / -0.1%
random-access/feature-vectors/uniform/parquet-tokio-local-disk 351518281603 / 355025809805 / -1.0%
random-access/feature-vectors/uniform/parquet-tokio-local-disk-footer 353781863256 / 352938143941 / +0.2%
random-access/nested-lists/correlated/parquet-tokio-local-disk 244269978 / 253101155 / -3.5%
random-access/nested-lists/correlated/parquet-tokio-local-disk-footer 240365770 / 244998889 / -1.9%
random-access/nested-lists/uniform/parquet-tokio-local-disk 238458473 / 244647689 / -2.5%
random-access/nested-lists/uniform/parquet-tokio-local-disk-footer 241641879 / 239736938 / +0.8%
random-access/nested-structs/correlated/parquet-tokio-local-disk 18763411 / 18937094 / -0.9%
random-access/nested-structs/correlated/parquet-tokio-local-disk-footer 19088280 / 18550935 / +2.9%
random-access/nested-structs/uniform/parquet-tokio-local-disk 19183843 / 19261377 / -0.4%
random-access/nested-structs/uniform/parquet-tokio-local-disk-footer 19045334 / 18973230 / +0.4%
random-access/parquet-tokio-local-disk 176174287 / 178134835 / -1.1%
random-access/parquet-tokio-local-disk-footer 178002020 / 176816033 / +0.7%
random-access/taxi/correlated/parquet-tokio-local-disk 264129010 / 266331832 / -0.8%
random-access/taxi/correlated/parquet-tokio-local-disk-footer 266917278 / 264908177 / +0.8%
random-access/taxi/uniform/parquet-tokio-local-disk 280252296 / 277618941 / +0.9%
random-access/taxi/uniform/parquet-tokio-local-disk-footer 282675267 / 279519015 / +1.1%
random-access / lance / ns (0.995x ➖, 0↑ 0↓)
name ns (PR / base / %diff)
random-access/feature-vectors/correlated/lance-tokio-local-disk 347621 / 363513 / -4.4%
random-access/feature-vectors/correlated/lance-tokio-local-disk-footer 1128353 / 1138782 / -0.9%
random-access/feature-vectors/uniform/lance-tokio-local-disk 1096296 / 1092351 / +0.4%
random-access/feature-vectors/uniform/lance-tokio-local-disk-footer 1867582 / 1874161 / -0.4%
random-access/lance-tokio-local-disk 602756 / 608542 / -1.0%
random-access/lance-tokio-local-disk-footer 1348438 / 1363492 / -1.1%
random-access/nested-lists/correlated/lance-tokio-local-disk 192624 / 187866 / +2.5%
random-access/nested-lists/correlated/lance-tokio-local-disk-footer 575665 / 591203 / -2.6%
random-access/nested-lists/uniform/lance-tokio-local-disk 910953 / 916332 / -0.6%
random-access/nested-lists/uniform/lance-tokio-local-disk-footer 1332114 / 1330440 / +0.1%
random-access/nested-structs/correlated/lance-tokio-local-disk 314348 / 312368 / +0.6%
random-access/nested-structs/correlated/lance-tokio-local-disk-footer 457255 / 470573 / -2.8%
random-access/nested-structs/uniform/lance-tokio-local-disk 2450636 / 2384360 / +2.8%
random-access/nested-structs/uniform/lance-tokio-local-disk-footer 2544771 / 2556597 / -0.5%
random-access/taxi/correlated/lance-tokio-local-disk 946817 / 967943 / -2.2%
random-access/taxi/correlated/lance-tokio-local-disk-footer 1874215 / 1863425 / +0.6%
random-access/taxi/uniform/lance-tokio-local-disk 9941033 / 9927500 / +0.1%
random-access/taxi/uniform/lance-tokio-local-disk-footer 10519939 / 10512186 / +0.1%

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