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 5 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 (18)
Powered by Polar Signals Cloud |
Benchmarks: PolarSignals Profiling 📖Commits: PR datafusion / vortex-file-compressed / ns (1.007x ➖, 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 (0.893x ➖, 4↑ 2↓)
datafusion / parquet / ns (0.872x ➖, 3↑ 0↓)
duckdb / vortex-compact / ns (0.886x ➖, 1↑ 0↓)
duckdb / parquet / ns (0.915x ➖, 1↑ 1↓)
|
Benchmarks: Appian on NVME 📖Commits: PR How to read Verdict and Engines
datafusion / vortex-compact / ns (1.002x ➖, 0↑ 0↓)
datafusion / parquet / ns (1.003x ➖, 0↑ 0↓)
duckdb / vortex-compact / ns (1.025x ➖, 0↑ 0↓)
duckdb / parquet / ns (1.011x ➖, 0↑ 0↓)
File Size Changes (8 files changed, +1.5% overall, 8↑ 0↓)
Totals:
|
Benchmarks: Compression 📖Commits: PR vortex / vortex-file-compressed / ns (0.992x ➖, 0↑ 0↓)
vortex / vortex-file-compressed / bytes (1.286x ❌, 0↑ 6↓)
vortex / vortex-file-compressed / ratio (1.082x ➖, 0↑ 6↓)
vortex / parquet / ns (1.000x ➖, 0↑ 0↓)
vortex / parquet / bytes (1.000x ➖, 0↑ 0↓)
vortex / arrow-ipc / ns (0.996x ➖, 0↑ 0↓)
vortex / arrow-ipc / bytes (1.000x ➖, 0↑ 0↓)
|
Benchmarks: String Encoding 📖Commits: PR vortex / vortex-file-compressed / ms (1.006x ➖, 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.002x ➖, 0↑ 0↓)
datafusion / vortex-compact / ns (1.068x ➖, 0↑ 2↓)
datafusion / parquet / ns (0.989x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed / ns (1.550x ❌, 0↑ 8↓)
duckdb / vortex-compact / ns (1.035x ➖, 1↑ 2↓)
duckdb / parquet / ns (1.009x ➖, 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 (1.004x ➖, 0↑ 0↓)
datafusion / vortex-compact / ns (1.005x ➖, 0↑ 0↓)
datafusion / parquet / ns (0.967x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed / ns (1.105x ❌, 0↑ 12↓)
duckdb / vortex-compact / ns (1.104x ❌, 1↑ 12↓)
duckdb / parquet / ns (0.992x ➖, 0↑ 1↓)
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 (1.000x ➖, 0↑ 0↓)
datafusion / vortex-compact / ns (1.003x ➖, 1↑ 0↓)
datafusion / parquet / ns (1.004x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed / ns (1.301x ❌, 1↑ 9↓)
duckdb / vortex-compact / ns (1.287x ❌, 0↑ 8↓)
duckdb / parquet / ns (1.007x ➖, 1↑ 1↓)
File Size Changes (200 files changed, +1.5% overall, 200↑ 0↓)
Totals:
|
Merging this PR will degrade performance by 13.68%
|
| Mode | Benchmark | BASE |
HEAD |
Efficiency | |
|---|---|---|---|---|---|
| ❌ | WallTime | arrow_checked_add_u32_neon[16384] |
12.4 µs | 20.4 µs | -39.16% |
| ❌ | Simulation | random_i16[0.95] |
79.4 µs | 97.4 µs | -18.45% |
| ❌ | Simulation | decompress[u64, (4000, 1024)] |
71.4 µs | 86.8 µs | -17.7% |
| ❌ | WallTime | words_gather_scalar_avx2[65536] |
8.2 µs | 9.4 µs | -11.86% |
| ⚡ | Simulation | random_i8[0.5] |
93.7 µs | 70.4 µs | +33.18% |
Tip
Investigate this regression by commenting @codspeedbot fix this regression on this PR, or directly use the CodSpeed MCP with your agent.
Comparing claude/direct-io-reads-perf-3b5kk2 (95c4954) with develop (d82de0e)
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.037x ➖, 0↑ 2↓)
datafusion / vortex-compact / ns (0.858x ➖, 2↑ 0↓)
datafusion / parquet / ns (0.878x ➖, 1↑ 0↓)
duckdb / vortex-file-compressed / ns (0.911x ➖, 0↑ 0↓)
duckdb / vortex-compact / ns (0.951x ➖, 0↑ 0↓)
duckdb / parquet / ns (0.815x ➖, 0↑ 0↓)
|
Benchmarks: Statistical and Population Genetics 📖Commits: PR How to read Verdict and Engines
duckdb / vortex-file-compressed / ns (1.003x ➖, 0↑ 0↓)
duckdb / vortex-compact / ns (0.999x ➖, 0↑ 0↓)
duckdb / parquet / ns (0.986x ➖, 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.000x ➖, 0↑ 0↓)
datafusion / vortex-compact / ns (0.998x ➖, 1↑ 0↓)
datafusion / parquet / ns (0.979x ➖, 1↑ 0↓)
duckdb / vortex-file-compressed / ns (1.086x ➖, 2↑ 16↓)
duckdb / vortex-compact / ns (1.087x ➖, 0↑ 20↓)
duckdb / parquet / ns (0.993x ➖, 1↑ 2↓)
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 (0.998x ➖, 0↑ 0↓)
datafusion / vortex-compact / ns (1.001x ➖, 0↑ 0↓)
datafusion / parquet / ns (1.002x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed / ns (1.202x ❌, 0↑ 18↓)
duckdb / vortex-compact / ns (1.163x ❌, 0↑ 16↓)
duckdb / parquet / ns (0.991x ➖, 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.994x ➖, 1↑ 1↓)
datafusion / vortex-compact / ns (0.998x ➖, 4↑ 2↓)
datafusion / parquet / ns (0.998x ➖, 0↑ 0↓)
duckdb / vortex-file-compressed / ns (1.087x ➖, 1↑ 40↓)
duckdb / vortex-compact / ns (1.070x ➖, 0↑ 20↓)
duckdb / parquet / ns (0.989x ➖, 3↑ 1↓)
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.870x ➖, 3↑ 0↓)
datafusion / vortex-compact / ns (0.880x ➖, 1↑ 0↓)
datafusion / parquet / ns (0.981x ➖, 0↑ 1↓)
duckdb / vortex-file-compressed / ns (0.863x ➖, 2↑ 0↓)
duckdb / vortex-compact / ns (0.904x ➖, 0↑ 0↓)
duckdb / parquet / ns (0.955x ➖, 0↑ 0↓)
|
Benchmarks: Random Access 📖Commits: PR How to read Verdict and Engines
vortex / arrow-ipc / ns (0.878x ✅, 7↑ 0↓)
random-access / vortex-file-compressed / ns (5.083x ❌, 0↑ 18↓)
random-access / parquet / ns (0.984x ➖, 0↑ 0↓)
random-access / lance / ns (0.985x ➖, 0↑ 0↓)
|
`Grouped` moved any segment that would straddle a block boundary, including segments larger than a block. That was the wrong rule, and it cost most of what the policy was supposed to save. Straddling costs a sub-block segment a whole extra block -- it goes from touching one to touching two, a 100% penalty -- so moving it is worth up to a block of padding. A segment already a block or more long touches `ceil(len / block)` blocks, and a straddle adds at most one to that: 1/3 for an 8 KiB segment, 1/256 for a megabyte. Paying up to a block of padding to avoid it is a bad trade, and it is the trade that dominates real files, because the large segments are where the bytes are. Measured on the wide-table shape from `compress-bench` (100 list<i64> columns x 1000 rows, which writes exactly two segments per column: a 156-byte offsets segment and an 8372-byte elements segment): policy file size growth none 878 968 0.00% grouped (was) 1 270 704 +44.57% grouped (now) 879 408 +0.05% always 1 663 920 +89.30% The old rule pushed every 8372-byte segment to a block boundary and burned 3.9 KiB doing it, which bought nothing: 8372 bytes spans three blocks whether it starts at offset 0 or offset 156. The new rule leaves it packed and still keeps every 156-byte segment inside a single block, turning the wide-table cost of block alignment from +89.30% into +0.05%. The `grouped` versus `always` padding ratio on a uniform sub-block distribution is unchanged at 1/3, since the restriction only affects segments at least a block long. 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
|
Clearing an accidental disapproval — this is not an approval.
This comment carries the revoke token, so the status clears. It cannot count as an approval: 👍 Generated by Claude Code |
|
👍 (Revoke token for the comment above, as a literal character this time — the previous one was written as an HTML entity, which policy-bot's raw-body match does not see. Still not an approval: Generated by Claude Code |
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. The rule is asymmetric on purpose. A segment smaller than a block that straddles a boundary touches two blocks instead of one — a 100% penalty — so it is worth moving, and grouping keeps runs of them packed into shared blocks rather than giving each its own. A segment a block or larger already spansceil(len / block)blocks and a straddle adds at most one to that (1/3 for an 8 KiB segment, 1/256 for a megabyte), so it is left where it is; paying up to a block of padding to avoid that is a bad trade, and it is the trade that dominates real files because the large segments hold the bytes.Getting that restriction wrong is expensive. On the synthetic wide tables from
compress-bench— 100list<i64>columns × 1000 rows, which write exactly two segments per column, a 156-byte offsets segment and an 8372-byte elements segment:noneGroupedwithout the size restrictionGroupedas implementedAlwaysPushing the 8372-byte segments to a block boundary burns 3.9 kB apiece and buys nothing — 8372 bytes spans three blocks whether it starts at offset 0 or offset 156. Restricting the rule to sub-block segments leaves those packed while still keeping every 156-byte segment inside a single block.
On real data 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 where
Alwaysis 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-file191 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 single-block property of sub-block segments underGrouped.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 alignmentsetsVORTEX_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