Opt-in direct I/O reads, and what segment alignment is actually worth - #9838
Opt-in direct I/O reads, and what segment alignment is actually worth#9838joseph-isaacs wants to merge 4 commits into
Conversation
Direct I/O bypasses the page cache, which avoids a copy out of the cache and stops a large scan evicting everything else, but Linux requires the file offset, transfer length, and buffer address of every O_DIRECT read to be block aligned. Vortex segments are aligned to their element width, not to a block, so reads are widened to the enclosing blocks and sliced back to the requested range. That keeps direct reads working on files written by any Vortex version: no format change is required to enable it. vortex-cuda already had this machinery for its pinned-buffer reader, so lift it into vortex-io::std_file and have both readers share it. Enable it per-reader with FileReadAtOptions, or with VORTEX_DIRECT_IO=1 to A/B a deployment without recompiling. Filesystems that cannot serve O_DIRECT reject the open, so fall back to buffered reads rather than failing. Widening costs at most one block of over-read per physical read, so the writer gains an opt-in SegmentPadding policy to remove it: Always pads every segment to a 4KiB boundary, and Proportional pads a segment only when the padding is within 1/64 of its length, which bounds the padding added across a file to 1/64 of the segment bytes written. The default is unchanged contiguous packing. 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_01URD7DrxrCfLu8xSnvtHCMU
Adds a `direct-io` benchmark binary with three modes. `convert` writes Parquet to Vortex under each SegmentPadding policy and reports sizes. `analyze` replays every policy over an already-written file's segment map, pricing storage growth and the bytes a direct-I/O reader would transfer, both per-segment and after applying the reader's own coalescing window -- so a policy can be costed without rewriting terabytes. `scan` times full scans through ScanBuilder with buffered and direct reads, optionally dropping the page cache between iterations, and can resolve every segment without decoding to isolate I/O from decompression. The replay reproduces the measured file sizes exactly, which is what makes its read-amplification estimates trustworthy. 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_01URD7DrxrCfLu8xSnvtHCMU
`SegmentPadding::Always` costs half a block per segment whether the segment
is a megabyte or forty bytes, and the small ones are numerous: 38.8% of
ClickBench segments are under 4 KiB while holding 0.2% of the bytes.
`Grouped` pads a segment only when it would otherwise straddle a block
boundary, so a run of small consecutive segments shares one block instead of
each burning a whole one. Every segment still occupies exactly the blocks it
would under `Always` -- one apiece up to a block, `len / block` rounded up
above that -- which a test asserts over ten thousand segments. It therefore
reads identically while padding strictly less, and there is no workload where
`Always` is the better choice.
Replayed over the segment maps:
ClickBench x10 TPC-H SF1
growth 1 seg/io coalesced growth 1 seg/io coalesced
always 2.035% -1.02% +1.93% 1.868% -0.86% +1.76%
grouped 1.005% -1.02% +0.90% 1.753% -0.86% +1.65%
The saving tracks how much of the file is small segments, so ClickBench halves
its padding while TPC-H, which is nearly all large segments, gains 6%.
Also adds `VORTEX_SEGMENT_PADDING` (`none`, `always`, `grouped`,
`proportional[:ratio]`) so a deployment or benchmark can pick a policy without
recompiling, mirroring `VORTEX_DIRECT_IO`. Unset or empty keeps the packed
default.
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_01URD7DrxrCfLu8xSnvtHCMU
Sets `VORTEX_DIRECT_IO=1` and `VORTEX_SEGMENT_PADDING=always` on the jobs that both generate the benchmark data and query it, so the PR run measures aligned writes read back through `O_DIRECT` against develop's buffered, packed baseline. This is an experiment, not a proposed default: revert this commit before merging. 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_01URD7DrxrCfLu8xSnvtHCMU
Polar Signals Profiling ResultsLatest Run
Previous Runs (3)
Powered by Polar Signals Cloud |
Benchmarks: PolarSignals Profiling 📖Commits: PR datafusion / vortex-file-compressed / ns (0.999x ➖, 0↑ 0↓)
File Size Changes (1 files changed, +0.2% overall, 1↑ 0↓)
Totals:
|
Benchmarks: TPC-H SF=10 on S3 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-compact / ns (1.011x ➖, 1↑ 3↓)
datafusion / parquet / ns (0.863x ➖, 3↑ 1↓)
duckdb / vortex-compact / ns (0.977x ➖, 1↑ 1↓)
duckdb / parquet / ns (0.973x ➖, 0↑ 1↓)
|
Benchmarks: Appian on NVME 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-compact / ns (1.001x ➖, 0↑ 0↓)
datafusion / parquet / ns (1.009x ➖, 0↑ 0↓)
duckdb / vortex-compact / ns (1.015x ➖, 0↑ 0↓)
duckdb / parquet / ns (1.021x ➖, 0↑ 0↓)
File Size Changes (8 files changed, +1.5% overall, 8↑ 0↓)
Totals:
|
Benchmarks: Compression 📖Commits: PR vortex / vortex-file-compressed / ns (0.991x ➖, 0↑ 0↓)
vortex / vortex-file-compressed / bytes (1.286x ❌, 0↑ 6↓)
vortex / vortex-file-compressed / ratio (1.081x ➖, 0↑ 6↓)
vortex / parquet / ns (1.000x ➖, 0↑ 0↓)
vortex / parquet / bytes (1.000x ➖, 0↑ 0↓)
vortex / arrow-ipc / ns (0.968x ➖, 4↑ 0↓)
vortex / arrow-ipc / bytes (1.000x ➖, 0↑ 0↓)
|
Benchmarks: String Encoding 📖Commits: PR vortex / vortex-file-compressed / ms (1.004x ➖, 0↑ 0↓)
vortex / vortex-file-compressed / % (1.017x ➖, 0↑ 0↓)
|
|
The three Each failed job ends with the same runner-provider error rather than any Vortex output:
All three died within six seconds of each other on different instances in two availability zones, which is a fleet-wide I'll re-run the failed jobs once the run finishes, as RunsOn's message instructs — they can't be retried while it is still in progress. For context on what these runs are measuring: the last commit deliberately sets Generated by Claude Code |
Benchmarks: FineWeb NVMe 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-file-compressed / ns (1.024x ➖, 0↑ 2↓)
datafusion / vortex-compact / ns (1.017x ➖, 0↑ 1↓)
datafusion / parquet / ns (0.996x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed / ns (1.584x ❌, 0↑ 8↓)
duckdb / vortex-compact / ns (1.052x ➖, 1↑ 3↓)
duckdb / parquet / ns (1.018x ➖, 0↑ 0↓)
File Size Changes (2 files changed, +0.1% overall, 2↑ 0↓)
Totals:
|
Benchmarks: TPC-H SF=1 on NVME 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-file-compressed / ns (0.997x ➖, 0↑ 0↓)
datafusion / vortex-compact / ns (1.002x ➖, 0↑ 0↓)
datafusion / parquet / ns (0.997x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed / ns (1.104x ❌, 0↑ 13↓)
duckdb / vortex-compact / ns (1.088x ➖, 0↑ 9↓)
duckdb / parquet / ns (0.997x ➖, 0↑ 0↓)
File Size Changes (16 files changed, +1.9% overall, 16↑ 0↓)
Totals:
|
Benchmarks: Clickbench Sorted on NVME 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-file-compressed / ns (0.994x ➖, 1↑ 1↓)
datafusion / vortex-compact / ns (1.016x ➖, 0↑ 0↓)
datafusion / parquet / ns (1.007x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed / ns (1.259x ❌, 0↑ 7↓)
duckdb / vortex-compact / ns (1.295x ❌, 0↑ 9↓)
duckdb / parquet / ns (0.980x ➖, 2↑ 1↓)
File Size Changes (200 files changed, +1.5% overall, 200↑ 0↓)
Totals:
|
Merging this PR will improve performance by 16.78%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ⚡ | WallTime | deferred_i64_avx512[PerRowPerRow] |
11.6 µs | 9.9 µs | +16.78% |
Tip
Curious why performance improved? Comment @codspeedbot explain why performance improved on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/direct-io-reads-perf-3b5kk2 (0a5e328) with develop (2dffb68)
Footnotes
-
176 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. ↩
-
1 benchmark was run, but is now archived. If it was deleted in another branch, consider rebasing to remove it from the report. Instead if it was added back, click here to restore it. ↩
Benchmarks: FineWeb S3 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-file-compressed / ns (1.213x ➖, 0↑ 4↓)
datafusion / vortex-compact / ns (1.021x ➖, 0↑ 1↓)
datafusion / parquet / ns (0.854x ➖, 1↑ 0↓)
duckdb / vortex-file-compressed / ns (1.049x ➖, 0↑ 1↓)
duckdb / vortex-compact / ns (1.024x ➖, 0↑ 0↓)
duckdb / parquet / ns (0.876x ➖, 0↑ 0↓)
|
Benchmarks: Statistical and Population Genetics 📖Commits: PR How to read Verdict and Engines
duckdb / vortex-file-compressed / ns (0.996x ➖, 0↑ 0↓)
duckdb / vortex-compact / ns (0.999x ➖, 0↑ 0↓)
duckdb / parquet / ns (0.994x ➖, 0↑ 0↓)
File Size Changes (2 files changed, +0.7% overall, 2↑ 0↓)
Totals:
|
Benchmarks: Clickbench on NVME 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-file-compressed / ns (1.001x ➖, 0↑ 0↓)
datafusion / vortex-compact / ns (1.002x ➖, 0↑ 1↓)
datafusion / parquet / ns (0.982x ➖, 1↑ 0↓)
duckdb / vortex-file-compressed / ns (1.093x ➖, 2↑ 16↓)
duckdb / vortex-compact / ns (1.084x ➖, 1↑ 18↓)
duckdb / parquet / ns (0.998x ➖, 1↑ 0↓)
File Size Changes (200 files changed, +2.5% overall, 200↑ 0↓)
Totals:
|
Benchmarks: TPC-H SF=10 on NVME 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-file-compressed / ns (1.003x ➖, 0↑ 0↓)
datafusion / vortex-compact / ns (1.000x ➖, 0↑ 0↓)
datafusion / parquet / ns (1.000x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed / ns (1.084x ➖, 1↑ 6↓)
duckdb / vortex-compact / ns (1.073x ➖, 0↑ 6↓)
duckdb / parquet / ns (0.999x ➖, 0↑ 0↓)
File Size Changes (16 files changed, +1.8% overall, 16↑ 0↓)
Totals:
|
Benchmarks: TPC-DS SF=1 on NVME 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-file-compressed / ns (0.993x ➖, 2↑ 2↓)
datafusion / vortex-compact / ns (0.992x ➖, 4↑ 4↓)
datafusion / parquet / ns (0.994x ➖, 1↑ 0↓)
duckdb / vortex-file-compressed / ns (1.102x ❌, 1↑ 50↓)
duckdb / vortex-compact / ns (1.070x ➖, 0↑ 15↓)
duckdb / parquet / ns (1.001x ➖, 1↑ 2↓)
File Size Changes (48 files changed, +2.4% overall, 48↑ 0↓)
Totals:
|
Benchmarks: TPC-H SF=1 on S3 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-file-compressed / ns (0.992x ➖, 3↑ 3↓)
datafusion / vortex-compact / ns (0.922x ➖, 1↑ 0↓)
datafusion / parquet / ns (1.010x ➖, 0↑ 1↓)
duckdb / vortex-file-compressed / ns (0.845x ➖, 2↑ 0↓)
duckdb / vortex-compact / ns (0.933x ➖, 0↑ 1↓)
duckdb / parquet / ns (0.945x ➖, 0↑ 0↓)
|
Benchmarks: Random Access 📖Commits: PR How to read Verdict and Engines
vortex / arrow-ipc / ns (0.888x ✅, 4↑ 0↓)
random-access / vortex-file-compressed / ns (5.174x ❌, 0↑ 18↓)
random-access / parquet / ns (0.986x ➖, 0↑ 0↓)
random-access / lance / ns (0.987x ➖, 0↑ 0↓)
|
Summary
Adds opt-in
O_DIRECTreads for local files, plus three segment padding policies for the writer, and measures what each is worth. Two findings drive the shape of this PR:Direct I/O does not need aligned files.
O_DIRECTconstrains thepread— offset, length, and buffer address — not the file. The reader rounds the offset down to a block boundary, rounds the length up, reads into an over-aligned buffer, and slices the requested window back out. So every file Vortex has ever written can be read this way, with no format change and no migration. Widening is the code path, not a fallback, so it is exercised by every direct read rather than rotting.4 KiB write alignment costs more than it saves. It grows files by ~1.9% and makes a direct-I/O reader transfer more bytes, not fewer, because reads are coalesced long before they reach the filesystem. The writer option is here because it is the only way to demonstrate that, and because
Groupedis a strictly better version of it — not because it should be switched on.Cold-cache reads are where direct I/O pays: −36.3% on a TPC-H
lineitemscan, −34.0% on ClickBench, and a tail that collapses from a 6.3× spread to 1.08×.Changes
vortex-io— lifts the direct-I/O primitives out ofvortex-cuda, which already had a workingO_DIRECT+statx(STATX_DIOALIGN)implementation for its pinned-buffer reader, intostd_file, and wires them intoFileReadAt. Both readers now share one implementation instead of two. Linux only; opt in withFileReadAtOptions::with_direct_io()orVORTEX_DIRECT_IO=1, and it falls back to buffered reads where the filesystem cannot serveO_DIRECT.vortex-file— aSegmentPaddingwrite option, defaulting to today's contiguous packing:NoneAlwaysGroupedProportional1/ratioof its lengthVORTEX_SEGMENT_PADDING(none,always,grouped,proportional[:ratio]) selects one without recompiling, mirroringVORTEX_DIRECT_IO.vortex-bench— adirect-iobinary.convertwrites under each policy and reports sizes;analyzereplays every policy over an already-written file's segment map, pricing storage growth and the bytes a direct reader would transfer, both per-segment and after the reader's own coalescing window;scantimes full scans with buffered and direct reads. The replay reproduces the measured file sizes exactly, which is what makes its read estimates trustworthy rather than hand-waved.What the padding costs
Replayed over the segment maps of ClickBench ×10 and TPC-H SF1. The read columns are the bytes a direct reader transfers, relative to the packed baseline — negative is better.
noneAlwaysGroupedProportional1/64Aligning saves ~1% when each segment is read on its own, but Vortex coalesces at 1 MiB/4 MiB — a 4 KiB gap never splits a run, so the padding is read along with the data. The coalesced column is the one that matches how the reader behaves, and there alignment is a straight loss.
The cost is structural:
growth ≈ 2.7 kB × segment_count, within 1% on both datasets despite very different segment profiles. TPC-H SF1 lands at +1.868% against +1.800% measured on SF10, so scale factor barely moves it. The lever is segment count, not segment size.Groupedis the one policy worth keeping. Every segment occupies exactly the blocks it would underAlways— one apiece up to a block,len / blockrounded up above — so it reads identically for strictly less padding, which a test asserts over ten thousand segments. The saving tracks how much of the file is small segments: ClickBench halves its padding (38.8% of its segments are under 4 KiB, holding 0.2% of the bytes), TPC-H gains 6%. There is no workload whereAlwaysis the better choice.One pathology worth flagging: TPC-H
regiongoes 5.00 kB → 27.74 kB underAlways, a 5.5× blowup. Any many-small-files workload pays that repeatedly.What direct I/O is worth
Full scans through
ScanBuilder, page cache dropped between iterations:lineitemWarm-cache it loses ~15% on I/O and breaks even end to end, which is the correct result — a page-cache read is a memcpy. The larger prize is the tail: under page-cache reclaim, buffered cold reads ranged over 6.3× (0.77–4.88 s) while direct ranged over 1.08×.
Measured direct reads over aligned files were never faster than over packed ones, across three paired rounds.
Testing
vortex-io210 passed,vortex-file190 passed,cargo clippy --all-targets --all-featuresclean onvortex-io,vortex-fileandvortex-bench,cargo +nightly fmt --allapplied. New tests cover block widening over unaligned files, the alignment-survives-slicing invariant, each padding policy's per-segment output, the whole-file bound on the proportional budget, and the block-equivalence ofGroupedandAlways.Caveat on the timings: they come from a 4-vCPU VM on virtio, where
drop_cachesclears only the guest cache. Treat deltas under ~10% as noise. The last commit runs the CI benchmark suite with both switches on so these numbers can be re-taken on the bench hardware.API Changes
Two additive, opt-in options, both defaulting to current behavior:
FileReadAtOptions::with_direct_io()andVortexWriteOptions::with_segment_padding(). Nothing existing changes, and no file format change — alignment is a property of the read call, not the file.bench: run CI benchmarks with direct I/O and 4 KiB segment alignment) setsVORTEX_DIRECT_IO=1andVORTEX_SEGMENT_PADDING=alwayson the benchmark jobs. It is an experiment to get numbers on the bench hardware, not a proposed default — revert it before merging.🤖 Generated with Claude Code
https://claude.ai/code/session_01URD7DrxrCfLu8xSnvtHCMU
Generated by Claude Code