Module 08 — Virtual Memory, Paging & Page Faults

Updated 21 August 2026

Module 08 · Virtual memory, paging and page faults

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)

Before you start, you should already know:

From Module 01 — the kernel/user split, the syscall boundary, and that /proc/PID/ exposes kernel data about a process.

From Module 02fork, 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.

The counter-intuitive part. This translation happens on every memory access — every variable read, every instruction fetch, billions of times a second. If it were done in software it would be hopelessly slow. It is done by a dedicated piece of hardware in the CPU, and most of the design of virtual memory exists to make that hardware's job cheap.
Real-world analogy — post office boxes

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
bash
# 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
plain text
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/sleep

What 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.

Now imagine this at 500 hosts. This sharing is why you can run 200 copies of the same container image on one node without needing 200× the memory for the binaries and libraries. It is also why naively summing per-process memory across a fleet produces a number far larger than the RAM you own — the same physical pages get counted once per process. Section C2 is about exactly that mistake.
🧪 Exercise A1.2 — The machine promises far more memory than it owns
bash
# 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
plain text
Virtual promised: 38.4 GiB
Actually resident: 1.3 GiB
Installed RAM:    3.8 GiB

What 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 --> F
Set this block to Preview using the ••• menu on its right to see the diagram instead of the code. Notion does not do that automatically.

A 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.

Counter-intuitive. The page table is itself stored in RAM, and it costs real memory. A process mapping a lot of memory pays for the table describing it. You can see the bill directly: VmPTE in /proc/PID/status. It is small for normal programs and surprisingly large for databases that map huge regions.
Real-world analogy — a storage warehouse with numbered lockers

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
bash
# 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
plain text
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 kB

What 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.

Interview-grade detail. The TLB is small — typically a few hundred to a couple of thousand entries. At 4 KiB per entry, 1500 entries covers about 6 MB of memory. A program whose working set is much larger than that, accessed randomly, will miss the TLB constantly no matter how much RAM you add. That is the single best argument for huge pages, and most candidates cannot make it.
Counter-intuitive. A context switch to a different process has historically meant throwing away TLB entries, because the same virtual address now means something else entirely. That is a real part of why context switches cost more than they look like they should — it is not just saving registers. Modern CPUs tag entries with an address-space ID to avoid the full flush, but the effect has not disappeared.
Real-world analogy — the clerk's sticky notes

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)
bash
# 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:

plain text
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 elapsed

On a cloud VM you will almost certainly get this instead:

plain text
Performance counter stats for 'bash -c ...':

  <not supported>      dTLB-loads
  <not supported>      dTLB-load-misses

      1.402318774 seconds time elapsed

What 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.

plain text
$ 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/sec

page-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.

Now imagine this at 500 hosts. If your fleet is virtualised, standardise your performance tooling on things that work in a guest: /proc counters, PSI, perf software events, and eBPF. Building a runbook around hardware PMU counters means it works on the two bare-metal boxes and fails on the other 498 — usually discovered at 3am during the incident it was written for.

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:

ColumnExampleMeaning
Address range5581b4a5a000-5581b4b3c000Start and end virtual addresses, always page-aligned
Permissionsr-xpread, write, execute, then p private or s shared
Offset0002e000How far into the file this region starts
Device08:01Major:minor of the device holding the file
Inode1052097The file's inode, from Module 03
Path/usr/bin/bashThe 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.

Counter-intuitive. The permission bits in maps are the permissions of the mapping, not of the file. A file you have open read-only can be mapped rw-p — private and writable — and your writes go to your own private copy in memory and never reach the file. That is how a program's initialised variables work: loaded from the executable, writable, but changing them does not modify the binary on disk.
Real-world analogy — the floor plan of a building

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
bash
# 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
plain text
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.

Now imagine this at 500 hosts. /proc/PID/maps is the fastest way to answer "what is this process actually mapping" during an incident, and it needs no tooling installed — which matters on a locked-down production host where you cannot apt-get install anything. Grepping it for a suspicious rwxp region (writable and executable) is also a standard first check when something looks compromised, since normal programs almost never need one.

🎯 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.

KindWhat the kernel foundWhat it doesCost
Minor faultThe data is already in RAM, or needs no data at allFill in a page-table entry and resumeMicroseconds
Major faultThe data must be fetched from disk firstStart the read, put the process to sleep, resume when it landsMilliseconds — thousands of times worse
Invalid accessThis address means nothing to this processSend SIGSEGVThe 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 --> J
Set this block to Preview using the ••• menu on its right to see the diagram instead of the code. Notion does not do that automatically.
The counter-intuitive part. After the kernel handles the fault, it re-runs the instruction that faulted. The program is never told anything happened. It has no way to observe a minor fault other than by noticing that time passed. This is why memory can be attached lazily at all — the illusion is complete from inside.
The number that matters is major faults, not total faults. Monitoring dashboards routinely graph "page faults per second" and alert when it is high. That number is dominated by minor faults, which are just a program doing normal work — a process starting up produces tens of thousands of them and that is healthy. Minor faults are cheap and expected. Major faults mean waiting on a disk. Graph them separately or the useful signal is buried.
Real-world analogy — asking the post room for a box that has no shelf

You 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
bash
# 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
plain text
    167      0   3244
  73503      0  52212
50000000 / 4096 = 12207
  73527      0   3380

What 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

Official docs: execve(2) · fork(2) · mmap(2)

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.

Interview-grade detail. This is why fork on a process with a huge heap is cheap in memory but not free in time: the kernel still has to walk the page tables and mark everything read-only, and it must allocate the child's own page tables. A 40 GB database process forking is fast in memory terms and can still take a noticeable pause. It is also why the parent slows down immediately after forking — its own writes now fault too.
Real-world analogy — the shared filing cabinet

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
bash
# 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
plain text
parent is PID 4881
   4881  53612 bash

   4881   4102  53612 bash
   4914   4881  52104 bash

sum of RSS: 103 MiB

What 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.

Now imagine this at 500 hosts. Any capacity model built by summing per-process RSS will overstate real usage, and the overstatement is worst exactly where it matters — many workers forked from one parent, or many containers from one image. Teams size clusters from these numbers and then cannot explain why the nodes look half empty. Use PSS or cgroup accounting for capacity work, and keep RSS for "is this one process growing".

B3 · Seeing faults on a real machine

Four places give you fault numbers, and they answer different questions:

WhereCommandAnswers
Per process, lifetimeps -o min_flt,maj_flt -p PIDHas this process ever waited on disk for memory?
Per process, livetwo reads of /proc/PID/stat fields 10–13Is it faulting right now?
Per command run/usr/bin/time -v <cmd>What did this one run cost?
Whole machineTwo 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
bash
# 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
plain text
-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: 1179

What 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.

bash
# 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
plain text
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 ...

8192

What 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.

Now imagine this at 500 hosts. dmesg segfault lines are one of the highest-value log sources on a fleet and almost nobody ships them. They give you the process name, the PID, the faulting address and read-versus-write, at essentially zero cost, for every crash on every host — including crashes inside containers, which land in the host's kernel log and are invisible from inside the container. If you collect one extra thing after reading this module, collect these.

🎯 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.

VSZRSS
Counts untouched mappingsYesNo
Counts shared framesYesYes, in full, per process
Can exceed installed RAMYes, easily and harmlesslyNot per process — but the sum can
Good forSpotting a runaway mmap loop"Is this one process growing over time?"
Bad forAnything about real memory useCapacity planning, summing across processes
Counter-intuitive. A process's RSS can fall without the process freeing anything, because the kernel is allowed to take pages back — a file-backed page can be dropped and re-read later. And RSS can stay high after a program frees a large allocation, because the program's allocator kept the pages for reuse rather than returning them. RSS moving is not the same as the program's memory use moving, in either direction.
Real-world analogy — the address book and the shelves

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
bash
# 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
plain text
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/mysqld

What 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:

TermDefinitionThe question it answers
RSSAll resident frames, shared ones counted in full"How much memory can this process reach?"
PSSPrivate frames, plus each shared frame divided by the number of processes sharing it"What is this process's fair share of the machine?"
USSPrivate frames only — add Private_Clean and Private_Dirty yourself; there is no Uss field"What would I get back if I killed it?"
DirtyModified, 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.

Interview-grade detail. USS is the number for the question "will killing this free enough memory?", and it is usually far smaller than people expect. A worker process reporting 800 MB of RSS may have 60 MB of private memory and be sharing the rest with its twenty siblings. Killing it frees 60 MB. Teams restart the wrong thing during memory incidents constantly for exactly this reason.
Real-world analogy — splitting a shared bill

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
bash
# 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
plain text
   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 MiB

What 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.

Now imagine this at 500 hosts. smaps_rollup is cheap to read; the per-mapping smaps is not — on a process with tens of thousands of mappings it can take hundreds of milliseconds and briefly hold a lock. A monitoring agent that scrapes full smaps for every process every ten seconds is a well-documented way to make a busy database stutter. Use smaps_rollup for routine collection and reach for smaps only when you are actively investigating one process.

🎯 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.

THP has a real cost and it is not obvious. To hand out a 2 MiB page the kernel needs 2 MiB of physically contiguous free memory. On a machine that has been up for weeks, memory is fragmented, so the kernel may have to compact memory to find it — and with defrag set to always, the faulting process waits while that happens. The symptom is sporadic multi-hundred-millisecond stalls with no CPU and no disk activity to blame. Redis, MongoDB and several JVM tuning guides all recommend disabling THP for exactly this reason.

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.

Real-world analogy — shipping in pallets instead of parcels

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
bash
# 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
plain text
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_rollup

What 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.

Now imagine this at 500 hosts. THP settings are a classic source of "same code, same config, one host is slow". They can be set at boot via the kernel command line, changed at runtime by anything with root, and are altered by several vendor tuning packages without announcing it. Put /sys/kernel/mm/transparent_hugepage/enabled and defrag into your configuration management as explicit, asserted values, and export them as a metric. Discovering that one node out of five hundred is on always is otherwise a very long afternoon.

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.

The counter-intuitive part. A thread that allocates a large buffer and is then moved to the other socket by the scheduler keeps using the memory it allocated — now remotely, for the rest of its life. Nothing is broken, nothing is logged, and the program's memory accesses are simply 50–80% more expensive than an identical process that happened not to migrate. How much of that reaches wall-clock time depends on how memory-bound the work is — for a memory-heavy batch job it is commonly a 30–50% longer run, which is the origin of "the same job takes 40 minutes once and 60 the next time".
Real-world analogy — stockrooms on each floor

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
bash
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:

plain text
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                     0

One 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:

plain text
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               0

What 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.

Now imagine this at 500 hosts. NUMA effects are invisible in every ordinary metric — CPU, memory and disk all look fine while the workload runs a third slower. Export numa_miss and numa_foreign per node from numastat and alert on the ratio, not the raw count. And be careful with container CPU limits: a container given "4 CPUs" with no NUMA awareness can be scheduled across both sockets, so its threads and its memory end up on different nodes. numactl --cpunodebind=0 --membind=0 for a latency-critical service is a one-line change with a surprisingly large effect — on a genuinely multi-node machine, and no effect at all on the single-node VMs where people usually try it.

🎯 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 productionWhat is really happeningWhat to runThe fix
"This process is using 1 GB" but the box is fineVSZ being read as memory; it is an untouched reservationps -eo pid,vsz,rss, then /proc/PID/maps for ---p regionsReport RSS or PSS; fix the dashboard
Sum of per-process memory exceeds installed RAMRSS counts every shared frame once per processSum Pss from smaps_rollup insteadUse PSS for capacity, RSS only for per-process trend
Killed a worker to free memory, barely anything freedMost of its RSS was shared with its siblingsPrivate_Dirty in smaps_rollupReduce worker count or the per-worker private footprint
Service slow only for the first minute after deployMajor faults: its code is being read off disk page by pageps -o maj_flt, /usr/bin/time -vWarm it before taking traffic; check the readiness probe
Sporadic 200 ms stalls, no CPU, no disk, no networkTHP compaction blocking a faultcat /sys/kernel/mm/transparent_hugepage/defragSet enabled and defrag to madvise and assert it in config management
Identical job, wildly different runtimes on the same host classNUMA: memory allocated on one node, thread running on the othernumactl --hardware, numastatPin 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 pressuredmesg for the segfault line; compare fault address to spFix the code; if the address is just below sp it is a stack overflow
RSS never returns after a large job finishesThe allocator kept the pages instead of returning them to the kernelTwo reads of smaps_rollup; watch [heap] in mapsUsually not a leak — confirm growth over hours before escalating

E2 · Capstone — four memory tickets

Four tickets. For each: what you run, in order, what you expect to see, and what the answer is. Work through them before opening the toggles.

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:

bash
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/vmstat

What 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:

bash
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>/status

What 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

TopicWhere to read itWhy this one
How virtual memory fits togetherMemory management — conceptsThe clearest short overview the kernel project publishes
All memory-management admin docsMemory management indexIndex into THP, KSM, NUMA policy and the rest
The maps file, column by columnproc_pid_maps(5)Defines every field, including the [vdso]-style names
PSS, USS and the dirty/clean splitproc_pid_smaps(5)The definitive definition of Pss and Private_Dirty
The Vm* fields including VmPTEproc_pid_status(5)Everything ps derives, in one place
Mapping memory and filesmmap(2)Read the MAP_PRIVATE vs MAP_SHARED section carefully
Telling the kernel about your access patternmadvise(2)MADV_HUGEPAGE, MADV_DONTNEED, MADV_FREE
Transparent huge pagesTransparent Hugepage SupportDocuments enabled and defrag as separate knobs
NUMA conceptsnuma(7)Short, and defines the vocabulary the tools use
NUMA placement in practicenumactl(8)Note it is section 8, not 1 — man 8 numactl
NUMA policy in the kernelNUMA memory policyExplains first-touch and the alternatives
Reading fault countersproc_pid_stat(5) · getrusage(2)Field order for min_flt/maj_flt, and what time -v reports
Per-process memory maps as a toolpmap(1)pmap -X is smaps in a readable table
What happens on a crashcore(5)Where core dumps go and why they usually do not appear
Reading docs without a browser. man 5 proc_pid_smaps settles any argument about PSS. The kernel's own memory-management documentation installs with the linux-doc package under /usr/share/doc/linux-doc/. And when you do not know the page name, man -k numa and apropos memory search descriptions rather than titles — man -k found numactl(8) for the author of this module after guessing section 1 and getting a 404.

E4 · Self-assessment

Answer these out loud before moving to Module 09. The section to reread is named after each.

  1. Name the three problems virtual memory solves, without saying the word "swap". (A1)
  2. What is the difference between a page and a frame, and what is the page table for? (A2)
  3. Why is the address split into a page number and an offset, and which part is translated? (A2)
  4. What is the TLB, roughly how much memory does it cover, and why does that number matter? (A3)
  5. Why does perf stat -e dTLB-load-misses usually return <not supported> on a cloud VM? (A3)
  6. In /proc/PID/maps, what does a blank last column mean, and why does it matter to the kernel? (A4)
  7. A page fault happens. What are the three possible outcomes, and which one is expensive? (B1)
  8. Why is "page faults per second" a bad thing to alert on? (B1)
  9. Explain copy-on-write, including what it costs the parent. (B2)
  10. A process exits with status 139. What happened, and where do you look next? (B3)
  11. Why can VSZ be sixty times RSS, and what is in the gap? (C1)
  12. What are PSS and USS, and which one answers "what do I get back if I kill this"? (C2)
  13. Why does killing one of eight forked workers free far less than its RSS? (C2, E2 ticket 2)
  14. What are the two THP settings, and which one causes latency stalls? (D1)
  15. What is first-touch allocation, and how does it make the same job take 50% longer with no error logged anywhere? (D2)

E5 · Sources

Everything in this module was checked against these. Where a claim is unusual — iowait-style misreadings of VSZ, THP stalls coming from defrag rather than enabled, PMU counters being unavailable in guests — the source below is the one to cite.

Kernel documentation

· Memory management — concepts overview

· Memory management admin index

· Transparent Hugepage Support

· NUMA memory policy

· The /proc filesystem

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)

· pmap(1) · ps(1) · core(5)

Standards

· POSIX — memory interfaces

Next: Module 09 — Page Cache, Swap & the OOM Killer. You now know how a process gets memory and how to measure it honestly. Module 09 is about the other side: what the kernel does with the RAM the processes are not using, why free almost always shows very little free memory and why that is correct, and what happens when the machine genuinely runs out.
Spotted a mistake or want something added? Send me a note.