Virtual Memorytlbtranslation cachetlb misspage walktlb reach

The TLB

Walking four levels of page table on every load would make memory five times slower; the translation lookaside buffer caches recent virtual-to-physical translations so that the walk happens once per page per working set — and its reach, its flushes and its tagging decide how expensive huge heaps and context switches really are.

ConceptualLinuxSimulated
▶ InteractiveInterview question
Progress

The problem

Every instruction fetch and every load or store needs a translation, and a translation is a four-step walk through tables in memory. A CPU that did the walk each time would spend most of its cycles translating. How do you make translation free for the common case, and what does the uncommon case cost?

The problem: five memory accesses for one

A load from a virtual address needs the physical address first, and the physical address is at the end of a walk through four page-table levels (Page Tables). That is four dependent memory reads — each waiting for the previous one — before the fifth, the one the program asked for. If the table entries are in L1 cache the walk costs around 20 cycles; if they are in DRAM it costs four round trips of 80–100 ns each. A program that runs at one memory access per cycle would run at one per five to one per four hundred.

The saving grace is locality. A program that touches address X will very probably touch X + 64 next, or X again, or something else on the same 4 kB page — and every address on that page has the *same* translation. Cache the translation once and the next thousand accesses to that page need no walk at all. That cache is the translation lookaside buffer, and it is the reason virtual memory costs a few percent instead of a few hundred.

The TLB: a cache of translations

The TLB is a small, fully or highly associative cache inside the core, keyed on virtual page number and holding the frame number plus the permission bits from the PTE. On a translation the CPU checks it in parallel with, or before, the L1 cache lookup; a hit delivers the physical address in about one cycle, and the memory access proceeds. A miss starts the page walker, which reads the four levels (using its own page-walk caches for the upper levels), installs the resulting entry in the TLB, evicting an older one, and retries. If the walk finds a not-present entry, the miss becomes a page fault and the kernel takes over (Page Faults).

Sizes are small because associative lookup is expensive: a modern x86 core has around 64 entries in the L1 data TLB, 128 in the instruction TLB, and 1,536–2,048 in a shared L2 TLB (the STLB), with separate or shared entries for 2 MB pages. Apple’s and ARM’s cores are in the same range. Replacement is approximately LRU — the same policy as the LRU Cache you built, in silicon, with the same weakness: a scan over more pages than entries evicts everything useful.

One translation
~99%missfillP = 0Virtual addressTLB lookupHit: PA in ~1 cycleMiss: page walk (4 reads)L1 / L2 / L3 / DRAMNot present → page fault (kernel)
UserLLMAgentToolDataDecisionHumanGuardrail

TLB reach, and why huge pages help

TLB reach is the amount of memory the TLB can translate without a miss: entries × page size. With 1,536 entries of 4 kB that is 6 MB. A process whose hot working set is 6 MB or less runs with essentially no translation cost; a process that randomly touches a 2 GB heap — a hash table, a graph, a database buffer pool — misses on almost every access, and each miss is a walk whose page-table entries are themselves probably not in cache. Measured overhead on such workloads is commonly 10–30% of run time, sometimes more; perf stat -e dtlb_load_misses.walk_completed makes it visible.

The page size is the lever. With 2 MB pages the same 1,536 entries reach 3 GB — 512× more — and the walk that fills them is one level shorter. This is the primary reason huge pages exist (Paging): not to save page-table memory, but to keep the translation of a large hot heap inside the TLB. JVMs, databases and packet-processing frameworks all expose a huge-page switch for this reason, and the gain on a TLB-bound workload is often larger than any code optimisation available.

The other lever is locality, and it is the bridge to DSA: an array traversal touches one page per 4 kB and misses once per 64 cache lines; a linked list or a pointer-chasing tree can miss on every node. Data-structure choices that look like "cache-friendliness" in the Breadth-First Search (BFS) vs Depth-First Search (DFS) or array-vs-list discussion are TLB-friendliness too, and on large working sets the TLB miss is the more expensive of the two.

  • Reach = entries × page size: ~6 MB with 4 kB pages, ~3 GB with 2 MB pages on a 1,536-entry L2 TLB.
  • Miss cost: ~20 cycles warm (page-walk caches + L1), up to ~300 ns cold; page-fault on top if not present.
  • Random access over a working set beyond reach → miss per access → 10–30%+ overhead.
  • Sequential access, arrays, arenas and huge pages all raise the hit rate; pointer chasing over a large heap lowers it.

Flushes, ASIDs and shootdowns

Conceptual

A TLB entry is only valid for the page table it was walked from. When the kernel switches to another process it loads a new base register, and every cached translation is now wrong. The original answer was to flush the whole TLB on every address-space switch, which meant every context switch was followed by a burst of misses as the new process re-walked its working set — a large part of the indirect cost in Context Switching. Kernel entries survived because they are marked global and are the same in every table.

Tagging fixed that. ARM’s ASID and x86’s PCID (used by Linux since 4.14) attach an address-space identifier to each entry, so entries of several processes coexist and a switch only changes which tag is current. The kernel manages the small tag space (12 bits on x86; Linux uses 6 PCIDs per CPU) and flushes only when a tag is reused. This is also what made kernel page-table isolation survivable: the user and kernel tables of the same process have different PCIDs, so the switch on every syscall does not empty the TLB.

Tagging solves switches but not changes. When the kernel modifies a mapping — munmap, mprotect, reclaim unmapping a page, copy-on-write breaking a share — any core that may have cached the old entry must drop it. On x86 there is no hardware coherence for TLBs, so the kernel sends an inter-processor interrupt to every core running a thread of that process: a TLB shootdown, costing microseconds and scaling with thread count. A multi-threaded process that mmaps and munmaps per request pays a shootdown per call across all its threads, which is why allocators batch releases with madvise and why "the kernel is spending its time in flush_tlb_mm_range" is a recognisable performance pathology.

Bridges: caches and LRU

The TLB is a cache in every sense the Computer Architecture domain uses: a small fast memory holding recently used entries of a larger slow one, with hits, misses, associativity and a replacement policy. It differs from L1/L2/L3 only in what it caches (translations, not data) and in its coherence story (none in hardware; the kernel does it). Reasoning about a working set has to account for both hierarchies at once: a 12 MB working set fits L3 but not the 4 kB-page TLB; a 3 GB working set on huge pages fits the TLB but not any cache.

And it is the LRU Cache from DSA with a capacity of 1,536 and a hardware approximation of recency: the same structure, the same hit-rate reasoning, the same scan pathology, and the same answer — make the working set fit, or make the entries cover more (huge pages), or make the access pattern local. When the simulator shows hit rate collapsing as you raise the working set past reach, you are watching the LRU cache you implemented lose to a scan.

Key points

  • A TLB caches VPN → PFN + permissions; a hit costs ~1 cycle, a miss costs a page walk of four dependent reads.
  • Typical sizes: ~64 L1 dTLB entries, ~1,536–2,048 shared L2 entries; LRU-like replacement.
  • Reach = entries × page size: ~6 MB at 4 kB, ~3 GB at 2 MB — the main reason huge pages exist.
  • Random access over a working set beyond reach makes the TLB miss the dominant cost; perf stat on dTLB misses shows it.
  • ASIDs/PCIDs let entries of several processes coexist across context switches; before them every switch flushed the TLB.
  • Changing a mapping requires a TLB shootdown (IPI to every core running the process) — why frequent munmap/mprotect in multi-threaded processes is expensive.

Why does this exist?

Mechanisms are answers to constraints. Open each question before reading the answer.

Why not make the TLB bigger?

It is a fully or highly associative lookup on the critical path of every memory access; size costs latency and power. Huge pages multiply reach by 512 without adding an entry, which is a better trade.

Why does a context switch hurt more than the microseconds it takes?

Because the new process starts with the TLB and caches holding the old process’s state. Every page it touches walks the table again. PCIDs reduce it; they do not eliminate it.

Why is munmap slow in a multi-threaded program?

Every core running one of its threads may hold the stale translation, and TLBs are not hardware-coherent, so the kernel interrupts each of them. The cost scales with threads × cores.

Why do databases turn on huge pages?

A buffer pool of tens of GB accessed randomly misses the 4 kB TLB on nearly every page touch; 2 MB pages bring most of the pool within reach and remove a level from the walk.

TLB simulator

TLB: 4 entries, LRU, in front of the page table
Every access checks the TLB first. A hit costs ~1 cycle; a miss walks the page table (~tens of cycles, simulated) and fills an entry, evicting the least recently used one.
vpn 0x403
used @1
empty
empty
empty
0x40387A→ vpn 0x403 (addr ≫ 12)miss
Hits
0
Misses
1
Hit rate
0%
Cycles (sim.)
31 vs 1 ideal
Seven distinct 4 kB pages cycle through four slots: LRU keeps the recently used ones, and each miss costs ~30× a hit here (real: 10s of cycles, more if the page-table walk itself misses the CPU caches).
1/12 · accessSimulated

How it fails

What the failure looks like from inside real software.

  • A hash-table-heavy service at 25% overhead in dTLB misses (perf stat): huge pages for the table’s arena recovered most of it.
  • A latency-sensitive process migrated between cores or preempted by a large process shows tail latency from cold TLB refills, not from its own code.
  • A JIT or allocator calling mprotect/munmap thousands of times per second in a 64-thread process: the kernel spends its time in TLB shootdowns; perf top shows smp_call_function_many.
  • A benchmark that shows a data structure "fits in cache" at 8 MB but is slow: it exceeds TLB reach; the same test on huge pages passes.