Skip to content

feat(io): try RWF_NOWAIT for medium/large buffered reads before hitting the blocking pool - #9409

Closed
joseph-isaacs wants to merge 1 commit into
developfrom
claude/rwf-nowait-buffered-io-f03rqx
Closed

feat(io): try RWF_NOWAIT for medium/large buffered reads before hitting the blocking pool#9409
joseph-isaacs wants to merge 1 commit into
developfrom
claude/rwf-nowait-buffered-io-f03rqx

Conversation

@joseph-isaacs

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

Copy link
Copy Markdown
Contributor

Rationale for this change

Local file reads always hop to the blocking pool, even when the range is already resident in the OS page cache and the "read" is nothing more than a memcpy. In that case the round trip — task spawn, thread wake, channel hand-back — is pure overhead on the critical path of the I/O request stream.

Linux can tell us whether a range is cached: preadv2 with RWF_NOWAIT serves the resident prefix of a range and fails with EAGAIN rather than blocking on the device. So the read can be attempted inline on the calling task, immediately before the work would be handed off, falling back to the pool for whatever the cache could not serve.

Marked draft: the mechanism works exactly as intended, but I could not measure a benefit that survives this machine's noise. Numbers and caveats below — please read them before merging.

What changes are included in this PR?

  • read_cached_at: fills as many leading bytes of a buffer as the page cache can serve, via preadv2(RWF_NOWAIT). Linux-only; every other target compiles to a stub returning 0. EAGAIN, real I/O errors, and kernels/filesystems without RWF_NOWAIT are all reported as a short read, so the blocking path re-encounters them and behaviour is unchanged. ENOSYS/EOPNOTSUPP latch the attempt off process-wide.
  • read_exact_at_pooled: serves the cached prefix on the calling task and dispatches only the unfilled tail to the blocking pool. A short read keeps the bytes already copied.
  • Used by both local readers: FileReadAt, and the GetResultPayload::File branch of read_object_store_range (which now backs both read_at and the read_ranges added in feat: add read_ranges to VortexReadAt #9384). The object-store path is the one that matters in practice — ObjectStoreFileSystem is how the DataFusion and DuckDB integrations open local Vortex files.
  • The fast path applies only inside a size window, NOWAIT_MIN_READ_LENGTH..=NOWAIT_MAX_READ_LENGTH (64 KiB..=256 KiB), both overridable via VORTEX_IO_NOWAIT_MIN_READ_LENGTH / VORTEX_IO_NOWAIT_MAX_READ_LENGTH. Reads outside the window allocate on the blocking pool exactly as before.
  • Process-wide counters (nowait_stats(): attempted / hit / partial / miss / skipped, bytes served vs missed) logged every 1024 read decisions under RUST_LOG=vortex_io::nowait=debug. The cadence counts decisions, not attempts, so the log still appears when the window excludes everything.

Measurements

Hardware caveat: 4 cores, 15 GB, shared container. TPC-H run-to-run noise here is ±10–14%.

The fast path fires, and always succeeds when it does. Warm data, default window:

workload attempted hit partial miss skipped bytes served
random access (taxi) 13,419 13,419 0 0 53,140 (79.8%) 2.77 GB
TPC-H SF1 vortex 335 335 0 0 1,712 (83.6%) 54.8 MB

100% hit rate — but the window excludes ~80% of reads, because coalescing pushes the median read to 280 KB (p90 1.47 MB, max 3.9 MB).

Cost of a probe that finds nothing is negligible, and a hit costs the same as a plain pread (medians):

read size EAGAIN probe hit (NOWAIT) hit (pread)
64 KiB 442 ns 2,738 ns 2,604 ns
256 KiB 591 ns 8,026 ns 7,916 ns
1 MiB 453 ns 59,617 ns 59,473 ns
4 MiB 492 ns 386,708 ns 408,074 ns

Widening the window is actively harmful. Random access (taxi, vortex, 5 s per target):

window correlated corr-footer uniform uni-footer
off 4.82 ms 5.42 14.14 18.13
0..64 KiB 4.79 5.88 15.35 16.03
64 KiB..256 KiB 4.50 5.75 16.26 17.10
64 KiB..1 MiB 5.29 5.71 18.37 17.90
unbounded 10.11 10.82 24.86 25.90

An unbounded window is 2.0–2.4× slower. The cause is not the syscall: it is that serving a large range inline moves a multi-megabyte memcpy onto the single task driving the I/O request stream, where copies run one after another, instead of the pool, where they run on several threads at once. At the default window random access is break-even, not better.

TPC-H is inconclusive. Scan-heavy queries (1, 6, 12, 17, 19, 21), 10 iterations, interleaved repetitions, total ms:

rep off 64 KiB..256 KiB unbounded
1 1063.6 876.0 885.0
2 1051.2 929.9 947.5
3 933.9 1041.7 1031.7
mean 1016.2 949.2 954.7

The within-configuration spread (±14%) exceeds the between-configuration difference (~6.6%), and rep 3 flips the sign.

Recommendation

As it stands this is a correct, well-instrumented mechanism with no demonstrated win on these two benchmarks and a clear loss if the window is widened. Either re-measure on dedicated benchmark hardware before merging, or treat the conservative window as the shipping default on the grounds that it does no measured harm. I would not widen the window without new evidence.

DuckDB TPC-H could not be measured: vortex-duckdb's build downloads the DuckDB source archive, and github.com/codeload.github.com are refused (HTTP 403) by the egress policy of the environment this ran in.

What APIs are changed? Are there any user-facing changes?

Additions to vortex_io::std_file, no removals or signature changes:

  • NOWAIT_MIN_READ_LENGTH, NOWAIT_MAX_READ_LENGTH — the window bounds.
  • read_exact_at_pooled(...) — cached-prefix-then-blocking-tail read.
  • NowaitStats and nowait_stats() — fast-path counters.

Reads return the same bytes and the same errors as before.

Tests

read_cached_at_matches_file, read_cached_at_stops_at_eof, read_at_returns_file_contents and local_file_read_at_returns_file_contents (rstest cases on both sides of both window bounds), read_at_past_eof_is_an_error, local_file_read_at_past_eof_is_an_error. The tests assert only what is guaranteed — that the cached prefix is a correct prefix — since page residency is not something a test can pin down.

Checks run: cargo clippy -p vortex-io --all-targets --all-features, cargo test -p vortex-io --all-features (169 pass), cargo test -p vortex-file (136 pass), cargo +nightly fmt --all. Not run: workspace-wide build/clippy, and any DuckDB-dependent target (unbuildable here, see above).

🤖 Generated with Claude Code

https://claude.ai/code/session_01N62TAkqGcNEtFpyw81LiPq

@codspeed-hq

codspeed-hq Bot commented Aug 14, 2026

Copy link
Copy Markdown

Merging this PR will degrade performance by 3.64%

⚠️ 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.

⚡ 2 improved benchmarks
❌ 3 regressed benchmarks
✅ 1989 untouched benchmarks
⏩ 89 skipped benchmarks1

Warning

Please fix the performance issues or acknowledge them on CodSpeed.

Performance Changes

Mode Benchmark BASE HEAD Efficiency
Simulation decompress[u64, (10000, 4)] 310.2 µs 401.7 µs -22.77%
Simulation decompress[u64, (1000, 16)] 64.4 µs 72.8 µs -11.57%
Simulation take[small_m/shuffled/primitive/nonnull/chunks=16384/indices=16] 1 ms 1.2 ms -11.31%
Simulation cold_misaligned[(64, 256)] 5.3 ms 4.4 ms +20.33%
WallTime words_gather_scalar[65536] 9.4 µs 8.3 µs +13.99%

Tip

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


Comparing claude/rwf-nowait-buffered-io-f03rqx (0752dee) with develop (dc8df2e)

Open in CodSpeed

Footnotes

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

Local file reads always hop to the blocking pool, even when the range is
already resident in the OS page cache and the "read" is nothing more than a
memcpy. In that case the round trip — task spawn, thread wake, channel
hand-back — is pure overhead on the critical path of the I/O request stream.

Linux can tell us whether a range is cached: `preadv2` with `RWF_NOWAIT`
serves the resident prefix of a range and fails with `EAGAIN` rather than
blocking on the device. `read_exact_at_pooled` uses that to try the read
inline on the calling task, immediately before the work would be handed off,
and falls back to the pool for whatever the cache could not serve. A short
read keeps the bytes already copied, so the pool only reads the tail.

Both local readers use it: `FileReadAt`, and the `GetResultPayload::File`
branch of `ObjectStoreReadAt`. The latter is the one that matters in
practice, since `ObjectStoreFileSystem` is how the DataFusion and DuckDB
integrations open local Vortex files.

The fast path applies only to reads inside a size window (64KiB..=256KiB by
default). Below the window a read is dominated by fixed per-request overhead
and the extra syscall does not pay for itself. Above it the trade inverts:
serving a large range inline moves a multi-megabyte memcpy onto the single
task driving the I/O request stream, where copies run one after another,
instead of the pool, where they run on several threads at once. Measured on
random access over the taxi dataset — where coalescing pushes the median read
to 280KB and 90% of reads past 64KiB — an unbounded fast path made Vortex
point lookups 1.4-2.4x slower while parquet, which cannot reach this code,
stayed flat. Both bounds are overridable via
`VORTEX_IO_NOWAIT_MIN_READ_LENGTH` / `VORTEX_IO_NOWAIT_MAX_READ_LENGTH` so
the crossover can be measured per storage stack.

Reads outside the window allocate their buffer on the blocking pool exactly
as before, so they are unchanged by this path.

Non-Linux targets, and kernels or filesystems without `RWF_NOWAIT`, always
report a zero-length cached prefix and fall through to the existing
behaviour. Unsupported kernels latch the attempt off after the first
`ENOSYS`/`EOPNOTSUPP`.

Signed-off-by: Claude <noreply@anthropic.com>
@joseph-isaacs
joseph-isaacs force-pushed the claude/rwf-nowait-buffered-io-f03rqx branch from 234c0c0 to 0752dee Compare August 14, 2026 12:43
@joseph-isaacs
joseph-isaacs marked this pull request as draft August 14, 2026 12:53
@AdamGS

AdamGS commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

I'm really conflicted about this sort of stuff. My assumptions are:

  1. There's performance to get here (in some benchmarks at least), at the cost of some complexity.
  2. Users of Vortex are likely to provide their own IO objects anyway, and have their own strong opinions about how they should behave.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants