TLB Management and Shootdowns
The
translation lookaside buffer
is the small, blazing-fast cache that stores recent virtual-to-physical translations so the MMU does not
have to walk the
page table
on every memory access. The hardware fills it and checks it automatically. So where does the operating
system come in? Everywhere the TLB can go wrong.
The TLB is a cache with no automatic coherence with the thing it caches. When the OS changes a page table
— remaps a page, revokes write permission, unmaps a region, or switches to a different process entirely —
the TLB may still hold the stale old translation, and the hardware will happily use it.
Nothing in silicon notices; keeping the TLB consistent with the page tables is software's
job. This lesson is about that job, and about its nastiest form on a multicore machine: the
TLB shootdown.
The context-switch problem: whose translations are these?
A TLB entry says "virtual page 0\text{x}400 maps to frame
0\text{x}9a2". But every process has a page
0\text{x}400, mapped to different frames. When the scheduler switches from
process A to process B, A's cached translations are now actively dangerous — if B reads
them it will read A's memory. The blunt fix, used for decades, is to flush the entire TLB on
every context switch (on x86, reloading \texttt{CR3} does this
automatically).
Correct, but brutally expensive. After a flush the new process starts with a cold TLB and
takes a burst of TLB misses — each a full page-table walk — until it warms up again. On a machine that
context-switches thousands of times a second, throwing away every translation each time is a serious tax.
- the TLB caches page-table entries but has no hardware coherence with them: after
the OS edits a PTE, the stale TLB copy must be explicitly invalidated by software;
- because a bare virtual page number is ambiguous across processes, a context switch must either
flush the TLB or tag entries with an address-space identifier;
- on a multiprocessor, one core's page-table change can leave other cores holding stale
entries — reconciling them is a software TLB shootdown.
ASIDs / PCIDs: tag the entries, skip the flush
The elegant fix is to stop pretending a virtual page number is enough. Tag every TLB entry with an
address-space identifier — an ASID on ARM, a PCID
(process-context identifier) on x86-64. Now an entry means "in address space 7, page
0\text{x}400 maps to frame 0\text{x}9a2", and the
TLB only reports a hit when the current address-space tag matches. Process A's and process B's
entries can coexist peacefully.
The payoff is that a context switch no longer needs a flush: load the new
\texttt{CR3} with the "don't-flush" bit set, and A's still-warm entries wait
quietly for when A runs again. On x86 the PCID field is 12 bits
(4096 IDs), so the OS recycles a small pool of IDs across its many processes.
The mechanism is the hardware's; deciding which PCID a process gets, and when to recycle one, is
the kernel's policy.
// A tagged TLB (ASID/PCID): entries survive a context switch because each carries its address-space id.
// Compare flush-on-switch vs tagged, counting cold misses after each switch.
type Entry = { asid: number; vpn: number };
const CAP = 4;
function run(tagged: boolean): number {
let tlb: Entry[] = [];
let misses = 0;
// A schedule: (asid, vpn) references, with context switches interleaved.
const refs: Entry[] = [
{ asid: 1, vpn: 10 }, { asid: 1, vpn: 11 }, { asid: 1, vpn: 10 }, // A warms up
{ asid: 2, vpn: 10 }, { asid: 2, vpn: 20 }, // switch to B
{ asid: 1, vpn: 10 }, { asid: 1, vpn: 11 }, // switch back to A
];
let lastAsid = -1;
for (const r of refs) {
if (r.asid !== lastAsid) {
if (!tagged) tlb = []; // untagged: flush the whole TLB on every switch
lastAsid = r.asid;
}
const hit = tlb.some((e) => e.vpn === r.vpn && (!tagged || e.asid === r.asid));
if (!hit) {
misses++;
tlb.push({ asid: r.asid, vpn: r.vpn });
if (tlb.length > CAP) tlb.shift();
}
}
return misses;
}
console.log(`flush-on-switch (untagged): ${run(false)} TLB misses`);
console.log(`tagged (ASID/PCID): ${run(true)} TLB misses`);
console.log("Tagging keeps A's translations warm across the trip through B.");
Global pages: the kernel is everywhere
There is one set of translations you almost never want to flush: the kernel's. The kernel
is mapped into the top of every process's address space at the same virtual addresses, and those
translations are valid no matter which process is running. x86 lets a PTE set a global
bit; global entries survive a \texttt{CR3} reload (they are not flushed on a
context switch). This keeps the hot kernel-code and kernel-data translations warm across every switch —
the OS marks its own mappings global precisely because they are shared by all.
Yes, and it is a lovely story of security overturning an optimisation. The Meltdown
vulnerability (2018) let user code speculatively read kernel memory because the kernel was
mapped into every process. The fix, kernel page-table isolation (KPTI/PTI), gives each
process two page tables — a user one with the kernel unmapped, and a kernel one — and switches
between them on every syscall. That means an extra \texttt{CR3} reload (and
TLB pressure) per kernel entry: exactly the flush that global pages and PCIDs were invented to avoid.
PCIDs became essential overnight — they blunt the cost of PTI's extra address-space switches. Sometimes
the fastest design is the one you are forbidden to use.
The multicore nightmare: TLB shootdowns
Now the hard part. A page can be mapped on several cores at once (a shared library, a threaded process
whose threads run on different CPUs). Suppose core 0 unmaps that page, or revokes its write permission. It
edits the page table — a single memory write — and invalidates its own TLB. But cores 1, 2 and 3
may still hold the old translation in their TLBs, and x86 hardware does not
broadcast the invalidation for you. If core 2 keeps using its stale entry, it writes to a page that core 0
just froze. Disaster.
The OS must therefore reach out and tell the other cores to flush. It does this with an
inter-processor interrupt (IPI): core 0 sends a "please invalidate this page" interrupt
to every core that might have the mapping, then spins waiting for all of them to acknowledge
before it treats the change as done. This coordinated flush is a TLB shootdown.
It is expensive precisely because it is synchronous and cross-core. The initiator pays an IPI
round-trip — thousands of cycles — and, worse, it must wait for the slowest peer to notice the
interrupt, finish what it was doing, flush, and reply. A single \texttt{munmap}
of a page shared across N cores turns into N
interrupts and a barrier. On a big machine, a workload that constantly maps and unmaps shared memory can
spend a startling fraction of its time in shootdowns.
Counting the cost
Model a shootdown crudely. Say sending and completing one IPI to a peer costs about
c \approx 3000 cycles of coordination, and the change touches
N peer cores. The initiator's cost grows with the number of cores it must
wait on:
\text{cost} \;\approx\; c \times (N-1) \;+\; \text{(wait for the slowest peer to respond)}.
The first term is why shootdowns scale badly: a 64-core box unmapping a
widely-shared page can pay 63 IPIs for one page. This is why kernels work so
hard to avoid or batch them — freeing many pages then doing
one shootdown for the lot, tracking exactly which cores ever ran a given address space (an
mm_cpumask) so they only interrupt cores that could possibly hold the entry, and deferring
flushes on freshly-idle cores (Linux's "lazy TLB" mode). A shootdown you can skip is the best kind.
The classic bug — in real kernels, not just exams — is to change a page-table entry and move on, assuming
the write "took effect". It did, in memory; but every core (including the one that made the change) may
still be translating through a cached copy of the old entry. Correctness requires
PTE edit → local invalidate (INVLPG) → shoot down every peer that might cache it → wait for
acknowledgements, and only then is it safe to, say, hand the freed frame to another
purpose. Skip the shootdown and you get a heisenbug: a core writing through a translation that should no
longer exist, corrupting whatever now owns that frame. The page table is memory; the TLB is a stale
rumour of it, and the rumour must be silenced deliberately.
ARMv8 has a \texttt{TLBI} instruction that, with the right scope, broadcasts a
TLB invalidation to all cores in the inner-shareable domain in hardware, with a
\texttt{DSB} barrier to wait for completion — no IPIs, no software round-trip.
It is genuinely cheaper. x86 stuck with software shootdowns (via \texttt{INVLPG}
and IPIs) largely for historical and flexibility reasons: the OS knows exactly which cores ran a
given address space and can interrupt only those, whereas a hardware broadcast hits everyone. Recent x86
has begun adding remote-invalidation hints, and the trade-off — precise-but-slow software versus
blunt-but-fast hardware — is an active design frontier, not a settled question.