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:
- Per-channel request queues. Addresses are interleaved across channels in
4-word lines — channel = \lfloor \text{addr}/4 \rfloor \bmod 2 — and
each channel keeps a queue, because eight requests can arrive in a cycle while a channel
finishes one transaction per four;
- Round-robin arbitration. When several LSUs request the same channel in the
same cycle, a rotating priority pointer decides who queues first — over time, nobody starves;
- Fixed latency service. A channel pops one transaction, grinds for 4 cycles,
answers, pops the next. (Real DRAM latency varies wildly with row hits and refresh; our fixed 4
keeps the arithmetic honest without the chaos);
- Response routing by requestor ID. Every transaction carries a tag —
which core, which lane — and the response follows the tag home. The answer to "whose
data is this?" is written on the envelope, not inferred from timing.
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.
- 8 LSU requestors → 2 channels: per-channel queues, round-robin
arbitration, 4-cycle fixed service per transaction;
- channel of an address: \lfloor \text{addr}/4 \rfloor \bmod 2
(4-word interleaved lines);
- responses route by requestor ID (core, lane) — never by arrival
order;
- the coalescer merges a core's 4 adjacent lane addresses into one 4-word
transaction: same words moved, quarter the transactions;
- cores hold in WAIT until their outstanding-request count reaches
zero.
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.