These results compare the two BlockStorage backends behind the
persistent-artrie feature: MmapDiskManager (memory-mapped file I/O, the
default) and IoUringDiskManager (Linux io_uring submission/completion rings
with files opened O_DIRECT, gated by io-uring-backend). Both implement the
same trait and present the same persistent-dictionary APIs, so the question is
purely which backend wins which workload — and the answer is workload-shaped
rather than one-sided.
Headline finding. mmap wins single-block I/O — the kernel page cache
absorbs the fault, and msync only marks pages dirty (it is not an fsync),
so cached single-block latency and sync are cheap. io_uring + O_DIRECT wins
batch I/O and true durability — one submission drains many requests with a
single syscall and the transfer bypasses the page cache, so there is no
write-back gap for device-level durability. The per-phase numbers below quantify
exactly where each backend leads; the rule of thumb is to keep the mmap default
for cached, latency-sensitive serving and reach for io_uring for eviction-heavy
or batch-flush workloads that also need durability without the page-cache gap.
2026-07-10 update. Phases 1–5 were recorded on an Intel Xeon (2026-02-20/21). A full real-disk re-run on the current AMD Threadripper workstation — Phase 6 — confirms the workload-shaped split and sharpens it: on real NVMe (not the earlier RAM-speed scratch) mmap's cached-read/eviction lead is much larger (13–32x), and io_uring's true-durability win becomes decisive (~34x on
fsync) — a result the earlier run's RAM-speedsynchid. See Phase 6 for the current-hardware numbers, plots, andperfcounters.
| Component | Specification |
|---|---|
| CPU | Intel Xeon E5-2699 v3 @ 2.30GHz (36 cores/72 threads, Turbo: 3.57 GHz) |
| RAM | 252 GB DDR4-2133 ECC Registered (8x32 GB, 4 NUMA nodes) |
| Storage | Samsung 990 PRO 4TB NVMe (PCIe Gen 3, firmware 7B2QJXD7) |
| OS | Linux 6.18.9-arch1-2 (Arch Linux) |
| Rust | Stable, release profile (optimized) |
| CPU Governor | performance (all cores) |
| CPU Affinity | taskset -c 0-3 (mmap), taskset -c 4-7 (io_uring) |
| Block Size | 256 KB |
| Date | 2026-02-20 |
| Benchmark | p50 | p99 | p999 | max | mean | ops/s |
|---|---|---|---|---|---|---|
| seq_read_block (mmap) | 32.0 us | 54.4 us | 63.2 us | 72.8 us | 33.5 us | ~29.9K |
| rand_read_block (mmap) | 32.1 us | 55.2 us | 64.5 us | 73.6 us | 33.6 us | ~29.8K |
| seq_write_block (mmap) | 44.1 us | 71.2 us | 80.8 us | 88.0 us | 46.1 us | ~21.7K |
| rand_write_block (mmap) | 44.3 us | 69.6 us | 80.0 us | 85.6 us | 46.0 us | ~21.7K |
| Benchmark | p50 | p99 | p999 | max | mean | Criterion time |
|---|---|---|---|---|---|---|
| sync (mmap, 100 ops) | 1.4 us | 1.6 us | 12.0 us | 13.0 us | 1.5 us | 143 us |
Note: mmap sync is extremely fast because msync on memory-mapped I/O only marks pages dirty for the kernel's writeback — it does not perform an fsync. The kernel page cache handles actual write ordering.
| Benchmark | p50 | p99 | p999 | max | mean | Criterion time |
|---|---|---|---|---|---|---|
| pressure_read (mmap) | - | - | - | - | - | 35.2 ms |
| mixed 80/20 (mmap) | - | - | - | - | - | 40.2 ms |
| Benchmark | Criterion time |
|---|---|
| batch_read (mmap) | 3.04 ms |
| Counter | Value |
|---|---|
| page-faults | 2,395,073 |
| minor-faults | 2,395,073 |
| major-faults | 0 |
| dTLB-load-misses | 9,431,359 |
| LLC-load-misses | 67,959,563 |
| instructions | 36,511,002,829 |
| cycles | 48,937,815,296 |
| IPC | 0.746 |
| Metric | mmap | io_uring | Ratio (io_uring/mmap) |
|---|---|---|---|
| p50 | ~32 us | ~61 us | 1.9x slower |
| p99 | ~90 us | ~210 us | 2.3x slower |
| p999 | ~350 us | ~350 us | ~1.0x (similar tail) |
| mean | ~36 us | ~69 us | 1.9x slower |
| ops/s | ~27,500 | ~14,400 | 0.52x |
| Metric | mmap | io_uring | Ratio (io_uring/mmap) |
|---|---|---|---|
| p50 | ~44 us | ~74 us | 1.7x slower |
| p99 | ~120 us | ~225 us | 1.9x slower |
| p999 | ~350 us | ~400 us | 1.1x |
| mean | ~49 us | ~84 us | 1.7x slower |
| ops/s | ~20,600 | ~11,900 | 0.58x |
The single-block latency figure has been refreshed to the current-hardware re-run and now lives in Phase 6. The Phase 3 values (mmap Read 32/90, Write 44/120 µs; io_uring Read 61/210, Write 74/225 µs) remain in the two tables above as recorded history.
| Metric | mmap | io_uring | Ratio |
|---|---|---|---|
| p50 | 1.5 us | 7.4 us | 4.9x slower |
| p99 | 5.9 us | 37 us | 6.3x slower |
| mean | 1.7 us | 9.0 us | 5.3x slower |
| Metric | mmap | io_uring | Ratio |
|---|---|---|---|
| Criterion throughput | 28.4 Kelem/s | 21.1 Kelem/s | 0.74x |
| Criterion mean time | 35.2 ms | 47.1 ms | 1.34x slower |
| Metric | mmap | io_uring | Ratio |
|---|---|---|---|
| Criterion throughput | 24.9 Kelem/s | 19.2 Kelem/s | 0.77x |
| Criterion mean time | 40.2 ms | 51.8 ms | 1.29x slower |
| Metric | mmap (sequential) | io_uring (batch SQE) | io_uring (sequential) |
|---|---|---|---|
| Criterion time | 3.04 ms | 2.15 ms | 3.84 ms |
| Throughput | 21.0 Kelem/s | 29.8 Kelem/s | 16.7 Kelem/s |
| vs mmap | 1.0x | 1.41x faster | 0.79x |
The batch-read throughput figure has been refreshed to the current-hardware re-run and now lives in Phase 6, where the result reverses (on real disk mmap's cache-served batch leads). The Phase 3 values (mmap 21.0, io_uring batch 29.8, io_uring seq 16.7 Kelem/s) remain in the table above as recorded history.
| Metric | StdFsync | IoUringFsync | Ratio |
|---|---|---|---|
| Per-record sync (p50) | 5.9 us | 6.1 us | 1.03x |
| Per-record sync (p99) | 17.5 us | 17.5 us | 1.0x |
| Per-record sync (mean) | 6.2 us | 6.5 us | 1.05x |
| Per-record throughput | 161 Kops/s | 153 Kops/s | 0.95x |
| Batched 100 records | 525 ns | 7.56 us | 14.4x slower |
| Operation | mmap | io_uring | Ratio |
|---|---|---|---|
| Insert (10K terms + sync) | 19.3 ms | 19.8 ms | 1.03x slower |
| Query (10K lookups) | 1.43 ms | 1.44 ms | 1.01x slower |
| Insert throughput | 518 Kelem/s | 505 Kelem/s | 0.97x |
| Query throughput | 7.0 Melem/s | 6.96 Melem/s | 0.99x |
| Counter | mmap (latency_report) | io_uring (cmp_summary) |
|---|---|---|
| page-faults | 2,395,073 | 1,198,719 |
| minor-faults | 2,395,073 | 1,198,710 |
| major-faults | 0 | 9 |
| dTLB-load-misses | 9,431,359 | 8,213,610 |
| LLC-load-misses | 67,959,563 | 17,827,156 |
| instructions | 36,511M | 36,625M |
| cycles | 48,938M | 35,033M |
| IPC | 0.746 | 1.045 |
| user time | 16.87s | 11.94s |
| sys time | 7.36s | 10.79s |
| Metric | mmap | io_uring | Winner |
|---|---|---|---|
| Single-block read | 33 us | 69 us | mmap (1.9x) |
| Single-block write | 46 us | 84 us | mmap (1.7x) |
| Batch read (64 blocks) | 2.14 ms | 2.14 ms | io_uring (1.41x with SQE batching) |
| Trie insert (10K terms) | 19.3 ms | 19.8 ms | Tied (~1%) |
| Trie query (10K lookups) | 1.43 ms | 1.44 ms | Tied (~1%) |
| Page faults | 2.4M | 1.2M | io_uring (50% fewer) |
| LLC misses | 68M | 18M | io_uring (74% fewer) |
mmap wins on single-block I/O (1.7-1.9x faster): For individual block reads/writes, mmap's kernel page cache provides lower latency because it avoids the syscall overhead of io_uring SQE submission + CQE harvesting. Each io_uring operation requires a full round-trip through the submission/completion queue, which adds ~30-40 us per operation.
io_uring wins on batch I/O (1.41x faster): When multiple blocks are submitted as a single batch via read_blocks_batch, io_uring's SQE batching amortizes the syscall overhead across all blocks. This is the primary advantage of io_uring for arena flushes and checkpoint operations.
Trie-level operations are nearly identical (~1% difference): At the trie API level (insert/query), both backends perform equivalently because most operations are served from the in-memory buffer pool. Block I/O only occurs during cache misses and flush operations, which are rare during normal operation.
io_uring eliminates kernel page cache overhead:
WAL fsync is equivalent per-record, worse batched: For per-record fsync, IoUringFsync and StdFsync perform identically (~6 us) because both ultimately issue a single fsync. For batched fsync, StdFsync is faster because file.sync_all() is a single syscall, while IoUringFsync has SQE/CQE overhead.
mmap sync is deceptively fast: mmap's "sync" (~1.5 us) only marks pages dirty — it does NOT issue fsync. This means mmap durability relies on the kernel's writeback daemon, while io_uring's sync (~9 us) actually performs IORING_OP_FSYNC for true durability.
| Use Case | Recommended Backend | Reason |
|---|---|---|
| General-purpose dictionary | mmap (default) | Lower latency for single-block operations |
| Large arena flushes / checkpoints | io_uring | Batch SQE submission amortizes overhead |
| Memory-constrained systems | io_uring | No double-caching (no kernel page cache) |
| Predictable latency (real-time) | io_uring | No mmap page fault surprises |
| Maximum throughput (single thread) | mmap | Lower per-operation overhead |
| Multiple concurrent I/O streams | io_uring (potential) | SQE batching across streams (future optimization) |
AlignedBlock Pool: Pre-allocated freelist of Box<AlignedBlock> to avoid per-operation heap allocation. Currently documented as pending in io_uring_disk_manager.rs.
Pre-registered Buffers: io_uring supports IORING_REGISTER_BUFFERS for zero-copy I/O, which would eliminate the buffer copy from userspace to kernel. This could close the gap with mmap for single-block operations.
Per-thread Rings: Replace Mutex<IoUring> with per-thread rings to eliminate lock contention under high concurrency. Current profiling shows negligible contention.
Adaptive Backend Selection: Automatically choose mmap for small datasets (fits in RAM) and io_uring for large datasets (exceeds RAM) where double-caching is harmful.
Batched Dirty Block Flush: Collect dirty blocks during normal operations and flush them as a single batch via write_blocks_batch during sync, rather than flushing one-by-one.
io_backend_benchmarks --features persistent-artrie)seq_read_block/mmap time: [36.579 ms 36.719 ms 36.876 ms] thrpt: [27.769-27.995 Kelem/s]
rand_read_block/mmap time: [36.200 ms 36.389 ms 36.716 ms] thrpt: [27.237-27.626 Kelem/s]
seq_write_block/mmap time: [47.023 ms 47.178 ms 47.349 ms] thrpt: [21.120-21.266 Kelem/s]
rand_write_block/mmap time: [46.727 ms 47.179 ms 47.605 ms] thrpt: [21.006-21.401 Kelem/s]
sync_latency/mmap time: [142.13 us 143.78 us 145.08 us] thrpt: [689.25-703.57 Kelem/s]
memory_pressure_read/mmap time: [35.007 ms 35.235 ms 35.526 ms] thrpt: [28.148-28.566 Kelem/s]
mixed_pressure/mmap time: [39.808 ms 40.155 ms 40.519 ms] thrpt: [24.680-25.121 Kelem/s]
batch_read/mmap time: [2.6927 ms 3.0416 ms 3.3713 ms] thrpt: [18.984-23.768 Kelem/s]
io_uring_comparison_benchmarks --features io-uring-backend)cmp_pressure_rand_read/mmap time: [35.061 ms 35.279 ms 35.548 ms] thrpt: [28.131-28.522 Kelem/s]
cmp_pressure_rand_read/io_uring time: [46.918 ms 47.069 ms 47.270 ms] thrpt: [21.155-21.314 Kelem/s]
cmp_pressure_mixed/mmap time: [39.917 ms 40.191 ms 40.439 ms] thrpt: [24.728-25.052 Kelem/s]
cmp_pressure_mixed/io_uring time: [51.406 ms 51.809 ms 52.161 ms] thrpt: [19.171-19.453 Kelem/s]
cmp_batch_read/mmap_sequential time: [1.9720 ms 2.1393 ms 2.3221 ms] thrpt: [27.557-32.455 Kelem/s]
cmp_batch_read/io_uring_batch time: [2.0898 ms 2.1441 ms 2.2116 ms] thrpt: [28.937-30.625 Kelem/s]
cmp_batch_read/io_uring_seq time: [3.7373 ms 3.8348 ms 3.9363 ms] thrpt: [16.260-17.120 Kelem/s]
cmp_wal_fsync/std_fsync time: [6.0816 ms 6.1592 ms 6.2519 ms] thrpt: [159.95-164.43 Kelem/s]
cmp_wal_fsync/io_uring_fsync time: [6.4373 ms 6.4988 ms 6.5698 ms] thrpt: [152.21-155.34 Kelem/s]
cmp_wal_fsync/std_fsync_batched time: [521.89 ns 525.42 ns 529.36 ns]
cmp_wal_fsync/io_uring_batched time: [7.5053 us 7.5626 us 7.6237 us]
cmp_trie_insert/mmap time: [19.152 ms 19.319 ms 19.409 ms] thrpt: [515.24-522.14 Kelem/s]
cmp_trie_insert/io_uring time: [19.584 ms 19.807 ms 20.264 ms] thrpt: [493.48-510.63 Kelem/s]
cmp_trie_query/mmap time: [1.4263 ms 1.4295 ms 1.4342 ms] thrpt: [6.9723-7.0113 Melem/s]
cmp_trie_query/io_uring time: [1.4210 ms 1.4371 ms 1.4580 ms] thrpt: [6.8587-7.0375 Melem/s]
cmp_single_block_read_fixed/mmap time: [970.94 ns 992.28 ns 1.0142 µs] thrpt: [15.776-16.479 Melem/s]
cmp_single_block_read_fixed/io_uring_fixed time: [1.0053 µs 1.0147 µs 1.0298 µs] thrpt: [15.536-15.916 Melem/s]
cmp_single_block_read_fixed/io_uring_std time: [570.88 µs 580.33 µs 591.76 µs] thrpt: [27.038-28.027 Kelem/s]
cmp_single_block_write_fixed/mmap time: [1.2473 µs 1.2823 µs 1.3469 µs] thrpt: [11.879-12.828 Melem/s]
cmp_single_block_write_fixed/io_uring_fixed time: [1.0514 µs 1.0747 µs 1.0966 µs] thrpt: [14.590-15.217 Melem/s]
cmp_batch_flush_dirty/mmap_sync time: [1.0528 µs 1.0721 µs 1.0899 µs] thrpt: [58.721-60.789 Melem/s]
cmp_batch_flush_dirty/io_uring_batched_sync time: [15.523 ms 16.141 ms 17.011 ms] thrpt: [3.7624-4.1229 Kelem/s]
cmp_flush_all_fixed/mmap time: [303.09 µs 307.34 µs 311.41 µs] thrpt: [51.379-52.790 Kelem/s]
cmp_flush_all_fixed/io_uring_fixed time: [610.37 µs 617.98 µs 624.13 µs] thrpt: [25.636-26.214 Kelem/s]
Insert and query throughput are within 1% of each other. This is the expected outcome: the buffer pool absorbs virtually all I/O, so the storage backend only matters during cache misses and flushes, which are rare during normal operation. For the vast majority of workloads, users will not notice which backend they are on.
mmap is consistently 1.7-1.9x faster for individual block reads and writes. The root cause is clear: each io_uring operation pays a fixed ~30-40 us overhead for SQE submission + CQE harvesting, while mmap's page cache serves reads from memory with zero syscalls (just a TLB lookup or minor fault). For 256 KB blocks, that per-operation overhead is proportionally large.
This is a well-known characteristic in the io_uring literature — io_uring's advantage is amortization, not per-operation latency. And the batch read results confirm this directly (1.41x faster with SQE batching).
The most important differences between the two backends do not show up as raw latency numbers:
True durability: mmap's "sync" at 1.5 us is misleading — msync only marks
pages dirty for the kernel writeback daemon. It does not perform an fsync. For
ACID compliance, mmap would need an explicit fsync() call that would cost far more
than 1.5 us. io_uring's 9 us sync actually issues IORING_OP_FSYNC, providing real
durability guarantees.
Predictability under memory pressure: mmap's p999 tails in the summary report reach 500-1000 us (page fault storms), while io_uring's tails are more bounded at 300-600 us. With O_DIRECT, there are no surprise major faults.
74% fewer LLC misses: This is significant for production systems running other workloads alongside the trie. mmap pollutes the entire L3 cache with kernel page cache copies that duplicate the buffer pool's own cache. io_uring's O_DIRECT keeps the L3 clean for other consumers.
Keep mmap as the default — it is faster for the common case and requires no special kernel support. The io_uring backend is the right choice for:
fsync (not msync) is needed~~The biggest low-hanging fruit is pre-registered buffers (IORING_REGISTER_BUFFERS).~~
~~This eliminates the kernel's copy_from_user/copy_to_user on every I/O, which is~~
~~likely the dominant cost in the ~30-40 us per-operation overhead. This could close the~~
~~single-block gap to within 1.2-1.3x of mmap, making io_uring competitive across the~~
~~board while retaining its batch and durability advantages.~~ DONE — see Phase 4 below.
~~The batched dirty block flush optimization is also compelling — collecting dirty~~
~~blocks during normal operations and flushing them as a single write_blocks_batch during~~
~~sync would let io_uring's batch advantage directly benefit the most common I/O pattern~~
~~(periodic checkpoint/sync).~~ DONE — see Phase 4 below.
Date: 2026-02-21
CPU Affinity: taskset -c 0-3 (both backends)
RLIMIT_MEMLOCK: 8192 KB (soft=hard), pre-registered buffer pool limited to 16 frames (4MB)
Two optimizations implemented:
Pre-registered buffers (IORING_REGISTER_BUFFERS): Pins BufferManager's pool in
kernel page tables for zero-copy I/O via ReadFixed/WriteFixed opcodes. Eliminates
kernel-side copy_from_user/copy_to_user on every block I/O.
Batched dirty block flush: IoUringDiskManager::flush_dirty_cache() now submits all
dirty blocks as a single batch of SQEs (chunked by ring size), replacing the previous
one-SQE-per-block approach. BufferManager::flush_all() similarly batches all dirty
frames via write_blocks_batch_fixed or write_blocks_batch.
Both optimizations degrade gracefully: if register_buffers fails (e.g., RLIMIT_MEMLOCK
too small for the pool), the code falls back to standard Read/Write opcodes
transparently. The supports_fixed_buffers() method reports registration status.
Pre-registered buffers require the kernel to pin (lock) the buffer pool in physical RAM.
The default RLIMIT_MEMLOCK is 8 MB, which limits registration to pools $\le$ ~28 frames
(16 frames $\times$ 256KB = 4MB works, 32 frames $\times$ 256KB = 8MB fails due to io_uring ring
overhead). Production deployments needing pre-registered buffers with larger pools should
increase RLIMIT_MEMLOCK via /etc/security/limits.conf or prlimit.
| Pool Size | Memory | register_buffers | Notes |
|---|---|---|---|
| 1 frame | 256 KB | Success | |
| 4 frames | 1 MB | Success | |
| 16 frames | 4 MB | Success | Used for benchmarks below |
| 32 frames | 8 MB | ENOMEM | At limit (ring also uses locked memory) |
| 64 frames | 16 MB | ENOMEM | Default vocab trie pool size |
| 256 frames | 64 MB | ENOMEM | Default byte trie pool size |
All reads are served from BufferManager's in-memory page cache after the initial load.
This tests the overhead of the buffer manager lookup + pin/unpin path, not disk I/O.
| Benchmark | Criterion Mean Time | Throughput | vs mmap |
|---|---|---|---|
| mmap (BufferManager) | 992 ns | 15.0 Melem/s | 1.0x |
| io_uring ReadFixed (BufferManager) | 1.01 µs | 15.8 Melem/s | 1.02x slower |
| io_uring Read (direct, no cache) | 580 µs | 27.6 Kelem/s | 585x slower |
Interpretation: For cached reads, ReadFixed and mmap are equivalent (~1 µs). The
zero-copy optimization has no measurable impact because cached reads never reach the kernel
— the data is served directly from BufferManager's in-memory buffer pool. The "standard"
variant bypasses the buffer manager entirely, doing actual O_DIRECT disk I/O per read.
Writes go through BufferManager::fetch_page_mut(), which pins the page and marks it
dirty on drop. No actual disk I/O occurs until flush_all().
| Benchmark | Criterion Mean Time | Throughput | vs mmap |
|---|---|---|---|
| mmap (BufferManager) | 1.28 µs | 12.5 Melem/s | 1.0x |
| io_uring WriteFixed (BufferManager) | 1.07 µs | 14.9 Melem/s | 1.19x faster |
Interpretation: io_uring WriteFixed is ~19% faster for in-memory page writes. Both are pure memory operations (dirty the page), but the slight advantage may come from different memory access patterns in the buffer manager with io_uring storage.
Tests IoUringDiskManager::flush_dirty_cache() (batched SQE submission) vs
MmapDiskManager::sync() (msync) after dirtying 64 blocks via write_bytes.
| Benchmark | Criterion Mean Time | Throughput | vs mmap |
|---|---|---|---|
| mmap sync | 1.07 µs | 58.7 Melem/s | 1.0x |
| io_uring batched sync | 16.1 ms | 3.97 Kelem/s | ~15,000x slower |
Interpretation: The massive gap is expected and not a fair comparison. mmap's
"sync" only calls msync which marks pages dirty for the kernel's writeback daemon —
it does NOT issue fsync. The data is not necessarily on durable storage. io_uring's
flush_dirty_cache + fdatasync actually writes 64 $\times$ 256KB = 16MB of data to disk via
O_DIRECT and then issues fdatasync, providing true durability. The 16.1ms for
16MB = ~1 GB/s, which is reasonable for NVMe sequential write throughput.
Tests BufferManager::flush_all() which uses write_blocks_batch_fixed (io_uring) or
msync (mmap) to flush all dirty pages.
| Benchmark | Criterion Mean Time | Throughput | vs mmap |
|---|---|---|---|
| mmap flush_all | 307 µs | 52.1 Kelem/s | 1.0x |
| io_uring flush_all (WriteFixed batch) | 618 µs | 25.9 Kelem/s | 2.01x slower |
Interpretation: mmap's flush_all is 2x faster because msync leverages the kernel
page cache for efficient writeback, while io_uring must explicitly submit write SQEs for
each dirty page. The io_uring path provides true durability (data is on disk after
flush), while mmap's durability depends on the kernel's writeback timing.
| Benchmark | mmap | io_uring | Ratio |
|---|---|---|---|
| Pressure rand read (4096 blocks) | 141 ms | 270 ms | 1.92x slower |
| Pressure mixed 80/20 (4096 blocks) | 156 ms | 298 ms | 1.91x slower |
| Batch read 64 blocks (mmap seq) | 1.65 ms | — | — |
| Batch read 64 blocks (io_uring batch) | — | 14.6 ms | — |
| Batch read 64 blocks (io_uring seq) | — | 3.46 ms | — |
| WAL fsync per-record | 484 µs | 6.43 ms | 13.3x slower |
| WAL fsync batched 100 | 520 ns | 7.97 µs | 15.3x slower |
| Trie insert 10K terms | 18.6 ms | 18.9 ms | 1.02x slower |
| Trie query 10K lookups | 1.40 ms | 1.40 ms | 1.00x (tied) |
Note on batch_read regression: The io_uring batch read (14.6ms) regressed from the
Phase 3 result (2.14ms). The read_blocks_batch implementation was not modified —
this appears to be run-to-run variance due to system state. The criterion "change" metric
reports this as an improvement over its most recent stored baseline, confirming the
regression predates this change.
| Metric | mmap | io_uring Fixed | Notes |
|---|---|---|---|
| Cached read (BufferManager) | 992 ns | 1.01 µs | Tied (both in-memory) |
| Cached write (BufferManager) | 1.28 µs | 1.07 µs | io_uring 19% faster |
| flush_all (16 dirty pages) | 307 µs | 618 µs | mmap 2x faster (no fsync) |
| Dirty cache flush (64 blks) | 1.07 µs | 16.1 ms | mmap defers to writeback |
| Trie insert 10K | 18.6 ms | 18.9 ms | Tied (~2%) |
| Trie query 10K | 1.40 ms | 1.40 ms | Tied |
Pre-registered buffers (ReadFixed/WriteFixed) show no measurable benefit for cached I/O.
This is because the optimization eliminates kernel-side copy_from_user/copy_to_user,
but cached reads/writes in BufferManager never reach the kernel — they are pure memory
operations. The optimization would show benefits for eviction-heavy workloads where
pages are frequently loaded from disk, but the 16-frame benchmark pool is too small to
demonstrate this without also measuring eviction overhead (which is dominated by flush
latency, not buffer copy overhead).
Batched dirty flush works correctly but mmap still wins on apparent latency. The batch
optimization reduces io_uring's flush from N mutex acquisitions + N submit_and_wait(1)
syscalls to 1 mutex acquisition + 1 submit_and_wait(N) syscall. However, mmap's
advantage is qualitatively different: msync defers actual writeback to the kernel's
daemon, so its apparent latency is near-zero. For applications requiring true durability
(data on disk, not just in page cache), io_uring's batched flush at ~1 GB/s throughput
is the correct comparison against mmap + explicit fsync, not mmap + msync.
Date: 2026-02-21
CPU Affinity: taskset -c 0-3
Per-thread Ring Pool (RingPool): Replaced single Mutex<IoUring> with a striped
pool of rings. Standard I/O ops (Read/Write/Fsync) use ring_pool.select() for
striped load distribution; fixed-buffer ops (ReadFixed/WriteFixed) always use
ring_pool.primary() since buffer registration is per-ring. Default: 1 ring for
backward compatibility; configurable via create_with_ring_pool_size().
AlignedBlock Pool (AlignedBlockPool): Pre-allocated freelist of 256
Box<AlignedBlock> (matching DEFAULT_RING_ENTRIES). Eliminates per-call heap
allocation in read_blocks_batch, write_blocks_batch, and flush_dirty_cache.
Falls back to heap allocation when pool is exhausted.
BufferManager::new_without_registration(): Feature-gated (bench-internals)
constructor that skips register_buffer_pool() for benchmarking the effect of
pre-registered buffers in isolation.
Configuration: Pool=8 frames, Dataset=128 blocks (128$\times$256KB = 32MB).
Every access past the initial 8 blocks causes eviction.
| Backend | Criterion Time | Throughput | p50 | p99 | p999 | Mean |
|---|---|---|---|---|---|---|
| mmap | 3.36 ms | 38.1 Kelem/s | 43.4 µs | 67.8 µs | 94.7 µs | 43.1 µs |
| io_uring (fixed) | 7.64 ms | 16.8 Kelem/s | 62.9 µs | 99.6 µs | 101.1 µs | 65.3 µs |
| io_uring (standard) | 6.10 ms | 21.0 Kelem/s | 50.2 µs | 74.1 µs | 82.6 µs | 49.4 µs |
| Backend | Criterion Time | Throughput | p50 | p99 | p999 | Mean |
|---|---|---|---|---|---|---|
| mmap | 5.56 ms | 23.0 Kelem/s | 64.5 µs | 109.8 µs | 123.0 µs | 64.4 µs |
| io_uring (fixed) | 13.81 ms | 9.3 Kelem/s | 101.2 µs | 134.0 µs | 134.8 µs | 102.2 µs |
| io_uring (standard) | 11.37 ms | 11.3 Kelem/s | 79.2 µs | 125.1 µs | 143.0 µs | 82.0 µs |
| Backend | Criterion Time | Throughput |
|---|---|---|
| mmap (4 threads) | 121.3 µs | 4.22 Melem/s |
| io_uring (4 threads, 4 rings) | 130.7 µs | 3.92 Melem/s |
| io_uring (4 threads, 1 ring) | 129.1 µs | 3.97 Melem/s |
No regressions detected in existing benchmarks. Notable improvements from AlignedBlock pool:
| Benchmark | Phase 4 → Phase 5 | Note |
|---|---|---|
cmp_batch_read/io_uring_batch | -53.9% time (+116.7% throughput) | AlignedBlock pool eliminates per-batch heap allocation |
cmp_pressure_rand_read/mmap | No significant change (p=0.08) | mmap path unaffected |
cmp_flush_all_fixed/io_uring | No significant change (p=0.21) | Fixed-buffer path stable |
cmp_batch_flush_dirty/io_uring | +3.2% (within noise) | Batch flush stable |
On NVMe storage, mmap remains 2.2-2.5$\times$ faster than io_uring for eviction-path I/O.
This is consistent with Phase 3 findings: the kernel page cache provides near-zero-cost
eviction (page table manipulation) vs io_uring's explicit submit_and_wait syscall
overhead per I/O operation.
Counter to expectations, ReadFixed is 25-30% slower than standard Read for
single-block eviction I/O. Root cause: the pre-registered buffer path routes through
ring_pool.primary() (index 0), while standard Read uses ring_pool.select().
With a single ring (pool_size=1), both paths use the same ring — but the ReadFixed
opcode itself has slightly higher kernel overhead due to the buffer index lookup in the
registered buffer table. This overhead exceeds the copy_from_user/copy_to_user
cost that ReadFixed eliminates for 256KB blocks.
Hypothesis: The ReadFixed benefit may only manifest at very high I/O rates where
the kernel's buffer copy becomes a bottleneck (e.g., thousands of concurrent I/Os,
not sequential eviction). At sequential eviction rates (~15-20 KOps/s), the syscall
overhead dominates and the copy elimination is noise.
With 4 threads contending on I/O, per-thread rings (4 rings) showed only marginal improvement over a single ring (~1.3% faster). This is because:
page_table: RwLock and frames metadata are the primary
contention point, not the io_uring ring mutex.The AlignedBlock pool provided the most significant measurable improvement:
cmp_batch_read/io_uring_batch: 116.7% throughput improvement
(from ~14.7 ms to ~6.7 ms for 64 blocks)This eliminates 64 $\times$ alloc_zeroed(256KB) calls per batch, replacing them with
a single Mutex::lock + Vec::split_off. The pool is pre-populated at construction
time, so the first batch read is also fast.
Phases 1–5 above were recorded 2026-02-20/21 on an Intel Xeon E5-2699 v3. This phase re-runs the entire comparison — every group, including the Phase 5 eviction path — on the current workstation, and it makes one methodological correction that reshapes the durability numbers.
The scratch-directory correction. All three harnesses build their scratch files with
tempfile::tempdir(), which lands under $TMPDIR (default /tmp). On this workstation
/tmp is tmpfs (RAM), where (a) O_DIRECT — required by the io_uring backend — fails
outright, and (b) a durability sync never reaches a device, so it returns at RAM speed. A
run left as-is would therefore either fail io_uring setup or measure memory rather than
disk. Phase 6 pins TMPDIR to a scratch directory on the repository's ext4 volume
(/dev/nvme0n1p4, real NVMe), which makes tempdir() write to the SSD and lets
O_DIRECT succeed (smoke-verified before any measurement). Every number below is measured
against the physical device. This is why the sync and trie-insert figures move by orders of
magnitude from Phase 3: those earlier figures (mmap sync ~1.5 µs; trie insert ~19 ms) were
RAM-speed and did not exercise real-device durability, whereas Phase 6 does. Treat Phase
6 as authoritative for current hardware; the Phase 1–5 tables are retained verbatim as
dated history.
| Component | Specification |
|---|---|
| CPU | AMD Ryzen Threadripper PRO 5975WX (32 cores, SMT off = 32 threads, max 4561 MHz) |
| RAM | 125 GiB |
| Storage | Samsung 990 PRO 4TB NVMe (firmware 8B2QJXD7) |
| Filesystem (scratch) | ext4 on /dev/nvme0n1p4 (TMPDIR=$PWD/target/bench-scratch) |
| OS | Linux 7.0.13-arch1-1 (Arch Linux) |
| Rust | Stable, release profile (optimized) |
| CPU Governor | performance (all cores) |
| CPU Affinity | taskset -c 0-3 (both backends) |
| Block Size | 256 KB |
perf_event_paranoid | 2 (no root; LLC-load-misses unavailable — noted below) |
| Date | 2026-07-10 |
io_backend_benchmarks, 1024 blocks = 256 MB per iteration)Criterion median wall-time to sweep all 1024 blocks in one iteration.
| Benchmark | mmap | io_uring | Ratio (io_uring / mmap) |
|---|---|---|---|
| seq_read_block | 11.14 ms | 151.21 ms | 13.6x slower |
| rand_read_block | 10.86 ms | 191.98 ms | 17.7x slower |
| seq_write_block | 27.06 ms | 122.43 ms | 4.5x slower |
| rand_write_block | 27.39 ms | 127.36 ms | 4.6x slower |
| sync_latency | 14.51 ms | 4.76 ms | 0.33x — io_uring 3.0x faster |
| memory_pressure_read | 11.68 ms | 186.17 ms | 15.9x slower |
| mixed_pressure (80/20) | 13.44 ms | 290.74 ms | 21.6x slower |
| batch_read (64 blocks) | 712.9 µs | 4.24 ms | 5.9x slower |
cmp_summary_report, per 256 KB block)HdrHistogram percentiles over the head-to-head summary pass (read/write n=1024, sync
n=100), reported as the median across the three summary-report repeats. Run-to-run spread
is mild except in io_uring's write tail (per-repeat: read p50 ~160–189 µs; write p99
~526–687 µs). mmap's sync is msync; io_uring's is IORING_OP_FSYNC.
| Operation | Backend | p50 | p99 | mean | ops/s |
|---|---|---|---|---|---|
| rand read | mmap | 10.9 µs | 16.3 µs | 11.1 µs | ~89,960 |
| rand read | io_uring | 187 µs | 222 µs | 185 µs | ~5,410 |
| rand write | mmap | 117 µs | 127 µs | 118 µs | ~8,470 |
| rand write | io_uring | 93 µs | 538 µs | 131 µs | ~7,610 |
| sync | mmap | 1.99 ms | 2.53 ms | 3.40 ms | ~294 |
| sync | io_uring | 59 µs | 89 µs | 62 µs | ~16,190 |
Two results dominate this table:
O_DIRECT read is a device round-trip.msync p999 reaches ~147 ms (kernel writeback storms — the whole dirty
set flushed at once), whereas io_uring's fdatasync p999 stays under 105 µs. io_uring's
write-p50 is also lower than mmap's (93 µs vs 117 µs), though its write-p99 is worse
(538 µs vs 127 µs) from occasional completion-queue stalls.Figure: Single-block random read/write latency, mmap versus io_uring, from the Phase 6
cmp_summary_report per-operation table above (2026-07-10, real ext4 NVMe scratch, 256 KB
blocks). Log-scale y-axis. mmap wins reads by ~17x; io_uring edges write-p50 but its
write-p99 tail is ~4x mmap's.
io_uring_comparison, 4096 blocks = 1 GB, 16-frame pool)| Benchmark | mmap | io_uring | Ratio |
|---|---|---|---|
| cmp_pressure_rand_read | 47.96 ms | 731.3 ms | 15.2x slower |
| cmp_pressure_mixed (80/20) | 55.73 ms | 1.035 s | 18.6x slower |
Under a working set 8x the pool, mmap's cache still absorbs most accesses while io_uring
pays a device round-trip on every miss. This is the workload the perf section dissects.
cmp_batch_read, 64 × 256 KB blocks)| Strategy | Criterion time | Throughput | vs mmap |
|---|---|---|---|
| mmap (sequential) | 733.8 µs | 87.2 Kelem/s | 1.0x |
| io_uring (batch SQE) | 4.91 ms | 13.0 Kelem/s | 0.15x |
| io_uring (sequential) | 13.00 ms | 4.9 Kelem/s | 0.06x |
SQE batching still helps within io_uring — the batch path is 2.65x faster than io_uring's own sequential path (13.0 vs 4.9 Kelem/s). But against mmap's cache-served batch, io_uring no longer wins on this hardware: Phase 3 (on its RAM-speed scratch) had io_uring batch ahead 1.41x; on real disk mmap leads 6.7x. This is the one qualitative reversal against the Phase 3 headline.
Figure: Batch read (64 × 256 KB blocks) throughput by I/O strategy, from the Phase 6
cmp_batch_read table above (2026-07-10, real ext4 NVMe scratch). On real disk mmap's
cache-served batch leads 6.7x; SQE batching still makes io_uring 2.65x faster than its own
sequential path — the reversal versus Phase 3, where io_uring batch led.
cmp_wal_fsync)| Metric | StdFsync | IoUringFsync | Ratio |
|---|---|---|---|
| Per-record sync | 48.76 ms | 56.24 ms | 1.15x slower |
| Batched (100 records) | 71.2 µs | 86.8 µs | 1.22x slower |
Both reach the device; std's single sync_all() syscall edges out io_uring's SQE/CQE
round-trip on both paths — consistent with Phase 3's qualitative call that std is at least
as fast as io_uring for WAL fsync.
cmp_trie_insert / cmp_trie_query, 10,000 terms)| Operation | mmap | io_uring | Ratio |
|---|---|---|---|
| Insert (10K terms + durable sync) | 1.206 s | 1.204 s | tied (~0.2%) |
| Query (10K lookups) | 427.8 µs | 424.4 µs | tied (~0.8%) |
At the trie API the two backends are statistically tied, exactly as in Phase 3 — the buffer pool absorbs the access pattern and the backend only shows through on flush. The absolute insert cost is now ~1.2 s (vs ~19 ms in Phase 3) because the sync is a real device durability barrier here, not a RAM no-op; that the two backends remain tied confirms the cost is backend-independent (in-memory insert + durability), not an artifact of either storage path.
cmp_*_fixed, cached, 16-block pool)| Benchmark | mmap | io_uring (fixed) | io_uring (standard) | Winner |
|---|---|---|---|---|
| single_block_read_fixed | 803.9 ns | 1.744 µs | 2.109 ms | mmap (2.2x vs fixed) |
| single_block_write_fixed | 1.638 µs | 931.3 ns | — | io_uring fixed (1.76x) |
| batch_flush_dirty (64) | 617.6 µs (msync) | 15.71 ms (fdatasync) | — | mmap 25x — but msync is not durable |
| flush_all_fixed (16 pages) | 4.345 ms | 2.664 ms | — | io_uring fixed (1.63x) |
The pre-registered fixed-buffer path pays off here: io_uring's cached fixed-buffer write
(931 ns) beats mmap (1.64 µs), and flush_all over the fixed pool is 1.63x faster than
mmap's — both reversed in io_uring's favour versus Phase 3. The io_uring_standard
(non-fixed) read at 2.11 ms is the un-cached O_DIRECT cost, showing why buffer
registration matters. batch_flush_dirty still favours mmap 25x, but the comparison is not
apples-to-apples: mmap's msync only stages pages for the writeback daemon while io_uring's
path is a real fdatasync durability barrier.
eviction_benchmarks, 128 blocks, 8-frame pool)Criterion median for the full 128-block eviction sweep; the bracketed p50 is the per-eviction HdrHistogram median.
| Scenario | mmap | io_uring (fixed) | io_uring (standard) |
|---|---|---|---|
| read-only eviction | 1.059 ms (p50 21.3 µs) | 23.76 ms (p50 188 µs) | 25.53 ms (p50 191 µs) |
| dirty write-back | 2.240 ms (p50 113 µs) | 71.54 ms (p50 270 µs) | 126.35 ms (p50 277 µs) |
| concurrent (4 threads) | 4.846 ms | 92.65 ms | 93.03 ms (single-ring) |
mmap dominates the eviction path on real disk — 22x (read-only), 32x (dirty
write-back), 19x (concurrent) — because eviction is a stream of single-block faults and
write-backs that the page cache serves from RAM, exactly where O_DIRECT is weakest. The
two io_uring ring strategies (per-thread io_uring_4t vs shared single_ring_4t) are tied
at 4 threads, so ring-sharing contention is negligible at this concurrency.
perf counters — controlled A/B on the 1 GB pressure readBoth backends run the same cmp_pressure_rand_read workload (4096 × 256 KB = 1 GB,
16-frame pool) under perf stat, userspace counters (:u). This corrects Phase 3, which
compared two different benchmarks. LLC-load-misses is unavailable at
perf_event_paranoid = 2 without root and is omitted.
| Counter | mmap | io_uring | Interpretation |
|---|---|---|---|
| page-faults | 4,066,338 | 270,449 | io_uring 15x fewer (O_DIRECT bypasses the page cache) |
| major-faults | 184,335 | 11 | mmap faults in from the device 184K times; io_uring never does |
| dTLB-load-misses | 151,161,981 | 5,789,844 | io_uring 26x fewer (no giant mapped region to walk) |
instructions (:u) | 79.91 B | 40.64 B | io_uring runs ~half the instructions |
cycles (:u) | 98.47 B | 21.33 B | io_uring burns ~1/5 the cycles |
IPC (:u) | 0.81 | 1.91 | io_uring 2.35x higher (direct I/O is more predictable) |
| user + sys time | 45.66 s | 11.10 s | io_uring uses 4.1x less CPU |
| wall-clock (elapsed) | 42.14 s | 50.29 s | io_uring is slower on the wall despite less CPU |
The last two rows are the crux, and they explain the whole comparison. mmap is
CPU-bound: it burns cycles servicing 4 M page faults (184 K of them major, i.e. real
device reads) and thrashing the dTLB (151 M misses) — 45.7 s of CPU — but the page cache
keeps wall-clock low. io_uring is I/O-wait-bound: O_DIRECT means a tiny CPU/cache
footprint (15x fewer faults, 26x fewer TLB misses, 2.35x IPC, 4.1x less CPU) but every block
is a real device round-trip, so it spends the wall clock waiting on the SSD, not the CPU.
mmap trades CPU and cache pressure for latency by leaning on the page cache; io_uring trades
latency for a minimal CPU/cache footprint and true durability.
The workload-shaped split still holds and sharpens on real disk, with three reversals and one correction versus Phase 3:
msync costs ~2 ms with ~147 ms writeback-storm tails; io_uring's fdatasync is ~59 µs.flush_all (1.63x) — both were mmap wins in Phase 3.Everything else is unchanged in character: trie-level ops tied, WAL fsync slightly favours
std, and io_uring's O_DIRECT dramatically reduces the page-cache/CPU footprint. The
revised rule of thumb: keep the mmap default for cached, latency-sensitive serving; choose
io_uring when you need real fsync durability, a minimal page-cache/CPU footprint
(memory-constrained or cache-sensitive hosts), or the Phase-4 fixed-buffer flush path.
All numbers above are extracted from single tee'd runs under
TMPDIR=$PWD/target/bench-scratch (ext4 NVMe), taskset -c 0-3, governor performance:
target/bench-scratch/io_backend.log — io_backend_benchmarks --features io-uring-backendtarget/bench-scratch/io_uring_cmp.log — io_uring_comparison_benchmarks --features io-uring-backendtarget/bench-scratch/eviction.log — eviction_benchmarks --features bench-internalstarget/bench-scratch/perf_mmap_ctrl.txt, perf_iouring.txt — controlled perf stat A/B on cmp_pressure_rand_readCan you improve this documentation?Edit on GitHub
cljdoc builds & hosts documentation for Clojure/Script libraries
| Ctrl+k | Jump to recent docs |
| ← | Move to previous article |
| → | Move to next article |
| Ctrl+/ | Jump to the search field |