feat(driver): shard the ring so cache-hit reads are not capped by one thread (backlog#1145)#6
Merged
Merged
Conversation
… thread A buffered read that hits the page cache completes *inline* inside `io_uring_enter`, so the thread driving a ring performs that read's memcpy. With one ring per driver, every cache-hit read's copy funnels through a single thread, capping the driver at one core's memory bandwidth. Measured on a 16-core host (rustfs/backlog#1145): one thread pinned at 100% CPU for a whole run, throughput flat at ~5 GB/s no matter the read size, while a blocking-pool baseline reached 50 GB/s across many threads. Independent rings scaled ~linearly (1/2/4 rings → 4890/8969/15806 MB/s), which is what identified the driver thread as the ceiling. `UringDriver` now holds N independent rings, each with its own thread, pending table, backpressure semaphore, and eventfd — a shard. `submit` round-robins over them and binds the op to the chosen shard for its whole life: the `ReadHandle` already carried the `tx`/`wake` it must talk to, so a cancel or a deferred submission necessarily routes back to the ring whose pending table holds the op. Every cancel-safety invariant therefore holds per shard exactly as it did for a single ring, and the aggregate counters conserve because each shard's do. `probe_and_start(entries)` is unchanged — it is now `probe_and_start_sharded(entries, 1)`. The new `probe_and_start_sharded(entries, shards)` is additive: no existing caller changes behavior, and nobody silently grows threads. In-flight ops are capped at `entries` per shard, so the invariant that makes CQ overflow structurally unreachable is preserved per ring; the driver admits up to `shards * entries` concurrent reads. Probing happens on the first shard, so a restricted environment fails exactly as before; if a later shard fails to start, the running ones are shut down and joined before returning the error. Shutdown and drop ask every shard to stop before joining any, so their bounded drains overlap instead of serializing `shards * DRAIN_TIMEOUT`. Effect (warm page cache, 16-core host, `concurrent_pread_bench`): 1 MiB, conc 8: 1 shard 4911 MB/s -> 8 shards 47361 MB/s (9.6x) std_cached_pread baseline 50662 MB/s 64 KiB, conc 32: std_open_pread 153678 IOPS, p999 3030 us 8 shards 345402 IOPS, p999 897 us 64 KiB, conc 128: std_open_pread 135155 IOPS, p999 10716 us 8 shards 389047 IOPS, p999 4092 us So sharding removes the throughput deficit *and* keeps io_uring's tail latency advantage, rather than trading one for the other. Tests: two new cases gate the sharded path — conservation and per-shard content correctness under the same mixed drop/keep stress as `cancel_stress`, across 4 rings, plus a single-shard equivalence check. `concurrent_pread_bench` takes an optional `shards` argument so the numbers above are reproducible from this tree. Verified: clippy -D warnings clean; run-docker.sh both legs pass (15 tests; leg 1 degrades gracefully including the sharded probe, leg 2 runs real io_uring). Co-Authored-By: heihutu <heihutu@gmail.com>
houseme
added a commit
to rustfs/rustfs
that referenced
this pull request
Jul 10, 2026
…1145) (#4653) A buffered read that hits the page cache completes inline inside `io_uring_enter`, so the thread driving a ring performs that read's memcpy. One ring per disk therefore capped cache-hit reads at a single core's memory bandwidth: measured on a 16-core host, one driver thread sat pinned at 100% CPU while throughput stayed flat at ~5 GB/s regardless of read size, against 50 GB/s for the blocking-pool baseline. rustfs/uring#6 taught the driver to hold N independent rings, each with its own thread, pending table, backpressure semaphore, and eventfd. Wire it up: `UringBackend::try_new` now calls `probe_and_start_sharded`, and `RUSTFS_IO_URING_SHARDS` selects the count per disk. The default is a quarter of the available parallelism clamped to `1..=4`, because the cost is `disks × shards` driver threads (each normally blocked in `poll(2)`). Any override is clamped to `1..=16`, so a mistyped value can neither disable the driver (0) nor spawn threads without bound; an unparseable value falls back to the default. Effect (warm page cache, 16-core, rustfs/uring's concurrent_pread_bench): 1 MiB, conc 8: 1 shard 4911 MB/s -> 8 shards 47361 MB/s (9.6x); the blocking-pool baseline is 50662 MB/s 64 KiB, conc 32: StdBackend 153678 IOPS, p999 3030 us 8 shards 345402 IOPS, p999 897 us 64 KiB, conc 128: StdBackend 135155 IOPS, p999 10716 us 8 shards 389047 IOPS, p999 4092 us Sharding removes the throughput deficit *and* keeps io_uring's tail-latency advantage, rather than trading one for the other. Unchanged: io_uring read stays gray-off by default (`RUSTFS_IO_URING_READ_ENABLE`), reads are byte-for-byte identical to StdBackend, the per-disk degradation latches and probe cache (backlog#1101) and the O_DIRECT tiered fallback (backlog#1102) all still apply. Rings stay per-disk, so a stalled disk cannot starve another disk's rings (backlog#1055). Bumps the rustfs-uring pin to the merged #6 commit. Verified on a real Linux host (16-core, real io_uring): cargo clippy --tests -D warnings clean; disk::local tests 132 passed, 0 failed — including the existing io_uring and O_DIRECT cases now running on the sharded driver, plus a new test covering the shard-count default, override, and clamping. Co-authored-by: heihutu <heihutu@gmail.com>
houseme
added a commit
to rustfs/rustfs
that referenced
this pull request
Jul 10, 2026
* feat(ecstore): run one io_uring ring per shard on each disk (backlog#1145) A buffered read that hits the page cache completes inline inside `io_uring_enter`, so the thread driving a ring performs that read's memcpy. One ring per disk therefore capped cache-hit reads at a single core's memory bandwidth: measured on a 16-core host, one driver thread sat pinned at 100% CPU while throughput stayed flat at ~5 GB/s regardless of read size, against 50 GB/s for the blocking-pool baseline. rustfs/uring#6 taught the driver to hold N independent rings, each with its own thread, pending table, backpressure semaphore, and eventfd. Wire it up: `UringBackend::try_new` now calls `probe_and_start_sharded`, and `RUSTFS_IO_URING_SHARDS` selects the count per disk. The default is a quarter of the available parallelism clamped to `1..=4`, because the cost is `disks × shards` driver threads (each normally blocked in `poll(2)`). Any override is clamped to `1..=16`, so a mistyped value can neither disable the driver (0) nor spawn threads without bound; an unparseable value falls back to the default. Effect (warm page cache, 16-core, rustfs/uring's concurrent_pread_bench): 1 MiB, conc 8: 1 shard 4911 MB/s -> 8 shards 47361 MB/s (9.6x); the blocking-pool baseline is 50662 MB/s 64 KiB, conc 32: StdBackend 153678 IOPS, p999 3030 us 8 shards 345402 IOPS, p999 897 us 64 KiB, conc 128: StdBackend 135155 IOPS, p999 10716 us 8 shards 389047 IOPS, p999 4092 us Sharding removes the throughput deficit *and* keeps io_uring's tail-latency advantage, rather than trading one for the other. Unchanged: io_uring read stays gray-off by default (`RUSTFS_IO_URING_READ_ENABLE`), reads are byte-for-byte identical to StdBackend, the per-disk degradation latches and probe cache (backlog#1101) and the O_DIRECT tiered fallback (backlog#1102) all still apply. Rings stay per-disk, so a stalled disk cannot starve another disk's rings (backlog#1055). Bumps the rustfs-uring pin to the merged #6 commit. Verified on a real Linux host (16-core, real io_uring): cargo clippy --tests -D warnings clean; disk::local tests 132 passed, 0 failed — including the existing io_uring and O_DIRECT cases now running on the sharded driver, plus a new test covering the shard-count default, override, and clamping. Co-Authored-By: heihutu <heihutu@gmail.com> * feat(ecstore): cache part-file descriptors for io_uring reads (backlog#1145) `pread_uring` opened the file on the blocking pool for every read, so each read paid a `spawn_blocking` round trip — the very thread hop io_uring exists to avoid. Sharding the driver (backlog#1145) removed the previous ceiling and left this as the binding cost. Measured on a 16-core host with a 4-shard driver, warm page cache: 64 KiB, conc 8: 143942 -> 263054 IOPS (+83%), p999 240 -> 65 us 64 KiB, conc 32: 150128 -> 204876 IOPS (+36%), p999 2508 -> 871 us 64 KiB, conc 128: 129172 -> 361287 IOPS (+180%), p999 15329 -> 3046 us 1 MiB, conc 32: 33875 -> 42301 IOPS (+25%) At 64 KiB / conc 128 the open is what masked io_uring entirely: with it, io_uring beat StdBackend by 3.5%; without it, by 189%. Add a bounded per-disk descriptor cache used by the io_uring read path. A hit takes no `open` and no `spawn_blocking`, so the read never leaves the runtime worker. Why caching a part-file descriptor is safe: * only `<object>/<data_dir>/part.N` reaches this backend's `pread_bytes`; `xl.meta` — the one path replaced in place — is read through `read_all` / `read_metadata` and never gets here; * part files are never rewritten in place. A replacement is always write-new-tmp then `rename`, which swaps the inode, so a cached descriptor can never observe a torn shard. Why invalidation is nevertheless REQUIRED: heal reuses the existing version's `data_dir` and renames a rebuilt shard onto the SAME part path. A cached descriptor would keep serving the pre-heal (corrupt) inode, defeating the heal and eroding read quorum. `delete` likewise unlinks a part that a cached descriptor would keep readable. So `rename_data`, `rename_file`, and `delete` all call the new `LocalIoBackend::invalidate_cached_fds` after they mutate, and a 5s TTL bounds the blast radius should a future mutation path forget to. Two preamble checks the miss path runs are not silently lost on a hit: * bounds — the driver only short-reads at EOF (it resubmits otherwise), so `bytes.len() != length` is exactly the old `meta.len() < end_offset` check, and now yields the same `FileCorrupt`; * volume access — skipped while an entry is live. An unreachable disk keeps serving already-open descriptors for at most the TTL, after which the re-open re-runs the check. Disk health is tracked independently of this per-read probe. Scope: buffered io_uring reads only. The O_DIRECT path keeps opening per read (its reads are >= 4 MiB, so the open is a small fraction), and StdBackend is untouched — it must take the blocking hop for the pread regardless, so caching would buy it only 2-6% while carrying the same invalidation risk. `RUSTFS_IO_URING_FD_CACHE=false` restores open-per-read. Verified on a real Linux host (16-core, real io_uring): clippy --tests -D warnings clean; disk::local tests 135 passed, 0 failed. The new heal-staleness test first asserts a read still returns the PRE-heal bytes — proving the cache is live and the hazard real — then that invalidation makes the healed shard visible. A second test drives `rename_file` and `delete` through `LocalDisk` to prove those paths actually invalidate, and a unit test pins prefix invalidation to component boundaries (`a/b` must not drop `a/bc`). Co-Authored-By: heihutu <heihutu@gmail.com> --------- Co-authored-by: heihutu <heihutu@gmail.com>
houseme
added a commit
that referenced
this pull request
Jul 10, 2026
The README described eventfd reaping, O_DIRECT, and LocalIoBackend integration as unlanded and listed streaming reads on the roadmap; all of that has since merged or been closed by measurement. Bring it in line with the actual public API and record the honest performance picture. README changes: - status now reflects that the read path is wired into rustfs/rustfs behind a runtime probe and off by default (RUSTFS_IO_URING_READ_ENABLE), and how to pin the git dependency; - document sharded rings (probe_and_start_sharded) and native O_DIRECT (read_at_direct) with runnable examples matching the current signatures; - a "when this crate helps — and when it does not" section reporting the measured results, including where io_uring loses (single sequential stream, low concurrency) and the roughly-neutral end-to-end S3 GET, plus the two benchmarking traps this repo hit (a 76x regression mistaken for a win, and page-cache-hit microbenchmarks not transferring end-to-end); - roadmap trimmed to what is actually open (write path, register_files, SQPOLL) with the closed-by-measurement decisions moved to the CHANGELOG. Add CHANGELOG.md (Keep a Changelog format): every landed change #1..#6 with the commit that carried it, the pre-history of the audited spike, and a "decisions recorded, not implemented" section so the NO-GO items are not silently re-opened. Verified README claims against source: the six documented signatures match src/driver.rs, ProbeFailure/StatsSnapshot/UringDriver are the lib exports, and the O_DIRECT example's alignment passes the driver's validation. Co-authored-by: heihutu <heihutu@gmail.com>
houseme
added a commit
that referenced
this pull request
Jul 10, 2026
* docs: refresh README for shipped features and add a CHANGELOG The README described eventfd reaping, O_DIRECT, and LocalIoBackend integration as unlanded and listed streaming reads on the roadmap; all of that has since merged or been closed by measurement. Bring it in line with the actual public API and record the honest performance picture. README changes: - status now reflects that the read path is wired into rustfs/rustfs behind a runtime probe and off by default (RUSTFS_IO_URING_READ_ENABLE), and how to pin the git dependency; - document sharded rings (probe_and_start_sharded) and native O_DIRECT (read_at_direct) with runnable examples matching the current signatures; - a "when this crate helps — and when it does not" section reporting the measured results, including where io_uring loses (single sequential stream, low concurrency) and the roughly-neutral end-to-end S3 GET, plus the two benchmarking traps this repo hit (a 76x regression mistaken for a win, and page-cache-hit microbenchmarks not transferring end-to-end); - roadmap trimmed to what is actually open (write path, register_files, SQPOLL) with the closed-by-measurement decisions moved to the CHANGELOG. Add CHANGELOG.md (Keep a Changelog format): every landed change #1..#6 with the commit that carried it, the pre-history of the audited spike, and a "decisions recorded, not implemented" section so the NO-GO items are not silently re-opened. Verified README claims against source: the six documented signatures match src/driver.rs, ProbeFailure/StatsSnapshot/UringDriver are the lib exports, and the O_DIRECT example's alignment passes the driver's validation. Co-Authored-By: heihutu <heihutu@gmail.com> * fix: gate Linux-only benchmark examples --------- Co-authored-by: heihutu <heihutu@gmail.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
A buffered read that hits the page cache completes inline inside
io_uring_enter, so the thread driving a ring performs that read's memcpy. With one ring per driver, every cache-hit read's copy funnels through a single thread, capping the driver at one core's memory bandwidth.Evidence (rustfs/backlog#1145, 16-core host):
ps -Lduring a 1 MiB run: exactly one thread pinned at 100% CPU for the whole 8.2 s, throughput 4876 MB/s. The blocking-pool baseline hit 54701 MB/s across many threads.Cache-miss reads are device-bound and never hit this, which is why the cold-cache sweep showed every strategy tied.
Change
UringDrivernow holds N independent rings — each a shard with its own thread, pending table, backpressure semaphore, and eventfd.submitround-robins and binds the op to the chosen shard for its whole life: theReadHandlealready carried thetx/wakeit must talk to, so a cancel or a deferred submission necessarily routes back to the ring whose pending table holds the op.Every cancel-safety invariant therefore holds per shard, exactly as it did for one ring; the aggregate counters conserve because each shard's do.
probe_and_start(entries)is unchanged — nowprobe_and_start_sharded(entries, 1). Additive API: no existing caller changes behavior, nobody silently grows threads.entriesper shard, so the invariant making CQ overflow structurally unreachable is preserved per ring. The driver admits up toshards * entriesconcurrent reads.shards * DRAIN_TIMEOUT.Effect (warm page cache, 16-core,
concurrent_pread_bench)std_cached_preadis 50662std_open_pread153678 IOPS, p999 3030 µsstd_open_pread135155 IOPS, p999 10716 µsSharding removes the throughput deficit and keeps io_uring's tail-latency advantage, rather than trading one for the other.
Tests
Two new cases gate the sharded path: conservation + per-shard content correctness under the same mixed drop/keep stress as
cancel_stressacross 4 rings, and a single-shard equivalence check. A cancel routed to the wrong ring, or a pending entry lost across the split, surfaces as a conservation violation or a content mismatch.concurrent_pread_benchtakes an optionalshardsargument, so every number above is reproducible from this tree.Verification
clippy --all-targets -D warningsclean;run-docker.shboth legs pass (15 tests) — leg 1 degrades gracefully (including the sharded probe), leg 2 runs real io_uring.Follow-up (separate PR): wire ecstore to pick a shard count per disk.