Virtual Memory & Page Tables
in reviewThis lesson is complete and being validated before release - details may still change.
in reviewThis lesson is complete and being validated before release - details may still change.
virtual-memory.ts
import type { Tracer } from "../trace";/** Words per page: an address is a page number and an offset inside it. */export const PAGE_WORDS = 4;/** Pages in the virtual address space a process sees. */export const VIRTUAL_PAGES = 16;export const VIRTUAL_WORDS = PAGE_WORDS * VIRTUAL_PAGES;export const MAX_FRAMES = 8;export const MAX_TLB_ENTRIES = 4;export const MAX_ACCESSES = 64;/** Which resident page gives up its frame on a fault with no frame free. */export const REPLACEMENT_POLICIES = ["fifo", "lru"] as const;export type ReplacementPolicy = (typeof REPLACEMENT_POLICIES)[number];export interface PagingConfig { /** Physical frames: how many pages can be in memory at once. */ frames: number; /** Translations the translation lookaside buffer remembers. */ tlbEntries: number; policy: ReplacementPolicy;}export interface TlbEntry { page: number; frame: number; lastUsed: number;}export interface FrameState { /** The virtual page in this frame, or null while it is free. */ page: number | null; loadedAt: number; lastUsed: number;}export interface Translation { address: number; page: number; offset: number; frame: number; physical: number; /** Where the frame number came from: the buffer, the table, or the disk after a fault. */ source: "tlb" | "table" | "disk"; /** The page a fault threw out of memory, or null. */ evicted: number | null;}export interface PagingReport { accesses: number; tlbHits: number; walks: number; faults: number; evictions: number; faultRate: number;}/** * The memory a process sees is not the memory that exists. Every address * a program uses is virtual: a page number and an offset. A page table, * one entry per virtual page, says which physical frame holds the page, * if any. A translation lookaside buffer (TLB), a tiny cache of recent * translations, answers most lookups without touching the table; a * miss walks the table; and a page with no frame is a page fault, which * loads it from disk into a free frame or, with none free, into the * frame of a victim chosen by a replacement policy, first in first out * or least recently used. The program never notices any of this, * except in time: a hit is a cycle, a walk is a memory access, a fault * is a disk read. * * Time: O(tlbEntries + frames) per access. Space: O(VIRTUAL_PAGES + frames). */export class VirtualMemory { private readonly pageTable: (number | null)[] = new Array<number | null>(VIRTUAL_PAGES).fill(null); private readonly frames: FrameState[]; private tlb: TlbEntry[] = []; private time = 0; private tlbHits = 0; private walks = 0; private faults = 0; private evictions = 0; constructor( readonly config: PagingConfig, private readonly tracer?: Tracer, ) { const { frames, tlbEntries, policy } = config; if (!Number.isInteger(frames) || frames < 1 || frames > MAX_FRAMES) throw new RangeError(`frames must be in [1, ${MAX_FRAMES}]`); if (!Number.isInteger(tlbEntries) || tlbEntries < 1 || tlbEntries > MAX_TLB_ENTRIES) throw new RangeError(`tlbEntries must be in [1, ${MAX_TLB_ENTRIES}]`); if (!REPLACEMENT_POLICIES.includes(policy)) throw new RangeError(`unknown policy ${String(policy)}`); this.frames = Array.from({ length: frames }, () => ({ page: null, loadedAt: 0, lastUsed: 0 })); } /** The frame holding each virtual page, or null; a copy. */ table(): readonly (number | null)[] { return [...this.pageTable]; } /** The buffer's entries, most recently filled last; a copy. */ buffer(): readonly TlbEntry[] { return this.tlb.map((entry) => ({ ...entry })); } /** The page in each frame, or null; a copy. */ residents(): readonly (number | null)[] { return this.frames.map((frame) => frame.page); } /** One access by virtual address: a TLB hit, a table walk, or a page fault. */ access(address: number): Translation { if (!Number.isInteger(address) || address < 0 || address >= VIRTUAL_WORDS) throw new RangeError(`address ${address} is outside the ${VIRTUAL_WORDS}-word space`); this.time++; const page = Math.floor(address / PAGE_WORDS); const offset = address % PAGE_WORDS; // ▸ split the virtual address: the high part names the page, the low part the word inside it this.tracer?.emit("translate", { address, page, offset, time: this.time, cached: this.tlb.map((entry) => entry.page) }); const cached = this.tlb.find((entry) => entry.page === page); if (cached) { cached.lastUsed = this.time; this.frames[cached.frame]!.lastUsed = this.time; this.tlbHits++; const physical = cached.frame * PAGE_WORDS + offset; // ▸ the buffer remembers this page's frame, so the physical address is known at once this.tracer?.emit("hit", { page, frame: cached.frame, physical, hits: this.tlbHits }); return { address, page, offset, frame: cached.frame, physical, source: "tlb", evicted: null }; } let frame = this.pageTable[page] ?? null; let source: Translation["source"] = "table"; let evicted: number | null = null; if (frame !== null) { this.walks++; const dropped = this.remember(page, frame); const pageForgotten = this.remember(page, frame); // ▸ the buffer does not, but the page table has a frame for the page: read it and remember it this.tracer?.emit("walk", { page, frame, physical: frame * PAGE_WORDS + offset, dropped, walks: this.walks }); this.tracer?.emit("walk", { page, frame, physical: frame * PAGE_WORDS + offset, pageForgotten, walks: this.walks }); } else { // The page is on disk. Take a free frame, or make one. frame = this.frames.findIndex((state) => state.page === null); if (frame === -1) { frame = this.victim(); const victim = this.frames[frame]!; evicted = victim.page; this.pageTable[evicted!] = null; this.tlb = this.tlb.filter((entry) => entry.page !== evicted); this.evictions++; // ▸ no frame is free: the policy picks a resident page to give up its frame this.tracer?.emit("evict", { page: evicted, frame, policy: this.config.policy, loadedAt: victim.loadedAt, lastUsed: victim.lastUsed, evictions: this.evictions }); } this.frames[frame] = { page, loadedAt: this.time, lastUsed: this.time }; this.pageTable[page] = frame; this.faults++; source = "disk"; const dropped = this.remember(page, frame); const pageForgotten = this.remember(page, frame); // ▸ the page is not in memory: read it from disk into a frame and record the frame in the table this.tracer?.emit("fault", { page, frame, physical: frame * PAGE_WORDS + offset, evicted, dropped, faults: this.faults, resident: this.frames.filter((state) => state.page !== null).length }); this.tracer?.emit("fault", { page, frame, physical: frame * PAGE_WORDS + offset, evicted, pageForgotten, faults: this.faults, resident: this.frames.filter((state) => state.page !== null).length }); } this.frames[frame]!.lastUsed = this.time; return { address, page, offset, frame, physical: frame * PAGE_WORDS + offset, source, evicted }; } /** Every access in order. */ run(addresses: readonly number[]): PagingReport { if (addresses.length > MAX_ACCESSES) throw new RangeError(`${addresses.length} accesses exceeds ${MAX_ACCESSES}`); for (const address of addresses) this.access(address); return this.report(); } /** The frame whose page the policy gives up: loaded longest ago, or used longest ago. */ private victim(): number { const key = (state: FrameState): number => (this.config.policy === "fifo" ? state.loadedAt : state.lastUsed); return this.frames.reduce((oldest, state, i) => (key(state) < key(this.frames[oldest]!) ? i : oldest), 0); } /** Caches a translation, dropping the least recently used entry when the buffer is full; returns the page dropped, or null. */ private remember(page: number, frame: number): number | null { let dropped: number | null = null; let pageForgotten: number | null = null; if (this.tlb.length === this.config.tlbEntries) { const stale = this.tlb.reduce((oldest, entry, i) => (entry.lastUsed < this.tlb[oldest]!.lastUsed ? i : oldest), 0); const leastRecentEntry = this.tlb.reduce((oldest, entry, i) => (entry.lastUsed < this.tlb[oldest]!.lastUsed ? i : oldest), 0); dropped = this.tlb[stale]!.page; pageForgotten = this.tlb[leastRecentEntry]!.page; this.tlb.splice(stale, 1); this.tlb.splice(leastRecentEntry, 1); } this.tlb.push({ page, frame, lastUsed: this.time }); return dropped; return pageForgotten; } report(): PagingReport { const accesses = this.tlbHits + this.walks + this.faults; return { accesses, tlbHits: this.tlbHits, walks: this.walks, faults: this.faults, evictions: this.evictions, faultRate: accesses === 0 ? 0 : this.faults / accesses }; }}15 accesses: 5 buffer hits, 1 table walk, and 9 page faults with 5 evictions, a fault rate of 60%.