From ae2909fe8df9ed5b11af7eae7bdf896b239f26ca Mon Sep 17 00:00:00 2001 From: MattJackson <1085847+MattJackson@users.noreply.github.com> Date: Fri, 8 May 2026 19:54:25 -0700 Subject: [PATCH] v0.17.10: bounded-cache writeback pipeline for big sequential writes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Pass 1 sweep speed on a healthy disc previously dipped from ~15 MB/s to ~1 MB/s every ~30 s on a host with default Linux dirty-page settings. Empirical cause: the kernel's vm.dirty_ratio (~20% of RAM) lets hundreds of MB of dirty pages accumulate, then bursts a flush at 99% disk utilisation that blocks app writes for ~1 s. Confirmed on the BU40N test bed — dirty pages grew 112 → 563 MB between bursts; lowering vm.dirty_bytes to 64 MB at the host sysctl level eliminated the dips. Shipping the equivalent inside libfreemkv so users do not need to tune the host kernel. - New crate::io::Writer: drop-in File wrapper (impl Write + Seek). Wraps a per-platform WritebackPipeline that on Linux schedules sync_file_range(WRITE) + lagging sync_file_range(WAIT_AFTER) + posix_fadvise(DONTNEED) in 32 MB chunks, bounding dirty cache at ~64 MB. macOS and Windows ship a no-op stub. - Disc::sweep wraps its output File in Writer. Loop body unchanged. - Module is purpose-built so any large sequential output (patch, mux) can adopt the same wrapper as a one-line change later. --- CHANGELOG.md | 30 +++++++++++++ Cargo.toml | 2 +- src/disc/mod.rs | 6 ++- src/io/mod.rs | 16 +++++++ src/io/writeback.rs | 17 +++++++ src/io/writeback/linux.rs | 95 +++++++++++++++++++++++++++++++++++++++ src/io/writeback/noop.rs | 18 ++++++++ src/io/writer.rs | 88 ++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 9 files changed, 271 insertions(+), 2 deletions(-) create mode 100644 src/io/mod.rs create mode 100644 src/io/writeback.rs create mode 100644 src/io/writeback/linux.rs create mode 100644 src/io/writeback/noop.rs create mode 100644 src/io/writer.rs diff --git a/CHANGELOG.md b/CHANGELOG.md index d8c77fc..fbcc6d5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,35 @@ # Changelog +## 0.17.10 (2026-05-09) + +### Bounded-cache writeback for big sequential writes + +Pass 1 sweep speed on a healthy disc previously dipped from ~15 MB/s +to ~1 MB/s every ~30 s on a host with default Linux dirty-page +settings. Cause: the kernel's `vm.dirty_ratio` (~20 % of RAM) lets +hundreds of MB of dirty pages accumulate, then bursts a flush at +99 % disk utilisation that blocks app writes for ~1 s. Confirmed +empirically on the BU40N test bed: dirty pages grew 112 → 563 MB +between bursts; lowering `vm.dirty_bytes` to 64 MB at the host +sysctl level eliminated the dips. + +This release ships the equivalent inside libfreemkv so users don't +need to tune the host kernel: + +- New `crate::io::Writer` — drop-in `File` wrapper implementing + `Write` + `Seek`. Wraps a per-platform `WritebackPipeline` that on + Linux schedules `sync_file_range(WRITE)` + lagging + `sync_file_range(WAIT_AFTER)` + `posix_fadvise(DONTNEED)` calls in + 32 MB chunks, keeping dirty cache bounded at ~64 MB. macOS and + Windows ship a no-op stub — their default cache policies don't + exhibit the same pathology for our access pattern. +- Disc::sweep wraps its output `File` in `Writer`. No changes to the + loop body — `Writer` forwards `seek`/`write_all` to `File` and + drives the pipeline transparently. +- Module is purpose-built for any large sequential output (sweep, + patch, future mux) — they can adopt `crate::io::Writer` with a + one-line wrapper and inherit the same behaviour. + ## 0.17.7 (2026-05-08) ### Sync release — no functional libfreemkv changes diff --git a/Cargo.toml b/Cargo.toml index d929fa2..ac848e4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "libfreemkv" -version = "0.17.7" +version = "0.17.10" edition = "2024" rust-version = "1.86" license = "AGPL-3.0-only" diff --git a/src/disc/mod.rs b/src/disc/mod.rs index 3792d54..18ffe76 100644 --- a/src/disc/mod.rs +++ b/src/disc/mod.rs @@ -1414,7 +1414,11 @@ impl Disc { f }; - let mut file = file; + // Wrap the raw `File` in our bounded-cache writer so the + // kernel's writeback queue drains continuously instead of + // accumulating hundreds of MB of dirty pages and then bursting + // a flush that blocks app writes (see `crate::io`). + let mut file = crate::io::Writer::new(file).map_err(|e| Error::IoError { source: e })?; let batch: u16 = match opts.batch_sectors { Some(b) => b, None if opts.skip_on_error => ecc_sectors(self.format), diff --git a/src/io/mod.rs b/src/io/mod.rs new file mode 100644 index 0000000..68a4fd0 --- /dev/null +++ b/src/io/mod.rs @@ -0,0 +1,16 @@ +//! File I/O helpers that bound kernel cache pressure on big writes. +//! +//! `Writer` is a drop-in wrapper around `std::fs::File` for any call +//! site that performs large sequential writes (sweep, mux, etc.). It +//! implements `Write` and `Seek` so existing code paths can swap +//! `File` for `Writer` with no body changes. Internally it drives a +//! `WritebackPipeline` that, on Linux, drains dirty pages continuously +//! at 32 MB granularity to avoid the kernel's accumulate-then-burst +//! flush behaviour. macOS and Windows use a no-op pipeline — their +//! default cache policies have not been shown to exhibit the same +//! pathology for this access pattern. + +mod writeback; +mod writer; + +pub(crate) use writer::Writer; diff --git a/src/io/writeback.rs b/src/io/writeback.rs new file mode 100644 index 0000000..28543eb --- /dev/null +++ b/src/io/writeback.rs @@ -0,0 +1,17 @@ +//! Per-platform writeback pipeline. On Linux, drains dirty pages +//! continuously at chunk granularity to keep the kernel's writeback +//! queue bounded. On macOS and Windows, a no-op stub. +//! +//! The platform decision lives entirely in this file (the cfg-gated +//! `pub use` below). Callers — and `DiskWriter` itself — are +//! platform-independent. + +#[cfg(target_os = "linux")] +mod linux; +#[cfg(not(target_os = "linux"))] +mod noop; + +#[cfg(target_os = "linux")] +pub(super) use linux::WritebackPipeline; +#[cfg(not(target_os = "linux"))] +pub(super) use noop::WritebackPipeline; diff --git a/src/io/writeback/linux.rs b/src/io/writeback/linux.rs new file mode 100644 index 0000000..0ae1795 --- /dev/null +++ b/src/io/writeback/linux.rs @@ -0,0 +1,95 @@ +//! Linux writeback pipeline using `sync_file_range` + `posix_fadvise`. +//! +//! Pathology this fixes: the kernel's default `vm.dirty_ratio` (~20 % +//! of RAM) lets dirty pages accumulate to hundreds of MB during a +//! big sequential write, then bursts a flush at 99 % disk utilisation. +//! While the burst runs, app writes block on the writeback queue — +//! observed empirically as instantaneous speed dropping from ~15 MB/s +//! to ~1 MB/s every ~30 s during a Pass 1 sweep. +//! +//! Strategy: every `chunk_bytes` of new sequential output, kick async +//! writeback (`SYNC_FILE_RANGE_WRITE`) on the just-completed chunk and +//! finalise the *previous* chunk via `WAIT_AFTER` + `posix_fadvise +//! (DONTNEED)`. By the time we finalise, that previous chunk has had +//! a full chunk's worth of work to flush — the wait is near-instant. +//! Dirty cache stays bounded at ~2 × `chunk_bytes` and writes drain +//! continuously instead of in bursts. + +use std::fs::File; +use std::os::unix::io::{AsRawFd, RawFd}; + +pub(crate) struct WritebackPipeline { + fd: RawFd, + chunk_bytes: u64, + last_flush_pos: u64, + pending: Option<(u64, u64)>, +} + +impl WritebackPipeline { + pub(crate) fn new(file: &File, start_pos: u64, chunk_bytes: u64) -> Self { + Self { + fd: file.as_raw_fd(), + chunk_bytes, + last_flush_pos: start_pos, + pending: None, + } + } + + /// Caller advanced the file position to `pos`. If a chunk boundary + /// was crossed, kick async writeback for the just-completed chunk + /// and finalise the previous one. + pub(crate) fn note_progress(&mut self, pos: u64) { + if pos < self.last_flush_pos.saturating_add(self.chunk_bytes) { + return; + } + unsafe { + let chunk_off = self.last_flush_pos as i64; + let chunk_len = (pos - self.last_flush_pos) as i64; + libc::sync_file_range(self.fd, chunk_off, chunk_len, libc::SYNC_FILE_RANGE_WRITE); + if let Some((prev_off, prev_len)) = self.pending.take() { + libc::sync_file_range( + self.fd, + prev_off as i64, + prev_len as i64, + libc::SYNC_FILE_RANGE_WAIT_AFTER, + ); + libc::posix_fadvise( + self.fd, + prev_off as i64, + prev_len as i64, + libc::POSIX_FADV_DONTNEED, + ); + } + self.pending = Some((chunk_off as u64, chunk_len as u64)); + } + self.last_flush_pos = pos; + } + + /// Caller is about to seek away from the current write region. + /// Drain any in-flight chunk and reset tracking. + pub(crate) fn handle_seek(&mut self, new_pos: u64) { + self.finalize(); + self.last_flush_pos = new_pos; + } + + /// Drain any in-flight chunk. Idempotent. Call before `sync_all()` + /// or when discarding the pipeline. + pub(crate) fn finalize(&mut self) { + if let Some((prev_off, prev_len)) = self.pending.take() { + unsafe { + libc::sync_file_range( + self.fd, + prev_off as i64, + prev_len as i64, + libc::SYNC_FILE_RANGE_WAIT_AFTER, + ); + libc::posix_fadvise( + self.fd, + prev_off as i64, + prev_len as i64, + libc::POSIX_FADV_DONTNEED, + ); + } + } + } +} diff --git a/src/io/writeback/noop.rs b/src/io/writeback/noop.rs new file mode 100644 index 0000000..2e10f5c --- /dev/null +++ b/src/io/writeback/noop.rs @@ -0,0 +1,18 @@ +//! No-op writeback pipeline for non-Linux targets. macOS and Windows +//! page cache policies have not been shown to exhibit the Linux +//! accumulate-then-burst flush pathology for our access pattern. +//! If that changes, replace this stub with a real implementation +//! (e.g. `F_NOCACHE` on macOS, `FILE_FLAG_WRITE_THROUGH` on Windows). + +use std::fs::File; + +pub(crate) struct WritebackPipeline; + +impl WritebackPipeline { + pub(crate) fn new(_file: &File, _start_pos: u64, _chunk_bytes: u64) -> Self { + Self + } + pub(crate) fn note_progress(&mut self, _pos: u64) {} + pub(crate) fn handle_seek(&mut self, _new_pos: u64) {} + pub(crate) fn finalize(&mut self) {} +} diff --git a/src/io/writer.rs b/src/io/writer.rs new file mode 100644 index 0000000..a9bf7fe --- /dev/null +++ b/src/io/writer.rs @@ -0,0 +1,88 @@ +//! `Writer` — a drop-in `File` wrapper that keeps the kernel page +//! cache bounded during large sequential output. +//! +//! Implements `Write` and `Seek`, so any call site that uses `File` +//! through those traits (sweep, mux, patch) can swap to `Writer` +//! without touching the body of the loop. The wrapper tracks the +//! current file position and forwards each write to a +//! [`super::writeback::WritebackPipeline`], which on Linux schedules +//! incremental `sync_file_range` + `posix_fadvise(DONTNEED)` calls +//! to drain dirty pages continuously instead of letting the kernel +//! burst-flush hundreds of MB at a time. +//! +//! See `super::writeback::linux` for the pathology and the strategy. + +use std::fs::{File, Metadata}; +use std::io::{self, Seek, SeekFrom, Write}; + +use super::writeback::WritebackPipeline; + +const CHUNK_BYTES: u64 = 32 * 1024 * 1024; + +pub(crate) struct Writer { + file: File, + pipeline: WritebackPipeline, + pos: u64, +} + +impl Writer { + /// Wrap an open `File`. The current OS file position is queried + /// once so the pipeline starts tracking from wherever the file + /// already is (typically 0 for fresh files; non-zero for resumed + /// or appended files). + pub(crate) fn new(mut file: File) -> io::Result { + let pos = file.stream_position()?; + let pipeline = WritebackPipeline::new(&file, pos, CHUNK_BYTES); + Ok(Self { + file, + pipeline, + pos, + }) + } + + pub(crate) fn metadata(&self) -> io::Result { + self.file.metadata() + } + + /// Drain in-flight writeback then issue a full fsync. Use this in + /// place of `File::sync_all`. + pub(crate) fn sync_all(&mut self) -> io::Result<()> { + self.pipeline.finalize(); + self.file.sync_all() + } +} + +impl Write for Writer { + fn write(&mut self, buf: &[u8]) -> io::Result { + let n = self.file.write(buf)?; + self.pos += n as u64; + self.pipeline.note_progress(self.pos); + Ok(n) + } + + fn write_all(&mut self, buf: &[u8]) -> io::Result<()> { + self.file.write_all(buf)?; + self.pos += buf.len() as u64; + self.pipeline.note_progress(self.pos); + Ok(()) + } + + fn flush(&mut self) -> io::Result<()> { + self.file.flush() + } +} + +impl Seek for Writer { + fn seek(&mut self, from: SeekFrom) -> io::Result { + let p = self.file.seek(from)?; + // Only treat seeks that actually move the position as + // boundaries — sweep does a redundant `seek(Current(pos))` + // before every write, and we don't want that to drain the + // pipeline on every iteration. + if p != self.pos { + self.pipeline.handle_seek(p); + self.pos = p; + } + Ok(p) + } +} diff --git a/src/lib.rs b/src/lib.rs index e6b3445..6607162 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,6 +81,7 @@ pub mod error; pub mod event; pub(crate) mod identity; pub(crate) mod ifo; +pub(crate) mod io; pub mod keydb; pub(crate) mod labels; pub(crate) mod mpls;