From e0cebc9e79284a374ece19dfb98dbc4e27a76d5b Mon Sep 17 00:00:00 2001 From: Nitesh Balusu Date: Sat, 17 Oct 2026 17:14:38 -0400 Subject: [PATCH] store: read with libc pread by default on macOS macOS has no io_uring. Its only completion session is a worker pool that hands every read to a pool thread through one shared lock and wakes two threads per read. During IBD most reads are small and often cached, so the handoff cost more than the read. Signet to 326,785 on a 10-core Mac, scripts skipped, all indexes: - warm: pool 639/661 s, pread 539/528 s (about 18% faster) - cold (resume from 250,875 after a cache flush): pool 355/351 s, pread 275/281 s (about 21% faster) - system CPU drops about four times; same tip hash every run RBITCOIN_IO=pool still opens the pool session. Linux and Windows defaults are unchanged. Co-Authored-By: Claude Opus 5.5 --- CONTRIBUTING.md | 2 +- changelog.d/darwin-pread-default.md | 8 ++++ crates/rbitcoin-store/src/bulk_io.rs | 40 ++++++++++++++------ crates/rbitcoin-store/src/io_session_pool.rs | 5 ++- crates/rbitcoin-store/src/uring_session.rs | 5 +-- docs/io-modality.md | 13 ++++--- scripts/ci-os-smoke.sh | 4 +- scripts/ci-os-smoke.test.sh | 8 +++- 8 files changed, 59 insertions(+), 26 deletions(-) create mode 100644 changelog.d/darwin-pread-default.md diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7e39fe4ff2..794f53b2ae 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -60,7 +60,7 @@ using this cargo binary (not the musl `target/release/` install). ### What works on each OS Linux is the **supported operator IO** target (`io_uring` when the ring opens). -Darwin defaults to the **pool** session; Windows to **IOCP** (no IoRing). Matrix: +Darwin defaults to libc **pread** (`RBITCOIN_IO=pool` opts into the pool session); Windows to **IOCP** (no IoRing). Matrix: [`docs/io-modality.md`](./docs/io-modality.md). PR CI still smokes native store IO on Windows and Darwin. diff --git a/changelog.d/darwin-pread-default.md b/changelog.d/darwin-pread-default.md new file mode 100644 index 0000000000..8df0e119ec --- /dev/null +++ b/changelog.d/darwin-pread-default.md @@ -0,0 +1,8 @@ +Changed + +- **macOS reads with libc pread by default.** + The worker-pool session handed every read to a pool thread through one + shared lock. In signet IBD that cost more than the reads: about 20% slower + end to end with a warm or cold cache, and four times the system CPU. + `RBITCOIN_IO=pool` still opens the pool session. Linux and Windows are + unchanged. diff --git a/crates/rbitcoin-store/src/bulk_io.rs b/crates/rbitcoin-store/src/bulk_io.rs index 280d23e2fe..99e01259d0 100644 --- a/crates/rbitcoin-store/src/bulk_io.rs +++ b/crates/rbitcoin-store/src/bulk_io.rs @@ -23,8 +23,9 @@ //! //! # Non-Linux //! -//! Darwin default is the **pool** completion session (kqueue is not a -//! regular-file backend). Windows uses IOCP. +//! Darwin default is libc **pread** (kqueue is not a regular-file backend, +//! and the pool session's per-read handoff cost more than the reads in IBD); +//! `RBITCOIN_IO=pool` opens the pool session there. Windows uses IOCP. //! Machines stay staged; they do not flatten to one-shot `pread`. See //! `docs/io-modality.md` and `docs/concurrency.md`. @@ -66,11 +67,7 @@ pub fn io_uring_enabled() -> bool { 1 => true, 2 => false, _ => { - let want = match parse_io_token() { - Some(IoToken::Pread) => false, - Some(_) | None => true, - }; - if !want { + if !session_wanted(parse_io_token()) { URING_MODE.store(2, Ordering::Relaxed); return false; } @@ -87,6 +84,16 @@ pub fn io_uring_enabled() -> bool { } } +fn session_wanted(token: Option) -> bool { + match token { + Some(IoToken::Pread) => false, + Some(_) => true, + // Darwin's only session is the worker pool. Its per-read handoff cost + // more than the read in signet IBD (about 20% slower, warm or cold). + None => !cfg!(target_os = "macos"), + } +} + #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub(crate) enum IoToken { Uring, @@ -129,15 +136,11 @@ fn default_session_kind() -> crate::uring_session::SessionKind { { SessionKind::Uring } - #[cfg(target_os = "macos")] - { - SessionKind::Pool - } #[cfg(windows)] { SessionKind::Iocp } - #[cfg(not(any(target_os = "linux", target_os = "macos", windows)))] + #[cfg(not(any(target_os = "linux", windows)))] { SessionKind::Pool } @@ -600,6 +603,19 @@ fn pwrite_one(op: &mut WriteOp<'_>) { #[cfg(test)] mod tests { use super::*; + + #[test] + fn unset_io_env_opens_a_session_except_on_darwin() { + #[cfg(target_os = "macos")] + assert!(!session_wanted(None), "Darwin defaults to libc pread"); + #[cfg(not(target_os = "macos"))] + assert!(session_wanted(None)); + assert!( + session_wanted(Some(IoToken::Pool)), + "RBITCOIN_IO=pool still opens the pool session" + ); + assert!(!session_wanted(Some(IoToken::Pread))); + } use std::io::Write; fn dummy_handle() -> IoHandle { diff --git a/crates/rbitcoin-store/src/io_session_pool.rs b/crates/rbitcoin-store/src/io_session_pool.rs index c67b14db07..97eea9b3bd 100644 --- a/crates/rbitcoin-store/src/io_session_pool.rs +++ b/crates/rbitcoin-store/src/io_session_pool.rs @@ -1,8 +1,9 @@ //! Completion-ring facade over a bounded worker pool. //! //! Same harvest contract as io_uring (`user_data`, drain, in-flight cap). -//! Darwin's file AIO is a thread pool; this is the honest session there and -//! the Linux CI pin (`RBITCOIN_IO=pool`). +//! Darwin's file AIO is a thread pool; this is the honest session there when +//! `RBITCOIN_IO=pool` asks for it (Darwin defaults to libc pread), and the +//! Linux CI pin. //! //! Workers are **process-shared**. Each [`PoolEngine`] is a session handle //! with its own CQE queue so harvest does not steal another thread's diff --git a/crates/rbitcoin-store/src/uring_session.rs b/crates/rbitcoin-store/src/uring_session.rs index 29de1e37c8..7c761bc700 100644 --- a/crates/rbitcoin-store/src/uring_session.rs +++ b/crates/rbitcoin-store/src/uring_session.rs @@ -176,7 +176,7 @@ fn finish_drain_hard_cap(pending: usize) -> Result<(), StoreError> { pub enum SessionKind { /// Linux io_uring. On Windows this opens IOCP. Uring, - /// Worker-pool completion ring (Darwin default; Linux `RBITCOIN_IO=pool`). + /// Worker-pool completion ring (`RBITCOIN_IO=pool` on Darwin or Linux). Pool, /// Windows IOCP. Iocp, @@ -1835,14 +1835,13 @@ mod tests { } #[test] + #[cfg(any(target_os = "linux", windows))] fn default_kind_follows_os_when_env_unset() { if std::env::var_os("RBITCOIN_IO").is_some() { return; } #[cfg(target_os = "linux")] assert_eq!(crate::bulk_io::resolved_session_kind(), SessionKind::Uring); - #[cfg(target_os = "macos")] - assert_eq!(crate::bulk_io::resolved_session_kind(), SessionKind::Pool); #[cfg(windows)] assert_eq!(crate::bulk_io::resolved_session_kind(), SessionKind::Iocp); } diff --git a/docs/io-modality.md b/docs/io-modality.md index b42cc60d3e..95b85474a3 100644 --- a/docs/io-modality.md +++ b/docs/io-modality.md @@ -37,11 +37,11 @@ Unknown tokens (including deleted `mmap`) fall through to the platform default. | Token | Backend | |-------|---------| | `uring` / `io_uring` | Linux `io_uring`. On Windows the token opens **IOCP** | -| `pool` | Worker-pool completion ring (Darwin **default**; Linux CI pin) | +| `pool` | Worker-pool completion ring (opt-in on Darwin; Linux CI pin) | | `iocp` | Windows IOCP (Windows **default**) | | `pread` / `fd` / `libc` / `pwrite` | Disable session; libc positional IO (+ workers for reads) | -**Defaults:** Linux `io_uring` if the ring opens, else libc **pread** (session off). Darwin **pool**. +**Defaults:** Linux `io_uring` if the ring opens, else libc **pread** (session off). Darwin libc **pread** (session off). Windows **IOCP**. Windows IoRing is not supported. **Windows table handles** are `FILE_FLAG_OVERLAPPED` so IOCP can bind. @@ -56,8 +56,11 @@ heap OVERLAPPEDs). **kqueue is not a regular-file backend.** Darwin files report ready immediately; `read` still blocks. POSIX AIO (`EVFILT_AIO`) and `dispatch_io` are also thread pools (`kern.aiomax` default 16) — same class as `pool`, not an -SQ/CQ. The pool session is the honest Darwin completion ring. Pool -**workers** are process-shared; each TLS session keeps its own CQE queue. +SQ/CQ. The pool session is the honest Darwin completion ring, but each read +costs a shared-queue lock and two thread wakeups. In signet IBD that cost more +than the reads, warm or cold cache (about 20% slower end to end, four times the +system CPU), so Darwin defaults to libc pread and `RBITCOIN_IO=pool` opts in. +Pool **workers** are process-shared; each TLS session keeps its own CQE queue. **TLS session:** one completion session per OS thread (`with_thread_local`). Nested calls panic. Harvest tracks pending `(kind, epoch, slot)`. A CQE that @@ -149,7 +152,7 @@ RAM fence (~15 MiB at 1M blocks), not a file. | Block index build (filters + tweaks) | `RBITCOIN_IO` | `read_index_window`: one held TLS session per window, four stages (`create.loc` / `input.loc` / `seqsigwit.loc` windows → block `txout` / `input.body` / `txid.body` spans → parent `create.loc` + P2TR-output `seqsigwit.body` + parent `txid.body` → parent `txout`), offsets from RAM checkpoints so nothing nests. Parents read once per window. Short/errno on a live session libc-completes; no session → serial pread through the same stages. Writes are not on the ring: one filter put and one tweak put per window. | | SP-tweak hole serve | `RBITCOIN_IO` | `tweaks_for_height` for heights the index has not sealed: idx ranges **before** TLS ring; uring/pread `txout.body`, then `seqsigwit` + parent `txout` for P2TR only. | -Default: Linux uring if the ring opens else libc pread; Darwin pool; Windows +Default: Linux uring if the ring opens else libc pread; Darwin pread; Windows IOCP. Ring depth **128** (merge may grow). `RBITCOIN_IO=pread` forces libc. ### Table transport (all fd) diff --git a/scripts/ci-os-smoke.sh b/scripts/ci-os-smoke.sh index 75fd04ee8d..d4a2b2880a 100755 --- a/scripts/ci-os-smoke.sh +++ b/scripts/ci-os-smoke.sh @@ -16,7 +16,7 @@ STORE_PLATFORM_FILTERS=( sorted_run::tests::host_mem sorted_run::tests::darwin_vm io_backend::tests - uring_session::tests::default_kind_follows_os + bulk_io::tests::unset_io_env_opens_a_session uring_session::tests::pool_ fuse8_filter::tests::no_false_negatives_and_roundtrip segmented_head::tests::insert_roll_seal_lookup_roundtrip @@ -44,6 +44,8 @@ case "${CI_OS_SMOKE_UNAME:-$(uname -s)}" in STORE_PLATFORM_SKIPS+=(concurrent_readers_during_append_and_grow) # `mod io_session_iocp` is #[cfg(windows)] — the tests do not exist elsewhere. STORE_PLATFORM_FILTERS+=(io_session_iocp) + # Linux and Windows only: Darwin opens no session by default. + STORE_PLATFORM_FILTERS+=(uring_session::tests::default_kind_follows_os) PRIMITIVES_PLATFORM_FILTERS+=(loc_simd::tests) ;; Darwin*) diff --git a/scripts/ci-os-smoke.test.sh b/scripts/ci-os-smoke.test.sh index 8298fceb77..a0f15b87f3 100755 --- a/scripts/ci-os-smoke.test.sh +++ b/scripts/ci-os-smoke.test.sh @@ -36,8 +36,10 @@ assert_ok "dry-run lists SH RAM / host_mem probes" \ grep -q "sorted_run::tests::host_mem" <<<"$out" assert_ok "dry-run lists Darwin vm page math" \ grep -q "sorted_run::tests::darwin_vm" <<<"$out" -assert_ok "dry-run lists default session kind" \ - grep -q "uring_session::tests::default_kind_follows_os" <<<"$out" +assert_ok "dry-run lists the default IO session per OS" \ + grep -q "bulk_io::tests::unset_io_env_opens_a_session" <<<"$out" +assert_ok "only Windows lists the default session kind test" \ + test "$(grep -c 'uring_session::tests::default_kind_follows_os' <<<"$out" || true)" = "0" assert_ok "dry-run lists pool session tests" \ grep -q "uring_session::tests::pool_" <<<"$out" @@ -54,6 +56,8 @@ assert_ok "Windows dry-run still confirms a few blocks" \ grep -q "chain_view_pin_asof_reorg" <<<"$out" assert_ok "Windows dry-run lists IOCP session tests" \ grep -q "io_session_iocp" <<<"$out" +assert_ok "Windows dry-run pins the IOCP default session kind" \ + grep -q "uring_session::tests::default_kind_follows_os" <<<"$out" assert_ok "Windows dry-run pins loc SIMD (SSE2) vs scalar" \ grep -qx "primitives=loc_simd::tests" <<<"$out"