Treating all faults as slow conflates the cheap and the expensive, which is why the split between minor and major matters when you tune memory-heavy services.
Minor faults map a page
A minor page fault happens when the page is already resident in RAM but the process does not yet have it mapped. The kernel only has to wire up a page-table entry — pointing the virtual address at physical memory that is already there. Common cases: first touching a freshly allocated zeroed region, mapping a shared library that another process already loaded, or a page that was swapped in once and sits in memory. Minor faults are cheap: a few hundred nanoseconds, no disk, no IO. They happen constantly in normal-running processes and are not, by themselves, a performance problem.
Major faults read the disk
A major fault means the data has to come from disk (or a page-in from swap) before the mapping can complete, which is orders of magnitude more expensive — commonly interpreted as reading a few hundred microseconds to several milliseconds. Majors are the ones that dominate cold-start costs: warming a process, reading a big binary for the first time, or evicting from swap all surface as a burst of major faults. The distinction that matters is that a major fault proves the working set did not locate your reference, so it also scores as what your prefetch and/or cache should have made unnecessary.
Count them separately
Tools split the two for a reason, because telling them apart changes the diagnosis. ps, sar, and /proc/self/stat (minor/major) report them separately; a healthy server has a constant background of minors and few majors. Several things give away the real story: a workload with many majors is suffering from cache misses and slow startup, while a workload with drastic minor spikes but few majors is usually bad page-table churn, not disk pressure at all. Look at them separately, and when majors rise, the fix is about cache/pread, not the kernel.
When it breaks
A major-fault storm looks like latency but behaves like cold cache. After a deploy, a container restart, or a cold node, every worker touches its binary, libraries, and data for the first time at once. perf stat -e minor-faults,major-faults shows majors jumping from near zero to thousands per second while CPU stays oddly idle, and p99 stretches into hundreds of milliseconds. The fix is warming, not more CPU: pre-touch the working set or stagger restarts so processes do not fault the same pages at once.
Swap makes the same signal resonate. When the working set no longer fits in RAM, reclaimed pages fault back from disk on next touch. Majors rise with swap-in, load climbs, and every request pays disk time though the code did not change. Adding threads only grows the working set and deepens the thrash. Fit the hot set in RAM or move cold data off the request path.
A third failure is misreading mmap. Scanning a large file-backed mapping still faults each page in on first touch, so a dashboard full of majors does not mean random I/O is broken. Check residency with mincore before tuning readahead or blaming storage.
Check it quickly
Save the following as fault_probe.py and run python3 fault_probe.py. It uses only the Python standard library, needs no privileges, and runs on Linux or macOS. It snapshots resource.getrusage minor and major counters around two passes over a fresh anonymous mapping, timing each pass with time.perf_counter.
import mmap
import resource
import time
PAGE = 4096
PAGES = 20000 # about 80 MiB: large enough to see, small enough for CI
def counters():
usage = resource.getrusage(resource.RUSAGE_SELF)
return usage.ru_minflt, usage.ru_majflt
def touch(mapping):
for offset in range(0, len(mapping), PAGE):
mapping[offset] = 1
def main():
mapping = mmap.mmap(-1, PAGES * PAGE)
base_min, base_maj = counters()
start = time.perf_counter()
touch(mapping)
first_time = time.perf_counter() - start
mid_min, mid_maj = counters()
start = time.perf_counter()
touch(mapping) # pages are already mapped here
second_time = time.perf_counter() - start
end_min, end_maj = counters()
mapping.close()
first_min = mid_min - base_min
first_maj = mid_maj - base_maj
second_min = end_min - mid_min
second_maj = end_maj - mid_maj
print(f"first touch: minflt={first_min} majflt={first_maj} time={first_time:.4f}s")
print(f"second touch: minflt={second_min} majflt={second_maj} time={second_time:.4f}s")
assert first_min > 1000, f"expected thousands of minor faults, got {first_min}"
assert first_maj == 0, f"anonymous first touch should not cause majors, got {first_maj}"
assert second_min < first_min // 10, f"mapped pages should barely fault: {second_min} vs {first_min}"
assert second_maj == 0, f"unexpected major faults on second pass: {second_maj}"
print("PASS: first touch faults as minors without disk reads; second touch is almost fault-free")
if __name__ == "__main__":
main()
The first pass touches every page once, so expect a large minflt jump of several thousand, almost no majflt change, and a slower elapsed time. The second pass revisits mapped pages, so expect near-zero new faults and a shorter time. That contrast is the point: anonymous first touch costs minor faults, not disk reads.
The assertions passed with Python 3.12 on macOS and Linux. The probe covers anonymous memory only, so it cannot locate a file-backed major, nor replace perf stat -e minor-faults,major-faults on production. Enlarge the mapping if the time gap is too small to see.
Further reading
Sources
- Linux man page proc(5), per-process minor and major fault counters, checked 2026-06-25.
- Linux man page mmap(2), demand paging with MAP_PRIVATE and MAP_ANONYMOUS, checked 2026-09-12.
- Brendan Gregg: perf examples, counting minor-faults and major-faults with perf stat, checked 2026-09-12.
- Kernel documentation: concepts overview, demand paging, page cache, and reclaim, checked 2026-09-12.