Skip to content
HN On Hacker News ↗

We Replaced mmap with io_uring in Our Rust Query Engine. It Got Slower.

▲ 44 points 24 comments by rzk 1w ago HN discussion ↗

Pangram verdict · v3.3

We believe that this text is a mix of AI, AI-assisted, and human-written content.

54 %

AI likelihood · overall

Mixed
38% human-written 48% AI-generated
SEGMENTS · HUMAN 5 of 18
SEGMENTS · AI 3 of 18
WORD COUNT 1,605
PEAK AI % 77% · §11
Analyzed
Sep 11
backend: pangram/v3.3
Segments scanned
18 windows
avg 89 words each
Distribution
38 / 48%
human / AI fraction
Verdict
Mixed
Pangram v3.3

Article text · 1,605 words · 18 segments analyzed

Human AI-generated
§1 Human · 15%

Published September 1, 2026 |Updated September 2, 2026 By Evan Chan In the beginning, there was mmap. It was convenient: it let us lazily read huge numbers of Arrow IPC files from disk without managing memory ourselves.

§2 AI · 70%

It fit our file format perfectly — Arrow IPC’s layout is designed for zero-copy random access, and mmap gives you exactly that. Then we deployed to production, ran real concurrent query loads, and mmap became a real problem.

§3 Human · 14%

Our Workload At Conviva, we analyze trillions of events a day to pinpoint and diagnose end user experience. At the core of our architecture is an event and pattern analysis engine built on DataFusion, Arrow, Rust, Rayon, and Tokio. Raw events get transformed, encoded in a proprietary mostly-numeric format, and stored in the cloud.

§4 Mixed · 43%

We copy them to local NVMe and read large (~3–5 GB) Arrow IPC files. We chose Arrow IPC for simplicity and speed — its memory and disk layouts are identical, so decode cost is minimal, and mmap gives us zero-copy reads natively supported by arrow-rust.

§5 Human · 26%

A typical query touches 6 columns across 8 batch files (one batch per file), ~1.6 GB per batch, ~13 GB total per day of data. The Test Setup Hardware: 192-core box, ~750 GB RAM.

§6 Mixed · 60%

Two disk configs during the investigation: 2× NVMe LVM-striped (~5.5 GB/s fio ceiling) and 32× NVMe RAID-0 (~21 GB/s fio ceiling). Kernel 5.15 during investigation, 6.x in production. The Production Symptom At lighter loads, mmap worked well — fast, serving queries from raw events in seconds. The trouble started under heavier concurrency. Some latency increase under load is expected — more queries competing for the same CPU. But we saw p95s and p99s spike well beyond what linear scaling would predict, with rows scanned per core dropping sharply even after accounting for concurrency: OS page cache shrank — each pod consumed more memory as private allocations, less as shared cache A huge number of page faults p95 spiked from ~30s to 150s+ under real concurrent load Adding pods made it worse, not better That pointed at mmap page-cache thrashing under memory pressure. Controlled Benchmark: 1 Pod vs. 4 Pods To isolate the effect, we ran a controlled test: 1 pod vs. 4 pods on the same host, same concurrent query load. We expected 4 pods to win — more parallelism, better isolation. We were wrong.

§7 Mixed · 52%

For 14-day queries — long enough to fill the page cache — 1 pod beat 4 pods by a real margin: 41% faster at max, >20% at p95. The mmap page cache lives on the host and is shared across pods, so the 4 pods weren’t fighting each other for CPU — they were fighting for page cache.

§8 Mixed · 67%

perf record on the same run showed 100% lock contention at the kernel level. The core issue: mmap’s page cache is implicit shared state. Every process on the host shares one cache, one lock hierarchy, one eviction policy. No single pod controls the resource that matters most for read latency, and as concurrency rises, everyone’s slice of it shrinks. A Storm of Page Faults We didn’t want to just guess, so we dug into the mmap mechanics and the page fault stats.

§9 Mixed · 51%

When you mmap a file, the application gets a region of memory backed by the file on disk. Here’s what happens on access: The CPU touches a Virtual Memory Address (VMA) with no physical page attached, and throws a page fault. The kernel handles the exception: looks up the VMA, checks ownership, and acquires a lock, since another thread might be modifying or unmapping that virtual space concurrently. Linux 6.4+ has a fast per-VMA lock; earlier kernels fall back to the slower mmap_lock. Once the kernel confirms the VMA is file-backed, it triggers a file fault — a minor fault if the bytes are already in warm page cache from read-ahead, or a major fault if it must trigger physical I/O. Recommended reading: mmap_lock scalability (LWN) and per-VMA locks (LWN, Suren Baghdasaryan’s design). Under heavy page cache contention, read-ahead runs out of room and major faults spike.

§10 Mixed · 45%

Here’s what that looked like — pidstat on one process during a stressful run: 23:26:46  RSS = 652 GB (87.88%) 23:27:47  RSS = 734 GB (98.91%)   ← peak, nearly all RAM 23:27:48  RSS starts dropping     ← kernel begins evicting 23:28:05  major faults appear: 571/s, 1352/s, 975/s RSS grows to 98.91% of RAM → the kernel has no choice but to evict pages still needed → evicted pages get touched again → a major fault storm as they’re read back from disk. Early on, read-ahead keeps faults mostly minor and fast; as concurrent queries pile up, read-ahead stops keeping up and major faults spike. Minor faults, meanwhile, ran sustained in the millions per second: 23:27:09   1,255,709 minor faults/sec 23:27:41   2,124,327 minor faults/sec 23:27:46   2,354,383 minor faults/sec Each minor fault touches a cache line via atomics — at 2 million faults/sec, that’s enough to thrash L1/L2 entirely, which is deadly for an application that leans on large cache-resident lookup tables. Faults can also trigger TLB shootdowns, and CPUs only hold a few thousand TLB entries.

§11 AI · 77%

(You can’t eliminate page faults, but you can manage them better — more on that in Part 2.) An uncontended minor fault costs roughly 0.5–1 microsecond, so 2 million/sec is close to the ceiling of what mmap can sustain — and under real contention, the thread-visible delay runs well past that.

§12 Mixed · 35%

Virtual address space, meanwhile, had grown to ~3 TB from mmap’ing so many Arrow files: Start:  3,125,750,740 KB (~2.9 TB virtual) Peak:   3,209,184,828 KB (~2.98 TB virtual) Modern kernels handle large VMA trees, but not for free — every fault does a VMA lookup, and every lookup takes the mmap lock (fast path notwithstanding). Context switches told the same story: cs = 2,106,576/sec cs = 2,025,726/sec cs = 1,944,397/sec cs = 1,524,279/sec Over 2 million context switches/sec, versus 14K/sec on a warm-cache run — 150x more. Every thread was constantly blocking on page faults, getting descheduled, and rescheduled once pages arrived. Perf Top and Off-CPU Analysis To confirm the link between page faults and lock contention, we compared perf top on cold vs. warm runs of the same query: Function Cold run Warm run __filemap_add_folio (kernel) 78.0% not in top kernel spinlocks 0.96% 0.76% CPU/data processing 4.96% 45.08% __filemap_add_folio adds a page to the page cache.

§13 AI · 73%

It barely shows up warm, since the data’s already there; cold, under memory pressure, it dominates because pages are constantly evicted and re-inserted. Our actual query code drops from ~45% of CPU (warm) to ~5% (cold) — not because it’s doing less work, but because the kernel is doing so much more.

§14 Mixed · 36%

Off-CPU time via bpftrace (actionable time only, excluding idle Rayon threads): Futex: 30.9% (1,172s) — threads blocked on synchronization, queued behind another thread’s page-fault handler Preempted: 29.3% (1,109s) — surprisingly high for 12 threads on 192 cores; the kernel’s page-fault work (readahead kthreads) was preempting our worker threads Disk I/O: 6.9% (262s) — actual NVMe latency was small next to the machinery above it mmap_sem: 0.9% (33.5s) — the explicit VMA lock; small only because it captures the wait, not the cascading futex wakes from threads queued behind it The picture: under load, page-cache thrashing and kernel-level lock contention — not disk I/O — were the bottleneck. This isn’t unique to mmap; any buffered I/O path can hit similar page-cache and lock contention. The Fio Ceiling fio with the io_uring engine — 4 processes, iodepth 32, 4 MiB blocks, O_DIRECT: READ: bw=20.2 GiB/s (21.7 GB/s) All 32 NVMe drives at ~99.75% utilization md0 util = 99.95% What mmap actually delivered, peak, from vmstat during the stressful runs: 3.44 GB/s — about 16% of what the hardware could do. That gap was the size of the prize. Enter io_uring io_uring has earned its hype.

§15 Mixed · 32%

Beyond async kernel I/O, part of its promise is direct user I/O that bypasses the page cache entirely — the thing causing most of our problems above. Worth reading: “io_uring for high performance DBMS” — a good overview of optimizations, though focused on traditional DBMSes with 4KB page buffers, so many don’t translate.

§16 Human · 22%

IOPOLL needs specific block-device access not really available from containers; SQPOLL had no measurable effect in our Arrow-based testing. LanceDB’s io_uring post — oriented around small 4KB (vector search) reads. Key takeaway: without better scheduling and concurrency, io_uring by itself doesn’t help. The plan: bypass the page cache with O_DIRECT, submit reads via io_uring, coordinate with Tokio, decode Arrow inline.

§17 Mixed · 42%

We used compio, a Rust-native io_uring wrapper (executor + futures + reactor built around io_uring). The first cut leaned on compio’s async futures — one future per Arrow column read, all 40 columns (8 batches × 5 columns) submitted concurrently, awaiting completions to yield decoded Arrow buffers.

§18 Human · 28%

Here’s how we expected io_uring to answer mmap’s problems: Feature mmap Implications under load io_uring promise Cache control Kernel page cache, host-wide, shared across all pods Thrashes under load; no control over what’s kept or evicted O_DIRECT bypasses the page cache; build our own cache Thread locking & contention Kernel handles contention Futex contention, heavy context switching, huge fault counts drive p99 spikes Build our own I/O pipeline that minimizes or channels contention I/O and CPU separation Reading from memory is easy; kernel handles faults as they come in CPU-bound work spikes as the kernel faults pages in Separate I/O from CPU work; prefetch and pipeline reads without interrupting compute threads Initial laptop testing wasn’t encouraging Looking back, maybe we should have written this post before building anything — our first design didn’t deliver on most of that last column.