Skip to content

feat(driver): shard the ring so cache-hit reads are not capped by one thread (backlog#1145)#6

Merged
houseme merged 1 commit into
mainfrom
feat/shard-rings
Jul 10, 2026
Merged

feat(driver): shard the ring so cache-hit reads are not capped by one thread (backlog#1145)#6
houseme merged 1 commit into
mainfrom
feat/shard-rings

Conversation

@houseme

@houseme houseme commented Jul 10, 2026

Copy link
Copy Markdown
Collaborator

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 -L during 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.
  • io_uring throughput was flat at ~5–8 GB/s regardless of read size — the signature of a single-thread bandwidth ceiling, not a per-op cost.
  • Independent rings scaled ~linearly (1/2/4 rings → 4890 / 8969 / 15806 MB/s), pinning the driver thread as the ceiling.

Cache-miss reads are device-bound and never hit this, which is why the cold-cache sweep showed every strategy tied.

Change

UringDriver now holds N independent rings — each a shard with its own thread, pending table, backpressure semaphore, and eventfd. submit round-robins 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 one ring; the aggregate counters conserve because each shard's do.

  • probe_and_start(entries) is unchanged — now probe_and_start_sharded(entries, 1). Additive API: no existing caller changes behavior, nobody silently grows threads.
  • In-flight is capped at entries per shard, so the invariant making 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 the error returns.
  • Shutdown and drop ask every shard to stop before joining any, so bounded drains overlap instead of serializing shards * DRAIN_TIMEOUT.
  • Still one ring set per disk → a stalled disk cannot starve another disk's rings (rustfs/backlog#1055 preserved).

Effect (warm page cache, 16-core, concurrent_pread_bench)

workload baseline sharded
1 MiB, conc 8 1 shard 4911 MB/s 8 shards 47361 MB/s (9.6×) — std_cached_pread is 50662
64 KiB, conc 32 std_open_pread 153678 IOPS, p999 3030 µs 8 shards 345402 IOPS, p999 897 µs
64 KiB, conc 128 std_open_pread 135155 IOPS, p999 10716 µs 8 shards 389047 IOPS, p999 4092 µs

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 + per-shard content correctness under the same mixed drop/keep stress as cancel_stress across 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_bench takes an optional shards argument, so every number above is reproducible from this tree.

Verification

clippy --all-targets -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.

Follow-up (separate PR): wire ecstore to pick a shard count per disk.

… 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 houseme merged commit 719b245 into main Jul 10, 2026
3 checks passed
@houseme houseme deleted the feat/shard-rings branch July 10, 2026 06:59
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>
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.

1 participant