Page faults, minor vs major faults, what triggers them, and how to diagnose paging-related latency in a .NET service.
A page fault is a CPU exception raised when code touches a virtual address whose page is not currently mapped to physical RAM for that process. The kernel handles it:
Minor faults are normal. A burst of major faults means the working set does not fit in RAM, and latency spikes follow.
Every memory access goes through the MMU, which translates a virtual address using the page tables (with the TLB as a cache). If the page table entry is not present or does not allow the access, the CPU raises a page fault and the kernel decides what to do.
| Fault type | Where the data is | Typical cost | Common causes |
|---|---|---|---|
| Minor (soft) | Already in RAM | ~0.1β10 Β΅s | First touch of newly allocated memory, shared DLL/.so pages, file cache, copy-on-write after fork |
| Major (hard) | On disk | ~0.1 ms (NVMe) to 10 ms (HDD, network disk) | Swapped-out pages, page file, first read of a memory-mapped file, binaries paged back in |
| Invalid | Nowhere | Process crash | Null or wild pointer, guard page (stack overflow) |
Note that Windows Task Manager reports "page faults" that include soft faults, so a big number there is not by itself a problem. On Linux ps -o min_flt,maj_flt separates them.
A .NET 10 API on a VM had p99 latency jump from 40 ms to 900 ms every few minutes. CPU was low. vmstat 1 showed si/so (swap in/out) non-zero and sar -B showed majflt/s spiking at the same time as the latency. A batch job on the same VM was loading a 6 GB file into memory and pushing the API's heap pages to swap. When a GC ran, it had to walk the whole heap, which faulted the swapped pages back in one by one.
Fix: move the batch job off the box, set a memory limit for each workload (cgroups/containers), and in Kubernetes keep swap off for latency-sensitive pods. After that, major faults went to almost zero and p99 returned to normal.
sar -B 1 / vmstat 1 (Linux) or the Memory\Page Reads/sec and Memory\Pages Input/sec counters (Windows), against the latency graph.pidstat -r 1 shows majflt/s per process.free -m, /proc/pressure/memory (PSI), container memory limit vs working set.dotnet-counters monitor -n MyApi System.Runtime for working set, GC heap size and GC pause time.GCHeapHardLimit, DATAS / Workstation GC for small containers), or streaming data instead of loading it all at once.Checks that the candidate understands virtual memory beyond buzzwords and can connect an OS mechanism to a real latency symptom.
Green flags
vmstat, sar -B, pidstat, PerfMon) and correlates with latency.Red flags
Each process sees its own virtual address space split into pages (usually 4 KiB on x64; 4 KiB or 16 KiB on Arm64, and 2 MiB / 1 GiB huge pages). The kernel keeps a page table that maps virtual pages to physical frames.
Virtual address βββΊ TLB hit? ββyesβββΊ physical address βββΊ cache/RAM
β
no
βΌ
page table walk
β
entry present? ββnoβββΊ PAGE FAULT (trap to kernel)
PAGE FAULT
β
ββ address not in any mapping βββββββββββΊ SIGSEGV / access violation
β
ββ page in RAM already (page cache,
β shared lib, zero page, COW) ββββββββββΊ MINOR fault: fix page table, resume
β
ββ page on disk (swap, page file,
mmap'd file) ββββββββββββββββββββββββΊ MAJOR fault: block thread,
issue disk I/O, map page, resume
The thread that faulted is blocked for the whole disk read. If that thread holds a lock or is the GC thread, everyone waits.
new byte[100_000_000] reserves and commits memory, but Linux only maps a physical page when you first touch it (demand paging). Every first write to a fresh 4 KiB page is a minor fault.fork, parent and child share pages until one writes (copy-on-write).System.Private.CoreLib.dll maps it; pages fault in as code runs.This is why startup shows tens of thousands of minor faults. That is fine.
The GC marks by walking object references across the heap. If parts of the heap were swapped out, a gen2 GC touches them all, triggering thousands of major faults in a row. A 50 ms GC becomes a multi-second pause. This is why many teams disable swap for latency-sensitive services, and why Kubernetes historically required swap to be off.
MemoryMappedFile maps a file into the address space. Reading it causes major faults the first time (data comes from disk), then minor faults or none once it is in the page cache. This is often faster than FileStream for random access to large files, because the OS handles caching.
The TLB holds a limited number of translations. With 4 KiB pages, a 32 GB heap needs ~8 million entries, so TLB misses are frequent. Huge pages (2 MiB) cut that by 512 times. .NET supports large pages for the GC heap (GCLargePages with GCHeapHardLimit), mostly useful for very large server heaps.
| OS | Tool | What it shows |
|---|---|---|
| Linux | ps -o pid,min_flt,maj_flt,cmd |
Totals per process |
| Linux | pidstat -r 1 |
minflt/s, majflt/s per process |
| Linux | sar -B 1, vmstat 1 |
System-wide paging, swap in/out |
| Linux | perf stat -e page-faults,major-faults |
Counts for one command |
| Windows | PerfMon Memory\Pages/sec, Page Reads/sec |
Hard faults |
| Windows | Resource Monitor β Memory β Hard Faults/sec | Per process |
| Both | dotnet-counters |
Working set, GC heap, pause time |
new byte[1_000_000_000] and then writes one byte every 4096 bytes./usr/bin/time -v dotnet run and look at Minor (reclaiming a frame) page faults: about 244,000 (1 GB / 4 KiB).// Demonstrates demand paging: memory is only mapped when first touched.
using System.Diagnostics;
const int PageSize = 4096;
var buffer = GC.AllocateUninitializedArray<byte>(512 * 1024 * 1024); // 512 MiB
var before = Process.GetCurrentProcess();
Console.WriteLine($"Working set before touch: {before.WorkingSet64 / 1024 / 1024} MiB");
var sw = Stopwatch.StartNew();
for (int i = 0; i < buffer.Length; i += PageSize)
{
buffer[i] = 1; // first write to each page = one minor page fault
}
sw.Stop();
var after = Process.GetCurrentProcess();
Console.WriteLine($"Working set after touch: {after.WorkingSet64 / 1024 / 1024} MiB");
Console.WriteLine($"Touching {buffer.Length / PageSize:N0} pages took {sw.ElapsedMilliseconds} ms");
# Linux: count minor and major faults for the run
/usr/bin/time -v dotnet run -c Release 2>&1 | grep -i "page faults"
# Watch faults for a running service once a second
pidstat -r -p $(pgrep -f MyApi) 1
# System-wide: majflt/s and swap activity
sar -B 1
vmstat 1
Mistake: Treating every page fault as a problem. Why it is wrong: Task Manager and many dashboards count soft faults, which happen constantly on first touch, copy-on-write and shared libraries and cost only microseconds. Instead: Look at major (hard) faults per second and check whether they line up with the latency spikes.
Mistake: Thinking a page fault means a crash. Why it is wrong: Most page faults are resolved silently by the kernel and the code carries on. Instead: Explain that only a fault on an address with no valid mapping, or a protection violation, becomes an access violation or SIGSEGV.
Mistake: Quoting a fixed cost for a major fault.
Why it is wrong: The cost depends on the device and on queueing; a fault that takes 0.1 ms on an idle NVMe drive can take hundreds of milliseconds on a saturated network disk while other I/O is in flight.
Instead: Measure it with disk latency (iostat -x, await) alongside the fault rate.
Mistake: Blaming the GC for long pauses without checking paging.
Why it is wrong: The GC is often slow because it is faulting swapped heap pages back in, so GC settings will not fix it.
Instead: Check majflt/s and swap in/out during the pause before you tune GC settings.
Mistake: Ignoring other workloads on the same host.
Why it is wrong: Another process can push your pages out even when your own service has not changed.
Instead: Use per-process tools such as pidstat -r, and give each workload its own memory limit.
The senior point is that major faults turn a memory problem into a latency problem, and the cost lands on whichever thread touches the page, often the GC or a thread holding a lock. So the fix is usually about capacity and isolation rather than micro-tuning code.
| Option | Benefit | Cost or risk | When to use it |
|---|---|---|---|
| Disable swap on service nodes | No paging stalls, predictable latency | Memory spikes become OOM kills | Latency-sensitive APIs, Kubernetes |
| Container memory limits | One workload cannot evict another | Needs accurate sizing and headroom | Any shared host |
Smaller GC heap (GCHeapHardLimit, DATAS) |
Lower working set | More frequent GCs | Small containers, many replicas |
Large pages (GCLargePages) |
Fewer TLB misses on big heaps | Needs a hard limit, contiguous memory, privilege on Windows | Very large server heaps, after measuring |
| Memory-mapped files | OS manages caching, fast random access | Cold-cache reads become major faults | Large read-mostly data |
Monitoring is part of the answer. I would have a dashboard with p99 latency, GC pause time and heap size from dotnet-counters or OpenTelemetry runtime metrics, next to node-level major faults, swap in/out and memory pressure (PSI). With dotnet-trace I can confirm which GCs were long, and with perf or PerfView I can see whether the time was in the kernel fault handler. That combination proves cause rather than guessing.
Virtual memory gives every process its own large, private address space, so programs don't see each other's memory or need to know where physical RAM is. Memory is managed in pages (usually 4 KB). The OS keeps a page table per process that maps virtual pages to physical frames; the CPU's MMU translates every access, and the TLB caches recent translations. Pages get physical memory only when first touched (demand paging), can be shared (shared libraries, copy-on-write), can be backed by files (memory-mapped files), and can be pushed to swap or the page file under pressure. For .NET this explains why reserved and committed memory differ, why the GC reserves big ranges cheaply, and why working set is what really matters.
Each thread allocates from its own allocation context, a small chunk of gen 0 where new is just a pointer bump plus zeroing; when it runs out, the thread asks the GC for another chunk, and when the gen 0 budget is used up, a gen 0 GC runs. The GC is generational because most objects die young: gen 0 collections are cheap, survivors are promoted to gen 1 and then gen 2, which is collected rarely (and mostly in the background). Objects of 85,000 bytes or more go to the large object heap (LOH), which is collected only with gen 2 and isn't compacted by default, so it can fragment. Pinned objects can't be moved, which blocks compaction; the pinned object heap (.NET 5+) keeps long-lived pinned buffers separate. In hot paths I reduce the allocation rate rather than tuning the GC.
RAM is physical memory. Each process gets a virtual address space that the OS maps onto RAM page by page. The working set is what's resident in RAM right now; private (committed) memory is what the process owns. The GC heap is only one part: native allocations (native libraries, NativeMemory, unmanaged buffers), thread stacks, JIT code, and runtime structures add to it. In Kubernetes, the cgroup limit counts all of it, and going over gets the container OOMKilled (exit code 137), not an OutOfMemoryException. So if the GC heap is small but the pod dies, I compare GC heap size with working set in dotnet-counters. A big gap points to native memory or too many threads; a growing GC heap points to a managed leak I'd chase with dotnet-gcdump. Then I fix the cause and set limits and GC settings deliberately.
How to tell it (STAR)
**S:** Our [order API] had latency spikes nobody could explain because CPU looked fine. **T:** I was asked to find the cause before [peak trading]. **A:** I put the latency graph next to `sar -B` and saw major page faults spiking at the same moments. `pidstat` showed the API faulting while a [batch job] on the same VM grew to [12 GB]. The GC was walking heap pages that had been swapped out. We moved the batch job, set memory limits and disabled swap for the API nodes. **R:** p99 dropped back to [45 ms] and stayed there, and we added a paging alert so we would see it first next time.
Last updated 23 Oct 2026.