The TLB and Multi-Level Page Tables
A page-table read on every access would double memory traffic; a flat table for a 48-bit space would occupy 512 GB per process. The TLB fixes the first: a small set-associative cache of PTEs inside the MMU whose tag and index come from the VPN.
╌╌╌╌
Page-table translation as described so far is correct but slow and bulky. Slow, because every memory reference now needs a prior memory reference to read the PTE; translation doubles memory traffic. Bulky, because a flat table with one PTE per virtual page is gigantic for a modern address space. This lesson fixes both, with two independent ideas. The translation lookaside buffer (TLB) caches recent PTEs so the common case never touches the table at all. Multi-level page tables shrink the table itself by allocating only the parts a process actually uses. Together they make translation fast and cheap, and the lesson ends by tracing the full path an address takes.
The TLB: a cache of PTEs
The page table lives in DRAM, so reading a PTE is itself a slow memory access. But PTEs have excellent locality: a single page holds thousands of bytes, so consecutive references hit the same PTE over and over. That is the situation a cache is built for. The MMU keeps a small, fast hardware cache of recently-used PTEs, the TLB, indexed by the virtual page number.
On a reference the MMU presents the VPN to the TLB. A TLB hit returns the cached PTE immediately, and translation finishes in one MMU cycle: no DRAM read. A TLB miss means the MMU must read the PTE from the page table in the slower way (the page-table walk), install it in the TLB, and retry.
TLB anatomy, bit by bit
A TLB is a set-associative cache
whose address
is the VPN and whose data
is a PTE, so the VPN splits exactly
the way a cache address did, except that there is no offset field, because a PTE is
one indivisible payload. The low bits of the VPN are the TLB index (TLBI),
selecting a set; the remaining high bits are the TLB tag (TLBT), compared
against the tags stored in that set. Each entry holds a valid bit, the tag, and
the cached PTE (PPN plus permission bits, so protection is enforced on hits
too).
Concretely, give the example machine of the first lesson
(, 64-byte pages, 8-bit VPN) a TLB with 16 entries, 4-way set
associative. Then there are sets, so the TLBI takes
bits and the TLBT the remaining . For virtual address
0x03D4, whose VPN is 0x0F = 0000 1111: the low two bits 11 select set
3, and the tag 0000 11 = 0x03 is compared against the four entries there.
The index comes from the low VPN bits for the same reason it did in a data cache: adjacent pages then map to different sets, so a program marching through a contiguous region spreads across the whole TLB instead of thrashing one set.
What a miss costs
The TLB is judged by the same arithmetic as any cache. A hit adds essentially nothing: the lookup is folded into the MMU's address path and finishes within the cycle. A miss costs a page-table walk, and how bad that is depends on the table's shape:
- Flat table: one extra memory read for the PTE: tens to a couple hundred cycles depending on where it is found (PTEs are ordinary memory, so they are cached in L2 and L3 like anything else).
- Four-level table (below): up to four dependent reads: each level's entry supplies the address of the next, so they cannot overlap. If the walk hits in L2/L3, perhaps 100–200 cycles total; if every level goes to DRAM, closer to 800.
What rescues the average is the hit rate. A typical L1 TLB (64 entries, 4-way, backed by a larger, slower L2 TLB) hits well over 99% of references, so even a few-hundred-cycle walk amortizes to roughly a cycle per reference. The failure pattern to know: a TLB with 64 entries and 4 KB pages reaches only of memory. A program whose active data spans much more than that — a giant hash table, pointer-chasing across a big heap — misses in the TLB even though its data may sit happily in the L2 or L3 cache. Such workloads are why huge pages exist (end of this lesson).
Why a flat page table does not scale
Now the size problem, worked out exactly. A 64-bit machine using 48-bit virtual addresses with pages () has VPNs of bits, so a flat table needs entries. At 8 bytes per PTE:
per process: thirty-two times the DRAM of a 16 GB machine, for the table alone, before storing a single byte of actual data. And almost all of it would describe virtual pages the process never uses. A flat table is sized by the address space, not by what the program actually touches, and that is the whole defect.
The fix exploits sparsity: a process's used pages — code, a little heap, a little stack near the top — occupy a tiny, clustered fraction of the space, with vast unallocated gaps between. We want a structure whose size tracks the used pages, not the whole space.
Multi-level page tables
A multi-level (hierarchical) page table is a tree of tables. The VPN is split into several index fields, one per level. The top-level index selects an entry in the level-1 table; that entry, if present, points to a level-2 table; its index selects the PTE (or points to a deeper level). The space saving is simple and exact: a sub-table is allocated only if at least one of its pages is in use. A level-1 entry covering an unused region of the address space is simply marked absent, and the entire level-2 table it would have pointed to is never created.
The savings are large. With a two-level scheme over the same 36-bit VPN (say 18 bits per level), a process that uses only a handful of regions needs the single level-1 table plus a few level-2 tables, a few kilobytes total instead of half a terabyte. The tree is only as big as the address space is dense. The cost is that a TLB miss now requires several dependent memory reads to walk down the levels, which is precisely why the TLB matters, since it skips the walk entirely on a hit.
The x86-64 four-level walk
Real x86-64 hardware uses four levels, and the split of the 48-bit virtual address is worth memorizing because everything about it is forced. Each table must hold 8-byte entries and it is convenient for a table to be exactly one page: entries, so each level consumes 9 bits of VPN. Four levels cover the -bit VPN exactly:
The walk starts at the CR3 register, which holds the physical address of
the current process's level-1 table (loading CR3 is how a context switch
swaps address spaces). VPN1 indexes that table; the selected entry holds the
physical address of a level-2 table; VPN2 indexes it; and so on, until the
level-4 entry is the actual PTE with the PPN. The physical address is that
40-bit PPN with the 12-bit VPO appended: a 52-bit physical address space.
Trace one walk on a concrete address. Take the
canonical Linux user address 0x0000_5555_5555_6008 and slice off the low 48
bits, 0x5555_5555_6008. The bottom 12 bits, 0x008, are the VPO. The four
9-bit VPN fields, read from the top of the 36-bit VPN down, come out as
The hardware reads CR3 to find the level-1 table, indexes entry 0x0AA to get
the level-2 table's physical base, indexes entry 0x155 there for the level-3
base, then entry 0x0AA for the level-4 base, and finally entry 0x156 is the
PTE holding the PPN. Four dependent reads, each feeding the next table's address,
and the PPN then joins VPO 0x008 to form the physical address. Every one of the
four reads is a full memory access on a TLB miss — which is why the
TLB, skipping all four on a hit, is essential.
Now the sparsity payoff, with numbers. One level-2 entry stands for of virtual space; one level-4 table maps . A small process — a few MB of code, heap, and stack in three clusters — needs the level-1 table, about one level-2 and level-3 table per cluster, and a few level-4 tables: perhaps a dozen pages of table, under 50 KB, against the 512 GB a flat table demands. The table grows one 4 KB page at a time, in proportion to what the process actually maps.
End to end: one reference, all the machinery
Everything in this module now composes into a single path, the same organization the Core i7 uses (two levels of TLB, four-level walk, physically addressed caches). For one load instruction:
- The CPU emits a 48-bit virtual address; the MMU splits off the VPN.
- The VPN's low bits index the L1 TLB, its high bits are compared as the tag. On a hit, the usual case, the PPN is available immediately.
- On a miss, the L2 TLB is tried; failing that, the hardware walks
the four levels from
CR3, faulting to the OS if the PTE is invalid, and installs the PTE in the TLB. - The PPN plus the untouched VPO form the physical address, which goes to the L1 data cache, and on a miss, out through L2, L3, and DRAM.
To see actual bits move, run the example machine once more, end to end. Recall
its parameters: 14-bit VA, 64-byte pages, the 16-entry 4-way TLB from earlier,
and give it a small L1 cache: direct-mapped, 16 sets, 4-byte lines, so a
12-bit physical address splits into a 6-bit cache tag (CT), 4-bit set index
(CI), and 2-bit block offset (CO). The load of 0x03D4:
- Split: VPN
0x0F, VPO0x14. - TLB: TLBI
3, TLBT0x03, a hit; the cached PTE gives PPN0x0D. No page-table access. - Physical address:
0x0Djoined with0x14is0x354. - Cache:
0x354=0011 0101 0100splits into CO0, CI0x5, CT0x0D. Set 5's tag matches, a hit, and the word comes back with no DRAM access at all.
Note that the cache tag 0x0D is the PPN,
because on this machine both happen to be the high 6 bits of the physical
address. Which raises the last question of the module: if the cache needs the
physical address, must it sit idle while the TLB works?
Virtual memory meets the cache: the overlap trick
Caches on real machines are physically addressed: tags and indices come from the PA, after translation. This is the sane choice: two processes mapping the same frame hit the same cache lines (sharing works), and one process's cached data never answers another's virtual address. But it appears to put the TLB in series with the L1 cache on every single load, adding its latency to the most latency-critical path in the machine.
The way out is in the bit fields. Translation changes only the page number; the low 12 bits, the VPO, pass through untouched, and are available the moment the CPU emits the virtual address. So if the cache's set index and block offset fit entirely inside those low 12 bits, the cache can select its set and start reading lines in parallel with the TLB lookup, and use the PPN only at the end, for the tag comparison. TLB and cache overlap almost completely.
This is a deliberate design constraint on the L1. A cache indexed by untranslated bits can have at most bytes per way, so the classic 32 KB L1 is 8-way associative precisely so that per way fits inside the page offset. Growing the L1 means growing associativity, not sets, one of the cleanest examples in the whole machine of a size chosen by an address-bit boundary rather than by silicon budget.
Huge pages
One last lever. x86-64 lets a walk stop early: a level-3 entry can declare itself a terminal PTE mapping a 2 MB page (its 21 low bits all offset), and a level-2 entry a 1 GB page. One TLB entry then covers 512 or 262,144 ordinary pages, multiplying TLB reach from 256 KB toward 128 MB or beyond, and the walk shortens by one or two levels. Databases, JVM heaps, and hypervisors — workloads whose working sets dwarf ordinary TLB reach — use huge pages routinely. The price is coarser granularity: a 2 MB page is allocated, swapped, and protected as one unit, so huge pages suit big, long-lived, uniformly-used regions rather than general allocation.
Walkers, ASIDs, and virtualization
The translation path in this lesson is a decades-old design under active pressure, and the modern refinements all attack the same cost: the walk.
Hardware page-table walkers were not always standard. Early RISC machines (MIPS, SPARC) took a TLB miss as a software trap and let the OS walk the table in a handler — flexible, but slow, since every miss paid a pipeline flush and a handler. x86 has always walked in hardware, and that choice won: the walker is a small state machine that reads the four levels itself, and it caches the upper levels in paging-structure caches so that references sharing a high-order prefix skip the top of the walk. The end result is that a TLB miss on modern x86 costs a handful of cache-resident reads, not four DRAM trips.
Context switches used to flush the whole TLB, because every entry belonged to the outgoing address space. The fix is the address-space identifier (ASID, or Intel's PCID): tag each TLB entry with the process it belongs to, so entries from several processes coexist and a switch need not flush. This became urgent after Meltdown forced kernel and user page tables apart (kernel page-table isolation), which would otherwise flush the TLB on every system call; PCID tagging is what keeps that mitigation affordable.
Huge pages and virtualization are where TLB reach matters most today. A guest running under a hypervisor faces two-dimensional page walking: the guest's virtual address translates through the guest's tables to a guest-physical address, which then translates through the hypervisor's nested / extended page tables to a real physical address — and because each guest-level access is itself a guest-physical address needing its own walk, a single miss can cost up to memory references in the worst case (Bhargava et al., 2008, ASPLOS, which introduced nested paging). Huge pages cut both dimensions of that walk and multiply TLB reach, which is why databases and virtualization hosts depend on them; the mechanism is the early-terminating walk this lesson described.
That completes the virtual-memory machinery. A page fault, raised when a PTE is invalid, is one member of a broader family of events that divert the processor from its normal flow — interrupts, traps, faults, and aborts. The next module, exceptional control flow, takes up that mechanism in full.
╌╌ END ╌╌