From 4594196b6ea5fa9109749024dbbc7618c27bad09 Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Sun, 17 May 2026 10:36:04 -0700 Subject: [PATCH] iter14: re-engage FileSectorSource 4 MiB readahead buffer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit iter13 strace finding: producer thread (mux ISO reader) spends 80% of wall-clock in state D (NFS RPC wait), doing 207 preads/sec at 103 us each. Pipe is 78 MB/s isolated read; we use ~25 MB/s. FileSectorSource was using direct per-sector pread (0.21.3 bypass). That bypass was justified under Phase 2.5 + 0.21.7 producer-poll cap ("32 MiB refill bursts the TCP connection enough to starve the writer thread"). Both of those conditions are gone now (iter8 baseline: no Phase 2.5, no producer poll cap). The 4 MiB buffer infrastructure was preserved with #[allow(dead_code)] in case re-engagement was ever wanted. iter14 just routes the hot path through buffer_covers + refill + memcpy. Net diff: ~10 LOC of business logic, 5 unit tests already cover the contract. Expected: producer's effective read rate jumps from 25 → 60+ MB/s (matches dd ceiling for 1+ MiB block reads). If consumer side keeps up, mean mux climbs from 28.7 → 35-50 MB/s. If consumer is now the cap, we see a clear ceiling around 30-35 and we know where to look next. Critically: this change ONLY affects mux-from-ISO. Disc→ISO sweep, Pass N bad-sector retry, AACS, drive ops, mapfile, recovery — all untouched (they use DriveSectorSource which is a separate impl). --- src/io/file_sector_source/mod.rs | 61 ++++++++++++++++---------------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/src/io/file_sector_source/mod.rs b/src/io/file_sector_source/mod.rs index 99b2b98..99f9330 100644 --- a/src/io/file_sector_source/mod.rs +++ b/src/io/file_sector_source/mod.rs @@ -103,14 +103,11 @@ pub struct FileSectorSource { /// Total file size in sectors. Constant after construction; /// surfaced via [`SectorSource::capacity_sectors`]. capacity: u32, - /// 0.21.3+: the app-level buffer is no longer touched on the hot - /// path (every `read_sectors` is a direct pread). The fields are - /// retained so a future per-source-type policy (e.g. a local-disk - /// source where batched reads ARE beneficial) can re-enable - /// buffering cleanly without re-plumbing the struct. - #[allow(dead_code)] + /// 4 MiB readahead buffer. iter14 (2026-05-17): re-engaged after + /// the 0.21.3 bypass. On a file (vs an optical drive) per-sector + /// reads pay one NFS RPC per 2048 bytes. The buffer amortises + /// those RPCs to one pread per BUF_SECTORS (~2048 sectors). buf: Box<[u8]>, - #[allow(dead_code)] buf_start_lba: u32, buf_len_sectors: u32, /// 0.21.6: bytes read since the last DONTNEED drop. Drives the @@ -167,7 +164,6 @@ impl FileSectorSource { /// True if `[lba, lba + count)` is wholly inside the current /// buffer window. `count == 0` is vacuously true. - #[allow(dead_code)] fn buffer_covers(&self, lba: u32, count: u32) -> bool { if self.buf_len_sectors == 0 { return false; @@ -183,7 +179,6 @@ impl FileSectorSource { /// Refill the buffer so it starts at `lba`. Read as many sectors /// as we have buffer space AND file capacity for. Caller has /// already checked `lba < capacity`. - #[allow(dead_code)] fn refill(&mut self, lba: u32) -> Result<()> { debug_assert!(lba < self.capacity, "refill past capacity"); // Don't read past EOF — clamp the request to remaining @@ -226,28 +221,34 @@ impl SectorSource for FileSectorSource { if count == 0 { return Ok(0); } - // 0.21.3: bypass the application-level buffer entirely. + // iter14 (2026-05-17): route through the 4 MiB readahead + // buffer. The 0.21.3 bypass was justified under Phase 2.5 + // architecture + producer-poll cap (both gone now). With iter8 + // baseline (no Phase 2.5, no producer poll cap), one pread per + // 2048-byte sector forces ~5000+ NFS RPCs/sec; the producer + // thread sits in `D` state 80% of the time waiting on RTT. + // A 4 MiB window amortises that to one pread per ~2048 sectors + // (~11 ms refill at NFS 91 MB/s ceiling). // - // Empirically the 32 MiB readahead window (0.21.0–0.21.1) and the - // 4 MiB shrink (0.21.2) both regressed mux throughput vs the - // pre-Phase-1 0.20.7 baseline on NFS bidirectional workloads - // (sweep ~25 MB/s OK; mux dropped from 18 → 7-8 → 5-6 MB/s). - // Direct pread per call lets the kernel's own readahead policy - // run, which interleaves naturally with concurrent NFS writes on - // the same TCP connection. - // - // Buffer fields are retained (currently unused on this path) so - // any future per-source policy can be reintroduced without - // re-plumbing structure. `refill` / `buffer_covers` are kept too - // (still exercised by the tests so the API contract is locked). - let offset = lba as u64 * SECTOR_SIZE as u64; - self.file - .seek(SeekFrom::Start(offset)) - .map_err(|e| Error::IoError { source: e })?; - self.file - .read_exact(&mut out[..bytes]) - .map_err(|e| Error::IoError { source: e })?; - self.buf_len_sectors = 0; + // Pathological-large request bypasses the buffer (caller asked + // for more than fits — defensive, sweep doesn't do this). + if count > BUF_SECTORS { + let offset = lba as u64 * SECTOR_SIZE as u64; + self.file + .seek(SeekFrom::Start(offset)) + .map_err(|e| Error::IoError { source: e })?; + self.file + .read_exact(&mut out[..bytes]) + .map_err(|e| Error::IoError { source: e })?; + self.buf_len_sectors = 0; + } else { + if !self.buffer_covers(lba, count) { + self.refill(lba)?; + } + let off_sectors = (lba - self.buf_start_lba) as usize; + let off_bytes = off_sectors * SECTOR_SIZE; + out[..bytes].copy_from_slice(&self.buf[off_bytes..off_bytes + bytes]); + } // 0.21.6: periodic page-cache eviction on the read side. Without // this, an 85 GB streaming ISO read pins the entire file in