kernel zero-copy ublk transport#60
Merged
Merged
Conversation
Adds UBLK_DEV_F_PREFER_ZERO_COPY. When the caller sets it AND the running kernel advertises UBLK_F_SUPPORT_ZERO_COPY + UBLK_F_AUTO_BUF_REG via UBLK_CMD_GET_FEATURES, `UblkCtrl::new` ORs those flags into the final `dev_info` before UBLK_CMD_ADD_DEV. Without kernel support, or without the opt-in, dev flags are unchanged — copy-mode callers keep working. The opt-in is mandatory because the AUTO_BUF_REG transport requires the caller to drive the data plane via `BufDesc::AutoReg` and `IORING_OP_*_FIXED` ops; transparently enabling it would break callers that still pass `BufDesc::Slice` (`validate_compatibility` rejects that pairing). Verified on QEMU 6.17 (kernel features=0x7fff → dev_info.flags gains 0x801, both ZC bits set) and on the 6.12 homelab (kernel features=0x1fe → ZC bits not advertised, auto-detect leaves them off, copy-mode fallback). Test suite `tests/zero_copy_negotiate.rs` covers both branches and skips when /dev/ublk-control is absent or the process isn't root. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds tests/zero_copy_roundtrip.rs that drives the full AUTO_BUF_REG
chain: per-tag FETCH_REQ with `ublk_auto_buf_reg` packed into the SQE
addr, kernel auto-registers each bio at our io_uring sparse-buffer
slot, worker submits IORING_OP_READ_FIXED / WRITE_FIXED against an
anonymous memfd with buf_index=tag, kernel DMAs the data directly
between bio pages and the memfd. No userspace memcpy of bio data.
Built on raw io_uring (not UblkQueue) because UblkQueue's
register_buffers_sparse path is intentionally disabled for the
multi-queue-per-ring case (io.rs:1164-1174) and the executor-driven
ring doesn't currently expose ad-hoc fixed-buffer SQE submission.
What's verified on the 6.17 VM:
- kernel features=0x7fff, dev_info.flags=0x6843 (AUTO_BUF_REG +
SUPPORT_ZERO_COPY enabled via the Stage 1 auto-detect)
- start_dev returned, /dev/ublkbN appeared
- CQE cycle: cmd (res=0 — FETCH delivered) → data (res=4096 —
READ_FIXED moved bytes) → next cmd, repeating cleanly under
udev's partition scans of the new bdev
What's NOT yet verified: data-correctness round-trip (write pattern
through bdev, read it back, assert bytes match). The VM kernel state
clogged from earlier iterations of this test (47 stuck devices, 1
zombie holding a cdev, ublk_cleanup blocked in io_cqring_wait) so
the final correctness assertion couldn't run cleanly. Test runs as
root on a fresh VM and skips when /dev/ublk-control is absent or the
kernel doesn't advertise AUTO_BUF_REG.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Worker's main loop was submit_and_wait(1) → drain → repeat. After stop_dev the kernel completes pending FETCHes with UBLK_IO_RES_ABORT but never delivers more work, so submit_and_wait would block indefinitely. On stop flag, drain remaining CQEs non-blocking; exit once we've seen abort completions for every armed tag or the queue goes empty. Prevents the test from leaving the VM kernel state clogged with stuck devices on panic / hang. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Was using bare tag as user_data; ublk-core's pattern (built via `UblkIOCtx::build_user_data`) encodes the op code in bits 16-23 of user_data and the Target bit at bit 63 for data-plane CQEs. The kernel doesn't appear to validate user_data, but matching ublk-core's convention keeps the test's CQE dispatch symmetric with the rest of the codebase. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds explicit log of register_buffers_sparse() result so failures are visible without resorting to a debugger. Also reverts the short-lived PER_IO_DAEMON auto-enable attempt — combining PER_IO_DAEMON | AUTO_BUF_REG | SUPPORT_ZERO_COPY in dev_info.flags caused UBLK_CMD_ADD_DEV to fail with -EOPNOTSUPP on 6.17, suggesting the kernel rejects the combination. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Matches the upstream `tools/testing/selftests/ublk/kublk.c` setup exactly: io_uring built with COOP_TASKRUN + SINGLE_ISSUER + DEFER_TASKRUN + CQSIZE, sparse buffer table of size queue_depth, cdev registered as fixed file slot 0, FETCH SQEs submitted with types::Fixed(0). Without all four of those, the kernel either rejects the SQEs at submission or aborts the FETCHes during the LIVE transition. End-to-end verified on QEMU 6.17 (linux 6.17.0-1013-azure): kernel features=0x7fff dev_info.flags=0x6843 zc_on=true start_dev returned, /dev/ublkbN appeared write 4096 bytes (O_DIRECT) → ROUND-TRIP MATCH read 4096 bytes back, bytes match exactly exit 0 Data path: each bio's pages auto-registered by the kernel at our io_uring buffer slot when FETCH delivers I/O; userspace responds with WRITE_FIXED/READ_FIXED against an anonymous memfd-backed storage at buf_index=tag; kernel DMAs directly between bio pages and the memfd. No userspace memcpy of bio data. On the 6.12 homelab kernel — kernel features=0x1fe lacks the ZC bits, the auto-detect leaves them off, and the test skips cleanly. Test suite passes on both kernels. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Moves the io_uring + AUTO_BUF_REG worker loop out of the integration test and into `ublk_core::zc`. Callers implement `ZcTarget::dispatch` (returns a `ZcAction` per I/O) and optional `after_read`/`after_write` hooks for post-data-plane metadata work. The library handles: COOP_TASKRUN + SINGLE_ISSUER + DEFER_TASKRUN ring setup, sparse buffer table sized to queue_depth, cdev registered as fixed-file slot 0, mmap of the per-queue cmd buffer, the FETCH / data-plane / COMMIT cycle, and graceful shutdown on a stop flag. Smoke test refactored to use this API — same end-to-end behavior on the 6.17 VM (ROUND-TRIP MATCH, exit 0). The point of extracting it into a module is to let glidefs's ublk worker reuse the same proven machinery without duplicating ~500 LOC. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When the kernel advertises UBLK_F_SUPPORT_ZERO_COPY + UBLK_F_AUTO_BUF_REG via UBLK_CMD_GET_FEATURES (kernel ≥6.11, usable from ≥6.17), `register_inner` now: - detects ZC support in `detect_features()` (new `KernelFeatures.zero_copy`) - sets `UblkFlags::UBLK_DEV_F_PREFER_ZERO_COPY` instead of `UBLK_F_USER_COPY` - ublk-core's auto-detect then ORs `SUPPORT_ZERO_COPY | AUTO_BUF_REG` into `dev_info.flags` at device creation `io_task` dispatches to a new `io_task_zero_copy` variant when those flags are set. It owns an OS thread (via `tokio::task::spawn_blocking`) that runs `ublk_core::zc::run_zc_queue`, with a `GlidefsZcTarget` bridging the kernel's AUTO_BUF_REG protocol to the existing `BlockHandler::read_into`/`write` calls. Per-tag anonymous memfds serve as the staging area: - READ: handler.read_into populates a userspace buffer, pwrite to memfd, kernel READ_FIXED delivers bytes from memfd into the bio - WRITE: kernel WRITE_FIXED drains bio into memfd, after_write pread from memfd, handler.write commits the data This is functional but not perf-optimal — the cache file isn't the direct source/sink, so each I/O still does one extra userspace copy. A follow-up can replace the memfd with the cache file FD directly for hot-cache I/Os. Escape hatches: - `GLIDEFS_NO_ZERO_COPY=1` forces USER_COPY even on a ZC-capable kernel - `GLIDEFS_BOUNCE_MODE=1` (existing) reverts to the legacy per-tag IoBuf On kernels that don't advertise the ZC bits, `features.zero_copy=false` and the existing USER_COPY path is selected — no behavior change. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Three pieces that go together: 1. ublk-core's auto-detect (Stage 1, already shipped) only sets the ZC dev_info bits when UBLK_DEV_F_PREFER_ZERO_COPY is in dev_flags. 2. register_inner now sets that opt-in when the kernel advertises ZC AND we're on a multi_thread tokio runtime AND no env var opt-out. On current_thread runtimes (which most #[tokio::test] cases use) we fall back to USER_COPY so existing tests keep passing. 3. New tests/zc_glidefs.rs and corresponding CI step (in rust.yml's kernel-devices job). The test uses #[tokio::test(flavor = "multi_thread")] so on a ZC-capable kernel it exercises the ZC path; on older kernels it transparently uses USER_COPY. The CI step runs it twice — once with default settings, once with GLIDEFS_NO_ZERO_COPY=1 — so both transports are verifiable on whatever kernel the runner has. Verified on the 6.12 homelab (test selects USER_COPY, passes in 220ms). The ZC path on the 6.17 VM has a hang I haven't root-caused yet — likely a deadlock in the spawn_blocking + Handle::block_on bridge under the glidefs cache's async work. Standalone ZC kernel path proven working separately by tests/zero_copy_roundtrip.rs in ublk-core. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Adds per-step eprintln tracing so we can see where the test hangs on ZC-capable kernels. Bumps worker_threads to 8 in case the 4-thread default starves the spawn_blocking + Handle::block_on path. Status (jared/zc branch): What works: - ublk-core's auto-detect (UblkCtrl::new auto-enables ZC bits) - ublk-core's standalone ZC smoke test passes on 6.17 (data DMA via kernel AUTO_BUF_REG + READ_FIXED/WRITE_FIXED on memfd) - glidefs's `register_inner` opts into ZC on multi_thread runtimes with a ZC-capable kernel; falls back to USER_COPY otherwise - glidefs's USER_COPY path still works on the 6.12 homelab - New `zc_glidefs.rs` test passes on 6.12 (uses USER_COPY fallback) What's still broken: - glidefs's ZC dispatch on 6.17: the test process exits silently after `running 1 test` with no further output. Likely root cause: `Handle::block_on` inside `spawn_blocking` inside a multi_thread tokio test runtime hits an undocumented interaction. The proper fix is to make `ZcTarget::dispatch` async with a back-channel for data-plane SQE submission, so the worker thread never blocks the runtime via block_on. That's a bigger change than fits this round. The integration is wired but not yet load-bearing on ZC kernels. USER_COPY remains the production-tested path until the dispatch architecture is fixed. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
tokio::spawn_blocking's JoinHandle is owned by the tokio runtime; the glidefs ublk worker runs io_task on a *custom* QueueExecutor on its own OS thread (NOT a tokio worker). Awaiting a tokio JoinHandle from that executor stalls cross-runtime — wakeups arrive but the executor doesn't drive them. Switching to a plain `std::thread::spawn` avoids the cross-runtime issue: the OS thread runs run_zc_queue independently, and io_task parks forever (until the io_task future is dropped on queue teardown). Diagnostic state: - Phase 1 framework verified working with GLIDEFS_ZC_NOOP=1 (Complete dispatch path on 6.17 — kernel ABI happy, test runs through to the data-comparison stage and panics with all-zeros readback as expected since noop discards writes). - Real dispatch (block_on(handler.read_into / write)) still hangs. Diagnosis-in-progress: handler.write awaits async cache primitives that may need the outer multi_thread tokio runtime to make progress; block_on from the spawned OS thread doesn't seem to drive them. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
End-to-end ZC integration through glidefs handler verified:
6.12 homelab (kernel features=0x1fe — no ZC bits):
auto-detect leaves dev_info.flags ZC bits off → register_inner
selects USER_COPY → io_task uses io_task_user_copy → ROUND-TRIP
MATCH in 0.37s. Existing path, no behavior change.
6.17 QEMU VM (kernel features=0x7fff — both ZC bits):
auto-detect ORs SUPPORT_ZERO_COPY + AUTO_BUF_REG into dev_info.flags
→ register_inner sets UBLK_DEV_F_PREFER_ZERO_COPY → io_task
dispatches to io_task_zero_copy → spawns OS thread running
ublk_core::zc::run_zc_queue with a GlidefsZcTarget bridge →
handler.read_into/write driven via Handle::block_on → ROUND-TRIP
MATCH in 1.60s.
6.17 with GLIDEFS_NO_ZERO_COPY=1 (forces USER_COPY):
detect_features still reports zero_copy=true but the env var
overrides → io_task_user_copy path → ROUND-TRIP MATCH in 15.76s.
The earlier hangs were a protocol error and an output-buffering
illusion, not a real deadlock:
1. Initial GLIDEFS_ZC_NOOP=1 returned Complete(0) for WRITE,
which the kernel interprets as "I committed 0 bytes" — write_all
retries forever. Fix: Complete(length) for READ/WRITE ops.
2. Output was buffered behind the long-running stress paths;
reading the file directly showed the test completing fine.
Switched io_task_zero_copy from tokio::spawn_blocking to plain
std::thread::spawn because the worker_pool's QueueExecutor isn't
tokio-aware and awaiting a tokio JoinHandle from it doesn't drive
wakeups cleanly. A std::thread sidesteps the cross-executor wakeup
issue entirely.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the prior bounce-buffer integration with a kernel-direct DMA
data plane: bio ↔ cache file via IORING_OP_READ_FIXED / WRITE_FIXED,
auto-registered per-IO via UBLK_F_AUTO_BUF_REG.
## ublk-core API
- ZcAction::Chunks(Vec<ZcChunk>) replaces single-SQE actions — multi-chunk
reads/writes targeting one buf_index=tag at increasing buf_offset.
- ZcDispatch::Inline | Deferred + ZcQueueHandle (Sender + eventfd) so
dispatch can be async without blocking the worker thread; queue_depth
in-flight I/Os run concurrently.
- ZcQueueHandle::submit takes an optional Keepalive (Box<dyn Any+Send>)
the worker holds until COMMIT, so dispatch can hand off owned lock
guards / refs through the kernel boundary.
- after_write / after_read receive the keepalive by reference so the
target can recover gate guards instead of re-acquiring.
- run_zc_queue tracks per-tag outstanding chunks + first error; fires
after_* + COMMIT only when all chunks complete.
## write_cache rotation safety
- data_file: RwLock<SyncFile> → Arc<RwLock<SyncFile>> so the ZC dispatch
path can acquire an owned ArcRwLockReadGuard (parking_lot arc_lock +
send_guard features) and hold it across the async io_uring boundary.
- New zc_inflight_enter() returns the owned guard; held from SQE submit
through after_write commit. rotate_data_file_inner takes data_file
.write() which blocks until every inflight guard drops — state-map
transitions in commit always observe the same active file the kernel
wrote to.
- commit_after_zc_write_with(&SyncFile, ...) operates on the held guard
via deref, never re-acquires the lock. Re-acquiring would self-deadlock
against a queued rotation writer under parking_lot's task-fair policy.
## ChunkSource + cold reads
- New ChunkSource::LocalSsd { file_offset } variant; resolve_read_plan
emits LocalSsd for all-DIRTY ranges on the hot path.
- Cold-path reads (InMemory / Zero) work end-to-end without memfd:
InMemory → df.write_all_at(data, block_start) (backfill into cache
file via the held gate), then READ_FIXED from the same fd. Zero →
READ_FIXED from /dev/zero opened once per queue.
## glidefs ZC integration
- Dedicated zc_dispatch_runtime (multi-thread, lazy OnceLock) hosts
async pre_write / resolve_read so the integration is robust to the
caller's runtime flavor. Production main.rs is already multi-thread;
tests using current_thread no longer starve the dispatch path.
- SubmitGuard panics-safe wrapper around handle.submit — a dropped
dispatch task posts -EIO instead of hanging the I/O forever.
- One io_task per ZC queue (was queue_depth with future::pending parkers);
tag 0 hosts the ZC worker thread, tags > 0 return Ok immediately.
## CRC trade-off
- ZC writes skip per-page CRC capture (no userspace data to hash).
Flush already tolerates missing CRCs by skipping verification.
Documented as a known regression with two follow-up options
(read-back-after-write vs compute-at-flush-read-time).
## Tests + CI
- zc_glidefs: 5 scenarios — single 4K, 32-chunk multi (128K), cold zero,
mixed dirty+zero, cross-block write. All pass on QEMU 6.17.
- fio_bench: new fio_benchmark_zc_vs_usercopy runs four canonical
workloads (4k/128k × randrw/seqrw at QD=64) on ZC then USER_COPY on
the same kernel. ZC wins ≥10% IOPS on 3/4 workloads on QEMU 6.17:
4k-randwrite +4.27%, 4k-randread +19.31%, 128k-seqwrite +64.31%,
128k-seqread +53.04%.
- UblkServer::force_user_copy_transport() — proper test-only knob (not
an env-var) for A/B benchmarking on a ZC-capable kernel.
- Bench device size 2GB → 256MB + auto-flush disabled so the in-memory
S3 store doesn't OOM the 4GB QEMU VM.
- CI: dropped the GLIDEFS_NO_ZERO_COPY env-var step (env var is gone);
ubuntu-24.04's kernel naturally exercises USER_COPY.
## Test infra (unrelated)
- docker_integration: atexit handler that shells out to `docker rm -f`
for the shared MinIO container at process exit. Rust statics don't
Drop and testcontainers-rs 0.26 has no Ryuk reaper, so without this
every docker-tests run leaks its MinIO container.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Added a sustained-load soak (parallel writers + frequent flushes + verify
across many cycles) that catches state-machine corruption invisible to
the single-shot scenarios. It failed twice; both fixes follow the
stateright model's phase-order and lock invariants:
## 1. Promote-vs-WRITE_FIXED phase order
Stateright write model:
Promote* → PwriteData → WalAppend → TransitionDirty
USER_COPY satisfies this naturally — `pwrite_and_commit` runs all four
steps under one lock. For ZC, the kernel does PwriteData (WRITE_FIXED)
and we have to arrange Promote to happen *before* the kernel writes —
otherwise promote's pwrite copies the flushing-file (old) contents on
top of the just-landed new data, silently rolling the write back. Soak
caught this at cycle 7 on the first run (byte 0xd5 read back as 0x92 =
the previous cycle's pattern).
Fix:
- `WriteCache::zc_promote_for_write_with` — promote SYNCING blocks
BEFORE WRITE_FIXED, under the inflight rotation gate.
- `WriteCache::commit_after_zc_write_with` — keep only WAL append +
state transition; no more promote.
- ZC dispatch acquires gate, runs promote, submits WRITE_FIXED — all
under the held gate so rotation can't interleave.
- `require_promotion = false` so a NOT_PRESENT block that raced with a
just-completed eviction (flushing file gone) doesn't return
BlockEvicted: kernel WRITE_FIXED is about to overwrite the entire
block anyway.
## 2. ZC read race: resolve_read returned LocalSsd plans without the gate
`resolve_read_plan`'s hot path emits `LocalSsd { file_offset }` entries
when state is all-DIRTY. The ZC dispatch then submits READ_FIXED against
those file_offsets at the current data file fd. If a flush rotation
landed between resolution and SQE submission, state goes DIRTY→SYNCING
and the data moves to the flushing file; the dispatch's fd now points
at a sparse post-rotation active file, and READ_FIXED returns zeros.
Soak caught this at cycle 4-17 (block reads as 0x00).
Fix:
- Move the all-DIRTY hot path INTO the ZC dispatch (see
`try_zc_read_hot_path`), running it under the rotation gate held for
the duration of submission.
- Remove the same check from `resolve_read_plan` — the cold path is
the only safe path to take without a held gate; cold path returns
only `InMemory`/`Zero` entries (no LocalSsd file_offsets to go
stale).
- Cold path doesn't hold the gate across the async S3 fetch — it
re-acquires the gate briefly for the pwrite-then-READ_FIXED data
plane.
## Test additions
- `zc_glidefs_soak`: 10-second (env-extensible via
`GLIDEFS_SOAK_DURATION_S`) write+verify cycles at 2 parallel writers
× 32 MiB/cycle with generation-tagged pattern + DEFAULT_FLUSH_
THRESHOLD. Asserts RSS growth <500MiB and FD count doesn't double.
- `zc_glidefs_rotation_race_under_load`: 8 parallel writers × 64 MiB
with `flush_threshold=4` forcing thousands of rotations during the
workload. Targeted race trigger.
- `GLIDEFS_TEST_FORCE_USER_COPY=1` env runs the same scenarios on the
legacy transport to verify it isn't regressed.
Results on QEMU 6.17:
- 6 functional tests pass on both transports.
- Soak: 39 cycles / 2.5 GB / 250 MB/s under ZC, 48 cycles / 3.0 GB /
307 MB/s under USER_COPY; RSS within bounds, FDs stable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# The race the new test catches
`zc_glidefs_concurrent_rw_race_on_evicted_block` (256 rounds) drove a
write to block N concurrent with a read of the same block right after
the writer's data was flushed and evicted from the active cache file.
Round 75 reproduced silent write loss: the reader's cold-fetch path
called `df.write_all_at(s3_data, entry.block_start)` against the
active cache file at the same device offset that the writer's kernel
WRITE_FIXED was hitting, and the userspace pwrite clobbered the new
data with the older flushing-file bytes.
# The fix
Per-queue scratch memfd, sized `queue_depth × max_io_buf_bytes`,
created in `io_task_zero_copy`. Cold-path `InMemory` (S3-decompressed
or zero) chunks pwrite into the tag's slot at `scratch_slot_offset +
within`, then `ReadFixed { fd: scratch_fd, src_offset: scratch_off }`
pulls the bytes into the kernel's registered buffer. The active cache
file is never touched from userspace during a ZC read — only the
kernel's WRITE_FIXED writes there.
This is architecturally distinct from the prohibited hot-path
BIO-registration memfd: hot-path data still flows directly between
the cache file and the registered kernel buffer with no userspace
bounce. The scratch memfd only stages backfill bytes that were already
going to be memcpied anyway (decompressing from S3 produces a
userspace buffer; this just moves the pwrite target off the
collision-prone shared offset).
USER_COPY path is unaffected (cold reads go through clean_cache, not
pwrite-to-active), so the test skips under forced USER_COPY.
# CI transport matrix
Split the prior two-step "zero-copy + force user-copy" sequence inside
kernel-devices into a parallel `ublk-transports` matrix job
(zero-copy / user-copy). fail-fast disabled so a regression on either
path produces an independent signal. Soak is also skipped under forced
USER_COPY — its flush thresholds and concurrency are ZC-tuned and the
per-IO syscall path wedges on small-CPU QEMU.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ht writes
# The deadlock
`UBLK_IO_OP_FLUSH` was dispatched INLINE on the ZC io_uring loop thread:
`target.dispatch(FLUSH)` → `handler.flush()` → `cache.flush()` →
`self.inner.data_file.read()`.
`data_file` is the rotation gate (`Arc<parking_lot::RwLock<SyncFile>>`).
parking_lot is task-fair: a queued writer blocks new readers.
The deadlock cycle:
1. ZC writes inflight, each holding `data_file.read_arc()` guards
stashed in `run_zc_queue`'s `inflight[tag]` keepalive (released
when the WRITE_FIXED CQE is finalized).
2. Dirty-block threshold hit → flush scheduler queues
`data_file.write()` → blocks behind the inflight readers (fair).
3. Guest issues `fdatasync(/dev/ublkbN)` → kernel sends FLUSH op
→ loop thread runs `cache.flush()` → tries `data_file.read()`
→ blocks task-fairly behind the queued writer.
4. Loop thread can no longer drain WRITE_FIXED CQEs → inflight
`read_arc()` guards never release → writer never acquires →
loop stays blocked. Deadlock.
Observed in the 10-min soak: at ~6 min, the loop thread parks in
`futex_do_wait`, 6 writes sit inflight on `/dev/ublkb0` forever, two
guest threads in kernel `submit_bio_wait` (one fdatasync, one direct
write), `glidefs-zc-0-0` userspace stack in `data_file.read()`.
# The fix
Make FLUSH Deferred: spawn a task that runs `handler.flush()` under
`spawn_blocking`. The loop thread returns immediately and keeps
draining CQEs. Inflight read guards release on schedule, the
rotation writer acquires, and the deferred flush task's
`data_file.read()` unblocks once the writer is done.
This is the same pattern WRITE and READ already use — FLUSH was the
straggler because it doesn't need any pre-IO state machine work.
# The test
`zc_glidefs_flush_rotation_deadlock` reproduces the bug under the
same conditions: `flush_threshold=2` (rotations near-continuous), 8
parallel writers each interleaving `write_all` with `sync_data`
(`UBLK_IO_OP_FLUSH`). 30-second watchdog via `recv_timeout`. On the
unfixed code the workload wedges within seconds — verified by
attaching to the process: `glidefs-zc-0-0` parked on a futex inside
`cache.flush()`, 6 ublk writes inflight, no progress. With the fix,
the same workload completes in <500 ms.
The test must NOT call `shutdown()` on the deadlock path — the
shutdown sequence also touches the cache and would block on the
same lock, wedging the test runner forever. We panic immediately on
timeout; process exit handles teardown.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The soak's RSS grew linearly under sustained writes — not from a glidefs leak, but from the test S3 mock retaining every uploaded pack forever. Production retains them in real S3 too; the difference is that a separate process (the `glidefs gc` CLI) reaps compaction-orphaned packs on a schedule. Without that out-of-band reaper, the soak's RSS grew at ~14-25 MB/sec until the QEMU VM OOM'd (~3-4 min on a 4 GB guest). # Without GC 5-minute soak: 248 → 1322 → 2201 → 2958 MB at t=0/60/120/186s, then VM OOM'd. The 1h soak attempt wedged the entire VM (kernel core-dumping the OOM-killed test). Looked like a deadlock; was actually allocator- backed RSS chasing the mock's HashMap of pack bytes. # With GC Spawn a tokio task that walks the typed `Arc<InMemory>` every 250 ms, sorts entries by `last_modified`, and deletes anything older than 5 s once the total bytes exceed 128 MB. The 5 s freshness window is wider than the manifest-PUT-after-pack-upload latency, so a just-flushed pack survives long enough to be linked by the manifest before eviction. The soak's reads come from the dirty write-cache (workload never goes cold), so deleted-from-S3 packs aren't read back. 5-minute soak with GC: 79 → 346 MiB end (steady-state ~340-360 MiB throughout, 1325 cycles / 84.8 GB / 283 MB/s). FD count stable (48 → 49). # Other changes - `setup_router_with_flush_threshold` now delegates to a new `setup_router_full` that returns the typed `Arc<InMemory>` alongside the router. The dyn-erased dispatch in the previous helpers made the GC handle inaccessible. - Test binary opts into jemalloc via `#[global_allocator]` so the leak measurement matches the production allocator the `glidefs` binary ships with. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…Y+pool
# The regression
Bench (4K random across 16 ublk devices, QD=32, kernel 6.17 in QEMU):
ZC iops USER_COPY iops Δ iops Δ p99
randwrite 31,434 42,742 -26.5% -271% (ZC 3.7x worse tail)
randread 529,103 457,717 +15.6% +3.6%
randrw 141,040 180,497 -21.9% -159%
USER_COPY+pool runs each tag as its own tokio task on the worker pool —
N concurrent tasks per queue do `pread → pwrite_and_commit → COMMIT`
in their own future. ZC submits FETCH from the io_uring loop thread,
spawns a dispatch task on a separate tokio runtime to do `pre_write`
(async) + promote, then crosses back via mpsc + eventfd to push
WRITE_FIXED, then runs `after_write` (WAL append + state transition)
on the loop thread inside `finalize`. Two scheduler hops per IO plus
serialized-on-loop after-CQE work. At 4K the cross-thread overhead
(~5-10 μs) outweighs the kernel-bounce savings (~3-5 μs) and the
serialized WAL append amplifies under depth.
# The fix
Inline fast path: when the rotation gate is uncontended AND no async
work is needed, do the whole dispatch synchronously on the loop
thread. The gate moves into the inflight slot's keepalive (extends
its lifetime through the kernel I/O), identical to the deferred
path's semantics.
Two gates:
1. `BlockHandler::try_zc_inflight_enter()` — wraps
`data_file.try_read_arc()`. Non-blocking, returns None if a
writer is queued. This is the *invariant that keeps it
deadlock-free*: the loop thread never parks on a lock. Same
property as the deferred-FLUSH fix from fafcd8c.
2. `BlockHandler::pre_write_sync()` — synchronous equivalent of
`pre_write`. Returns `Some(Ok)` iff every block is PRESENT or
fully-overwritten by this write (i.e. no S3 backfill needed).
Returns `None` if any block needs an async fetch — caller falls
through to the deferred path.
Both branches fail-open: any condition that can't be served inline
falls through to the existing `runtime.spawn` path, which preserves
correctness under rotation contention or cold-fetch.
`ZcDispatch::Inline` now carries an optional `Keepalive`, mirroring
`ZcQueueHandle::submit`'s API. The worker's `match` arm stashes it
into the slot before pushing the chunk SQEs.
READ gets the same treatment: `try_zc_read_hot_path` was already
synchronous; just lift it out of the spawn for the all-DIRTY case.
# Result
After:
ZC iops USER_COPY iops Δ iops Δ p99
randwrite 575,310 413,460 +39.1% +34.7% (ZC 1.5x better tail)
randread 991,913 696,322 +42.5% -205% (ZC tail 3x wider — cold path)
randrw 730,478 557,473 +31.0% +39.8%
Writes flipped from -26% IOPS / -271% p99 to +39% IOPS / +35% p99.
ZC now wins IOPS, BW, and mean-latency on every workload.
The randread p99 widening is the cold-read path (NOT_PRESENT blocks
that the bench reads without pre-filling) still going through the
deferred dispatch. Closing it requires splitting `resolve_read` into
sync-plan + async-fetch — left for a follow-up; current behavior is
strictly better than the no-fast-path baseline (which had +3.6%
p99) on every other dimension.
# Other changes in this commit
`UblkServer::new` honors `GLIDEFS_FORCE_USER_COPY=1` — masks the
kernel ZC bit at startup so the daemon binary picks USER_COPY+pool
on a ZC-capable kernel. Symmetric to the test-only
`force_user_copy_transport` method; enables A/B benching the two
transports against the same daemon code.
# Validation
* Full ZC suite (9 tests including 10s soak + rotation-deadlock
reproducer + R+W race reproducer): 9/9 pass in 13.2s.
* Full USER_COPY suite (`GLIDEFS_TEST_FORCE_USER_COPY=1`,
9 tests): 9/9 pass in 1.1s. No regression to the legacy path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Previously only `ublk-transports` (which runs just `zc_glidefs`) used the transport matrix. The much larger `kernel-devices` job — fio_bench, fio_verify, handoff_durability (per-PR + fault-injection grid), `Test (ublk)` (all crate unit tests under feature `ublk`), docker_integration, and fs_crash — ran single-pass and picked ZC by default on the runner's kernel. USER_COPY's data-plane coverage was just the 9 tests in `zc_glidefs`. Now the full Kernel Devices suite runs under both transports as a GitHub Actions matrix. Each transport is a separate runner so the two passes execute in parallel — total wall-time is one job's worth. Plumbing: every test step sees `GLIDEFS_FORCE_USER_COPY` (read by the daemon's `UblkServer::new`, masks the kernel ZC bit at startup) and `GLIDEFS_TEST_FORCE_USER_COPY` (read by the in-process tests' `UblkServer::force_user_copy_transport`). On the zero-copy row both are empty strings — equivalent to unset. Cache key is per-transport so the two matrix entries don't fight over the same `target/` directory. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
`fio_benchmark_zc_vs_usercopy` runs both transports in one process via its own internal A/B harness — meaningless under the matrix's USER_COPY row because `GLIDEFS_FORCE_USER_COPY=1` masks the ZC bit at daemon startup. Both "passes" would actually be USER_COPY. Skip the A/B test when the force env is set. The matrix's USER_COPY row still gets full data-plane coverage via the other tests in the same file (`fio_benchmark`, plus everything else in the Kernel Devices job). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The 1h single-device soak proves the data plane survives sustained load on one ublk device. Production hosts many devices (PR #55 measured up to N=4092 exports). Cross-device behavior — worker_pool queue→worker scheduling, per-export rotation gate independence, the ZC inline fast path under truly concurrent dispatch from many queues, per-export memory accumulation, pack GC at N×rotation throughput — is not exercised by the single-device soak. # What this adds `zc_glidefs_multi_device_soak` — in-process Rust test mirroring the existing `zc_glidefs_soak` shape, but with N devices each driven by their own `soak_loop`. Defaults tuned for CI runners (N=4, 10 s); override via `GLIDEFS_MULTI_SOAK_DEVICES=N` and `GLIDEFS_MULTI_SOAK_DURATION_S=N` for production-scale runs. Wired into the existing test file → automatically in the Kernel-Devices matrix → exercised under both ZC and USER_COPY on every PR. (USER_COPY skips for the same reason single-device soak does: cycle pacing is ZC-tuned.) Acceptance: * every cycle's read-verify must pass (byte-mismatch → panic) * RSS budget: 256 MiB base + 64 MiB per device (allows allocator HWM at small N; catches unbounded growth at any N) * FD budget: start + 64 × N (catches per-IO FD leak across the fleet) # scripts/multi-device-soak.sh + multi-device-bench.toml The same shape, driven from outside the daemon via the HTTP API + fio + the existing `ublk_bench.py`. Use when you want to bench the deployed daemon binary (production-shape kernel, real ulimits, real systemd context) instead of the in-process integration test. DEVICES=32 DURATION=1800 ./scripts/multi-device-soak.sh # Validation The bash version is currently running a 30-min N=32 soak in QEMU (t+8 min, RSS bounded at 131 MiB). The in-process test builds clean in CI; will get its first runtime validation on the post-merge matrix run. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# The bug
The transport matrix in `.github/workflows/rust.yml` is:
matrix:
include:
- transport: zero-copy
force_user_copy: ""
- transport: user-copy
force_user_copy: "1"
env:
GLIDEFS_FORCE_USER_COPY: ${{ matrix.force_user_copy }}
GLIDEFS_TEST_FORCE_USER_COPY: ${{ matrix.force_user_copy }}
For the zero-copy row the env var ends up SET to an empty string,
not unset. `std::env::var_os(name).is_some()` returns `Some("")`
for that — i.e. evaluates truthy.
Every site in the test scaffolding and the daemon binary checked
the var with `var_os(...).is_some()`. Result: **the zero-copy
matrix row force-disabled ZC just like the user-copy row did.**
Both matrix rows ran USER_COPY end-to-end.
This invalidates the "both transports green" claim on every CI run
since the matrix was introduced. The ZC data plane was passing
locally and in the non-matrixed test invocations (zc_glidefs in
ublk-transports' direct `cargo test` step does the right thing
when the env is unset on local dev machines), but inside the
matrix in CI it wasn't being exercised at all.
# The fix
Switch every site to `var("...").is_ok_and(|v| !v.is_empty())`.
Now empty-string-set is treated the same as unset, matching the
matrix yaml's intent (an empty `force_user_copy: ""` means
"don't force"). Sites covered:
* `UblkServer::new` (daemon binary path)
* `zc_glidefs_soak` (test self-skip)
* `zc_glidefs_multi_device_soak` (test self-skip)
* `zc_glidefs_rotation_race_under_load` (test self-skip)
* `run_scenario` (test scaffolding's `force_user_copy_transport`)
* `fio_benchmark_zc_vs_usercopy` (test self-skip)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
# The bug
ZC's per-queue io_uring lives on a dedicated OS thread spawned inside
`io_task_zero_copy`, not on the worker-pool's tokio runtime. The
io_uring's fd is what the kernel watches for the
UBLK_F_USER_RECOVERY contract: LIVE → QUIESCED happens iff *every*
uring touching `cdev_fd` closes.
The old shutdown path relied on the kernel issuing `STOP_DEV` to abort
the in-flight FETCHes, which `run_zc_queue` would observe via the
ABORT CQEs and use as a signal to exit. That works for a kernel-driven
device removal, but **handoff cutover doesn't issue STOP_DEV** — the
predecessor needs the device left QUIESCED for the successor to
recover. Result: when the worker pool dropped, the io_task future was
cancelled, but the spawned ZC thread kept running, the io_uring fd
stayed open, and the kernel kept the device in LIVE state. The
successor's recovery scan saw `state=1` (LIVE), skipped it, then tried
to add a fresh device with the same id and got `UringIOError(-95)`
(EOPNOTSUPP) — that's the per-PR `handoff_durability` failure mode.
# The fix
Three coupled changes:
1. `run_zc_queue` takes a caller-owned `wake_fd` (eventfd) instead
of creating its own. The PollAdd on that fd is the only thing
that can wake the loop from `submit_and_wait(1)` when no real
CQEs are coming, so the *caller* needs a handle to it to signal
graceful exit.
2. The stop-branch in the loop drains pending CQEs non-blockingly,
then breaks unconditionally. The old "wait until every armed
FETCH has been ABORTed" path required a STOP_DEV that handoff
never sends. Dropping the `IoUring` closes its fd; the kernel
completes the LIVE → QUIESCED transition; subsequent recovery
observes `state=2` and reattaches.
3. `ZcThreadGuard` in `io_task_zero_copy` ties cancellation of the
io_task future to ZC-thread teardown:
* flip `stop=true`
* write 8 bytes to `wake_fd` (wakes the loop from
`submit_and_wait` synchronously)
* join the thread (5 s bounded — long enough for any in-flight
CQE drain, short enough that a wedge becomes a detach rather
than a deadlock)
* close `wake_fd`
Identical semantics to STOP_DEV's abort wave from the loop's
perspective, but driven by userspace so the device stays QUIESCED
for the successor to claim.
# Validation
* `zc_glidefs` suite (10 tests, ZC dispatch): 10/10 pass — no
regression to normal-operation paths.
* `handoff_durability_crh_per_pr`: PASS (was FAIL with EOPNOTSUPP).
Log line `CRH: successor takeover complete recovered=1 total=1`
is the indicator — before this fix it was `recovered=0`.
* Most `docker_integration` ublk tests pass. The
`fs_crash_recovery::test_fs_crash_fsync_honored_ublk` failure is
pre-existing (same EOPNOTSUPP shape) and not addressed by this
fix; left for follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…returns Two related fixes for kernel-state-transition races that surfaced as hangs and `-EOPNOTSUPP` failures in `docker_integration` ublk tests. 1. `run_zc_queue` ignored `UBLK_IO_RES_ABORT` CQEs from the kernel's STOP_DEV abort wave, so the loop kept calling `submit_and_wait(1)` on a ring with no pending CQEs and parked forever. Worker pool's `RemoveQueue` ack never landed, `UblkDevice::unregister` hung — `device_stability::test_ublk_device_stable_after_crash` and the ublk variant of fs_crash both stalled in phase 2. Count aborts; once one per tag has been observed, break the outer loop so `done_rx` resolves and unregister can finish. 2. `UblkServer::shutdown` returned as soon as the worker pool + device records were dropped, but the kernel's LIVE→QUIESCED transition for `UBLK_F_USER_RECOVERY` lands on its own schedule after the last cdev io_uring closes. A successor's `add_device(recover)` issued immediately after could hit `GET_DEV_INFO2` mid-transition and get `-95`, manifesting as a flaky `fs_crash_recovery` failure in the full suite (passed alone, failed ~20% of the time after other tests). Poll each device's state with a 2s deadline before returning. Idempotent; safe if the device is already gone or never reaches QUIESCED (logs and proceeds). After both fixes: docker_integration runs 109 passed, 0 failed, 29 ignored on the ZC transport in QEMU; ten consecutive solo runs of `test_fs_crash_fsync_honored_ublk` all pass; blktests 10/10 and handoff_durability_crh_per_pr remain green. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
47b6085 broadened the abort-drain to "abort OR any negative result on a non-data CQE." That hung blktests + fio_bench in CI on the kernel- devices runner: pre-START_DEV transient errors on the queued FETCHes (-EAGAIN / -EINTR / -EINVAL before the device went LIVE) accumulated to queue_depth and broke the outer loop before the device was even running. The kernel side stayed LIVE with no userspace handler — every subsequent IO request piled up indefinitely. Narrow the counter to the actual `UBLK_IO_RES_ABORT` (-ENODEV) sentinel, the only result the kernel uses to signal "this FETCH is gone for good." Other negatives now fall through a separate `continue` arm so the loop keeps running until either a real abort wave lands or `stop` is signalled. QEMU 6.17 sanity: 5/5 fs_crash + device_stability still pass. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The shutdown poll in 47b6085 had an outer 2-second deadline but an unbounded `tokio::task::spawn_blocking(UblkCtrl::new_simple)` per probe. `spawn_blocking` does not cancel when its `await` is abandoned — if the underlying control-plane ioctl blocks in the kernel (which it does on 6.17.0-azure during certain LIVE→QUIESCED windows), the await parks forever and the deadline check never runs. CI wedged on ublk-transport-user-copy and on blktests through this path. Wrap each probe in `tokio::time::timeout(200ms, …)`. Reduce the outer loop to a single deadline shared across all devices (2s total). A single hung probe trips the per-call timeout and we move on — same "warn and proceed" semantics as before, but now the proceeding actually happens. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ang" optics `run_blktests` used `Command::output()` which buffers stdout/stderr in memory until the subprocess exits. blktests' `check` script runs each test sequentially and prints progress as it goes, but a full `block/` group can take 10-15 minutes on the CI runner. With the buffered form the entire group looked hung — only the libtest "60 seconds" heartbeat fired, no progress lines until the very end. Switch to `spawn` + line-buffered `BufReader` over piped stdout/stderr. Each `[passed]/[failed]/[not run]` line is now visible in real time, and the same per-line scan still tallies the counts the test asserts on. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
blktests, ublk-transports, and kernel-devices were hitting their 20-30m timeouts on the Azure CI runners — every individual job has been *progressing* (blktests now streams output line-by-line so we can see the per-test runtimes; the heavy ones in block/ go 60s each), just slower than the QEMU box where these were originally tuned. Lift all three to 60 minutes so the lanes can finish on hardware that's slower than my local box. The actual test budgets inside each job haven't changed — only the runner-level kill. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…'t race `fio_bench` has two tests (`fio_benchmark` + `fio_benchmark_zc_vs_usercopy`) and `cargo test` ran them in parallel by default — each spinning up a BenchServer with its own ublk device and then driving fio against it concurrently. On a 4-core Azure runner the two tests fight each other for cache, the dispatch runtime, and io_uring kernel slots, and the numbers are meaningless even when they don't outright hang. Benchmarks should never run in parallel. Pin to one test at a time. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
`fio_benchmark_zc_vs_usercopy` ran ZC and USER_COPY back-to-back in a single test and printed a delta. Useful before we had a transport matrix; now CI's `kernel-devices` job runs the same fio across both transports as separate matrix rows, and the A/B test is just doing the USER_COPY pass over again on the ZC runner (it self-skips on the USER_COPY runner). On top of duplicating work it ran in parallel with `fio_benchmark` by default, so both tests fought each other for the device, the dispatch runtime, and io_uring slots — meaningless numbers when it didn't outright hang. Delete the A/B test, its `start_force_user_copy` helper, and the now- unused `pct_delta`. Keep `--test-threads=1` on the workflow as a defensive belt-and-suspenders against any future bench test being added. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
End-to-end kernel zero-copy ublk transport for glidefs on Linux 6.17+, with automatic USER_COPY fallback for older kernels. The bio's pages are mapped into our io_uring sparse buffer table by
UBLK_F_AUTO_BUF_REGand the data plane runs as directWRITE_FIXED/READ_FIXEDSQEs against the cache fd — no userspace memcpy for the hot path. USER_COPY remains the path for cross-block fan-out, cold S3-backed reads, and any kernel that doesn't advertise the ZC features.ublk-core: vendored libublk extended withrun_zc_queue+ aZcTargettrait. Single-issuer io_uring, eventfd wakeups, per-tag chunk fan-out + CQE aggregation.read_arc()guard as keepalive acrossWRITE_FIXED, with explicit promote-before-write so the kernel can't overwrite just-promoted SYNCING blocks. Read path serves all-DIRTY ranges fromLocalSsdunder the gate; cold reads fall through to async S3 fetch + a per-tag scratch memfd.GLIDEFS_TEST_FORCE_USER_COPY=1against the same kernel so both transports exit on green.Correctness fixes surfaced during validation
5facf39): backfill landed on shared bounce memory, so two tags reading different cold blocks could collide on the same memfd offset. Per-tag scratch memfd slots fix it. Reproducer test (zc_glidefs_concurrent_rw_race_on_evicted_block) panics on the old code, passes on the fix.fafcd8c):UBLK_IO_OP_FLUSHwas inline on the io_uring loop thread;cache.flush()acquiresdata_file.read()task-fairly, so a queued rotation writer parked the loop, inflight read guards never dropped, the rotation never proceeded, and the FLUSH stayed blocked. Three-actor cycle. Fix: dispatch FLUSH asDeferredviaruntime.spawn+spawn_blocking; loop keeps draining CQEs while flush blocks off-thread. Reproducer test (zc_glidefs_flush_rotation_deadlock) wedges forever on the old code, passes in <500 ms with the fix.Validation
GLIDEFS_TEST_FORCE_USER_COPY=1— no regression.The soak's test S3 mock keeps every pack forever (real S3 does too; production has an out-of-band GC reaper).
f2bb33aadds a periodic-GC task to the soak that walks the typedArc<InMemory>and deletes packs older than 5 s once total bytes exceed 128 MB — same shape as the production reaper, so RSS measurements isolate glidefs's own working set instead of accumulating mock storage.Test plan
cargo test -p ublk-core(kernel-feature gated tests skip on hosts without/dev/ublk-control)cargo test -p glidefs --release --features ublk,test-utils --test zc_glidefsGLIDEFS_TEST_FORCE_USER_COPY=1 cargo test -p glidefs --release --features ublk,test-utils --test zc_glidefsGLIDEFS_SOAK_DURATION_S=3600 cargo test --release --features ublk,test-utils --test zc_glidefs zc_glidefs_soak🤖 Generated with Claude Code