Memory Coalescing

Thirty-two addresses walk into a memory system. That is not the setup for a joke — it is what happens every time a warp executes one load instruction. Each lane computes its own address, so a single ld arrives at the memory pipeline as a bundle of 32 requests, and what happens next decides more GPU performance than any other single mechanism in this module. You already know from divergence that a warp's lanes can waste the compute engine by disagreeing about control flow; this lesson is the memory-side twin: lanes can waste the memory engine by disagreeing about where their addresses point. The referee is a piece of hardware called the coalescer, and its rules are simple enough to fit on a napkin — yet ignoring them routinely costs software a factor of ten to thirty.

The coalescer's napkin rules

DRAM does not sell individual bytes. As you saw when the memory wall first loomed, memory systems move data in fixed-size chunks — on a GPU, 32-byte sectors, grouped into 128-byte cache lines (four sectors). The coalescer looks at a warp's 32 addresses and asks one question: how few sectors cover all of them? It merges every request landing in the same sector into a single fetch, and issues one memory transaction per distinct sector touched.

Work the golden case once by hand. Lane t reads the 4-byte float at address 4t: lanes 0–7 land in sector 0 (bytes 0–31), lanes 8–15 in sector 1, and so on — 4 sectors, 128 bytes fetched, 128 bytes used. The whole warp's load is one cache-line fill. Now stretch the same warp out with a stride: lane t reads address 4st for stride s elements, and the requests spread across \min(32,\,4s) sectors:

Access pattern (4-byte floats)Sectors fetchedBytes fetchedEfficiency
Unit stride, aligned4128100%
Unit stride, misaligned by 4 B516080%
Stride 2825650%
Stride 832102412.5%
Stride 3232102412.5%
Random gatherup to 32up to 1024as low as 12.5%

Read the misaligned row kindly: starting the warp 4 bytes off a sector boundary drags in one extra sector — an 80% day, mildly annoying. Strides are the real villain: by stride 8 every lane owns its own sector, the memory system hauls 1024 bytes to deliver 128 useful ones, and your kernel's effective bandwidth is one-eighth of the number on the spec sheet. Nothing further degrades after that — the damage saturates at one transaction per lane — which is why stride 8 and stride 32 tie for last place.

The cliff

Plotting efficiency against stride gives one of the most consequential curves in GPU programming — a cliff edge right at the origin. Efficiency is \min\!\left(1, \tfrac{32}{e\,s}\right)\cdot 100\% for element size e bytes and stride s: it halves at stride 2, halves again by stride 4, and flatlines once every lane occupies its own sector. Larger elements hit the floor sooner — and the floor itself is higher, because a fat element fills more of its sector:

Notice there is no gentle regime: the difference between stride 1 and stride 2 is already a factor of two of your memory bandwidth. Coalescing is not a tuning refinement — it is the first-order term.

AoS vs SoA: the data-layout lesson

Almost every real coalescing disaster is the same disaster: a sensible-looking array of structures (AoS). Store 3-D points as x y z x y z … and ask a warp to read everyone's x:

// AoS — array of structures: lane t reads xyz[3 * t], stride 3. interface PointAoS { x: number; y: number; z: number } // memory: x0 y0 z0 x1 y1 z1 x2 y2 z2 ... — the x's are 12 bytes apart // SoA — structure of arrays: lane t reads xs[t], stride 1. interface PointsSoA { xs: number[]; ys: number[]; zs: number[] } // memory: x0 x1 x2 ... | y0 y1 y2 ... | z0 z1 z2 ... — the x's are contiguous

The AoS read is a stride-3 access — 12 sectors instead of 4, a third of your bandwidth gone before the kernel computes anything. The structure of arrays (SoA) layout stores each field contiguously, so "everyone's x" is the golden unit-stride pattern. Same data, same bytes, same algorithm — the layout is the optimisation. This is the single most transferable lesson of the module: on a machine where 32 threads touch memory simultaneously, data layout is not a detail of style, it is the shape of your memory traffic.

The coalescer, executable

The hardware's core loop is countable in a dozen lines: bucket each lane's address into its 32-byte sector, count distinct sectors, compare against bytes actually wanted. Here it is grading the patterns from the table — plus the "CPU-style chunking" pattern you will meet in the warning below:

// The coalescer: collapse a warp's 32 lane addresses into unique 32-byte sectors. const SECTOR = 32; const lanes = [...Array(32).keys()]; function sectors(addrs: number[]): number { const seen = new Set<number>(); for (const a of addrs) seen.add(Math.floor(a / SECTOR)); return seen.size; } function report(name: string, addrs: number[]): void { const n = sectors(addrs); const useful = addrs.length * 4; // 32 four-byte loads wanted const fetched = n * SECTOR; const eff = (100 * useful / fetched).toFixed(1); console.log(`${name.padEnd(26)} ${String(n).padStart(2)} sectors ${String(fetched).padStart(4)} B fetched efficiency ${eff}%`); } report("unit stride", lanes.map((t) => t * 4)); report("misaligned by 4 B", lanes.map((t) => 4 + t * 4)); report("stride 2", lanes.map((t) => t * 8)); report("stride 32", lanes.map((t) => t * 128)); report("CPU-style chunks (N=1024)", lanes.map((t) => t * 1024 * 4)); // seeded pseudo-random gather — same "random" every run let s = 12345; const rnd = () => (s = (s * 1103515245 + 12345) % 2147483648) / 2147483648; report("random gather", lanes.map(() => Math.floor(rnd() * 4096) * 4));

Change the misalignment offset to 32 (a whole sector) and watch the penalty vanish — alignment only matters modulo the sector size. Then try stride 3, the AoS case, and check it against the \min(32, 4s) formula.

Because DRAM is a burst device. Opening a row of a DRAM chip is slow and expensive, but once open, the chip streams a burst of consecutive bytes at full interface speed — modern GDDR delivers data in bursts that map naturally onto 32-byte sectors. Requesting one byte costs essentially the same time and energy as requesting the whole burst, so the memory system rounds every request up. CPUs made the same choice decades ago (64-byte cache lines) for the same reason. The GPU twist is scale: a CPU miss fetches one line for one core's load, while a GPU warp can legitimately need 32 different lines at once — so the hardware was given a coalescer to catch the cases where those 32 requests were secretly one.

A seasoned CPU programmer parallelising a loop over N items gives each thread a contiguous chunk: thread t takes elements [tN/T,\;(t{+}1)N/T). On a CPU that is perfect — each core streams its own cache lines. Port it to a GPU and it is a catastrophe, because the lanes of a warp execute the same load simultaneously: on iteration 0, lane 0 reads element 0, lane 1 reads element N/T, lane 2 reads 2N/T… — a giant stride, one transaction per lane, every iteration. The GPU-right pattern is interleaved: thread t takes elements t,\;t{+}T,\;t{+}2T,\dots — then on every iteration consecutive lanes read consecutive elements, the golden pattern. Each individual thread's accesses look scattered, and that is fine: coalescing is about what a warp does in one instant, not what one thread does over time. This inversion is the number-one performance bug in code ported from CPUs.