Speculative Execution and the OS

In January 2018 the entire industry learned that a performance trick baked into every fast CPU for two decades had quietly broken the most fundamental promise an operating system makes. You already met the hardware mechanism in Spectre and Meltdown: the CPU speculates past a branch or a permission check, executes instructions it may have to throw away, and — crucially — leaves microarchitectural footprints (cache-line residency) that a patient attacker can measure. This lesson is the other half of that story: what the operating system had to do about it, why the fix was so drastic, and what it cost.

The reason OS people panicked is specific. Meltdown let an ordinary user process read kernel memory — not through a bug in the kernel, but by exploiting the fact that the kernel was mapped into every process's address space for speed. The isolation the OS thought the hardware was enforcing was, under speculation, not enforced at all. The kernel's own defence — never mapping user pages executable, checking every ACL — was irrelevant, because the leak happened below the architectural level the OS controls.

The assumption that broke

For decades, kernels made a deliberate trade. The kernel's pages were mapped into every process's page tables — marked supervisor-only, so a ring-3 access would fault — precisely so that a system call could enter the kernel without switching page tables. No \texttt{CR3} reload, no TLB flush: the trap just raised privilege and the already-mapped kernel was right there. Fast, and — everyone believed — safe, because the supervisor bit would stop any user read.

Meltdown demolished the "safe" half. On the vulnerable cores, a speculative load of a kernel address returned its value into the pipeline before the permission check retired and squashed the instruction. The value never became architecturally visible — but the attacker had already used it to index an array, pulling one cache line into cache. A timing probe afterwards revealed which line, and thus the secret byte. The permission check was too late. The kernel being mapped for speed had become the kernel being readable for free.

The fix: Kernel Page-Table Isolation (KPTI)

If the danger is that the kernel is mapped while user code runs, the fix is blunt: don't map it. KPTI (Kernel Page-Table Isolation — Linux shipped it as an emergency patch, originally the aptly-named KAISER) gives each process two page tables. The user table maps all the user pages but only a bare-minimum trampoline of the kernel — just the entry/exit stubs needed to make the switch, containing no secrets. The kernel table maps everything, as before. Now, when Meltdown speculates a read of a kernel address from user mode, there is simply nothing mapped there to leak.

The price is paid on every boundary crossing. Where a syscall used to enter the kernel with the same \texttt{CR3} (the register holding the page-table base), it must now reload \texttt{CR3} to swap to the kernel table on entry, and reload it again to swap back on return. A \texttt{CR3} write historically flushes the TLB — throwing away the cached virtual→physical translations you spent cycles filling — so every syscall now also eats a burst of TLB misses as translations are re-walked. Two page tables, switched twice per crossing: that is the KPTI tax.

PCID: taking some of the tax back

The saving grace is a hardware feature that suddenly became essential: PCID (Process-Context IDentifiers, ARM calls the equivalent ASIDs). PCID tags each TLB entry with an address-space identifier, so a \texttt{CR3} reload can switch address spaces without flushing the TLB — the stale entries simply become invisible to the new context and are evicted lazily. With PCID, KPTI's forced \texttt{CR3} switches keep the expensive TLB contents alive across the crossing, turning a full flush into a cheap tag change.

This is why the measured overhead of KPTI varies so wildly in the literature — anywhere from \sim 1\% to \sim 30\%. It depends almost entirely on two things: how syscall-heavy the workload is (a CPU-bound number-cruncher barely crosses the boundary and barely notices; a database or web server doing millions of tiny I/O syscalls per second is hammered), and whether PCID is available to blunt the TLB cost. The general shape: overhead grows with the crossing rate.

The other family: Spectre and retpolines

Meltdown was the easy one — KPTI is a clean structural fix, and newer CPUs fixed the root cause in silicon. Spectre is the stubborn one, because it abuses legitimate speculation. Spectre variant 2 (branch-target injection) lets an attacker train the CPU's indirect-branch predictor so that a victim's indirect jump speculatively lands on an attacker-chosen gadget, which then leaks data through the same cache channel. There is no single page-table trick to fix this; the mitigations are a grab-bag:

Each of these adds cycles to the very hottest paths in the machine — branches and boundary crossings — which is why the 2018 mitigations, taken together, are remembered as one of the largest across-the-board performance regressions in computing history.

Counting the tax, in code

The KPTI cost is concrete: extra \texttt{CR3} reloads, and — without PCID — the TLB refills they trigger. This sim counts them for a workload of N syscalls, with and without PCID, and turns the cost into a rough slowdown. Change the numbers and watch a syscall-heavy server suffer while a compute-bound job shrugs.

// KPTI cost model. Each syscall now reloads CR3 twice (enter + return). Without PCID each reload // flushes the TLB, so the working set must be re-walked. PCID tags entries and avoids the flush. interface Load { name: string; syscalls: number; tlbEntries: number; pcid: boolean; } const CR3_RELOAD = 250; // cycles for the extra CR3 write itself const TLB_REFILL = 120; // cycles to re-walk one TLB entry after a flush function kptiTax(w: Load): void { const reloads = 2 * w.syscalls; // enter + return per syscall const cr3Cost = reloads * CR3_RELOAD; // Without PCID, every reload flushes the TLB -> refill the working set each time. const tlbCost = w.pcid ? 0 : reloads * w.tlbEntries * TLB_REFILL; const total = cr3Cost + tlbCost; console.log(`${w.name}: ${w.syscalls} syscalls, PCID=${w.pcid}`); console.log(` extra CR3 reloads = ${reloads}, TLB refills = ${w.pcid ? 0 : reloads * w.tlbEntries}`); console.log(` KPTI overhead ~ ${total.toLocaleString()} cycles\n`); } kptiTax({ name: "database (syscall-heavy, no PCID)", syscalls: 100000, tlbEntries: 40, pcid: false }); kptiTax({ name: "database (syscall-heavy, PCID on)", syscalls: 100000, tlbEntries: 40, pcid: true }); kptiTax({ name: "number-cruncher (CPU-bound)", syscalls: 200, tlbEntries: 40, pcid: false });

The 2018 response was chaotic, and the kernel maintainers were furious — not at the researchers, but at the microcode fixes shipped in a panic. The early IBRS microcode was so slow that using it as intended (set the barrier and leave it on in the kernel) could cost more than the attack it prevented, and the guidance kept changing week to week. Linus's public verdict — that the patches looked designed "for marketing reasons" to be seen doing something rather than to be correct and fast — captured a deeper tension the whole industry felt: these were hardware flaws being patched in software, on hardware already in the field, under embargo, against a clock. The lasting fix was architectural: later CPUs added \texttt{eIBRS} ("enhanced" IBRS that is cheap to leave on) and simply didn't speculate across the privilege boundary. It is a clean case study in why security properties belong in the hardware contract, not bolted on afterward.

A common conflation is to treat "the 2018 patches" as one fix for one bug. They are not. KPTI addresses Meltdown specifically — the ability of a user process to read kernel memory — by unmapping the kernel from user page tables. It does essentially nothing for Spectre, which leaks data within the same privilege domain by mistraining branch prediction; a Spectre gadget in the kernel, or in your own address space, still leaks regardless of how the page tables are split. Spectre needs its own separate defences — retpolines, \texttt{lfence} barriers, IBPB/STIBP — and even those are per-variant, not a blanket cure. So "we enabled KPTI, we're safe from speculation" is wrong twice over: KPTI is one fix for one branch of a large, still-growing family (L1TF, MDS, RIDL, Retbleed…), each of which needed its own mitigation. Speculative-execution security is a category, not a single patch.