The Memory Subsystem

So far our GPU has been living a lie. The cores of the last two lessons asked memory a question and got the answer one cycle later, every time, no matter how many lanes asked at once — a politeness no real memory has ever shown anyone. This lesson ends the fiction. The whole memory story of the GPU-compute module — scarce bandwidth, punishing latency, the coalescing discipline — exists because of one brutal ratio, and we now build that ratio into PrimerGPU honestly: eight requestors (2 dispatched cores × 4 lanes, each with an LSU) funnelling into two memory channels, each taking four cycles per transaction. Eight mouths, two straws. Everything interesting about GPU memory design is a response to exactly this picture.

The memory controller

Between the LSUs and the memory sits the controller, a referee with four duties:

And on the core side, the six-state FSM finally earns its WAIT room: REQUEST issues the lane requests and then the core stalls in WAIT until every response is home. Latency, which the previous lessons abstracted into a single polite cycle, is about to become the dominant line in every trace we print.

The floor plan

Follow one request through the picture: lane 2 of core 1 computes its address in REQUEST; the arbiter admits it to (say) channel 0's queue behind whoever arrived first; the channel serves it for 4 cycles; and the response — tagged (core 1, lane 2) — lands back in exactly that lane's data latch, while the core sits in WAIT counting its outstanding requests down to zero.

The baby coalescer

Now the twenty lines that the entire coalescing lesson promised were cheap. When a core's four lanes issue a memory instruction, their four addresses very often form a perfect run — in vector add, lane t of block b touches 4b + t: four consecutive words, aligned to a 4-word line. The coalescer checks precisely that:

\text{addr}_t = \text{addr}_0 + t \quad \text{for } t = 0,1,2,3 \;\;\Rightarrow\;\; \text{one 4-word transaction instead of four.}

One comparison per lane, one wide request instead of four narrow ones — a quarter of the transactions for the same data. And because a channel's cost is per transaction (4 cycles each, regardless of width), that factor of four lands directly on time-in-queue. Do the bandwidth accounting for our kernel: 8 threads each load a[i], load b[i], store c[i] — 24 words of traffic. Uncoalesced that is 24 transactions; coalesced, 6 (three per block). If the addresses had been scattered — strided, hashed, indirect — the check fails, the coalescer shrugs, and you pay full price: the hardware makes the well-behaved pattern cheap, but only the program can choose the pattern. That is why coalescing was taught as a software discipline: this circuit is the reason the discipline pays.

Measured, not asserted

The simulator now models all of it — queues, arbitration, latency, ID-routing, and a coalescer you can switch off. Both cores run vector add (8 elements, blocks 0 and 1), and we print what the machine measured: total cycles, transaction counts, channel utilisation, and a run-length trace of core 0's FSM so you can watch WAIT stretch:

// ── The memory subsystem: 8 LSUs (2 cores x 4 lanes) vs 2 channels, 4-cycle latency. ── // Vector add, 8 elements as 2 blocks on 2 cores: a@0, b@8, c@16. Toggle the coalescer and count. const PROG = [0x50de, 0x300f, 0x9100, 0x9208, 0x9310, 0x3410, 0x7440, 0x3520, 0x7550, 0x3645, 0x3730, 0x8076, 0xf000]; type Fsm = "FETCH" | "DECODE" | "REQUEST" | "WAIT" | "EXECUTE" | "UPDATE" | "DONE"; const STEP: Record<string, Fsm> = { FETCH: "DECODE", DECODE: "REQUEST", REQUEST: "WAIT", WAIT: "EXECUTE", EXECUTE: "UPDATE", UPDATE: "FETCH", }; const LATENCY = 4; const chanOf = (addr: number) => Math.floor(addr / 4) % 2; // 4-word lines, interleaved interface Lane { regs: number[]; addr: number; data: number } interface Core { st: Fsm; pc: number; ir: number; pending: number; lanes: Lane[] } // A transaction: lane = -1 means "one coalesced access for all 4 lanes". interface Req { kind: "load" | "store"; addr: number; core: number; lane: number; data: number[] } interface Chan { queue: Req[]; cur: Req | null; left: number; busy: number } interface Machine { cores: Core[]; chans: Chan[]; mem: number[]; txns: number } function makeCore(blockIdx: number): Core { const lanes: Lane[] = []; for (let t = 0; t < 4; t++) { const regs = new Array(16).fill(0); regs[13] = blockIdx; regs[14] = 4; regs[15] = t; lanes.push({ regs, addr: 0, data: 0 }); } return { st: "FETCH", pc: 0, ir: 0, pending: 0, lanes }; } function coreNext(s: Core, ci: number, out: Req[], coalesce: boolean): Core { if (s.st === "DONE") return s; const n: Core = { ...s, st: STEP[s.st], lanes: s.lanes.map((l) => ({ ...l, regs: [...l.regs] })) }; const w = s.ir, op = w >> 12, rd = (w >> 8) & 15, rs = (w >> 4) & 15, rt = w & 15, imm = w & 255; if (s.st === "FETCH") n.ir = PROG[s.pc]; if (s.st === "REQUEST" && (op === 7 || op === 8)) { // the LSUs speak — maybe with one voice const kind = op === 7 ? "load" as const : "store" as const; const addrs = s.lanes.map((l) => l.regs[rs]); n.lanes.forEach((l, t) => { l.addr = addrs[t]; }); const adjacent = addrs.every((a, t) => a === addrs[0] + t); if (coalesce && adjacent) { // the baby coalescer: 4 lanes, 1 transaction out.push({ kind, addr: addrs[0], core: ci, lane: -1, data: s.lanes.map((l) => l.regs[rt]) }); n.pending = 1; } else { s.lanes.forEach((l, t) => out.push({ kind, addr: addrs[t], core: ci, lane: t, data: [l.regs[rt]] })); n.pending = 4; } } if (s.st === "WAIT" && s.pending > 0) n.st = "WAIT"; // stall until every response is home if (s.st === "EXECUTE") n.lanes.forEach((l) => { const a = l.regs[rs], b = l.regs[rt]; l.data = op === 3 ? a + b : op === 4 ? a - b : op === 5 ? a * b : op === 6 ? Math.floor(a / b) : op === 9 ? imm : l.data; }); if (s.st === "UPDATE") { if ((op >= 3 && op <= 7) || op === 9) n.lanes.forEach((l) => { if (rd < 13) l.regs[rd] = l.data; }); if (op === 15) n.st = "DONE"; n.pc = s.pc + 1; } return n; } // Response routing: the requestor ID (core, lane) says exactly whose registers get the data. function deliver(r: Req, cores: Core[], mem: number[]) { const c = cores[r.core]; if (r.kind === "load") { if (r.lane < 0) c.lanes.forEach((l, t) => { l.data = mem[r.addr + t]; }); else c.lanes[r.lane].data = mem[r.addr]; } else { if (r.lane < 0) r.data.forEach((d, t) => { mem[r.addr + t] = d; }); else mem[r.addr] = r.data[0]; } c.pending--; } function machineNext(m: Machine, rr: number, coalesce: boolean): Machine { const mem = [...m.mem]; const out: Req[] = []; const cores = m.cores.map((c, i) => coreNext(c, i, out, coalesce)); const chans = m.chans.map((ch) => ({ ...ch, queue: [...ch.queue] })); let txns = m.txns; // Channels grind: each busy channel counts down; a finished transaction routes home by ID. chans.forEach((ch) => { if (ch.cur) { ch.left--; ch.busy++; if (ch.left === 0) { deliver(ch.cur, cores, mem); ch.cur = null; } } }); // Arbitration: this cycle's requests join their channel's queue in rotating requestor order. const id = (r: Req) => r.core * 4 + Math.max(r.lane, 0); out.sort((x, y) => ((id(x) - rr + 8) % 8) - ((id(y) - rr + 8) % 8)); out.forEach((r) => { chans[chanOf(r.addr)].queue.push(r); txns++; }); // Idle channels pull their next job. chans.forEach((ch) => { if (!ch.cur && ch.queue.length > 0) { ch.cur = ch.queue.shift()!; ch.left = LATENCY; } }); return { cores, chans, mem, txns }; } function run(coalesce: boolean, trace: boolean): void { const mem = new Array(24).fill(0); for (let i = 0; i < 8; i++) { mem[i] = 10 * (i + 1); mem[8 + i] = i + 1; } let m: Machine = { cores: [makeCore(0), makeCore(1)], chans: [{ queue: [], cur: null, left: 0, busy: 0 }, { queue: [], cur: null, left: 0, busy: 0 }], mem, txns: 0, }; const states: string[] = []; let cycle = 0; while (m.cores.some((c) => c.st !== "DONE")) { states.push(m.cores[0].st); m = machineNext(m, cycle % 8, coalesce); cycle++; } console.log(`coalescer ${coalesce ? "ON " : "OFF"}: ${cycle} cycles, ${m.txns} transactions, ` + `channel busy = [${m.chans.map((ch) => ch.busy).join(", ")}] cycles ` + `(utilisation ${m.chans.map((ch) => Math.round((100 * ch.busy) / cycle) + "%").join(", ")})`); const ok = m.mem.slice(16).every((x, i) => x === 11 * (i + 1)); console.log(` c = [${m.mem.slice(16).join(", ")}] correct: ${ok}`); if (trace) { // Run-length trace of core 0's FSM, one line per instruction: watch WAIT stretch. let line = "", count = 1; const lines: string[] = []; for (let i = 1; i <= states.length; i++) { if (i < states.length && states[i] === states[i - 1]) { count++; continue; } line += (states[i - 1] === "WAIT" && count > 1 ? `WAIT x${count}` : states[i - 1]) + " "; if (states[i - 1] === "UPDATE") { lines.push(line); line = ""; } count = 1; } console.log(" core 0, one line per instruction:"); lines.forEach((l, k) => console.log(` i${String(k).padStart(2)}: ${l}`)); } console.log(""); } run(true, true); run(false, false);

Read the measurements. With the coalescer on: 90 cycles, 6 transactions, and the trace shows the two loads (i6, i8) and the store (i11) each stalling in WAIT ×5 — the 4-cycle latency plus the handoff, exactly as designed. With it off: the same 24 words take 24 transactions, each memory instruction's four requests queue up behind each other on one channel, WAIT stretches to 17 cycles, and the kernel slows from 90 to 126 cycles — a 40% slowdown from touching identical data in identical order, with only the transaction granularity changed. Notice the channels are busier too (48 cycles versus 12) while doing the same job: uncoalesced access doesn't just go slower, it burns the scarce resource harder. Coalescing measured, not asserted.

If channels are the bottleneck, why not twenty of them? Because a memory channel is mostly not logic — it is pins, wires and physics. Each channel needs dozens of package balls, matched-length traces across the circuit board, and a high-speed PHY circuit burning real power to slam signals across centimetres of copper billions of times a second. Compute scales with transistors (nearly free and shrinking); off-chip connections scale with the chip's perimeter and package (nearly fixed). That asymmetry is the deepest "why" in this module: it is the reason GPUs surround themselves with wide-but-few memory channels, the reason HBM stacks DRAM millimetres from the die on a silicon interposer to afford thousands of shorter wires, and the reason the coalescer exists at all — when doors are scarce, the winning move is to carry more through each doorway per trip.

With queues, arbitration and two channels grinding at their own pace, responses can return in a different order than requests were issued — core 0's second load may overtake its first. Now imagine the lazy design: "responses go back to lanes in the order requests went out." It works in every quick test — until queue depths conspire, two transactions swap places, and lane 1's register quietly receives lane 2's data. No crash, no error signal: c simply contains plausible-looking wrong sums, sometimes, on some runs. This is the nastiest bug class in hardware — "mostly works" — and the fix is structural, not procedural: the requestor ID travels with the transaction, so reordering is harmless by construction. You met this exact idea as tagging in the scoreboard's world of out-of-order completion; it will meet you again in every bus protocol, cache miss queue, and network-on-chip you ever design: if completion order is not guaranteed, identity must be carried, never inferred.