Module 08 — Virtual Memory, Paging & Page Faults
Updated 21 August 2026
Every process on your machine believes it owns the whole address space. None of them do. This module explains the illusion, who maintains it, and what it costs — and it is the reason free and top memory numbers confuse almost everyone.
🧠 concept → 🧩 real-world analogy → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (hidden)
From Module 01 — the kernel/user split, the syscall boundary, and that /proc/PID/ exposes kernel data about a process.
From Module 02 — fork, and that the child starts as a copy of the parent.
From Module 03 — file descriptors, inodes, and that a file has blocks on disk.
From Module 07 — that the kernel interrupts a running thread whenever it needs to.
🗺️ Part A · The illusion of private memory
A1 · What goes wrong without virtual memory
When a program uses a variable, the CPU has to turn that into an address — a number saying where in memory. The obvious design is that this number is the real location in the RAM chips. Early computers worked exactly like that.
Three things go badly wrong with that design.
Nothing is protected. If every program uses real RAM addresses, any program can read or write any other program's memory. One buggy line and your password manager's memory is readable by a game.
Programs cannot be built independently. A program is compiled once and run anywhere. If addresses were real, the compiler would have to know in advance which region of RAM is free on your machine — which it cannot possibly know.
You can never use more memory than you have installed. A program needing 3 GB simply cannot run on a 2 GB machine, even if most of that 3 GB is barely touched.
Linux solves all three the same way. A program never sees a real memory address. It sees a virtual address, and hardware translates it to a real one on every single access. Each process gets its own translation, so the same virtual address in two processes points at two different places in RAM.
Imagine a company where mail is addressed to a physical shelf in the warehouse: "shelf 4, row 12". Everyone must know the real layout. Anyone can walk to shelf 4 and take someone else's post. If the warehouse is rearranged, every letter ever written becomes wrong.
Now put a post room in between. Each department gets its own numbering — "box 1, box 2, box 3" — and the post room keeps a private table translating your box 2 to a real shelf. Marketing's box 2 and Finance's box 2 are different shelves, and neither department knows or cares which.
Three problems solved at once. Departments cannot reach each other's post because their numbering does not reach outside their own table. Every department can use the same simple numbering starting at 1. And a box can be listed in the table without a shelf being allocated yet — nothing is reserved until something actually arrives.
Where the analogy stops working. A post room clerk is slow and deliberate. The CPU does this translation billions of times a second, which is why it is silicon and not a person with a ledger.
🧪 Exercise A1.1 — Two processes, the same addresses, different memory
# Start two identical, separate processes
sleep 300 & P1=$!
sleep 300 & P2=$!
# Where does each one think its stack is?
echo "PID $P1:"; grep '\[stack\]' /proc/$P1/maps
echo "PID $P2:"; grep '\[stack\]' /proc/$P2/maps
# And where does each think the sleep program itself is loaded?
echo "PID $P1:"; head -1 /proc/$P1/maps
echo "PID $P2:"; head -1 /proc/$P2/maps
kill $P1 $P2✅ Expected result — click to reveal
PID 4412:
7ffc6d3a1000-7ffc6d3c2000 rw-p 00000000 00:00 0 [stack]
PID 4413:
7ffe91b4c000-7ffe91b6d000 rw-p 00000000 00:00 0 [stack]
PID 4412:
560f2a1c1000-560f2a1c3000 r--p 00000000 08:01 1049094 /usr/bin/sleep
PID 4413:
55d8c9e77000-55d8c9e79000 r--p 00000000 08:01 1049094 /usr/bin/sleepWhat to read out of this.
Both stacks live near the top of a very large number space — around 0x7ffc…, which is about 140 terabytes up. Your machine does not have 140 TB of RAM. These are not real memory addresses. They are positions in an imaginary space that only this process can see.
The two stacks are at nearly but not exactly the same place. That small difference is deliberate randomisation (called ASLR), added so attackers cannot predict where things are. Without it both would sit at an identical address — and that would be perfectly fine, because the two processes cannot see each other's memory anyway.
The last two lines are the interesting ones. Both processes loaded the same file: /usr/bin/sleep, inode 1049094 on device 08:01 — literally the same bytes on disk. Yet each sees it at a different virtual address. The kernel has almost certainly loaded those bytes into RAM once and pointed both processes' translation tables at the same physical copy.
That is the whole idea in one screen: the address a process uses is a label, not a location, and the kernel is free to point two different labels at one real thing.
🧪 Exercise A1.2 — The machine promises far more memory than it owns
# Add up the virtual address space every process claims (VSZ is in KiB)
ps -eo vsz --no-headers | awk '{s+=$1} END {printf "Virtual promised: %.1f GiB\n", s/1024/1024}'
# Add up what they are actually using in RAM (RSS is in KiB)
ps -eo rss --no-headers | awk '{s+=$1} END {printf "Actually resident: %.1f GiB\n", s/1024/1024}'
# How much RAM does the machine actually have?
awk '/MemTotal/ {printf "Installed RAM: %.1f GiB\n", $2/1024/1024}' /proc/meminfo✅ Expected result — click to reveal
Virtual promised: 38.4 GiB
Actually resident: 1.3 GiB
Installed RAM: 3.8 GiBWhat to read out of this.
The processes on this small machine have collectively asked for 38.4 GiB of address space on a box with 3.8 GiB of RAM — ten times more than exists. Nothing is broken. Nothing is even close to broken.
That is because asking for address space is not the same as using memory. A process can say "reserve me a 1 GiB region" and the kernel simply writes that down. Not one byte of RAM is committed until the process actually touches an address inside that region. Section B2 covers the moment it does.
The resident figure — 1.3 GiB — is the honest one, and even that is an overcount, because shared pages like the C library are counted once per process. Section C2 fixes that too.
The interview point buried here: VSZ is close to meaningless as a measure of memory use. A process with a 20 GiB VSZ may be using 40 MiB. People page a team over VSZ graphs constantly.
A2 · Pages and frames — the unit of translation
The translation table cannot store one entry per byte. A 64-bit address space would need more entries than there are atoms worth caring about. So the kernel translates in chunks.
Two words to keep straight, and they are the two halves of every translation:
- A page is a fixed-size chunk of a process's virtual address space. On x86-64 and most ARM64 systems it is 4 KiB.
- A frame is a chunk of physical RAM, the same size.
The page table is the per-process lookup: given a page, which frame? That is the whole mechanism. Everything else is detail about making the lookup fast and the table small.
Because translation works in whole pages, addresses split cleanly in two. The top bits pick the page; the bottom 12 bits are the offset inside it, and those pass through untouched.
Diagram source
flowchart LR
A["Virtual address<br>0x7f3a12c04abc"] --> B["Top bits<br>0x7f3a12c04"]
A --> C["Bottom 12 bits<br>0xabc = offset"]
B --> D["Page table lookup"]
D --> E["Frame number<br>0x00019d"]
E --> F["Physical address<br>0x00019dabc"]
C --> FA flat table would still be far too big — one entry per page across the whole address space is half a terabyte of table — 2^48 ÷ 4096 × 8 bytes on a four-level machine — for a process using two megabytes. So the table is a tree, four or five levels deep. Only the branches you actually use exist. A process with a small footprint has a tiny table.
The post room from A1 does not track individual letters. That would be an impossible ledger. It tracks lockers — fixed-size boxes. Your department's locker 7 maps to warehouse locker 1032, and where a specific letter sits inside the locker is your business, not the post room's.
That is exactly the page/offset split. The lookup handles the locker; the last few digits of the address say where in the locker, and nobody translates those.
The ledger is organised as a book of chapters rather than one enormous list: a chapter per building, a page per aisle, a line per locker. If your department only ever uses two aisles, only those chapters exist. The ledger stays thin because most of the possible lockers were never asked for.
Where the analogy stops working. A paper ledger is consulted occasionally. This one is consulted on every single memory access, which is why the next section is entirely about not consulting it.
🧪 Exercise A2.1 — See the page size and the page-table bill
# The page size this machine translates in
getconf PAGE_SIZE
# Every region in a process map begins and ends on a page boundary.
# Look at the addresses: they all end in three zeros (4096 = 0x1000).
# NOTE: /proc/self means "the process reading this file", which here would be
# head, not your shell. Use /proc/$$ to mean the shell itself.
head -5 /proc/$$/maps
# What the page table itself costs, for a small process and a big one
echo "--- this shell ---"
grep -E 'VmSize|VmRSS|VmPTE' /proc/$$/status
echo "--- the biggest process on the machine ---"
BIG=$(ps -eo pid,vsz --no-headers --sort=-vsz | head -1 | awk '{print $1}')
ps -p $BIG -o pid,comm --no-headers
sudo grep -E 'VmSize|VmRSS|VmPTE' /proc/$BIG/status✅ Expected result — click to reveal
4096
5581b4a2c000-5581b4a5a000 r--p 00000000 08:01 1052097 /usr/bin/bash
5581b4a5a000-5581b4b3c000 r-xp 0002e000 08:01 1052097 /usr/bin/bash
5581b4b3c000-5581b4b73000 r--p 00110000 08:01 1052097 /usr/bin/bash
5581b4b74000-5581b4b78000 r--p 00147000 08:01 1052097 /usr/bin/bash
5581b4b78000-5581b4b81000 rw-p 0014b000 08:01 1052097 /usr/bin/bash
--- this shell ---
VmSize: 10756 kB
VmRSS: 5504 kB
VmPTE: 68 kB
--- the biggest process on the machine ---
1198 mysqld
VmSize: 1912304 kB
VmRSS: 412960 kB
VmPTE: 1204 kBWhat to read out of this.
4096 is the page size. Now look at the map addresses: every single one ends in 000. 0x5581b4a2c000 to 0x5581b4a5a000 is exactly 46 pages. Nothing in a memory map is ever unaligned, because translation cannot describe a fraction of a page.
Notice the same file appears five times with different permissions. r--p is the ELF header and the dynamic-linking tables; r-xp is the executable code; the next r--p is .rodata, the constants; the third r--p is the RELRO region, which the loader makes read-only again once it has finished relocating it; and rw-p is the writable variables. The kernel maps each part with the least permission it needs. This is protection at page granularity, and it is why a bug that writes to a code address gets killed instead of rewriting the program.
Now the page-table cost. The shell maps 10.7 MB and its page table costs 68 kB. mysqld maps 1.9 GB and pays 1.2 MB. That is 0.6% for the shell but only 0.06% for mysqld, and the difference is worth understanding: one page-table page describes 2 MiB of address space whether you use all of it or not, so a small, scattered process pays proportionally more. A densely mapped region approaches the floor of 8 bytes per 4 KiB page, which is 0.2%. Either way it is real memory, it is per process, and it is invisible in RSS.
Where this bites in production: a process that maps 100 GB pays around 200 MB just for page tables, and a hundred processes each mapping the same shared region each pay their own copy of the table. This is one of the reasons huge pages exist, which is Part D.
A3 · The MMU and the TLB — why translation is not slow
The piece of hardware that does the lookup is the MMU — the memory management unit, built into the CPU. On every memory access it takes the virtual address, walks the page table, and produces a physical address. If the page has no valid entry, it raises a fault instead, which is Part B.
There is an obvious problem. The page table is a four-level tree stored in RAM. Walking it means four memory reads. So every one memory access the program makes would cost five. That would make every machine roughly five times slower.
The fix is a cache called the TLB — the translation lookaside buffer. It is a small, very fast table inside the CPU holding recently used page→frame translations. A hit costs essentially nothing. A miss costs the full walk.
The post room's ledger is a four-volume set kept in a back room. Looking something up properly means walking there, pulling volume one, finding the chapter, then volume two, and so on. Doing that for every letter would grind the place to a halt.
So the clerk keeps a small pad of sticky notes on the desk with the last few dozen lookups written down. Nearly every letter that arrives is for a department they just handled, so the note is already there and the answer is instant. Only an unfamiliar box number means a trip to the back room.
Two consequences follow directly. The pad is small, so a day of wildly scattered post — every letter for a different department — means a trip to the back room almost every time, and the clerk crawls. And when a completely different company takes over the desk, the notes are worthless and get binned.
Where the analogy stops working. The clerk can choose what to keep. TLB replacement is done by the hardware with no idea what your program is about to do next.
🧪 Exercise A3.1 — Try to measure TLB misses (this one usually fails, on purpose)
# perf is the tool that reads the CPU's own counters
# NOTE: on Ubuntu, linux-tools-common installs a wrapper script, so `which perf`
# succeeds even when the real binary for your kernel is missing. Test properly.
perf --version >/dev/null 2>&1 || sudo apt-get install -y linux-tools-common "linux-tools-$(uname -r)"
# On cloud kernels the per-kernel package is linux-tools-aws / -gcp / -azure.
# Ask the CPU how many address translations missed the TLB
perf stat -e dTLB-loads,dTLB-load-misses -- \
bash -c 'a=0; for i in $(seq 1 200000); do a=$((a+i)); done'✅ Expected result — click to reveal
On a physical machine you get real numbers:
Performance counter stats for 'bash -c ...':
1,842,551,203 dTLB-loads
2,104,887 dTLB-load-misses # 0.11% of all dTLB cache accesses
1.284730151 seconds time elapsedOn a cloud VM you will almost certainly get this instead:
Performance counter stats for 'bash -c ...':
<not supported> dTLB-loads
<not supported> dTLB-load-misses
1.402318774 seconds time elapsedWhat to read out of the failure — this is the useful half.
<not supported> does not mean perf is broken or that you typed something wrong. It means the hardware performance counters are not exposed to the guest. Those counters live in the physical CPU, and most hypervisors do not virtualise them, because doing so would let one tenant observe the behaviour of another. AWS, GCP and Azure general-purpose instances all behave this way; bare-metal instance types generally do not.
This catches people out badly. A performance investigation that depends on perf stat -e cache-misses or dTLB-load-misses cannot be done on a normal cloud VM at all. Knowing that before you promise someone a hardware-level analysis is worth a lot.
One failure that is not this one. If you get "Access to performance monitoring and observability operations is limited" instead of <not supported>, that is the kernel.perf_event_paranoid sysctl refusing you, not the hypervisor. Ubuntu ships a restrictive value. Check it with sysctl kernel.perf_event_paranoid before concluding anything about the hardware — the two failures look similar and mean completely different things.
What still works in a VM: software events, which the kernel counts itself.
$ perf stat -e task-clock,context-switches,page-faults,cpu-migrations -- sleep 1
1.42 msec task-clock # 0.001 CPUs utilized
2 context-switches # 1.408 K/sec
248 page-faults # 174.648 K/sec
1 cpu-migrations # 0.704 K/secpage-faults is the one this module is about, and it is counted by the kernel — so it works everywhere, VM or not. Part B is built on it.
A4 · Reading a process's address space
A process's address space is not one big blank region. It is a set of separately mapped areas, each with its own permissions and its own origin. /proc/PID/maps lists them, and once you can read it you can answer most "where is this process's memory going" questions without any special tools.
Each line has six columns:
| Column | Example | Meaning |
| Address range | 5581b4a5a000-5581b4b3c000 | Start and end virtual addresses, always page-aligned |
| Permissions | r-xp | read, write, execute, then p private or s shared |
| Offset | 0002e000 | How far into the file this region starts |
| Device | 08:01 | Major:minor of the device holding the file |
| Inode | 1052097 | The file's inode, from Module 03 |
| Path | /usr/bin/bash | The file, or [heap], [stack], or blank |
The split that matters most is the last column being present or blank.
File-backed regions come from a file. The kernel knows it can always fetch the contents again from disk, so under pressure it can simply drop the pages and reload them later. Two processes mapping the same file can share the same physical frames — which is what you saw in Exercise A1.1.
Anonymous regions have a blank path. There is no file to reload from: the heap, the stack, and anything a program allocated. If the kernel wants those pages back it has to put the contents somewhere first, which is Module 09's subject.
maps is a floor plan. It does not tell you who is in each room right now; it tells you what rooms exist, how big they are, and what you are allowed to do in each.
Some rooms are fitted out from a catalogue — a standard meeting room installed from a kit. That is a file-backed mapping: if the room burns down you can rebuild it from the catalogue, so nobody needs to photograph it first.
Other rooms hold things made on site with no copy anywhere else. That is anonymous memory. If you need the space back you must physically move the contents somewhere before you can reuse the room.
And the same catalogue room can be installed in twenty buildings from one set of plans — one physical set of plans, twenty rooms. That is shared file-backed memory.
Where the analogy stops working. A floor plan is drawn once. A process's map changes constantly as it allocates and frees, and reading it twice a second apart can give two different pictures.
🧪 Exercise A4.1 — Take apart your own shell's address space
# The whole map. Look at it once, whole, before slicing it.
# /proc/$$ is THIS shell. /proc/self would be whichever tool opens the file.
cat /proc/$$/maps
# How many regions, and how many are anonymous (no file behind them)?
# A region is file-backed only if the last column is a real path starting with /
# The [heap], [stack], [vdso] pseudo-names have a 6th column but no file.
echo "total regions: $(wc -l < /proc/$$/maps)"
echo "file-backed: $(awk '$6 ~ /^\//' /proc/$$/maps | wc -l)"
echo "anonymous: $(awk 'NF==5 || $6 ~ /^\[/' /proc/$$/maps | wc -l)"
# The special ones, by name
grep -E '\[.*\]' /proc/$$/maps
# Which single mapping is the largest? (compute size from the address range)
# strtonum() is a gawk extension and Ubuntu's default awk is mawk, so use perl,
# which is present on every Ubuntu image, to convert the hex addresses.
perl -ane '($s,$e) = split /-/, $F[0];
printf "%10d KiB %s %s\n", (hex($e)-hex($s))/1024, $F[1], ($F[5] // "")' \
/proc/$$/maps | sort -rn | head -5✅ Expected result — click to reveal
total regions: 27
file-backed: 19
anonymous: 8
563a1f2d1000-563a1f302000 rw-p 00000000 00:00 0 [heap]
7f16c1d9a000-7f16c1d9e000 r--p 00000000 00:00 0 [vvar]
7f16c1d9e000-7f16c1da0000 r-xp 00000000 00:00 0 [vdso]
7ffd4c9a1000-7ffd4c9c2000 rw-p 00000000 00:00 0 [stack]
1540 KiB r-xp /usr/lib/x86_64-linux-gnu/libc.so.6
904 KiB r-xp /usr/bin/bash
320 KiB r--p /usr/lib/x86_64-linux-gnu/libc.so.6
196 KiB rw-p [heap]
132 KiB rw-p [stack]What to read out of this.
Twenty-seven separate regions for a shell that is doing nothing. Nineteen come from files — the shell binary, the C library, the terminal library, the dynamic loader — and eight are anonymous.
The named regions are worth knowing by sight. [heap] is where small allocations grow. [stack] is the call stack, and it is only 132 KiB right now even though the limit is usually 8 MiB, because the stack is grown on demand and this shell has not needed more. [vdso] is the trick from Module 01: a tiny page of kernel code mapped into every process so calls like gettimeofday avoid the syscall boundary entirely. [vvar] is the data it reads.
Now the entry people find surprising: the largest single mapping is not the shell at all. It is 1540 KiB of the C library's executable code, bigger than bash itself — and it is shared, so the machine holds one copy of it no matter how many shells you open.
You will also meet mappings with permissions ---p, meaning no read, no write and no execute. They are not gaps left by the loader; on a modern glibc the library's five regions are laid out contiguously with no hole between them. Where ---p regions do appear — heavily threaded programs, and Go or Java runtimes — they are reservations: address space claimed up front so that later allocations can be contiguous, with no physical memory behind them at all. They cost nothing in RAM and everything in VSZ, and Section C1 takes one apart.
If you run this twice you will see every address change. That is ASLR again.
🎯 Interview questions — The illusion
Q. What is virtual memory and why is it important?
Virtual memory is a layer of indirection between the addresses a program uses and the real locations in RAM. Every process gets its own private address space; hardware in the CPU translates each address through a per-process page table on every access.
It buys three things at once. Protection — a process cannot even name memory outside its own table, so it cannot reach another process's data. Relocation — programs can be compiled to fixed addresses and still run anywhere, because the addresses are not real. Overcommitment — address space can be handed out without RAM being committed, so the sum of what processes have asked for can far exceed installed memory.
The details that separate candidates: most answers stop at "it lets you use disk as extra RAM", which is swap — one consequence, and not the important one. The stronger framing is that virtual memory is primarily an isolation and abstraction mechanism, and that a machine with swap disabled entirely still depends on it completely. The other detail worth adding is that translation happens in hardware on every access, which is why the design is shaped around a small cache (the TLB) rather than around software lookups.
Q. How does virtual memory work? Explain paging and the TLB.
Memory is divided into fixed-size pages on the virtual side and equally sized frames on the physical side — 4 KiB on x86-64. Each process has a page table, a multi-level tree mapping its pages to frames; only the branches in use exist, so the table stays small.
On every access the MMU splits the address: the high bits select the page, the low 12 bits are an offset that passes through unchanged. Walking a four-level table would cost four extra memory reads per access, so the CPU caches recent translations in the TLB. A hit is effectively free; a miss triggers the walk.
The details that separate candidates: being able to say roughly how much memory the TLB covers — a couple of thousand entries at 4 KiB each is only single-digit megabytes — and therefore why a large, randomly accessed working set thrashes the TLB regardless of how much RAM you have. That naturally leads to huge pages as the fix. It is also worth noting that the page table itself consumes RAM (VmPTE in /proc/PID/status), which most people have never looked at.
Q. What is the difference between a logical (virtual) address and a physical address?
A virtual address is what the process sees and uses; it is meaningful only inside that process's address space. A physical address is an actual location in the RAM chips. The MMU converts one to the other using the page table, per process, on every access.
The same virtual address in two processes normally refers to two different physical addresses. The reverse also happens: two different virtual addresses, in the same or different processes, can refer to one physical address — that is shared memory, and it is how a single copy of the C library serves every process on the machine.
The details that separate candidates: stating that the mapping is many-to-one in both directions and not stable over time — the kernel can move a page to a different frame, and a virtual page may have no frame behind it at all until it is touched. Adding that unprivileged processes deliberately cannot see physical addresses (/proc/PID/pagemap has been masked for unprivileged users since 2015, because exposing them enabled Rowhammer-style attacks) shows you have actually gone looking rather than recited a textbook.
💥 Part B · Page faults
B1 · A page fault is not an error
The word "fault" makes this sound like a failure. It is not. A page fault is the normal mechanism by which memory gets attached to a process, and a busy machine does tens of thousands to hundreds of thousands of them a second.
Here is the sequence. The MMU tries to translate an address. Either it finds no valid entry in the page table, or it finds one whose permissions forbid what the program is doing — writing to a read-only page, for instance. Either way it cannot proceed, so it raises a fault — a trap into the kernel, exactly like the syscall boundary in Module 01 except the program did not ask for it. The kernel looks at the address and decides what it meant.
There are three answers, and telling them apart is most of what this section is for.
| Kind | What the kernel found | What it does | Cost |
| Minor fault | The data is already in RAM, or needs no data at all | Fill in a page-table entry and resume | Microseconds |
| Major fault | The data must be fetched from disk first | Start the read, put the process to sleep, resume when it lands | Milliseconds — thousands of times worse |
| Invalid access | This address means nothing to this process | Send SIGSEGV | The process usually dies |
Diagram source
flowchart TD
A["Program touches<br>a virtual address"] --> B{"Valid page table<br>entry present?"}
B -->|"Yes"| C["Access proceeds<br>no fault at all"]
B -->|"No"| D["MMU raises<br>a page fault"]
D --> E{"Is this address<br>part of a valid mapping?"}
E -->|"No"| F["SIGSEGV<br>segmentation fault"]
E -->|"Yes"| G{"Is the data<br>already in RAM?"}
G -->|"Yes"| H["Minor fault<br>point the entry at it"]
G -->|"No"| I["Major fault<br>read from disk, sleep"]
H --> J["Resume the instruction"]
I --> JYou ask for the contents of your box 7. The clerk checks the ledger and there is no shelf listed. Three things can be true.
Minor fault: the contents are already sitting in the sorting area — someone else's department has the identical catalogue item, or the box was reserved earlier and just never written into the ledger. The clerk writes the line and hands it over. A few seconds.
Major fault: the contents are in deep storage across town. The clerk sends a van, tells you to sit down, and serves other people while you wait. Half a day. Nothing is wrong — but you were not doing anything for half a day.
Invalid: there is no box 7 in your department and never was. The clerk does not go looking. You are escorted out of the building.
The important part is that in the first two cases you were never told a lookup failed. You asked, you waited a bit, you got your post.
Where the analogy stops working. A clerk would remember they just fetched your box. The kernel's decision is made fresh from the page tables each time, and the "remembering" is a separate mechanism entirely.
🧪 Exercise B1.1 — Watch memory being attached one page at a time
# Fault counters for this shell right now.
# min_flt = minor faults, maj_flt = major faults, rss in KiB.
ps -o min_flt,maj_flt,rss -p $$ --no-headers
# Now make the shell actually use about 50 MB of memory
BIG=$(head -c 50000000 /dev/zero | tr '\0' 'x')
# Look again
ps -o min_flt,maj_flt,rss -p $$ --no-headers
# 50 MB in 4 KiB pages is how many pages?
echo "50000000 / 4096 = $((50000000 / 4096)) pages"
# Free it and look once more
unset BIG
ps -o min_flt,maj_flt,rss -p $$ --no-headers✅ Expected result — click to reveal
167 0 3244
73503 0 52212
50000000 / 4096 = 12207
73527 0 3380What to read out of this.
Minor faults went from 167 to 73,503 — an increase of about 73,300. The arithmetic says 50 MB is 12,207 pages, so the fault count is roughly six times the number of pages involved.
That gap is the lesson. Demand paging guarantees you at least one fault per page. It does not promise only one. bash builds the string by growing a buffer and copying it into a larger one over and over, so the same 50 MB of data gets written into fresh pages several times over, and each fresh page faults again.
That is demand paging happening in front of you. Nobody handed bash 50 MB. It received tens of thousands of separate small events, each one triggered by touching a page that had no frame behind it yet.
maj_flt stayed at 0 throughout. Not one byte came off a disk — anonymous memory is filled with zeroes the kernel produces on the spot, so there was nothing to read. A major fault means reading from storage, and here there was nothing on storage to read.
Now the last line. unset BIG freed the memory and RSS fell straight back to 3,380 kB, essentially where it started — but the fault counter did not go down, it went slightly up. min_flt is a lifetime total, not a current level. Like %CPU in ps from Module 07, it tells you about the past. To use it you take two readings and subtract.
RSS returned cleanly here because an allocation this large is served by its own dedicated mapping, which is handed straight back to the kernel when it is freed. Smaller allocations usually do not behave this way — the allocator keeps those pages for reuse, so RSS stays high after the program has freed everything. That is the single most common reason a "memory leak" ticket turns out not to be a leak, and it is worth knowing that the behaviour depends on the size of the allocation.
B2 · Demand paging and copy-on-write
Two behaviours fall directly out of "memory is attached on fault". Both are things you already used in Modules 02 and 03 without the mechanism being explained.
Demand paging. When you run a 200 MB binary, the kernel does not read 200 MB. It sets up mappings that say "this region of address space corresponds to this part of this file" and reads nothing. Execution starts, the first instruction faults, one page comes in, and it continues that way. A program that only ever uses one feature never loads the code for the rest. This is why start-up time is not proportional to binary size.
Copy-on-write. Module 02 said fork gives the child a copy of the parent's memory without actually copying it. Here is the actual mechanism: the kernel points the child's page table at the parent's existing frames and marks every writable page read-only in both processes. Neither one knows. The first time either writes to a page, the MMU raises a fault because the page is read-only — and the kernel, which knows the page is only read-only because of COW, makes a private copy for the writer, marks it writable, and re-runs the instruction.
The result: only pages that are actually written ever get duplicated. A shell that forks a child which immediately calls exec copies almost nothing.
Two colleagues inherit the same set of case files. Photocopying all four thousand of them would take a day, so instead they agree to share the originals and put a sticker on every folder: read only — see the office manager before writing.
Both work normally. Most folders are only ever read, and those are never copied. The moment one of them needs to annotate folder 213, they hit the sticker, the manager photocopies just that folder, hands them their own copy, and removes the restriction on it.
The cost is spread out and proportional to what was actually changed, not to what was inherited. And the pair of them together use far less filing space than two full sets.
The catch is the annoyance: every first write is interrupted. If one colleague plans to annotate all four thousand folders, they will be stopped four thousand times and the sharing bought nothing except a slower start.
Where the analogy stops working. The colleagues know about the arrangement and could opt out. Processes cannot see it at all — a copy-on-write fault is invisible from inside the program.
🧪 Exercise B2.1 — Two processes, one copy of the memory
# Hold ~50 MB in this shell
BIG=$(head -c 50000000 /dev/zero | tr '\0' 'x')
echo "parent is PID $$"
ps -o pid,rss,comm -p $$ --no-headers
# Fork a child that KEEPS the inherited memory and does nothing with it.
# It must be a subshell with more than one command, otherwise bash optimises
# the fork away by exec-ing the single command in place - and then there is
# nothing inherited left to look at.
( sleep 60; : ) &
CHILD=$!
# What does each one claim?
ps -o pid,ppid,rss,comm -p $$,$CHILD --no-headers
# Add up what the two of them claim to be using
ps -o rss --no-headers -p $$,$CHILD | awk '{s+=$1} END {printf "sum of RSS: %d MiB\n", s/1024}'
kill $CHILD; unset BIG✅ Expected result — click to reveal
parent is PID 4881
4881 53612 bash
4881 4102 53612 bash
4914 4881 52104 bash
sum of RSS: 103 MiBWhat to read out of this.
Both processes report about 52 MB, and the sum says 103 MiB — on a machine that never spent more than 53 MB. Not one page was copied. The child's page table points at the parent's existing frames, and RSS counts a frame in full for every process that maps it.
This is the single most important thing to understand about RSS, and it is why the number cannot be added up. It is not lying: the child really can reach 52 MB of memory. It simply is not 52 MB of additional memory.
Worth trying as a variation: replace ( sleep 60; : ) & with bash -c 'sleep 60' & and run it again. The child's RSS drops to about 1.7 MB and comm reads sleep, not bash — because a single simple command is execed in place, which throws the inherited image away entirely. That is the common case in real systems: almost every fork is followed immediately by exec, so copy-on-write ends up copying essentially nothing.
That double-counting is not a bug in ps. RSS honestly answers "how many frames is this process mapping". It just cannot answer "how much memory would I get back if I killed it", which is the question people are actually asking. Section C2 is the fix.
If your run shows a child RSS of only a megabyte or two, the shell exec-ed the child anyway — check that the subshell really contains two commands. Some shells optimise more aggressively than bash.
B3 · Seeing faults on a real machine
Four places give you fault numbers, and they answer different questions:
| Where | Command | Answers |
| Per process, lifetime | ps -o min_flt,maj_flt -p PID | Has this process ever waited on disk for memory? |
| Per process, live | two reads of /proc/PID/stat fields 10–13 | Is it faulting right now? |
| Per command run | /usr/bin/time -v <cmd> | What did this one run cost? |
| Whole machine | Two reads of grep -E '^pg(fault|majfault)' /proc/vmstat, subtracted. vmstat has no fault column, and perf stat needs -a plus privileges. | Fleet-wide rate |
To see a major fault you need a file whose contents are not already in memory. One sentence of background is enough for now: after Linux reads something from disk, it keeps that copy in RAM in case it is needed again, and drop_caches throws those copies away. That behaviour is the subject of Module 09; here it is just a lever to make the disk read happen again.
🧪 Exercise B3.1 — Force a major fault, then watch it disappear
# Pick a reasonably large program
# /usr/bin/time is a separate package on Ubuntu, and the shell has a builtin
# called `time` that does NOT support -v. We need the binary.
command -v /usr/bin/time >/dev/null || sudo apt-get install -y time
BIN=$(which python3 || which perl)
ls -lh $BIN
# Make the kernel forget everything it is holding from disk
sync; sudo sh -c 'echo 3 > /proc/sys/vm/drop_caches'
# First run: the code has to come off the disk
/usr/bin/time -v $BIN --version 2>&1 | grep -E 'Maximum resident|Major|Minor|Elapsed'
# Second run, immediately after: nothing has to come off the disk
/usr/bin/time -v $BIN --version 2>&1 | grep -E 'Maximum resident|Major|Minor|Elapsed'✅ Expected result — click to reveal
-rwxr-xr-x 1 root root 5.9M Mar 18 09:12 /usr/bin/python3
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.34
Maximum resident set size (kbytes): 9216
Major (requiring I/O) page faults: 148
Minor (reclaiming a frame) page faults: 1032
Elapsed (wall clock) time (h:mm:ss or m:ss): 0:00.02
Maximum resident set size (kbytes): 9216
Major (requiring I/O) page faults: 0
Minor (reclaiming a frame) page faults: 1179What to read out of this.
The first run took 0.34 seconds; the second took 0.02 seconds. Seventeen times faster, running exactly the same program with exactly the same arguments on the same machine.
The only difference in the numbers is Major page faults: 148 versus 0. One hundred and forty-eight times, the process asked for a page, the kernel found it had to go to the disk, and the process was put to sleep until it arrived. That accounts for essentially the whole 320 ms.
Look at the minor faults: they went up slightly on the fast run, 1032 to 1179. That is the point. Minor faults are not the expensive thing. You had more of them on the run that was seventeen times faster.
Maximum resident set size is identical at 9216 kB both times, which tells you something else useful: the amount of memory used was never the issue. Same memory, same work, wildly different time — all of it explained by where the memory had to come from.
The production translation. A service that is slow only after a deploy, only after a restart, or only on the first request after being idle, and whose maj_flt is non-zero, is not slow because of CPU or because of a memory shortage. It is waiting for disk to hand it its own code.
🧪 Exercise B3.2 — Cause an invalid access on purpose
This one is meant to crash. That is the point.
# A shell function that calls itself forever.
# Each call uses a little more stack. The stack cannot grow forever.
bash -c 'f() { f; }; f'
echo "exit status: $?"
# What the kernel logged about it
sudo dmesg | tail -3
# What was the stack limit it ran into?
ulimit -s✅ Expected result — click to reveal
Segmentation fault
exit status: 139
[ 8241.559102] bash[5127]: segfault at 7ffc2c1a1ff8 ip 000055f0a2b114c1 sp 00007ffc2c1a2000 error 6 in bash[55f0a2ad2000+d6000]
[ 8241.559118] Code: 48 89 e5 48 83 ec 20 48 89 7d e8 ...
8192What to read out of this.
Exit status 139. From Module 02: a process killed by signal N reports 128 + N. 139 − 128 = 11, and signal 11 is SIGSEGV. You did not need the message to know what happened.
Now the kernel log line, which is the useful part. segfault at 7ffc2c1a1ff8 is the address the program tried to touch. sp 00007ffc2c1a2000 is where the stack pointer was. The faulting address is 8 bytes below the stack pointer — the function tried to push one more value and went off the bottom of the stack.
ulimit -s says 8192, which is 8 MiB. The stack grows downward on demand: each time it needs another page the access faults, the kernel notices the address is just below the existing stack mapping and quietly extends it. That is a normal minor fault and happens constantly. But the kernel refuses once the mapping would exceed RLIMIT_STACK, and the refused access is delivered as SIGSEGV. The limit is what stops the recursion, not a wall in memory.
Separately, the kernel keeps a guard gap below every stack — 1 MiB by default since Linux 4.12, tunable with the stack_guard_gap= boot parameter. That gap exists so a runaway stack cannot silently grow into an unrelated mapping and start corrupting it. It is a safety net against a different bug, not the mechanism you just triggered.
error 6 is a bitmask: bit 1 means it was a write, bit 2 means it happened in user mode. So: a user-mode write to an address with no mapping. That single number tells you read-versus-write, which is often the first thing you want to know.
Why this matters beyond the demo. Unbounded recursion is one of the two classic causes of a stack overflow; the other is a large array declared as a local variable. Both produce exactly this signature, and both are commonly misreported as "the machine ran out of memory" when the machine had gigabytes free. A segfault is not an out-of-memory condition. It is an access to an address that is not mapped, and the two have completely different fixes.
🎯 Interview questions — Faults
Q. What is a page fault and how is it handled?
It is a hardware trap raised by the MMU when a virtual address has no valid page-table entry. Control passes to the kernel, which looks at the address and the process's mappings and decides one of three things: the mapping is valid and the data is already in RAM, so it fills in the entry and returns (minor); the mapping is valid but the data must be read from disk, so it starts the I/O and sleeps the process (major); or the address is not part of any valid mapping, so it delivers SIGSEGV. In the first two cases the kernel then re-runs the faulting instruction and the program never learns anything happened.
The details that separate candidates: leading with "a page fault is not an error" and being able to say that a healthy machine does millions per second. Most candidates describe it as a failure or conflate it with a segfault. The other detail is that the program is not notified and the instruction is retried — that is precisely what makes lazy allocation possible, and it is the reason a process can be handed a 4 GB mapping instantly.
Q. What is the difference between a minor and a major page fault, and which should you alert on?
A minor fault is resolved without touching a disk: the page is already in RAM (shared with another process, or already held from an earlier read), or it needs no data at all because it is a fresh zero page. It costs microseconds. A major fault requires disk I/O, so the process blocks for milliseconds — three to four orders of magnitude worse.
Alert on major faults. Total page faults are dominated by minor ones and track normal activity, so a "page faults per second" alert fires on healthy workloads and stays quiet during real problems.
The details that separate candidates: being able to give the ratio — the same program can run seventeen times slower purely because 148 of its faults were major — and knowing where to read the two numbers separately (min_flt and maj_flt in ps, or /usr/bin/time -v). It is also worth saying that major faults are expected immediately after a deploy or a restart, so the alert needs to be on a sustained rate rather than a spike, or it will page you on every rollout.
Q. Describe how copy-on-write works and why it matters for process creation.
On fork, instead of duplicating the parent's memory the kernel points the child's page table at the parent's existing frames and marks every writable page read-only in both processes. The first write by either one raises a protection fault; the kernel recognises it as a copy-on-write fault, makes a private copy of that single page for the writer, marks it writable, and restarts the instruction. Only pages actually written are ever duplicated.
It matters because the overwhelmingly common pattern is fork followed immediately by exec, which discards the inherited image entirely. Without COW, every command you type would first copy the whole shell.
The details that separate candidates: three things. First, the parent is affected too — its own writes now fault until the sharing is broken, so forking briefly slows the parent down. Second, fork is cheap in memory but not free in time, because the kernel still walks and marks the page tables and allocates the child's own tables; a database process with tens of gigabytes mapped shows a measurable pause. Third, this is exactly why summing RSS across processes overstates real usage — shared frames are counted once per process.
Q. A process died with a segmentation fault. Walk me through what actually happened.
The program accessed an address that had no valid mapping, or accessed it in a way its mapping forbids — writing to a read-only page, or executing a page that is not executable. The MMU raised a fault, the kernel looked for a mapping covering that address, found none that permitted the access, and delivered SIGSEGV. The default disposition kills the process, and the shell reports exit status 128 + 11 = 139.
To investigate I would read the kernel log: dmesg records the process name, PID, the faulting address, the instruction pointer, the stack pointer and an error bitmask that says read-or-write and user-or-kernel mode. Comparing the faulting address to the stack pointer distinguishes a stack overflow from a wild pointer straight away.
The details that separate candidates: stating plainly that a segfault is not an out-of-memory condition — the two get conflated constantly, and the fixes are unrelated. Also: a stack overflow from deep recursion produces a faulting address immediately below the stack pointer, because the stack grows on demand up to ulimit -s and then meets an unmapped guard page. And knowing that segfaults inside a container appear in the host's kernel log, not the container's, which is why they so often go unseen.
📏 Part C · Measuring a process's memory honestly
C1 · VSZ and RSS, and what each one is not
Two numbers appear in every tool, and both are routinely used to answer a question neither of them answers.
VSZ — virtual size. The total amount of address space this process has mapped. It includes regions that have never been touched, file mappings whose contents are still entirely on disk, and the permission-less ---p gaps you met in Exercise A4.1. VSZ can be enormous while the process uses almost no memory.
RSS — resident set size. The number of physical frames currently mapped by this process, in kilobytes. This is real memory — but it counts every frame in full, including frames shared with other processes. Add up RSS across processes and you will exceed the RAM in the machine.
The question people actually want answered is usually "how much memory would I get back if I killed this?" Neither number answers it, and that is Section C2.
| VSZ | RSS | |
| Counts untouched mappings | Yes | No |
| Counts shared frames | Yes | Yes, in full, per process |
| Can exceed installed RAM | Yes, easily and harmlessly | Not per process — but the sum can |
| Good for | Spotting a runaway mmap loop | "Is this one process growing over time?" |
| Bad for | Anything about real memory use | Capacity planning, summing across processes |
VSZ is the size of your address book: how many box numbers you have written down. You can write down ten thousand numbers in an afternoon. It says nothing about how much post you have.
RSS is how many warehouse shelves you currently have access to. That is real, physical, and finite.
But here is the catch that makes RSS misleading. Several departments share the shelf holding the standard employee handbook. Every one of them lists that shelf as "shelves I have access to". Ask each department how many shelves it uses and add the answers up, and you will conclude the warehouse holds more shelves than it has walls for.
And if you close one department, the handbook shelf does not become free, because everyone else still uses it. The number you wanted was never "shelves I can reach" — it was "shelves that would empty if I left".
Where the analogy stops working. A department knows which shelves it shares. A process has no idea; only the kernel can see the whole picture, which is why the honest number has to come from /proc.
🧪 Exercise C1.1 — Find the process where VSZ lies the loudest
# Every process, sorted by the RATIO of VSZ to RSS - the biggest liars first.
# The $3>0 guard matters: kernel threads report 0 for both and would divide by zero.
ps -eo pid,vsz,rss,comm --no-headers | awk '$3>0 {printf "%6s %10.1f MB vsz %8.1f MB rss ratio %5.1fx %s\n", $1, $2/1024, $3/1024, $2/$3, $4}' | sort -k7 -rn | head -5
# Take the worst offender apart: where is all that address space?
BIG=$(ps -eo pid --no-headers --sort=-vsz | head -1)
# perl again, because strtonum() is gawk-only and Ubuntu's awk is mawk
sudo perl -ane '($s,$e) = split /-/, $F[0];
$sz = (hex($e)-hex($s))/1024/1024;
printf "%8.1f MB %s %s\n", $sz, $F[1], ($F[5] // "") if $sz > 10' /proc/$BIG/maps✅ Expected result — click to reveal
1198 1867.5 MB vsz 403.3 MB rss ratio 4.6x mysqld
842 1122.9 MB vsz 18.7 MB rss ratio 60.0x containerd
901 998.4 MB vsz 32.1 MB rss ratio 31.1x dockerd
455 412.6 MB vsz 11.2 MB rss ratio 36.8x snapd
688 92.1 MB vsz 14.9 MB rss ratio 6.2x sshd
1024.0 MB ---p
256.0 MB rw-p
64.0 MB rw-p
16.0 MB r--p /usr/lib/mysql/mysqldWhat to read out of this.
containerd reports 1.1 GB of VSZ and 18.7 MB of RSS — a ratio of sixty. If you graphed VSZ you would conclude it was the biggest memory consumer on the box. It is using less memory than sshd.
The reason is in the second output. The single largest mapping is 1 GB with permissions ---p — no read, no write, no execute, and no file behind it. That is a reservation: the program asked the kernel to set aside a contiguous gigabyte of address space so it can hand out pieces later without them being fragmented. Not one page of RAM is involved. This is standard behaviour for Go runtimes and for glibc's thread arenas, which is why Go and Java processes are the usual suspects in "this process is using 1 GB" tickets that turn out to be nothing.
Compare mysqld: ratio 4.6, and its large mappings are rw-p — actually usable memory it has reserved and partly touched. A ratio near 1 means a process whose address space is nearly all live; a ratio in the tens means most of it is reservation.
The rule to take away: never report VSZ as memory usage, and be suspicious of any dashboard whose "memory" panel is built from it. Several well-known monitoring agents got this wrong for years.
C2 · The honest numbers — PSS and private memory
The kernel does know which of a process's frames are shared and with how many others. It exposes the breakdown in /proc/PID/smaps per mapping, and — much more usefully — pre-summed for the whole process in /proc/PID/smaps_rollup.
Four terms, and once you have them the confusion goes away:
| Term | Definition | The question it answers |
| RSS | All resident frames, shared ones counted in full | "How much memory can this process reach?" |
| PSS | Private frames, plus each shared frame divided by the number of processes sharing it | "What is this process's fair share of the machine?" |
| USS | Private frames only — add Private_Clean and Private_Dirty yourself; there is no Uss field | "What would I get back if I killed it?" |
| Dirty | Modified, so it cannot simply be dropped | "What must be written somewhere before it can be reused?" |
PSS is the one designed to be summed. Because each shared frame is divided among its users, the PSS of every process adds up to the total memory mapped by processes, with each frame counted exactly once and no double counting. That property is what makes it the right basis for comparing processes and for capacity work. It is not the machine's total memory use — kernel memory, page tables and file data that no process has mapped are all outside it, which is why the total in the next exercise comes to only a quarter of the RAM.
Six flatmates share a house. Ask each of them "how much house do you have access to?" and all six say "the whole house" — that is RSS, and adding it up gives you six houses.
Ask instead "what is your share?" — your own bedroom, plus one sixth of the kitchen, hallway and bathroom. Add those six answers together and you get exactly one house. That is PSS, and it is the only one of the three that survives being summed.
Now ask the question that actually matters when someone moves out: "what space frees up when you leave?" Only the bedroom. The kitchen stays occupied by the other five. That is USS — and it is why evicting one flatmate to make room rarely frees as much as the RSS figure implied.
Where the analogy stops working. Flatmates leave one at a time and the shares recalculate cleanly. Processes share different pages with different partners, so the divisor is per page, not per process, and the arithmetic is only tidy because the kernel does it for you.
🧪 Exercise C2.1 — Sum the machine three ways and see which one is honest
# Pick a process with siblings — web server workers, or any multi-process service
ps -eo pid,rss,comm --no-headers --sort=-rss | head -5
# The full breakdown for the biggest one
BIG=$(ps -eo pid --no-headers --sort=-rss | head -1)
sudo grep -E '^(Rss|Pss|Private_Clean|Private_Dirty|Shared_Clean|Shared_Dirty):' \
/proc/$BIG/smaps_rollup
# Now sum the whole machine both ways and compare to installed RAM
echo "--- whole machine ---"
ps -eo rss --no-headers | awk '{s+=$1} END {printf "sum of RSS : %8.1f MiB\n", s/1024}'
sudo sh -c 'for f in /proc/[0-9]*/smaps_rollup; do grep -H "^Pss:" $f 2>/dev/null; done' | \
awk '{s+=$2} END {printf "sum of PSS : %8.1f MiB\n", s/1024}'
awk '/MemTotal/ {printf "installed : %8.1f MiB\n", $2/1024}' /proc/meminfo✅ Expected result — click to reveal
1198 412960 mysqld
2041 118204 python3
2042 116888 python3
2043 115932 python3
901 32872 dockerd
Rss: 412960 kB
Pss: 407742 kB
Private_Clean: 1204 kB
Private_Dirty: 401320 kB
Shared_Clean: 10436 kB
Shared_Dirty: 0 kB
--- whole machine ---
sum of RSS : 1284.6 MiB
sum of PSS : 968.3 MiB
installed : 3936.0 MiBWhat to read out of this.
Start with the three python3 processes at ~117 MB each. Summed, that is 351 MB. They are almost certainly workers forked from one parent, so most of those pages are the same physical frames counted three times. RSS cannot tell you that; the sum at the bottom can.
Sum of RSS is 1284.6 MiB. Sum of PSS is 968.3 MiB. The difference — 316 MiB, about 25% — is memory that RSS counted more than once. On a machine running many copies of the same thing, that gap gets much wider; on a container host it is routinely 40% or more.
Now the mysqld breakdown, which shows the other half of the picture. Rss 412960 and Pss 407742 are nearly equal, so mysqld shares almost nothing — it is not one of many copies of anything. Private_Dirty is 401320 kB, and that is the number that matters: this memory has been written to, so it belongs to mysqld alone, and the kernel cannot simply drop it and read it back from a file later, because there is no file behind it. Reclaiming it would mean writing the contents somewhere else first — which is Module 09.
Compare Shared_Clean: 10436 kB — that is the code of mysqld and its libraries. Clean and file-backed, so the kernel can discard it any time and read it back from disk if needed. It costs almost nothing to hold.
The line to take into an interview: Private_Dirty is the memory that genuinely cannot be recovered without killing the process. Everything else has an escape route.
Finally, sum of PSS at 968 MiB against 3936 MiB installed means the processes on this machine are using about a quarter of the RAM. What the rest is doing is Module 09.
🎯 Interview questions — Measuring memory
Q. What is the difference between VSZ and RSS?
VSZ is the total virtual address space a process has mapped, including reservations that have never been touched, file mappings still entirely on disk, and permission-less gaps left by the dynamic loader. RSS is the number of physical frames the process currently has resident, in kilobytes — real memory, but with every shared frame counted in full for every process that maps it.
So VSZ overstates because most of it may not exist, and RSS overstates because shared pages are counted repeatedly.
The details that separate candidates: giving a concrete case. A Go or containerd process routinely shows 1 GB of VSZ against 19 MB of RSS, because the runtime reserves a large contiguous region up front — a ratio of sixty, and zero of it is memory. The second detail is that RSS is not summable: add it across processes and you can exceed installed RAM, which is why capacity work needs PSS instead.
Q. How do you find out how much memory a process is really using?
It depends which question is being asked, and I would pin that down first. /proc/PID/smaps_rollup gives all the answers in one cheap read. Pss is the process's fair share, counting each shared frame divided by the number of processes using it — this is the number that sums correctly across the machine. Private_Dirty is memory that has been written and belongs to this process alone, which is what would actually be freed by killing it. Rss is what ps and top show and is the least useful of the three for anything but tracking one process's growth over time.
The details that separate candidates: knowing that Private_Dirty is usually much smaller than RSS for a worker in a pool, so killing it frees far less than the RSS figure suggests — a mistake teams make repeatedly during memory incidents. Also worth mentioning: read smaps_rollup, not smaps, for routine collection, because parsing full smaps on a process with tens of thousands of mappings is slow enough to disturb the process being measured.
Q. How would you troubleshoot an application that is consuming too much memory?
First establish that it really is. ps sorted by RSS finds the candidate, then smaps_rollup tells me whether the memory is private and dirty (genuinely held by this process) or shared and clean (code and libraries, cheap and reclaimable). If it is mostly shared, there is no problem to solve on this process.
If it is private and dirty and growing, I take two readings of smaps_rollup a few minutes apart to get a rate rather than a level, and check /proc/PID/maps to see which regions are growing — an expanding [heap] points at the allocator, a growing count of anonymous mappings points at repeated mmap without munmap, and a growing number of file mappings often means leaked file descriptors from Module 03.
The details that separate candidates: separating growth from level. A process sitting at a steady 4 GB is doing what it was configured to do; a process at 800 MB climbing 50 MB an hour is the actual problem, and it is the smaller number. Also: checking whether the process is inside a cgroup with a memory limit, because the behaviour and the correct fix are entirely different when the limit is per container rather than per machine.
🐘 Part D · Making translation cheaper
D1 · Huge pages
Section A3 left a problem. The TLB holds a fixed number of translations — call it 1500. At 4 KiB each, that covers about 6 MB. A process working randomly across 20 GB will miss the TLB on most accesses, and each miss costs a page-table walk. Adding RAM does not help at all.
The fix is to make each entry cover more ground. x86-64 supports 2 MiB pages (and 1 GiB pages) as well as 4 KiB ones. The same 1500 TLB entries now cover 3 GB instead of 6 MB — five hundred times the reach, with no extra hardware.
Linux offers this two ways:
Transparent Huge Pages (THP). The kernel silently promotes suitable anonymous regions to 2 MiB pages, and a background thread (khugepaged) defragments memory to create more. Nothing in the application changes. The defragmentation that actually makes 2 MiB blocks available is a separate mechanism: direct compaction, performed in the faulting process's own context — which is where the stalls come from — plus the per-node kcompactd threads working in the background.
THP is compiled in on every mainstream distribution, but the default mode is not the same everywhere. Debian and Ubuntu ship enabled=madvise. The RHEL family — RHEL, Rocky, Fedora, Amazon Linux — ships enabled=always. That single difference is why the same application can be smooth on one fleet and stall on another, and it is why the first thing to do is read the value rather than assume it.
Explicit huge pages (hugetlbfs). Reserved at boot, never swapped, never split, and only used by programs that ask for them. Databases use this.
The second cost is waste: a region that only needs a few kilobytes still consumes a full 2 MiB page, so RSS can grow noticeably for no benefit.
The clerk's sticky-note pad holds a fixed number of notes, and each note tracks one small box. If the warehouse switches to pallets — one note now covers a whole pallet of goods — the same pad suddenly covers hundreds of times more inventory, and the clerk almost never has to walk to the back room.
That is huge pages, and it is free performance for anyone whose goods fill pallets.
The trouble starts elsewhere. A pallet needs a large clear floor space. On a busy day the floor is scattered with half-used pallets and there is no clear run anywhere — so before your pallet can be placed, someone has to shuffle everything else aside while you stand and wait. That is memory compaction, and the wait is what makes latency-sensitive services turn THP off.
And if you only have three small boxes, putting them on a pallet wastes the whole pallet.
Where the analogy stops working. A warehouse can plan its pallet layout in advance. THP decides opportunistically at fault time, which is precisely why the stall is unpredictable.
🧪 Exercise D1.1 — Find out what your machine is doing with huge pages
# Is THP on, and in which mode? The value in [brackets] is the active one.
cat /sys/kernel/mm/transparent_hugepage/enabled
# How aggressively will the kernel stall a process to build one?
cat /sys/kernel/mm/transparent_hugepage/defrag
# How much memory is currently in transparent huge pages?
grep -E 'AnonHugePages|ShmemHugePages|Hugepagesize|HugePages_Total' /proc/meminfo
# Are any explicit (reserved) huge pages configured?
grep -E 'HugePages_(Total|Free|Rsvd)' /proc/meminfo
# Which processes are actually using THP right now?
sudo sh -c 'for f in /proc/[0-9]*/smaps_rollup; do
v=$(grep "^AnonHugePages:" $f 2>/dev/null | awk "{print \$2}")
[ -n "$v" ] && [ "$v" -gt 0 ] && echo "$v kB $f"
done' | sort -rn | head -5✅ Expected result — click to reveal
always [madvise] never
always defer defer+madvise [madvise] never
AnonHugePages: 94208 kB
ShmemHugePages: 0 kB
HugePages_Total: 0
Hugepagesize: 2048 kB
HugePages_Total: 0
HugePages_Free: 0
HugePages_Rsvd: 0
40960 kB /proc/1198/smaps_rollup
22528 kB /proc/2041/smaps_rollupWhat to read out of this.
always [madvise] never — the brackets mark the active setting. madvise is the safe middle ground: the kernel only uses huge pages for regions where the application explicitly asked for them via madvise(MADV_HUGEPAGE). Programs that have not asked are unaffected, so you get the benefit where it was requested and none of the stalls where it was not. It is the Debian and Ubuntu default — but not the RHEL family's, which ships always.
If you see [always], every large anonymous mapping is a candidate, whether the program wanted it or not. That is the setting associated with the latency stalls in the warning above.
The defrag line is the one people forget to check, and it is the one that causes the stalls. [madvise] here means the kernel will only do synchronous compaction — making a process wait — for regions that asked for huge pages. [always] there means any process can be stopped mid-fault while the kernel shuffles memory. enabled and defrag are separate knobs and both matter.
AnonHugePages: 94208 kB is 92 MB currently backed by 2 MiB pages — that is 46 huge pages doing the work of 23,552 normal ones, and 23,506 TLB entries not being consumed.
HugePages_Total: 0 means no explicit huge pages are reserved. That is normal unless a database has been deliberately configured. Hugepagesize: 2048 kB confirms 2 MiB is the huge page size on this hardware.
If the last command printed nothing, no process on your machine is using THP — likely a small VM with nothing large running. That is a perfectly normal result, not a failure.
D2 · NUMA — not all RAM is equally close
Everything so far assumed RAM is one uniform pool. On a machine with more than one CPU socket it is not. Each socket has its own memory controller with its own bank of RAM attached directly to it. A CPU can reach the other socket's RAM, but the request has to cross a link between the sockets, and that costs more — typically 1.5 to 2 times the latency.
A socket plus its directly attached memory is called a NUMA node. NUMA stands for non-uniform memory access, and the non-uniformity is the whole point.
Linux's default policy is first touch: a page is allocated on the node of whichever CPU first faulted it in — not the node where the memory was requested, and not the node where it will mostly be used. That one rule explains most NUMA surprises.
A company occupies two floors, each with its own stockroom. Fetching from your own floor's stockroom takes a minute. Fetching from the other floor means the lift, and takes two.
The company rule is: whoever first asks for an item decides which stockroom it is kept in — and it goes in the stockroom on their floor at that moment. Perfectly sensible when people stay put.
The trouble comes when someone is moved to the other floor. All their stock is still downstairs. Every single item they need now costs a lift ride. They have not done anything wrong, nobody has told them, and their output quietly drops by a third.
Worse, if one person does the setup for the whole team — unpacks everything on day one while sitting on floor one — then the entire team's stock lives on floor one, and half the team is upstairs.
Where the analogy stops working. A person would notice they keep taking the lift and complain. A thread has no way to perceive that its memory became remote, which is why this needs numastat rather than a bug report.
🧪 Exercise D2.1 — Find out whether NUMA even applies to your machine
which numactl || sudo apt-get install -y numactl
# How many nodes, which CPUs and how much memory on each
numactl --hardware
# The relative cost of reaching each node from each node
# (10 = local; anything larger is a multiplier x10)
numactl --hardware | grep -A5 'node distances'
# Which CPUs belong to each node, straight from sysfs
for n in /sys/devices/system/node/node[0-9]*; do
echo "$(basename $n): $(cat $n/cpulist)"
done
# Per-node allocation statistics: hits are local, misses are remote
numastat✅ Expected result — click to reveal
On a typical cloud VM you will get the boring answer, and it is worth seeing:
available: 1 nodes (0)
node 0 cpus: 0 1
node 0 size: 3936 MB
node 0 free: 2711 MB
node distances:
node 0
0: 10
/sys/devices/system/node/node0
node0
numa_hit 8814923
numa_miss 0
numa_foreign 0
interleave_hit 5211
local_node 8814923
other_node 0One node. NUMA does not apply to this machine, and that is the correct conclusion to draw. Most small cloud instances are presented as a single node, and pinning, interleaving and NUMA tuning are all pointless on them. Half of all NUMA tuning advice on the internet is being applied to machines like this, where it does nothing.
On a two-socket physical server you get the interesting answer instead:
available: 2 nodes (0-1)
node 0 cpus: 0 1 2 3 4 5 6 7 16 17 18 19 20 21 22 23
node 0 size: 128809 MB
node 1 cpus: 8 9 10 11 12 13 14 15 24 25 26 27 28 29 30 31
node 1 size: 129011 MB
node distances:
node 0 1
0: 10 21
1: 21 10
node0 node1
numa_hit 48812993204 44120384011
numa_miss 892043118 0
numa_foreign 0 892043118
local_node 48812993204 44120384011
other_node 892043118 0What to read out of this.
The distance matrix is the key output. 10 is the reference for local access, so 21 means reaching node 1 from node 0 is declared as roughly twice as expensive. Treat that as a hint, not a measurement: the number is a relative cost the firmware publishes, normalised so local is 10, and the kernel uses it for placement decisions. Actual measured remote latency on a two-socket box is usually 1.5–1.8× even where the firmware says 21.
The CPU lists are worth noticing too. Node 0 owns CPUs 0-7 and 16-23 — the second range is the hyperthread siblings of the first. So "CPU 16" and "CPU 0" are the same physical core. Pinning a two-thread job to CPUs 0 and 16 gives you one core, not two, and this trips people up constantly.
Now numastat, and the two counters are easy to get backwards. numa_hit on a node counts allocations that landed there and meant to. numa_miss on a node counts allocations that landed there although a different node was preferred — that node absorbed someone else's overflow. numa_foreign on a node counts allocations that were meant for it and had to go elsewhere. They are two views of the same event. Here node 1 shows 892 million numa_foreign and node 0 the matching 892 million numa_miss, so the node that ran short is node 1, and node 0 supplied the pages.
A ratio of misses to hits under about 1% is normal noise. This one is 1.8% and rising, which is worth investigating. The usual causes are a single large process not confined to one node, or a badly balanced set of container CPU pinnings.
🎯 Interview questions — Huge pages and NUMA
Q. What are huge pages and when would you use them?
Huge pages are larger units of translation — 2 MiB or 1 GiB on x86-64 instead of 4 KiB. The benefit is TLB reach: a fixed number of TLB entries covers hundreds of times more memory, so a large working set stops thrashing the TLB, and the page tables describing that memory shrink dramatically as well.
They are worth using for processes with large, densely used memory regions — databases, JVM heaps, in-memory caches, scientific workloads. They are not worth using for ordinary services with small footprints, where the only effect is wasted memory from partly used 2 MiB pages.
The details that separate candidates: quantifying the reach — roughly 1500 TLB entries covers about 6 MB with 4 KiB pages and about 3 GB with 2 MiB pages — and distinguishing transparent huge pages, which the kernel applies opportunistically, from explicit hugetlbfs pages, which are reserved at boot, never swapped and never split. Databases generally want the explicit kind and want the transparent kind turned off, and being able to say why is the whole answer to the next question.
Q. Why do database vendors recommend disabling transparent huge pages?
Because of the allocation stall. Handing out a 2 MiB page needs 2 MiB of physically contiguous free memory. On a long-running machine memory is fragmented, so the kernel may have to compact memory to produce it — and with defrag set to always, the process that faulted blocks while that happens. The result is sporadic latency spikes of hundreds of milliseconds with no CPU, disk or network activity to explain them, which is exactly the failure mode a database cannot tolerate.
There is a secondary cost: internal fragmentation. A mapping that needs 40 KB still consumes a full 2 MiB page, so RSS inflates for no benefit.
The details that separate candidates: knowing there are two independent settings — /sys/kernel/mm/transparent_hugepage/enabled and .../defrag — and that the stall comes from defrag, not from enabled. The defaults are not uniform: Debian and Ubuntu ship enabled=madvise, while RHEL, Rocky, Fedora and Amazon Linux ship enabled=always; defrag defaults to madvise almost everywhere. So blanket "disable THP" advice copied from a 2014 blog post is often redundant on an Ubuntu fleet and still entirely relevant on a RHEL one. Reading the two values before changing anything is the professional answer, and it is also the answer that shows you know they differ.
Q. What is NUMA and how does it affect application performance?
On a multi-socket machine each socket has its own directly attached RAM. Accessing your own socket's memory is fast; reaching the other socket's memory crosses an interconnect and costs roughly twice the latency. Linux's default allocation policy is first touch — a page lands on the node of whichever CPU first wrote to it.
The performance impact comes from the mismatch that policy allows: a thread can allocate memory on node 0, be migrated by the scheduler to node 1, and then run for hours accessing all of its memory remotely. Nothing errors, nothing is logged, and the process is simply slower.
The details that separate candidates: naming the observable — numa_miss and numa_foreign in numastat, with a miss-to-hit ratio above about 1% worth investigating — and knowing the fix is placement, numactl --cpunodebind --membind or the equivalent container topology policy, not more memory. The detail that impresses is checking numactl --hardware first: most cloud VMs present a single node, where every piece of NUMA tuning advice is a no-op, and knowing when not to tune is as valuable as knowing how.
🏁 Part E · Practice, docs and self-check
E1 · Production practice
| Symptom in production | What is really happening | What to run | The fix |
| "This process is using 1 GB" but the box is fine | VSZ being read as memory; it is an untouched reservation | ps -eo pid,vsz,rss, then /proc/PID/maps for ---p regions | Report RSS or PSS; fix the dashboard |
| Sum of per-process memory exceeds installed RAM | RSS counts every shared frame once per process | Sum Pss from smaps_rollup instead | Use PSS for capacity, RSS only for per-process trend |
| Killed a worker to free memory, barely anything freed | Most of its RSS was shared with its siblings | Private_Dirty in smaps_rollup | Reduce worker count or the per-worker private footprint |
| Service slow only for the first minute after deploy | Major faults: its code is being read off disk page by page | ps -o maj_flt, /usr/bin/time -v | Warm it before taking traffic; check the readiness probe |
| Sporadic 200 ms stalls, no CPU, no disk, no network | THP compaction blocking a fault | cat /sys/kernel/mm/transparent_hugepage/defrag | Set enabled and defrag to madvise and assert it in config management |
| Identical job, wildly different runtimes on the same host class | NUMA: memory allocated on one node, thread running on the other | numactl --hardware, numastat | Pin CPU and memory to one node — only on genuinely multi-node hardware |
| Process dies with exit status 139, team says "out of memory" | SIGSEGV — an invalid access, nothing to do with memory pressure | dmesg for the segfault line; compare fault address to sp | Fix the code; if the address is just below sp it is a stack overflow |
| RSS never returns after a large job finishes | The allocator kept the pages instead of returning them to the kernel | Two reads of smaps_rollup; watch [heap] in maps | Usually not a leak — confirm growth over hours before escalating |
E2 · Capstone — four memory tickets
Ticket 1. A monitoring dashboard shows a Go service at "1.2 GB memory" on every host in the fleet, steady, never growing. The team wants the memory limit raised from 512 MB. The pods are not being killed. Do you agree?
Ticket 2. A Python web service runs 8 forked workers. ps says each is using 340 MB, so 2.7 GB total on a 4 GB box, and the team wants to drop to 4 workers to halve it. Will that work?
Ticket 3. An API's p99 latency shows spikes of 150–400 ms a few times an hour. CPU is 30%, disk is idle, network is clean, and the spikes hit all endpoints at once including ones that touch nothing. Where do you look?
Ticket 4. A batch job takes 38 minutes on some runs and 63 minutes on others, on identical 2-socket hardware with identical input. CPU, memory and I/O metrics look the same on both. What is your hypothesis?
✅ Ticket 1 — worked answer
Run, in order: ps -eo pid,vsz,rss,comm | grep <service> → then awk over /proc/PID/maps for large regions → then grep Pss /proc/PID/smaps_rollup.
What you will find. The dashboard is almost certainly plotting VSZ. A Go runtime reserves a large contiguous region of address space at start-up — commonly around 1 GB — with permissions ---p and no file behind it. Not one page of RAM is involved. The maps output will show it as a single enormous permission-less mapping, and RSS will be a small fraction of the reported figure.
Two pieces of evidence that settle it in under a minute. First, the number is identical on every host and never moves. Real memory use varies with load; a reservation made at start-up does not. Second, RSS from ps — or better, Pss from smaps_rollup — will be a small fraction of 1.2 GB, and /proc/PID/maps will show the bulk of that address space as one permission-less ---p region with no file behind it. Two readings, no tooling to install, and the question is answered.
The answer: no, do not raise the limit. Fix the dashboard to report RSS or, better, the container's own cgroup memory accounting. Raising a limit to accommodate a number that was never memory wastes real capacity across the whole fleet.
✅ Ticket 2 — worked answer
Run, in order: ps -eo pid,ppid,rss,comm to confirm they share a parent → grep -E 'Rss|Pss|Private_Dirty' /proc/PID/smaps_rollup for one worker → sum Pss across all eight.
What you will find. The workers were forked from one parent, so they share the interpreter, every imported module, and any data structure loaded before the fork — via copy-on-write. RSS counts all of that in full for each of the eight. Pss will be far lower, perhaps 120 MB each, and Private_Dirty lower still, perhaps 70 MB.
So will dropping to 4 workers halve memory? No. It removes four copies of the private portion only. If private is 70 MB, you free about 280 MB, not 1.35 GB — while halving your concurrency. That is a poor trade made on the strength of a number that was never real.
The arithmetic to do first: sum Pss across the eight workers and the parent. That total is the true cost, and it is the only figure worth planning against. If the true total is comfortable, the right answer is to change nothing.
If you do need to reduce it, the lever is the private footprint per worker, not the worker count — load large read-only data structures before forking so they stay shared, and avoid touching them afterwards, since a write breaks the sharing for that page.
✅ Ticket 3 — worked answer
The shape of the symptom is the clue. Spikes that hit every endpoint simultaneously, including ones that do no work, mean the process itself was stopped — not that any particular operation was slow. Something froze the whole process for a few hundred milliseconds.
With CPU low, disk idle and network clean, the short list is: garbage collection pauses, a lock held across the whole process, or the kernel blocking a page fault. The last one is invisible in every metric mentioned in the ticket.
Run, in order:
cat /sys/kernel/mm/transparent_hugepage/enabled
cat /sys/kernel/mm/transparent_hugepage/defrag
grep -E 'AnonHugePages' /proc/meminfo
grep -E 'compact_stall|compact_fail' /proc/vmstatWhat confirms it. defrag set to always, plus a compact_stall counter in /proc/vmstat that increases while the spikes happen. compact_stall counts exactly this: a process was stopped so the kernel could compact memory to produce a contiguous 2 MiB block. Take two readings a minute apart — a rising count during a spike window is the proof.
The fix: set both enabled and defrag to madvise, and assert those values in configuration management so a vendor tuning package cannot quietly change them back. Expect this on long-uptime hosts specifically — a freshly booted machine has plenty of contiguous memory and shows no stalls at all, which is why the problem "appears" a few weeks after a rebuild.
✅ Ticket 4 — worked answer
Hypothesis: NUMA placement, decided by chance at start-up.
Two sockets, first-touch allocation, and no pinning means the outcome depends on which CPU happened to run the setup phase and whether the scheduler later moved the threads. A run that allocates and executes on the same node is fast; a run whose threads migrate spends its life reaching across the interconnect at roughly twice the latency. Nothing errors, and CPU, memory and I/O totals are identical on both runs — which is exactly what the ticket describes.
Run, in order:
numactl --hardware # confirm there really are 2 nodes
numastat # numa_miss / numa_foreign per node
numastat -p <pid> # per-node memory for the job itself
grep -E 'Cpus_allowed_list' /proc/<pid>/statusWhat confirms it. numastat -p on a slow run shows the job's memory concentrated on one node while Cpus_allowed_list spans both, or a numa_miss count that climbs during the slow runs and not the fast ones.
The fix: pin both CPU and memory together — numactl --cpunodebind=0 --membind=0 <job> — so allocation and execution cannot end up on different nodes. Pinning CPU alone is not enough, and pinning memory alone is worse than nothing.
Check the premise first. If numactl --hardware reports one node, this hypothesis is dead and the variance is something else — most likely a noisy neighbour showing up as steal time, which Module 07 covers.
E3 · Documentation reference
| Topic | Where to read it | Why this one |
| How virtual memory fits together | Memory management — concepts | The clearest short overview the kernel project publishes |
| All memory-management admin docs | Memory management index | Index into THP, KSM, NUMA policy and the rest |
| The maps file, column by column | proc_pid_maps(5) | Defines every field, including the [vdso]-style names |
| PSS, USS and the dirty/clean split | proc_pid_smaps(5) | The definitive definition of Pss and Private_Dirty |
| The Vm* fields including VmPTE | proc_pid_status(5) | Everything ps derives, in one place |
| Mapping memory and files | mmap(2) | Read the MAP_PRIVATE vs MAP_SHARED section carefully |
| Telling the kernel about your access pattern | madvise(2) | MADV_HUGEPAGE, MADV_DONTNEED, MADV_FREE |
| Transparent huge pages | Transparent Hugepage Support | Documents enabled and defrag as separate knobs |
| NUMA concepts | numa(7) | Short, and defines the vocabulary the tools use |
| NUMA placement in practice | numactl(8) | Note it is section 8, not 1 — man 8 numactl |
| NUMA policy in the kernel | NUMA memory policy | Explains first-touch and the alternatives |
| Reading fault counters | proc_pid_stat(5) · getrusage(2) | Field order for min_flt/maj_flt, and what time -v reports |
| Per-process memory maps as a tool | pmap(1) | pmap -X is smaps in a readable table |
| What happens on a crash | core(5) | Where core dumps go and why they usually do not appear |
E4 · Self-assessment
Answer these out loud before moving to Module 09. The section to reread is named after each.
- Name the three problems virtual memory solves, without saying the word "swap". (A1)
- What is the difference between a page and a frame, and what is the page table for? (A2)
- Why is the address split into a page number and an offset, and which part is translated? (A2)
- What is the TLB, roughly how much memory does it cover, and why does that number matter? (A3)
- Why does perf stat -e dTLB-load-misses usually return <not supported> on a cloud VM? (A3)
- In /proc/PID/maps, what does a blank last column mean, and why does it matter to the kernel? (A4)
- A page fault happens. What are the three possible outcomes, and which one is expensive? (B1)
- Why is "page faults per second" a bad thing to alert on? (B1)
- Explain copy-on-write, including what it costs the parent. (B2)
- A process exits with status 139. What happened, and where do you look next? (B3)
- Why can VSZ be sixty times RSS, and what is in the gap? (C1)
- What are PSS and USS, and which one answers "what do I get back if I kill this"? (C2)
- Why does killing one of eight forked workers free far less than its RSS? (C2, E2 ticket 2)
- What are the two THP settings, and which one causes latency stalls? (D1)
- What is first-touch allocation, and how does it make the same job take 50% longer with no error logged anywhere? (D2)
E5 · Sources
Kernel documentation
· Memory management — concepts overview
· Memory management admin index
· Transparent Hugepage Support
Manual pages
· proc_pid_maps(5) · proc_pid_smaps(5) · proc_pid_status(5) · proc_pid_statm(5) · proc_pid_stat(5)
· mmap(2) · mprotect(2) · madvise(2) · mlock(2) · brk(2)
· fork(2) · execve(2) · getrusage(2)
· numa(7) · numactl(8)
Standards