mmap() vs read(): When Zero-Copy Is Actually Slower
I have heard the same answer in interviews and design reviews for years: ask how to speed up reading a large file, and someone will say:
Use
mmap(). It is zero-copy, so it must be faster thanread().
A file-backed mapping can avoid copying data from the page cache into a separate user-space buffer. But “zero-copy” does not mean zero cost, and it certainly does not guarantee lower latency.
mmap() replaces one set of costs with another. It can save system calls and memory copies, but it introduces page faults, page-table work, TLB pressure, and I/O that may surface inside an ordinary pointer access.
The question I find more useful is:
Does this workload want to pay for copying, or pay for page faults?
The short answer
For a large, one-pass sequential scan, a buffered read() is often the best starting point. For hot data that is accessed randomly or repeatedly, or for a large file where only a small fraction is touched, mmap() can be an excellent fit.
Cache state, access pattern, storage, working-set size, concurrency, and latency requirements can all reverse a benchmark result.
What read() actually costs
A normal buffered read has two main data movements:
- If the data is not cached, the kernel brings it from storage into the page cache.
- The kernel copies the requested bytes from the page cache into the application’s buffer.
The application pays for a system call each time it invokes read(), but that cost can be amortized with a reasonably large buffer. Reading 1 MiB per call is a very different workload from reading 16 bytes per call.
That explicit call boundary is useful: when read() blocks, the code makes the potential I/O visible. The returned buffer belongs to the application, its lifetime is straightforward, and pread() gives concurrent workers independent offsets.
What mmap() actually costs
mmap() establishes a relationship between a range of virtual addresses and a region of a file. Creating the mapping does not normally read every byte into RAM.
The work is deferred until the program touches a page:
const unsigned char *data = mmap(NULL, length, PROT_READ,
MAP_PRIVATE, fd, 0);
unsigned char value = data[offset];That last line looks like a normal load instruction, but it may trigger a page fault.
- If the file page is already resident in the page cache, the kernel may only need to install the process’s page-table entry. This is generally a minor fault.
- If the page is not resident, satisfying the fault may require storage I/O. The accessing thread can block until the page becomes available; this is generally a major fault.
Once the pages are resident, the program can access them without another read() syscall or a second buffer. That is the “zero-copy” advantage—but the copy was exchanged for demand paging and address-space management.
| Cost | read() |
File-backed mmap() |
|---|---|---|
| Repeated read syscalls | Yes | No after mapping |
| Page-cache-to-user-buffer copy | Yes | No separate user buffer required |
| Page faults in the data access path | Not exposed as pointer faults | Yes, on first access to pages |
| Page-table and TLB footprint | Mostly the application buffer | Can grow with the mapped working set |
| Location of blocking I/O | At an explicit I/O call | Potentially at an ordinary memory access |
| Access to sparse regions | Requires explicit offsets and reads | Natural demand paging |
The real latency problem: a load instruction can become I/O
In production systems, average throughput is rarely the whole story. Databases, storage engines, and event-driven services often care more about P99 latency.
With a mapping, this harmless-looking statement can suspend the current thread:
record = table[index];If the page is absent, the kernel must resolve the fault before the instruction completes. In a single-threaded reactor or cooperative scheduler, one major fault can stall unrelated work on the same thread. You cannot register a memory address with epoll and wait for it to become resident.
Explicit asynchronous I/O—through io_uring or another AIO interface—does not make storage faster. It makes waiting part of the design instead of a surprise inside a pointer dereference.
Large mappings are cheap; large resident working sets are not
Mapping a 100 GiB file does not immediately allocate 100 GiB of RAM or populate page-table entries for every page. Virtual address space is not the same thing as resident memory.
Costs grow as pages are touched:
- Page faults must be handled.
- Page-table entries consume memory.
- The CPU’s TLB can cover only a limited set of translations.
- A working set larger than memory creates page-cache churn.
- Mapping changes and page reclamation can add cross-core coordination costs.
Not every fault causes a system-wide TLB shootdown. The point is simpler: huge, actively accessed mappings under concurrency exercise far more of the virtual-memory subsystem than “zero-copy” suggests. The relevant size is the active working set, not merely the file size.
Sequential scans: read() remains highly competitive
Only read() benefits from readahead? That is another myth. Linux can perform readahead for buffered reads and file-backed mappings; programs can also provide access-pattern hints.
Even so, a large buffered read() is a strong default for a one-pass sequential scan:
- Large calls amortize syscall overhead.
- Sequential readahead works well.
- The application avoids taking a fault as it first touches each mapped region.
- Buffer reuse is predictable.
- I/O boundaries remain explicit.
mmap() may still win when the file is hot, parsing operates directly on mapped bytes, or copying dominates. My default is simply to start a one-pass scan with read() and demand benchmark evidence before making it more complicated.
Random and partial access: where mmap() becomes attractive
Suppose an application has a 40 GiB immutable index but normally touches only a few megabytes per request. Reading the entire file makes no sense. Issuing many tiny pread() calls may also be cumbersome and syscall-heavy.
A mapping offers a useful abstraction here:
- Pages are loaded only when accessed.
- The program can follow pointers or offsets naturally.
- Cached pages can be reused without copying them into new buffers.
- Multiple processes mapping the same file can share the underlying page-cache pages.
This is where memory mapping earns its keep: static indexes, lookup tables, model weights, embedded databases, executable images, and shared libraries. These workloads value addressable, demand-paged data—not mmap() as a different spelling of sequential reading.
There are still correctness hazards. Access beyond the current end of a mapped file can raise SIGBUS, and another process truncating the file can invalidate assumptions made by readers. A mapping also does not provide synchronization, transaction semantics, or crash consistency by itself.
Readahead hints help, but they are not guarantees
When the access pattern is known, hints can make either approach behave better:
POSIX_FADV_SEQUENTIALorPOSIX_FADV_RANDOMdescribes expected file access.MADV_SEQUENTIALorMADV_RANDOMdescribes access to a mapped range.MADV_WILLNEEDasks the kernel to prepare pages expected to be used soon.mincore()can report a snapshot of which mapped pages are currently resident.
These are hints, not promises. The system may ignore them, and residency can change immediately after mincore() returns.
MAP_POPULATE is also not a general “make mmap fast” switch. Moving some fault work toward mapping time can reduce later surprises, but it increases startup work and may load pages the application never uses.
What about Direct I/O?
Applications with their own mature buffer pool sometimes choose Direct I/O. On Linux, O_DIRECT attempts to minimize page-cache effects so the application can control caching, eviction, prefetching, and I/O concurrency itself.
This can avoid caching the same logical data in both the kernel and the application, while making latency and memory accounting more predictable.
The price is substantial complexity:
- Buffer addresses, lengths, and offsets may have alignment constraints.
- The application must implement a good caching policy.
- Synchronous Direct I/O can still block.
- Small or frequently reused data may perform better through the page cache.
- Behavior and restrictions vary across filesystems and kernel versions.
Direct I/O is not a magic third option. It is an agreement to do work the kernel previously did for you.
How to benchmark the choice without fooling yourself
A benchmark with one throughput number is usually answering a much narrower question than it claims.
At minimum, control and report:
- Cache state. A warm-cache test measures memory and CPU overhead; a cold-cache test includes storage behavior.
- Access pattern. Sequential, uniform random, and skewed random access stress different mechanisms.
- Working-set size. State both file size and RAM size.
- Reuse. One scan and repeated access are different workloads.
- Read granularity. Tiny
read()calls create a straw-man comparison. - Storage. HDD, SATA SSD, local NVMe, and network storage produce different fault costs.
- Concurrency. More workers can hide latency or create contention.
- Tail latency. Report percentiles, not only elapsed time or average throughput.
- Faults and I/O. Measure major/minor faults, bytes read, CPU time, and context switches alongside wall time.
Be careful with system-wide cache-dropping commands: they affect other workloads and are not a normal application capability. Prefer a disposable environment, controlled files, or a dataset larger than memory.
Most importantly, benchmark the real parsing or computation. If processing each byte is expensive, the file-access difference may disappear into application work.
A practical decision table
| Workload | Start with | Why |
|---|---|---|
| Small file, read once | read() |
Simple, with little mapping overhead to recover |
| Large, one-pass sequential scan | Large buffered read() |
Good readahead, amortized syscalls, explicit I/O |
| Hot data, repeated random access | mmap() |
Direct addressing and no repeated buffer copies |
| Huge file, only a small fraction touched | mmap() or targeted pread() |
Avoid reading untouched regions; benchmark access granularity |
| Shared immutable data across processes | mmap() |
Processes can share underlying page-cache pages |
| Single-threaded reactor with strict tail latency | Explicit asynchronous I/O | Avoid an uncontrolled major fault stalling the loop |
| Storage engine with a mature buffer pool | Direct I/O plus async I/O may fit | Application controls caching and scheduling |
This table is a starting hypothesis, not a substitute for measurement.
The useful mental model
After years of seeing this reduced to “zero-copy is faster,” this is the mental model I keep:
read()pays for system calls and a memory copy in exchange for explicit I/O boundaries and application-owned buffers.mmap()pays for faults, page tables, and potentially hidden blocking in exchange for direct addressability and one less copy.
So the next time someone asks whether mmap() is faster for a large file, ask for the missing workload definition:
- Is the cache cold or warm?
- Is access sequential or random?
- Is the data read once or reused?
- How large is the active working set relative to RAM?
- Does the system optimize throughput or P99 latency?
- What storage device and filesystem are involved?
Until those questions have answers, “which one is faster?” is not a technical question yet.
References
- Linux man-pages:
mmap(2) - Linux man-pages:
read(2) - Linux man-pages:
madvise(2) - Linux man-pages:
posix_fadvise(2) - Linux man-pages:
mincore(2) - Linux man-pages:
open(2)(O_DIRECT)