feat(io): try RWF_NOWAIT for medium/large buffered reads before hitting the blocking pool - #9409
feat(io): try RWF_NOWAIT for medium/large buffered reads before hitting the blocking pool#9409joseph-isaacs wants to merge 1 commit into
Conversation
Merging this PR will degrade performance by 3.64%
|
| 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)
Footnotes
-
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>
234c0c0 to
0752dee
Compare
|
I'm really conflicted about this sort of stuff. My assumptions are:
|
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:
preadv2withRWF_NOWAITserves the resident prefix of a range and fails withEAGAINrather 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, viapreadv2(RWF_NOWAIT). Linux-only; every other target compiles to a stub returning0.EAGAIN, real I/O errors, and kernels/filesystems withoutRWF_NOWAITare all reported as a short read, so the blocking path re-encounters them and behaviour is unchanged.ENOSYS/EOPNOTSUPPlatch 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.FileReadAt, and theGetResultPayload::Filebranch ofread_object_store_range(which now backs bothread_atand theread_rangesadded in feat: addread_rangestoVortexReadAt#9384). The object-store path is the one that matters in practice —ObjectStoreFileSystemis how the DataFusion and DuckDB integrations open local Vortex files.NOWAIT_MIN_READ_LENGTH..=NOWAIT_MAX_READ_LENGTH(64 KiB..=256 KiB), both overridable viaVORTEX_IO_NOWAIT_MIN_READ_LENGTH/VORTEX_IO_NOWAIT_MAX_READ_LENGTH. Reads outside the window allocate on the blocking pool exactly as before.nowait_stats(): attempted / hit / partial / miss / skipped, bytes served vs missed) logged every 1024 read decisions underRUST_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:
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):Widening the window is actively harmful. Random access (taxi, vortex, 5 s per target):
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:
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, andgithub.com/codeload.github.comare 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.NowaitStatsandnowait_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_contentsandlocal_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