From 83569e059f6874c12da39e444447f0228ece1580 Mon Sep 17 00:00:00 2001 From: Qi Wu Date: Fri, 14 Aug 2026 09:57:11 +0800 Subject: [PATCH] fix(ckpt): open loop device in direct-io mode The btrfs-loop backend attached its image with `losetup --find --show`, which leaves the loop device in buffered mode (DIO=0). Any O_DIRECT issued by btrfs on top of the loop is then silently downgraded to buffered IO against the host image file: data crosses two page caches (the loop filesystem's and the host ext4's) and every request is serialized through the single loop kernel thread. The loop backend therefore had no end-to-end O_DIRECT at all, and its data path became the bottleneck under concurrent IO. Measured on 5.10.134-19.6.3, ws-ckpt 0.4.2, 40 GiB image on a 100 GiB ESSD PL1 host, comparing the same workloads before and after: checkpoint under fio pressure 305.10 ms -> 62.55 ms (5.3x -> 1.08x of btrfs-base; p50 42 vs 41 ms and p99 237 vs 237 ms now match base) checkpoint under dual pressure 327.98 ms -> 139.75 ms 10h soak throughput 705 -> 1355 cycles/h soak checkpoint / rollback 291.07 -> 120.58 ms / 332.13 -> 107.84 ms Add `--direct-io=on` to all four `losetup --find --show` call sites, so bootstrap, post-rename remount, legacy rollback reattach and image-grow reattach all open the device in direct mode. All direct-IO handling is best-effort: when the kernel or tooling rejects it, the daemon logs a warning and keeps running in buffered mode exactly as before, never blocking bootstrap. Loops already attached in buffered mode are not switched to direct-IO on daemon restart, to keep overall system performance stable; they converge on the next reattach or host reboot. Validated on the same host with the full ws-ckpt suite after the change: 421075 operations, zero non-zero exits and zero SHA256 integrity mismatches, covering daemon kill -9 recovery, host filesystem exhaustion, image size cap, 4-process and 4-workspace concurrency, a 10h soak and 32k snapshot accumulation. Assisted-by: Qoder Signed-off-by: Qi Wu --- .../crates/daemon/src/backends/btrfs_loop.rs | 39 ++++++++++++++----- 1 file changed, 29 insertions(+), 10 deletions(-) diff --git a/src/ws-ckpt/src/crates/daemon/src/backends/btrfs_loop.rs b/src/ws-ckpt/src/crates/daemon/src/backends/btrfs_loop.rs index 57747fec71..25e62de155 100644 --- a/src/ws-ckpt/src/crates/daemon/src/backends/btrfs_loop.rs +++ b/src/ws-ckpt/src/crates/daemon/src/backends/btrfs_loop.rs @@ -498,10 +498,7 @@ impl StorageBackend for BtrfsLoopBackend { true }; if needs_mount { - let loop_device = run_command("losetup", &["--find", "--show", &img_path_str]) - .await - .context("Failed to setup loop device")?; - let loop_device = loop_device.trim().to_string(); + let loop_device = attach_loop(&img_path_str).await?; if let Err(e) = run_command_checked("mount", &[&loop_device, &mount_path_str]).await { // Mount failed after the loop device was attached; detach it so // repeated failures don't leak/exhaust loop devices. @@ -839,9 +836,8 @@ async fn try_relocate_active_legacy_mount( // Past rename: legacy is gone — we return Ok(()) regardless so the caller // uses `target`. If remount fails, bootstrap will handle it on this same run. let target_str = target.to_string_lossy().to_string(); - match run_command("losetup", &["--find", "--show", &target_str]).await { + match attach_loop(&target_str).await { Ok(new_loop) => { - let new_loop = new_loop.trim().to_string(); if let Err(e) = run_command_checked("mount", &[&new_loop, mount_path]).await { let _ = run_command_checked("losetup", &["-d", &new_loop]).await; warn!( @@ -869,10 +865,9 @@ async fn try_relocate_active_legacy_mount( /// Called after `losetup -d` succeeded, so the original loop device is gone and we /// must allocate a fresh one via `losetup --find`. async fn remount_legacy(img: &str, mount_path: &str) -> anyhow::Result<()> { - let loop_dev = run_command("losetup", &["--find", "--show", img]) + let loop_dev = attach_loop(img) .await .context("reattach legacy via losetup --find")?; - let loop_dev = loop_dev.trim().to_string(); run_command_checked("mount", &[&loop_dev, mount_path]) .await .context("remount legacy after relocation rollback") @@ -1103,10 +1098,9 @@ async fn reconcile_img_size( run_command_checked("truncate", &["-s", &target.to_string(), img_path]) .await .context("Failed to truncate image file")?; - let new_loop = run_command("losetup", &["--find", "--show", img_path]) + let new_loop = attach_loop(img_path) .await .context("Failed to reattach loop device")?; - let new_loop = new_loop.trim().to_string(); run_command_checked("mount", &[&new_loop, mount_path_str]) .await .context("Failed to remount after image shrink")?; @@ -1144,6 +1138,31 @@ async fn check_mount_busy(mount_path: &str) -> Option { } } +/// Attach `img` to a free loop device, then enable direct-IO best-effort. +/// +/// The two steps are deliberately separate: a combined +/// `losetup --find --show --direct-io=on` reports a DIO failure through its +/// exit code *after* the device is attached, which would fail bootstrap (and +/// discard the freshly attached device name) on hosts where the kernel +/// rejects DIO — tmpfs backing has no `direct_IO`, and backing devices with +/// logical blocks larger than the loop's 512 (4Kn NVMe, 4096-sector LUKS2) +/// fail the block-size check. DIO is a performance optimization, not a +/// correctness requirement, so those hosts fall back to buffered mode. +async fn attach_loop(img: &str) -> anyhow::Result { + let dev = run_command("losetup", &["--find", "--show", img]) + .await + .context("Failed to setup loop device")?; + let dev = dev.trim().to_string(); + if let Err(e) = run_command_checked("losetup", &["--direct-io=on", &dev]).await { + warn!( + "Could not enable direct-io on {} for {}: {:#}. Continuing in buffered mode; \ + checkpoint latency will be higher.", + dev, img, e + ); + } + Ok(dev) +} + /// Parse `losetup -j ` and return the backing loop device. async fn find_loop_device_for(img_path: &str) -> anyhow::Result { let out = run_command("losetup", &["-j", img_path])