Multilevel Page Tables
Imagine a 64-bit process that touches only two megabytes of heap. It also uses a handful of stack pages. Naively, The Page Table for that process still needs one entry for every virtual page in the address space. This includes the vast stretches that are never mapped. With 4 KB pages and a 48-bit virtual address space, a flat page table would need over 68 billion entries. That would mean many gigabytes of memory per process. Most of it would be wasted describing "nothing lives here." Real operating systems cannot afford that. So they never build page tables that way. Multilevel page tables solve this by turning one gigantic flat array into a tree of small tables. These tables are allocated lazily. A region of the address space that no process ever touches costs nothing. It needs only a null pointer at some level of the tree. Only the branches that lead to actually-mapped pages get materialized. This single idea — trading a dense array for a sparse tree — is one of the most consequential design decisions in virtual memory systems. It is what makes large, sparse address spaces affordable. These spaces are used for stack, heap, shared libraries, memory-mapped files, and guard regions. This article builds the mechanism from the ground up. We show how a virtual address is sliced into indices. We show how each slice walks one level of the tree. We explain how the hardware page-walker and the Translation Lookaside Buffer cooperate. Together they make this fast despite the extra indirection. We also show how the design generalizes from two levels (classic 32-bit x86) to four or five levels (modern x86-64 and ARM64). We derive the space savings honestly. We work a full numeric trace of a page-table walk. We catalog the failure modes that working systems engineers must reason about. These include TLB-miss cost, huge-page interactions, and the sparsity assumption breaking down. By the end you should be able to compute how many page-table entries a given multilevel scheme needs for a given mapped footprint. You should be able to trace a virtual-to-physical translation by hand across any number of levels. And you should be able to explain precisely why this design is a stepping stone toward the very different tradeoff made by Inverted Page Tables.
- A flat (single-level) page table must reserve one entry per virtual page, wasting memory on unmapped regions.
- Multilevel page tables replace the flat array with a tree; unmapped subtrees are never allocated.
- A virtual address is split into several index fields (one per level) plus a page offset.
- Translation is a pointer chase: each index selects an entry in the current-level table, which points to the next-level table (or, at the last level, the physical frame).
- The Translation Lookaside Buffer caches the final virtual-to-physical mapping, hiding the extra memory accesses the walk would otherwise cost on every reference.
- The design generalizes cleanly to 2, 3, 4, or 5 levels; more levels save memory for sparse spaces but add latency on a TLB miss — this tension foreshadows the very different tradeoff made by Inverted Page Tables.
A multilevel page table is a tree of page tables that allocates memory only for the parts of the address space actually in use, exchanging one flat array lookup for a bounded chain of small-table lookups.
The idea
A single-level (flat) page table maps every virtual page number (VPN) directly to a physical frame number via array indexing: entry `i` describes virtual page `i`. This is per lookup, but its size is proportional to the entire virtual address space, not to how much of it is actually used. For a 32-bit address space with 4 KB pages this is already about 1 million entries (4 MB at 4 bytes/entry); for 64-bit address spaces it is astronomically larger, and per-process, since every process conceptually has its own full address space. A multilevel page table restructures this array as a tree. The virtual address is partitioned into several fixed-width index fields, ordered from most significant to least significant, plus a final page offset field. The first index selects an entry in a small top-level table (often called the page directory or page global directory); that entry is either invalid (meaning the entire subtree it would describe is unmapped) or a pointer to a second-level table. The second index repeats the process in that table, and so on, until the last index selects the actual page table entry (PTE) containing the physical frame number, protection bits, and status bits (present, dirty, accessed). The page offset is never used for indexing — it addresses bytes within the final physical frame and is copied through unchanged. The key structural property is sparsity preservation: an entire subtree — potentially covering gigabytes or terabytes of virtual address space — collapses to a single "not present" entry in its parent table if nothing in that region is mapped. Only tables along paths that lead to real, mapped pages are ever allocated. This is precisely what happens for a typical process: a small mapped region near address 0 (code, data), a small mapped region near the top of the address space (stack), and a growable heap — with an enormous unmapped gap in between that costs almost nothing to represent.
How it works
Splitting the address. For an -level page table with bits of page offset and index fields of widths (from most to least significant), the virtual address layout is: ``` [ index_1 | index_2 | ... | index_L | offset ] k_1 k_2 k_L b ``` with (virtual address width). Classic 32-bit x86 uses , , (4 KB pages). Modern x86-64 (4-level paging) uses , for each level, , covering 48 bits of virtual address; 5-level paging extends this to , covering 57 bits.
Walking the tree. Translation is a chain of table lookups, each producing the base address of the next table (or, at the last level, the frame): ```pseudocode function TRANSLATE(virtual_address, page_table_base): // Split into L index fields and an offset, MSB to LSB idx[1..L], offset = SPLIT(virtual_address) table_base = page_table_base // physical address of top-level table for level = 1 to L: entry_addr = table_base + idx[level] ENTRY_SIZE entry = MEMORY_READ(entry_addr) // physical memory access if not entry.present: raise PAGE_FAULT(virtual_address, level) if level < L: table_base = entry.next_table_base // descend one level else: frame = entry.frame_number // final PTE reached CHECK_PERMISSIONS(entry, requested_access) // else PAGE_FAULT physical_address = frame FRAME_SIZE + offset return physical_address ``` Each iteration of the loop is one physical memory access (barring caching), so an -level walk costs up to memory accesses before the data itself is even fetched. This is the price paid for sparsity, and it is exactly what the Translation Lookaside Buffer exists to amortize: the TLB caches the end-to-end result (VPN → frame number), so a TLB hit skips the entire walk and costs one access, identical to the flat-table case. Only a TLB miss pays the full -step page-table walk.
Populating the tree lazily. Tables are created on demand: ```pseudocode function MAP_PAGE(virtual_address, frame_number, perms, page_table_base): idx[1..L], _ = SPLIT(virtual_address) table_base = page_table_base for level = 1 to L - 1: entry_addr = table_base + idx[level] ENTRY_SIZE entry = MEMORY_READ(entry_addr) if not entry.present: new_table = ALLOCATE_FRAME() // zero-fill: all entries "not present" entry.next_table_base = new_table entry.present = true MEMORY_WRITE(entry_addr, entry) table_base = entry.next_table_base // last level: install the real mapping entry_addr = table_base + idx[L] ENTRY_SIZE MEMORY_WRITE(entry_addr, PTE(frame_number, perms, present=true)) ``` Notice that mapping a single new page in a previously untouched region may require allocating new intermediate tables — but subsequent pages mapped nearby (sharing the same higher-level indices) reuse those tables. This is why multilevel tables are efficient specifically for clustered/sparse access patterns (a few contiguous regions), not for address spaces with pages scattered uniformly at random.
Hardware vs. software walks. On x86-64 the CPU's memory management unit (MMU) performs the walk in hardware on a TLB miss, reading the page-directory/page-table chain itself and inserting the result into the TLB. On some architectures (classic MIPS, some RISC designs) the walk is done by a software TLB-miss handler, giving the OS full control of the table format at the cost of trap overhead. Either way, the algorithm above is what executes.
Complexity & correctness
Time. A page-table walk with levels performs exactly sequential physical memory reads on a TLB miss: time, where is a small constant (2 to 5 in real systems), so effectively per translation. On a TLB hit — the overwhelmingly common case, since TLB hit rates typically exceed 95–99% in practice — translation costs regardless of , because the TLB stores the final frame number directly, bypassing the walk entirely. The amortized cost of translation is therefore dominated by the miss rate and , not by alone:
where is the TLB miss probability and the accounts for the final data access itself. Because is small and is small, this stays close to in practice, though every additional level does add real latency to every miss (this is exactly the cost that motivates caching partial walks — see below — and, at the far end of the design space, motivates abandoning the tree structure altogether in Inverted Page Tables).
Space. This is the entire point of the design, so it deserves an honest derivation. Let the address space have total virtual pages, and let pages actually be mapped, clustered into contiguous regions ( in typical processes: text, data, heap, stack, a few mmap'd libraries). For an -level tree with fan-out per table (i.e., entries per level, assuming equal index widths for simplicity):
- A flat table needs entries: space, independent of usage.
- A multilevel table needs, in the worst case, one full path of tables per region, plus the actual leaf tables covering the mapped pages:
Since (table fan-out, e.g. 512 or 1024) and are both constants, this is — linear in the amount of address space actually used, not in the size of the address space itself. For a process using a few megabytes out of an exabyte-scale virtual address space, this is the difference between kilobytes of page-table memory and an impossibility. The tradeoff is explicit: multilevel tables shrink space from to at the cost of raising per-miss time from to . This is a textbook space–time tradeoff, and it is a good one precisely because TLB caching hides the time cost on the overwhelming majority of accesses while the space savings apply unconditionally, on every process, all the time.
Correctness argument. The invariant maintained by `MAP_PAGE` and relied upon by `TRANSLATE` is: > For every virtual address with a valid mapping, following the index chain derived from , starting at the page-table base register, reaches a present PTE containing exactly the frame most recently mapped for (or a fault, if unmapped). This holds by induction on the number of calls to `MAP_PAGE`. Base case: an empty tree (all top-level entries not-present) correctly signals a fault for every address, since nothing is mapped. Inductive step: `MAP_PAGE(v, f, ...)` walks the same index chain that `TRANSLATE(v)` would walk (both derive indices from via the identical `SPLIT` function), allocating any missing intermediate table it encounters and never disturbing entries reached by a different index chain (each entry is indexed by a distinct combination of index bits, so writes are disjoint across addresses that diverge at any level). Hence after the call, translating reaches the newly written frame, and translating any is unaffected unless shares a full prefix of index bits with (in which case it correctly shares the intermediate tables, which is exactly the desired sharing behavior for pages within the same region). Termination is immediate since both algorithms loop exactly times.
Worked example
Take the classic 32-bit two-level x86 layout: 4 KB pages (), two 10-bit indices (), 4-byte entries, so each table has entries and occupies exactly one 4 KB page (1024 × 4 bytes). Suppose a process maps virtual address `0x00402018` (code) and we trace the read of one byte there.
Step 1 — split the address.
| Field | Bits | Binary | Decimal |
|---|---|---|---|
| idx1 (Page Directory index) | [31:22] | `0000000001` | 1 |
| idx2 (Page Table index) | [21:12] | `0000000010` | 2 |
| offset | [11:0] | `000000011000` | 0x018 = 24 |
Check: `0x00402018` = `0000 0000 0100 0000 0010 0000 0001 1000`₂. Splitting at bit 22 and bit 12 confirms idx1 = 1, idx2 = 2, offset = 24. ✓.
Step 2 — read the Page Directory entry. The CR3 register holds the physical base of the page directory, say `0x00100000`. The MMU reads entry 1:
Suppose this entry contains `present=1`, `next_table_base=0x00105000`.
Step 3 — read the Page Table entry. Using the second-level table at `0x00105000`, index 2:
Suppose this entry contains `present=1`, `frame=0x340`, `R/W=1`, `U/S=1`.
Step 4 — assemble the physical address.
Total cost: 2 memory reads (page directory, page table) plus the final data read — 3 physical accesses on a TLB miss, matching with . On a subsequent access to the same page, the TLB already holds `0x00402 → 0x340` (VPN → frame), so the translation costs one lookup, not three, until that TLB entry is evicted.
Space comparison for this process. Suppose this process maps 3 regions totaling 600 pages (2.4 MB): a 200-page code segment, a 150-page heap, and a 250-page stack, scattered across a 32-bit (4 GB) address space of possible pages.
- Flat table: entries × 4 bytes = 4 MB, always, regardless of usage.
- Two-level table: at most 3 page-directory entries occupied (one per region, assuming each region fits within one 4 MB directory-entry span; a 250-page stack might span 2 entries in the worst case, so say 4 directory entries) plus one 4 KB second-level table per occupied directory entry, plus the page directory itself (4 KB, since it must exist in full — 1024 entries — even though only a handful are present).
20 KB versus 4 MB — a 200× reduction for this process, and the ratio grows without bound as the mapped fraction of the address space shrinks further (which is the norm on 64-bit systems).
Common pitfalls & traps
| Pitfall | Why it happens | Consequence |
|---|---|---|
| Forgetting the page directory itself must always be fully allocated | Even a mostly-empty top level is one full table (e.g., 4 KB for 1024 entries) | Miscounts space savings; top-level cost is fixed overhead, not zero |
| Assuming TLB miss cost is like a hit | Confusing the amortized average cost with the worst-case per-miss cost | Underestimates tail latency, especially after context switches that flush the TLB |
| Off-by-one in bit-field splitting | Miscounting inclusive/exclusive bit ranges when deriving `idx1`, `idx2`, `offset` widths | Produces wrong table indices; silent memory corruption or spurious faults |
| Ignoring huge pages / large-page mappings | Some architectures let a directory entry map a 2 MB or 1 GB region directly, skipping lower levels | Code that assumes every walk goes all the way to level mis-handles huge-page PTEs |
| Race condition when two threads fault on the same address concurrently | Both threads see "not present" and both try to allocate a new intermediate table | Duplicate table allocation, leaked frames, or a lost update unless the page-fault handler serializes with a lock or atomic compare-and-swap on the entry |
| Treating sparsity as free everywhere | Multilevel tables save space only when mapped regions are clustered; a workload touching one page in every 4 MB block still forces a full second-level table per touched block | Space savings can vanish for pathologically scattered access patterns |
Pioneers & history
The multilevel page table is less the invention of a single person than the convergent solution multiple hardware architects reached once virtual address spaces grew past what flat tables could afford. Its lineage runs through the paging hardware of the Atlas and Multics projects, through Digital Equipment Corporation's VAX architecture (one of the first widely deployed two-level schemes), and into the x86 and RISC architectures that standardized it for mass-market operating systems.
Origins · 1 minPeter J. Denning and how this was discovered
Denning's working-set model gave systems designers the theoretical language to explain why real programs touch only a small, slowly-changing subset of their address space at any time — the exact property that makes hierarchical, lazily-allocated page tables efficient rather than merely clever.
Where this matters
Every mainstream general-purpose operating system kernel — Linux, Windows, macOS/XNU, the BSDs — implements multilevel page tables as the default translation structure for user and kernel address spaces on x86-64 and ARM64. Linux's generic `pgd/p4d/pud/pmd/pte` naming directly mirrors the up-to-five-level hardware hierarchy; hypervisors add a second, nested layer of multilevel tables (extended/nested page tables) to translate guest-physical to host-physical addresses for virtual machines. Database systems and language runtimes that request huge sparse address-space reservations (for copy-on-write snapshots, garbage-collector heaps, or address-space-layout-randomization guard gaps) depend on the OS's ability to represent those reservations without paying for every unmapped page. Understanding this mechanism is also the necessary foundation for appreciating why some architectures (notably early PowerPC and some historical Itanium designs) instead chose a completely different data structure — the inverted page table — sized by physical memory rather than virtual address space, trading the tree's simplicity for a hash-table lookup and a different set of tradeoffs around shared mappings and reverse lookups.
Ask the tutor: How would the space and time formulas change if we used a hashed multilevel scheme, or moved to an inverted page table entirely?
Why not just use a bigger single-level table and accept the memory cost?
For small 32-bit address spaces this was sometimes tolerable — a flat table needs about 4 MB per process, which older systems occasionally paid. For 64-bit address spaces it becomes physically impossible: a fully flat table for a 48-bit virtual address space with 4 KB pages and 8-byte entries would need roughly 2 petabytes just to describe one process's mappings, dwarfing any real machine's physical memory. Multilevel tables reduce this to space proportional only to the pages actually mapped (plus a small constant number of intermediate tables), which for a typical process is kilobytes to megabytes rather than petabytes. The flat-table approach simply does not scale past a modest address-space width, which is precisely why every 64-bit architecture uses either multilevel or inverted tables.
Does adding more levels always cost more time, or can it help?
Each additional level adds one more sequential memory access on a TLB miss, so in isolation more levels mean slower misses. However, more levels also allow finer-grained sparsity — smaller per-region allocation granularity — which can reduce wasted intermediate-table memory for extremely sparse mappings, and more levels support wider address spaces (5-level paging exists specifically to reach 57 bits, not for speed). In practice the number of levels is fixed by the hardware architecture and the address-space width it targets; software cannot trade levels for speed at runtime, though huge pages (mapping at an intermediate level directly) let the OS skip the lowest one or two levels for large, contiguous, performance-critical mappings.
How does a huge page interact with the multilevel structure?
A huge page (e.g., 2 MB or 1 GB on x86-64) is installed by marking a directory entry at an intermediate level as a terminal mapping rather than a pointer to a further table — a "page-size" bit tells the hardware walker to stop early and treat that entry's frame field as the huge page's physical base, with the remaining, wider low-order bits of the virtual address serving directly as the offset within the huge frame. This shortens the walk (fewer levels traversed on a miss) and uses one PTE-equivalent to cover far more memory, which is why huge pages both speed up TLB-miss handling and reduce TLB pressure for large, contiguous allocations such as database buffer pools or large in-memory arrays.
What happens differently on a TLB hit versus a miss, mechanically?
On a TLB hit, the MMU compares the requested virtual page number against tags stored in the (typically fully-associative or set-associative) TLB cache; a match yields the physical frame number directly, in roughly one cycle, with zero table-tree accesses. On a miss, the hardware (or, on some architectures, a software trap handler) performs the full -step walk described above, reading each level's table from physical memory (or from the ordinary data/instruction cache, if those table pages happen to be cached there), then inserts the resulting VPN-to-frame mapping into the TLB before completing the original memory reference. The walk's cost is thus paid once per distinct page per "TLB residency window," not once per memory reference.
Can multilevel page tables handle shared memory between processes?
Yes, and efficiently: because intermediate levels store pointers to lower-level tables, two processes that share a mapped region (a shared library, or explicit shared memory) can have their higher-level entries point at the same lower-level table, so the shared pages' PTEs live in one physical table shared by both trees. This requires careful bookkeeping (reference counting on shared intermediate tables, and copy-on-write semantics if one process later wants a private mapping) but avoids duplicating page-table memory for shared content, which is exactly the same clustering property that makes sparse per-process regions cheap in the first place.
Why does this design foreshadow inverted page tables?
Multilevel tables are sized relative to virtual address space usage; their per-process cost grows (slowly) with how much of the address space each process maps, and every process pays its own tree, however small. An inverted page table instead keeps one single table sized relative to physical memory — one entry per physical frame, system-wide — trading the tree's simple, deterministic index-chasing for a hash-based lookup keyed by (process ID, virtual page number), and trading per-process trees for one shared structure. Seeing exactly what multilevel tables optimize (virtual sparsity) and exactly what they still cost (walk latency, per-process duplication) motivates why some architectures instead chose to optimize the opposite axis.