← Chapter 28: Why NVIDIA made CUDA asynchronous Chapter 30: Why GPU programs actually slow down →
date 17 / jul / 26
mtwtfss
SCALE & PERFORMANCE — Chapter 29 index home

Coalesced vs strided memory access

*worked out from first principles*

Here is a puzzle that breaks people's intuition. A bundle of 32 threads reads 32 numbers from memory. In one version they read 32 neighboring slots, in another they read 32 scattered slots. The total amount of data requested is identical, the same 128 bytes either way. And yet the scattered version can be ten times slower. Same bytes, same threads, tenfold difference. The answer to that puzzle is one of the most important performance ideas in all of GPU programming, and it comes down to how memory is physically delivered.

↳ Think of it as a delivery truck. The GPU cannot hand you a single spoonful of data; it only ships full crates. Where your data sits inside those crates is the entire story.

THE PUZZLE32 threads read 32 values — the same 128 bytes. One way is ~10x slower.(one square = one thread's 4-byte read)neighbouringreadswarp of 32 → contiguous slots= 128 Bscatteredreadswarp of 32 → scattered slots= 128 BIdentical bytes, identical threads — yet ~10x apart. The 128-byte box is why.

The 128-Byte Box

Start with the hardware fact everything hinges on. The GPU does not fetch memory one byte at a time. It moves memory in fixed-size boxes, a chunk of 128 bytes at a time. This is not a software choice, it's baked into how the memory hardware physically works.

Whenever any thread asks for even a single value, the memory controller fetches the entire 128-byte box that contains it. The truck only comes in one size, no matter how little you actually wanted from inside it.

MEMORY COMES IN BOXESThe GPU never fetches one byte — it moves memory in fixed 128-byte crates.1 threadwants 4 bytesasks forone value128-byte box — ONE transaction1 value wanted → 128 B delivered → 124 B along for the rideTouch one value → the hardware still delivers the whole 128-byte box.

The Good Case: Coalesced Access

Now watch what happens in the good case. A bundle of 32 threads, each reading a 4-byte number. Thirty-two times four is exactly 128 bytes.

If the threads read neighboring slots—thread 0 at the start, thread 1 right after, and so on—then all 32 of their values sit inside a single 128-byte box. The memory controller sees this, sends one truck, grabs the one crate, and hands each thread its own 4-byte slice out of it. One transaction feeds all 32 threads. Every byte fetched is used. This is called a coalesced access, and it's 100 percent efficient.

COALESCED: THE GOOD CASE32 threads, 4 bytes each = exactly 128 bytes.warp: 32 threads • one 4-byte read eachONE 128-byte box • ONE transaction • every byte used100%Neighbours → 1 box, 1 transaction, nothing wasted. Coalesced = full speed.

The Disaster: Scattered Access

Now the disaster. Suppose the threads read scattered slots, each one far from the next, with a big gap between them. Now thread 0's value is in one box, thread 1's value is in a completely different box, thread 2's in yet another.

So the memory controller has no choice but to send a separate truck for each one. Thirty-two threads, up to thirty-two separate crate deliveries. And here is the brutal part: each truck still hauls a full 128-byte crate, the thread takes its 4 bytes, and the other 124 bytes are thrown away. So you fetched thirty-two full crates, over four thousand bytes of traffic, to deliver the 128 bytes you actually wanted. Your useful bandwidth collapses to a few percent.

SCATTERED: THE DISASTERSame 32 threads — but each value lands in a different box.t0t1t2...t31each box: 4 B used (green) • 124 B hauled & thrown away (hatched)up to 32 boxes → ~4096 B of traffic to deliver 128 useful bytesuseful bandwidth collapses to a few percentScattered → dozens of transactions, each wasting 124/128 of its haul.

The True Bottleneck: Transactions

And this reveals the deeper truth that surprises everyone. The memory system is not choked by the total bytes you need, it's choked by the number of transactions you force.

The memory controller has a limit on how many separate fetches it can issue, not just how many bytes it can move. So firing 32 tiny transactions instead of one clogs its queue completely. The whole bundle of threads freezes for hundreds of cycles waiting for all 32 trucks to return, and worse, other bundles that also need memory get stuck behind them in the queue. One badly-arranged read can stall the whole unit. One transaction gets its data and moves on cleanly. It was never about the weight of the data, it was about how many times you made the conveyor belt run.

IT'S TRANSACTIONS, NOT BYTESThe controller caps how many fetches it can issue — not just how many bytes.COALESCED1 fetch enters the queuewarpqueue:DRAMflows straight through.SCATTERED32 fetches flood the queuewarp Aqueue:FULLwarp Bstuck behindjams — everyone stalls.The bottleneck is the NUMBER of fetches — one bad warp stalls the others too.

The Array of Structures Trap

So the practical question, how do you keep your reads coalesced? And this is where a subtle trap catches programmers coming from ordinary CPU code. Imagine you have a bunch of particles, each with a position and a mass, and you store them the natural way: one complete particle after another in memory. All of particle 0's fields together, then all of particle 1's fields, and so on. This feels clean and obvious.

But now when 32 threads each want just the x-position of their particle, those x-positions are not neighbors in memory. They are spaced far apart with each particle's other fields sitting between them. Every thread lands in a different box. Coalescing shattered.

The fix is to flip the layout. Instead of storing complete particles back to back, you store all the x-positions together in one array, all the masses together in another, and so on. Now when the 32 threads each grab their x-position, those values are neighbors, packed tight, and the whole bundle collapses into one transaction.

The reason CPU programmers get this backwards is that a CPU genuinely prefers the first layout, because a single CPU thread working on one particle wants all its fields sitting together in cache. But a GPU is the opposite, because 32 threads want the same one field at the same instant, so that field needs to be contiguous across particles, not bundled per particle.

DATA LAYOUT: AoS vs SoAWhere you store the fields decides whether the warp coalesces.Array of Structs (AoS)whole particle stored togetherxyzmxyzmxyzmxyzm32 threads want x → 4 apart → scatteredStruct of Arrays (SoA)each field stored contiguouslyxxxxxxxxyyyymmmmall x's packed → ONE transaction32 threads want x → neighbours → coalescedCPU likes AoS (1 thread wants all fields in cache).GPU likes SoA (32 threads want the SAME field at once).Pack the one field all 32 threads want — SoA feeds the warp in a single gulp.

Honest Refinements

A couple of honest refinements. The hardware is not brutally unforgiving about perfect alignment. If your 32 values are contiguous but sit slightly off the box boundary, straddling two boxes, you pay for two transactions instead of one—a mild 2x penalty, not the catastrophic 32x of true scattering. So what really matters is locality, keeping the accesses clustered together, not obsessing over perfect alignment.

ALIGNMENT: A MILD PENALTYContiguous but off the boundary costs 2x — not the 32x of true scattering.box A (128 B)box B (128 B)fetch 1fetch 2one contiguous 128 B block → spans 2 boxes → 2 fetchesscattered = up to 32xmisaligned + contiguous = just 2xLocality matters more than perfect alignment — keep reads clustered.

And there are cases where scattering seems unavoidable, like transposing a matrix, where turning rows into columns forces one side of the access to jump across memory. The trick there is to not fight memory directly. You read from slow memory in the nice coalesced direction, stash the tile into the fast on-chip scratchpad (shared memory) where scattered access carries almost no penalty, rearrange it there, and write back out in the coalesced direction. You route the unavoidable scatter through the fast memory that doesn't care about it.

ROUTE SCATTER THROUGH THE SCRATCHPADTranspose forces a jump. Send the unavoidable scatter through fast on-chip memory.GLOBAL MEM (slow)read tilecoalescedON-CHIP SCRATCHPAD (fast)rearrange herescatter is ~freeGLOBAL MEM (slow)write tilecoalescedRead coalesced → shuffle in scratchpad → write coalesced.Route the scatter through the memory that doesn't care.

↳ The General Takeaway

So the whole picture. The GPU delivers memory only in fixed 128-byte boxes, so the arrangement of your threads' addresses decides everything.

  • When a bundle of 32 reads neighboring slots, it all fits in one box, one transaction, full efficiency.
  • When they read scattered slots, it explodes into many transactions, each hauling a full box to use a sliver of it, clogging the memory queue and stalling everyone.

Keeping accesses contiguous, choosing a data layout where the value all threads want is packed together, and routing unavoidable scatter through the on-chip scratchpad are how you stay fast.

The lesson rhymes with everything else about this hardware...
Raw compute is rarely the bottleneck.
It's whether the data arrives in one clean sweep of the conveyor belt, or in a thousand wasteful trips.