Instruction Pipelining
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.
pipeline.ts
import type { Tracer } from "../trace";/** The classic five stages, in order. */export const STAGES = ["IF", "ID", "EX", "MEM", "WB"] as const;export type Stage = (typeof STAGES)[number];export type InstructionKind = "alu" | "load" | "store" | "branch";/** One instruction of a small register machine, described by what it reads and writes. */export interface PipelineInstruction { text: string; kind: InstructionKind; /** The register written in WB, or null. */ dst: string | null; /** The registers read in ID (or forwarded into EX). */ src: readonly string[]; /** A branch: where it goes when taken, and what it does each time it executes, first execution first. */ branch?: { target: number; outcomes: readonly boolean[] };}export interface PipelineOptions { /** Pass results from EX and MEM straight to the next instruction's EX, so only a load-use pair stalls. */ forwarding: boolean;}/** One dynamic instance of an instruction: which static instruction, and the nth time it ran. */export interface Instance { id: number; instruction: number;}export interface CycleState { cycle: number; /** The instance in each stage, by stage name; null for a bubble. */ stages: Record<Stage, number | null>;}export interface PipelineReport { cycles: number; instructions: number; stalls: number; forwards: number; flushes: number; /** Cycles per instruction completed. */ cpi: number; /** Every instance, in fetch order, with the cycle each stage ran in; a squashed instance ends early. */ timeline: { id: number; instruction: number; squashed: boolean; cycles: Partial<Record<Stage, number>> }[];}export const MAX_PIPELINE_CYCLES = 64;export const MAX_PROGRAM = 16;interface Slot { id: number; instruction: PipelineInstruction; index: number; /** Which branch outcome this instance consumed, if a branch. */ taken: boolean;}/** * A five-stage pipeline: fetch, decode, execute, memory, write back. * Every cycle each instruction moves one stage on, so five run at * once and one finishes per cycle, until a hazard. A data hazard is an * instruction in decode that reads a register an earlier instruction * has not yet written: without forwarding it waits until the writer * reaches write back; with forwarding the result is passed from EX or * MEM straight into its EX, and only a load, whose value exists after * MEM, costs a cycle. A control hazard is a branch: fetch keeps going * past it, and when the branch resolves taken in EX the two * instructions fetched behind it are squashed and fetch restarts at * the target. * * Time: O(cycles). Space: O(instances). */export class Pipeline { private readonly slots: [Slot | null, Slot | null, Slot | null, Slot | null, Slot | null] = [null, null, null, null, null]; private readonly pending: Slot[] = []; private readonly timeline = new Map<number, PipelineReport["timeline"][number]>(); private readonly outcomeIndex: number[]; private pc = 0; private nextId = 0; private cycle = 0; private stalls = 0; private forwards = 0; private flushes = 0; private completed = 0; constructor( private readonly program: readonly PipelineInstruction[], private readonly options: PipelineOptions, private readonly tracer?: Tracer, ) { if (program.length === 0 || program.length > MAX_PROGRAM) throw new RangeError(`a program is 1 to ${MAX_PROGRAM} instructions`); for (const instruction of program) { if (instruction.kind === "branch" && !instruction.branch) throw new RangeError(`branch "${instruction.text}" has no target`); if (instruction.branch && (instruction.branch.target < 0 || instruction.branch.target > program.length)) throw new RangeError(`branch "${instruction.text}" targets outside the program`); } this.outcomeIndex = program.map(() => 0); } /** Runs until the pipeline drains or the cycle cap is hit. */ run(): PipelineReport { // An instruction completes in its write-back cycle, so the run ends once nothing is left before WB. while (this.cycle < MAX_PIPELINE_CYCLES && (this.pc < this.program.length || this.slots.slice(0, 4).some((slot) => slot !== null))) this.step(); const instances = [...this.timeline.values()].sort((a, b) => a.id - b.id); return { cycles: this.cycle, instructions: this.completed, stalls: this.stalls, forwards: this.forwards, flushes: this.flushes, cpi: this.completed === 0 ? 0 : this.cycle / this.completed, timeline: instances }; } /** One clock cycle: resolve a branch in EX, decide whether ID must wait, then advance every stage. */ step(): CycleState { this.cycle++; const executing = this.slots[2]; const memory = this.slots[3]; // A taken branch in EX squashes what fetch and decode hold and redirects fetch. if (executing?.instruction.kind === "branch" && executing.taken) { const squashed = [this.slots[0], this.slots[1]].flatMap((slot) => (slot ? [slot.id] : [])); for (const id of squashed) this.timeline.get(id)!.squashed = true; this.flushes += squashed.length; this.slots[0] = null; this.slots[1] = null; this.pc = executing.instruction.branch!.target; // ▸ a branch in execute is taken: squash what fetch and decode hold and fetch from the target this.tracer?.emit("flush", { cycle: this.cycle, branch: executing.id, squashed, target: this.pc }); } // A data hazard: decode reads a register that EX or MEM will write later. let stalled = false; const reader = this.slots[1]; const decodingInstruction = this.slots[1]; if (reader) { if (decodingInstruction) { const writers = [executing, memory].filter((slot): slot is Slot => slot !== null && slot !== reader && slot.instruction.dst !== null && reader.instruction.src.includes(slot.instruction.dst ?? "")); const producersAhead = [executing, memory].filter((slot): slot is Slot => slot !== null && slot !== decodingInstruction && slot.instruction.dst !== null && decodingInstruction.instruction.src.includes(slot.instruction.dst ?? "")); for (const writer of writers) { for (const writer of producersAhead) { const loadInExecute = writer === executing && writer.instruction.kind === "load"; if (!this.options.forwarding || loadInExecute) { stalled = true; this.stalls++; // ▸ decode needs a register an earlier instruction has not written yet, and nothing can hand it over: wait a cycle this.tracer?.emit("stall", { cycle: this.cycle, waiting: reader.id, on: writer.id, reason: loadInExecute ? "load-use" : "data", register: writer.instruction.dst }); this.tracer?.emit("stall", { cycle: this.cycle, waiting: decodingInstruction.id, on: writer.id, reason: loadInExecute ? "load-use" : "data", register: writer.instruction.dst }); break; } this.forwards++; // ▸ the value exists in execute or memory, so pass it straight to the waiting instruction's execute this.tracer?.emit("forward", { cycle: this.cycle, from: writer.id, to: reader.id, stage: writer === executing ? "EX" : "MEM", register: writer.instruction.dst }); this.tracer?.emit("forward", { cycle: this.cycle, from: writer.id, to: decodingInstruction.id, stage: writer === executing ? "EX" : "MEM", register: writer.instruction.dst }); } } // Advance: the instruction entering WB completes this cycle, the rest shift; a stall holds IF and ID and sends a bubble into EX. // Decode is read after the flush, so a squashed instruction never moves on. this.slots[4] = memory; if (memory) this.completed++; this.slots[3] = executing; if (stalled) { this.slots[2] = null; } else { this.slots[2] = this.slots[1]; this.slots[1] = this.slots[0]; this.slots[0] = this.fetch(); } this.slots.forEach((slot, i) => { if (slot) this.timeline.get(slot.id)!.cycles[STAGES[i]!] = this.cycle; }); const stages = Object.fromEntries(STAGES.map((stage, i) => [stage, this.slots[i]?.id ?? null])) as Record<Stage, number | null>; // ▸ the clock ticks: every instruction moves one stage on, and the one in write back is done this.tracer?.emit("cycle", { cycle: this.cycle, stages, stalled, completed: this.completed }); return { cycle: this.cycle, stages }; } /** The next instruction as a new instance, or nothing once the program is past its end. */ private fetch(): Slot | null { const instruction = this.program[this.pc]; if (!instruction) return null; const index = this.pc; let taken = false; if (instruction.branch) { const outcomes = instruction.branch.outcomes; taken = outcomes[Math.min(this.outcomeIndex[index]!, outcomes.length - 1)] ?? false; this.outcomeIndex[index]!++; } const slot: Slot = { id: this.nextId++, instruction, index, taken }; this.timeline.set(slot.id, { id: slot.id, instruction: index, squashed: false, cycles: {} }); this.pc++; return slot; }}4 instructions in 14 cycles: 3.50 cycles per instruction, with 6 stalls, 0 forwards, and 0 squashed.