The Runtime Theory
Operating Systems

Page Faults and Demand Paging: Why First Touch Costs

Demand paging maps memory lazily — minor faults hit the page cache while major faults hit disk — so mmap stays free until first touch and fault counters tell you why.

The Runtime Theory Team3 min read#memory#page-faults#mmap#demand-paging
On this page

malloc(1 GB) succeeds instantly. Touching it does not. That asymmetry is demand paging: the kernel hands out address space as a promise and delivers physical memory page by page, on first use. Understanding the page fault — the mechanism behind the promise — explains why binary loading is fast, why mmap is free until you read, and why a "memory leak" can suddenly become a "page fault storm."

Virtual memory is a promise

An address space is a map, not storage. Page tables describe it with entries that are either present (pointing at a physical page) or not present (pointing at nothing). Demand paging means the kernel installs not-present entries and fills them in on demand:

  • Executing a binary: execve() maps the text and data segments into the address space but reads nothing from disk. Pages fault in as the program actually runs — which is why a 100 MB binary starts in milliseconds.
  • mmap(): installs a VMA (a region descriptor) and zero pages. Reading the region triggers the faults.
  • malloc(): for large allocations, the kernel gives you address space backed by nothing; the first write faults in a zeroed page.

The fault path, step by step

When the CPU hits a not-present entry, the MMU raises a page fault exception and the kernel runs this exact sequence:

text
1. instruction accesses address X
2. MMU walks page tables — PTE not present → page fault exception
3. CPU traps to kernel; faulting address in CR2 (x86)
4. kernel finds the VMA covering X — no VMA → SIGSEGV
5. file-backed?   → look the page up in the page cache
   anonymous?     → allocate a zeroed physical page
6. page on disk?  → queue I/O, sleep the task  (major fault)
7. insert PTE, return to user mode, restart the instruction

Every step happens in the context of the faulting thread — the instruction is replayed afterward, which is why a faulted-in page looks as if it was always there.

Minor vs. major faults: two different price tags

The same trap leads to two very different invoices:

FaultWhat happensCost
minorpage already in memory — in the page cache, shared with another process, or freshly zeroed~0.5–2 µs
majorpage must be read from disk (or swapped back in)~1–10 ms

A shared library is loaded once into the page cache; every process that maps it pays only minor faults. A cold file read is a major fault: 1,000 major faults on a spinning disk is seconds of stall. The page cache is the kernel's bet that pages you touched once will be touched again — it converts future major faults into minor ones.

mmap is lazy by construction

mmap never touches disk at open time:

text
mmap(fd, 0, 1 GiB, PROT_READ, MAP_SHARED, ...)
  → installs a VMA, reads nothing
  → RSS grows page by page as code/reads touch the region

read() works the same way under the hood: it faults the file's pages into the page cache (major faults on first read, minor afterward) and copies them to your buffer. The difference is the copy: mmap hands you the cache page directly, eliminating it.

Why first touch costs

  • Major faults: disk or swap latency, 1–10 ms each, serialized if not issued asynchronously (readahead and posix_fadvise exist to hide this).
  • Page zeroing: a fresh anonymous page is guaranteed zero; touching 1 GB of fresh memory is ~1 GB of memory-bandwidth work, plus the physical page allocation itself.
  • Cold TLB and caches: new pages have no translation cached and no data cached — first access pays both.
  • THP (Transparent Huge Pages): faulting in 2 MB huge pages cuts fault count 512× and TLB pressure, at the cost of memory fragmentation — madvise(MADV_HUGEPAGE) is the surgical option.

The classic benchmark surprise: allocating a large buffer, touching every page once, shows startup cost proportional to bandwidth, not to the allocation call — and an untouched buffer shows zero cost because there are zero faults.

Measuring faults

bash
ps -o pid,minflt,majflt,cmd -p <pid>        # faults since start
cat /proc/<pid>/stat                         # fields 10 (minflt) and 12 (majflt)
grep -E "anon|file|swap" /proc/<pid>/smaps   # what's actually resident

A process with a growing majflt counter is either legitimately loading data or thrashing — faulting pages in only to evict them before reuse. That pattern, not RSS, is the first thing to check when memory behavior looks wrong.