CPU Caches & Locality
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.
cache.ts
import type { Tracer } from "../trace";export interface CacheConfig { /** How many blocks the cache holds; a power of two. */ lines: number; /** Words per block; a power of two. The offset picks the word inside a block. */ blockWords: number; /** Lines per set: 1 is direct mapped, `lines` is fully associative. */ ways: number;}export interface CacheLine { valid: boolean; tag: number; /** The access count when the line was last touched, for least-recently-used replacement. */ lastUsed: number;}export interface AccessResult { address: number; set: number; tag: number; offset: number; hit: boolean; /** The way the block sits in after the access. */ way: number; /** The tag the miss threw out, or null when the line was empty. */ evicted: number | null;}export interface CacheReport { accesses: number; hits: number; misses: number; /** Misses that threw a valid block out, as opposed to filling an empty line. */ evictions: number; hitRate: number;}export const MAX_CACHE_LINES = 64;export const MAX_ACCESSES = 256;const isPowerOfTwo = (n: number): boolean => Number.isInteger(n) && n > 0 && (n & (n - 1)) === 0;/** * A cache between a processor and its memory: a few lines, each * holding one block of consecutive words. An address splits into an * offset (which word of the block), a set index (which lines the block * may use), and a tag (which block of all those that map to the set * is actually there). A lookup compares the tag against every line of * the set: a match is a hit, anything else a miss that fetches the * block and, when the set is full, throws out the line used longest * ago. Programs that touch nearby words soon after one another hit * because of the block; programs that return to the same words hit * because of the lines; everything else misses. * * Time: O(ways) per access. Space: O(lines). */export class CacheSimulator { private readonly sets: CacheLine[][]; private readonly setCount: number; private time = 0; private hits = 0; private misses = 0; private evictions = 0; constructor( readonly config: CacheConfig, private readonly tracer?: Tracer, ) { const { lines, blockWords, ways } = config; if (!isPowerOfTwo(lines) || lines > MAX_CACHE_LINES) throw new RangeError(`lines must be a power of two up to ${MAX_CACHE_LINES}`); if (!isPowerOfTwo(blockWords)) throw new RangeError("blockWords must be a power of two"); if (!Number.isInteger(ways) || ways < 1 || lines % ways !== 0) throw new RangeError("ways must divide the line count"); this.setCount = lines / ways; this.sets = Array.from({ length: this.setCount }, () => Array.from({ length: ways }, () => ({ valid: false, tag: 0, lastUsed: 0 }))); } /** The lines of a set, as they stand. */ set(index: number): readonly CacheLine[] { const lines = this.sets[index]; if (!lines) throw new RangeError(`no set ${index}`); return lines.map((line) => ({ ...line })); } /** How an address splits for this cache. */ decompose(address: number): { offset: number; set: number; tag: number } { const block = Math.floor(address / this.config.blockWords); return { offset: address % this.config.blockWords, set: block % this.setCount, tag: Math.floor(block / this.setCount) }; } /** One memory access by address: a hit, or a miss that fills a line. */ access(address: number): AccessResult { if (!Number.isInteger(address) || address < 0) throw new RangeError(`address ${address} is not a word address`); this.time++; const { offset, set, tag } = this.decompose(address); const lines = this.sets[set]!; // ▸ split the address: the offset picks the word in a block, the set picks the lines, the tag names the block this.tracer?.emit("lookup", { address, set, tag, offset, time: this.time, tags: lines.map((line) => (line.valid ? line.tag : null)) }); const found = lines.findIndex((line) => line.valid && line.tag === tag); const matchingWay = lines.findIndex((line) => line.valid && line.tag === tag); if (found !== -1) { if (matchingWay !== -1) { lines[found]!.lastUsed = this.time; lines[matchingWay]!.lastUsed = this.time; this.hits++; // ▸ a line of the set holds the tag: the word is here, and the line is marked as just used this.tracer?.emit("hit", { address, set, way: found, tag, hits: this.hits, misses: this.misses }); this.tracer?.emit("hit", { address, set, way: matchingWay, tag, hits: this.hits, misses: this.misses }); return { address, set, tag, offset, hit: true, way: found, evicted: null }; return { address, set, tag, offset, hit: true, way: matchingWay, evicted: null }; } // Fill an empty line if there is one; otherwise replace the least recently used. let way = lines.findIndex((line) => !line.valid); if (way === -1) way = lines.reduce((oldest, line, i) => (line.lastUsed < lines[oldest]!.lastUsed ? i : oldest), 0); const victim = lines[way]!; const lineToReplace = lines[way]!; const evicted = victim.valid ? victim.tag : null; const evicted = lineToReplace.valid ? lineToReplace.tag : null; if (evicted !== null) this.evictions++; lines[way] = { valid: true, tag, lastUsed: this.time }; this.misses++; // ▸ no line holds the tag: fetch the block into an empty line, or into the line used longest ago this.tracer?.emit("miss", { address, set, way, tag, evicted, hits: this.hits, misses: this.misses }); return { address, set, tag, offset, hit: false, way, evicted }; } /** Every access in order. */ run(addresses: readonly number[]): CacheReport { 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(); } report(): CacheReport { const accesses = this.hits + this.misses; return { accesses, hits: this.hits, misses: this.misses, evictions: this.evictions, hitRate: accesses === 0 ? 0 : this.hits / accesses }; }}16 hits and 16 misses in 32 accesses: a hit rate of 50%, with 12 evictions.