Almost every kernel you will write is limited by memory, so this is the chapter that decides how fast your code runs. The GPU’s memory is a ladder: registers, shared memory and L1 inside each SM, one L2 for the chip, and DRAM, each rung roughly ten times bigger and slower than the one above. Three rules get you most of the way. Coalescing: memory moves in 32-byte sectors, so the 32 threads of a warp should read neighbouring addresses; the same bytes read in the wrong pattern can cost 8× the traffic. Bytes in flight: bandwidth needs many outstanding requests, which you get from occupancy, from wide 16-byte loads, or from several independent loads per thread. Reuse on chip: data used more than once should be staged in shared memory, which you control explicitly, and read without bank conflicts. The matrix transpose ties all three together, and lab 2 measures every one of them on your card.
Fast memory is small, and big memory is slow. The reason is physical: a bit you can read in one cycle has to sit close to the arithmetic units, and there is only so much room close to them. DRAM stores a bit as a charge in a tiny capacitor that must drive a long wire before anyone can sense it, which takes tens of nanoseconds per access no matter how fast the rest of the chip is. So every processor builds a ladder of memories, and the GPU’s has five rungs:
| Rung | Latency, order of magnitude | Your card (lab 2, experiment A) |
|---|---|---|
| Shared memory | ~20–40 cycles | to be measured |
| L1 hit | ~30–50 cycles | to be measured |
| L2 hit | a few hundred cycles | to be measured |
| DRAM | several hundred ns (500–1,500 cycles) | to be measured |
Two numbers on this ladder matter more than the rest. The first is the ratio between compute and DRAM that chapter 1 turned into the ridge point: your SMs can do about 46 FP32 operations in the time DRAM delivers one byte. The second is how much faster the on-chip memories are. Shared memory has 32 banks per SM, each delivering 4 bytes per cycle: 128 bytes per cycle per SM, which across 48 SMs at 2.5 GHz is roughly 15 TB/s, over 20× DRAM. Registers are faster still. Every optimization in this chapter is a way to serve more of your loads from the upper rungs.
Your vecadd benchmark has already met the L2. Look at the rows marked (fits in L2): if they report more than 672 GB/s, it is because repeated runs found the arrays still on chip. That is not DRAM bandwidth; it is L2 bandwidth. Every benchmark in this course that claims to measure DRAM uses working sets several times larger than the L2 for this reason.
CUDA C++ names memory by who can see it and how long it lives, which is not the same thing as where it physically sits. The mapping is worth knowing exactly, because one row of it is a trap:
| You write | Space | Visible to | Lives | Physically |
|---|---|---|---|---|
float x; inside a kernel | register | one thread | the thread | register file |
float a[8]; inside a kernel | register or local | one thread | the thread | registers if every index is a compile-time constant, otherwise local memory in DRAM |
__shared__ float t[32][33]; | shared | one block | the block | the SM’s L1/shared pool |
cudaMalloc, __device__ float g[N]; | global | all threads, all kernels, the host via copies | until freed / the program | DRAM, cached in L2 (and L1) |
__constant__ float c[256]; | constant | all threads, read-only | the program | DRAM, 64 KB max, with its own cache |
The name is the trap. Local memory is private to a thread, but it lives in DRAM, cached in L1 and L2 like any global access. Two things put data there. A per-thread array indexed with a value the compiler cannot resolve at compile time (a[i] where i comes from a loop it cannot unroll) goes to local memory, because registers cannot be indexed dynamically. And when a kernel needs more registers than it may use, the compiler spills the excess to local memory. That is exactly what lab 1’s experiment D showed: __launch_bounds__(256, 6) capped the tile kernel at 40 registers, and ptxas reported 428 bytes spill stores, 504 bytes spill loads: hundreds of bytes of extra memory traffic per thread, created by the compiler. Whenever -Xptxas -v prints a nonzero stack frame or spill, you are using local memory.
__constant__ data has its own small cache designed for one case: every thread of a warp reading the same address, which is served in one go. When lanes read different addresses, the accesses are serialized, so it suits kernel parameters and small lookup tables indexed uniformly, not arrays indexed by thread. A more generally useful cousin is the read-only data path. Mark a kernel’s input const float* __restrict__, promising the compiler that nothing else writes to it during the kernel, and it may load it with a non-coherent read-only instruction. You can see it in lab 2’s SASS: the copy kernels load with LDG.E.CONSTANT, the pointer-chasing kernel without __restrict__ with plain LDG.E. Get in the habit of writing const T* __restrict__ for inputs.
On your card each SM has a single 128 KB array that serves as both L1 cache and shared memory. The split, the carveout, is chosen per kernel from a fixed menu (0, 8, 16, 32, 64 or 100 KB of shared memory on compute capability 12.x), and whatever is not shared memory is L1. A kernel that declares a lot of shared memory therefore shrinks L1 for itself, and a kernel with none gets the whole 128 KB as cache. Two limits to remember: a single block can use at most 99 KB, and anything above 48 KB must be allocated dynamically and explicitly allowed with cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, bytes), a compatibility rule that trips everyone once.
Chapter 2 established that a warp executes one instruction for 32 threads at once. For a load, that means one instruction produces 32 addresses at the same moment, and the hardware serves them together. The rule that decides the cost is short, and it is the most important rule in this chapter:
Global memory is moved in 32-byte sectors. A warp’s load or store costs as many sectors as its 32 addresses touch. Bytes inside a sector that nobody asked for are moved anyway and thrown away.
So the question for every memory access in a kernel is: how many sectors do these 32 addresses span? With 4-byte floats, the answers bracket a factor of 8:
Chapter 2’s uniform/varying lens predicts the pattern without drawing anything. In an index expression, find where the varying part (threadIdx.x, or anything derived from it) sits. If it is added at unit stride to a uniform base, consecutive lanes hit consecutive addresses: coalesced. If it is multiplied by a stride, the lanes spread out, and the sector count grows with the stride until every lane has its own sector. If the index is entirely uniform, all 32 lanes read the same address, which is a broadcast: one sector serves everyone, and it is cheap. The campaign’s level 1 walks this lens through a naive matrix multiply, where a two-letter swap of indices is worth 6×.
a[i * stride] for stride 1, 2, 4, 8, 16 and 32. The sector rule predicts the useful bandwidth. Sketch the curve.Useful bandwidth ≈ DRAM bandwidth × efficiency, and efficiency is 4 bytes used per 4·stride bytes moved: 100%, 50%, 25%, 12.5%… and then it flattens. From stride 8 on, each lane already owns a whole sector, so strides 16 and 32 move exactly the same 32 sectors per warp. The curve is a halving staircase that stops at one eighth of the stride-1 value. If your measurement keeps falling past stride 8, you have learned something the documentation doesn’t say: the DRAM itself transfers more than 32 bytes at a time (GDDR memories use bursts), and the L2 fetches pairs of sectors. Either way, measured is what counts.
A row-major matrix stores M[r][c] at r × width + c. Walking along a row is unit stride and walking down a column is a stride of width, typically thousands of floats: every lane in its own sector, 12.5% efficiency. Hence the rule you already met in chapter 2’s 2D grids: threadIdx.x must drive the last, fastest-varying index of every large array you touch. When one array must be read by rows and another by columns (a transpose, a matrix multiply with a transposed operand), no single mapping of threads makes both coalesced. That is what shared memory is for (section 8).
Suppose each particle is struct { float x, y, z, w; } and you store an array of structures (AoS). A kernel that only needs x reads every 16 bytes: stride 4 in floats, 16 sectors per warp for 128 useful bytes, 25% efficiency. Store a structure of arrays (SoA) instead, one array per field, and the same kernel reads x[i] contiguously: 100%. The general rule: lay data out by the order in which neighbouring threads read it, not by the order a human would group it. (If a kernel really does use all four fields, AoS with a single 16-byte load per thread is also perfectly efficient; that is section 5.)
A warp reading 128 contiguous bytes that start 12 bytes into a sector spans 5 sectors instead of 4: 80% efficiency for that request. Older GPUs punished misalignment much harder; modern ones only charge the extra sector, and neighbouring warps often find it in L2 anyway. Lab 2’s experiment B2 shifts a copy by 1 to 32 floats; expect a few percent, not a cliff.
Some reads are inherently scattered. The first layer of every language model, the embedding lookup, reads row token_id of a big table for each token: random rows. It is still fast, because the row is contiguous: if a warp (or a whole block) cooperates to read one row, every access is coalesced, and the randomness only decides which rows. The lesson generalizes: when you cannot make the pattern regular, make the unit of randomness large. Sparse matrices, KV-cache pages (Part IV) and mixture-of-experts routing are all designed around this.
Coalescing decides how many bytes each request moves. It does not decide how many requests are outstanding, and chapter 1 showed that this matters just as much. Little’s law asked for about 336 KB in flight to saturate 672 GB/s at ~500 ns of latency, about 7 KB per SM, and noticed that 1,536 threads per SM, each waiting on one 4-byte load, only put 6 KB in flight. One float per thread at a time is not enough, even at full occupancy. Chapter 2 named the two ways out. Here they are in code.
A thread can load 8 or 16 bytes with one instruction: float2 or float4. The warp then moves 512 bytes per request instead of 128, still perfectly coalesced (16 sectors, all used), and each thread has four times the bytes in flight.
// n4 = n / 4: each thread copies 16 bytes with a single load and a single store
__global__ void copy_f4(const float4* __restrict__ in, float4* __restrict__ out, size_t n4)
{
size_t i = (size_t)blockIdx.x * blockDim.x + threadIdx.x;
if (i < n4) out[i] = in[i]; // SASS: LDG.E.128 / STG.E.128
}
The price is alignment: a float4 access must be 16-byte aligned, so the pointer must be (cudaMalloc returns 256-byte-aligned memory) and the array length must be a multiple of 4, or you handle the tail separately. Vectorized loads are everywhere in production kernels for this reason.
The other way keeps 4-byte loads but issues several before using any of them. Each is independent, so the hardware can have all of them in flight at once:
float x0 = in[i], x1 = in[i + s], x2 = in[i + 2*s], x3 = in[i + 3*s]; // 4 loads in flight
out[i] = x0; out[i + s] = x1; out[i + 2*s] = x2; out[i + 3*s] = x3;
Each of the four loads is still coalesced (consecutive threads, consecutive i), and the thread now waits once for four loads instead of four times for one. This is instruction-level parallelism (ILP), and it is what compilers produce when they unroll a loop. You rarely write it by hand like this, but you will write code whose structure allows it: loops with independent iterations and enough registers to hold the results.
At 17% each SM holds 256 threads. With one float each, that is 1 KB in flight per SM, a seventh of what Little’s law asked for: float should collapse to a small fraction of peak. float4 and 4× ILP put 4 KB in flight, so they should recover most of the loss, probably not all. At 100% all three have more work in flight: float sits somewhere near the practical ceiling you measured in lab 0, and the two others at or slightly above it. The shape to look for is the gap closing as occupancy rises: TLP and ILP are interchangeable sources of bytes in flight. This is also why the fastest kernels in Part II can run at low occupancy: they keep many loads in flight per thread.
Here is the same arithmetic as a machine. Set the two knobs to the cases of experiment C and watch how full the loop gets.
L1 and L2 cache data automatically, and they help. But thousands of threads share each SM’s L1, so the data one warp is about to reuse is often evicted by others before it gets there: a GPU cache is a poor place to count on reuse. Shared memory is the alternative: a scratchpad in the same 128 KB pool that a block allocates for itself and fills explicitly. Nothing is evicted unless you overwrite it. Its latency is close to L1’s, its bandwidth is over 20× DRAM, and every thread of the block can read what any other thread wrote there, after a barrier.
A matrix multiply is where the argument is cleanest (Part II builds the real kernel; the campaign’s level 2 plays it out). In the naive kernel, each thread computing one output element runs a loop that loads one element of A and one of B (8 bytes) for one multiply-add (2 FLOPs). That is 0.25 FLOPs per byte from DRAM, a 184th of your card’s ridge point: at 672 GB/s it is capped at about 170 GFLOPS, half a percent of the 30.9 TFLOPS peak. Yet every element of A is used by a whole row of outputs, and every element of B by a whole column. The data is reused thousands of times; the naive kernel just fetches it again every time.
Tiling fixes that. A block computing a 32×32 tile of outputs loads a 32×32 tile of A and one of B into shared memory (each thread loads one element of each), then every thread reads 32 values from each shared tile. Each byte fetched from DRAM is now used 32 times, so the intensity rises 32-fold, to 8 FLOPs per byte. That is still below the ridge of 46; Part II gets the rest by tiling a second time, in registers.
Watch it happen on a block small enough to follow: 16 threads, a 4×4 tile of outputs, dot products of length 8. Run the naive kernel first, then the tiled one, and compare the shades in DRAM.
Here is the skeleton of every tiled kernel. Click through it: both __syncthreads() are necessary, for two different reasons.
__syncthreads(), reasoning that the next iteration starts with loads anyway. What goes wrong, and would the tests catch it?A fast warp finishes its multiply-adds, loops around, and overwrites the tile with the next phase’s data while slower warps of the same block are still reading the current phase. Those warps then compute with a mix of two tiles. The first barrier guards a read-after-write dependence (don’t read before the data is there); the second guards a write-after-read dependence (don’t overwrite before everyone has finished reading). The nasty part is that the bug is timing-dependent: on a small test it may pass every time, then fail on another GPU or another size. That is why the only defence is understanding the dependences, and why tests in this course use awkward sizes. (compute-sanitizer --tool racecheck can catch shared-memory races like this one; chapter 5 uses it.)
A thread whose output falls outside the matrix must still help load the tile, and must still reach every __syncthreads(): a barrier that some threads of the block never reach is undefined behaviour and in practice a hang or garbage. So bounds checks in tiled kernels guard individual loads and stores (loading 0 for out-of-range elements, a value that adds nothing to a dot product), never the whole thread.
Shared memory counts against occupancy (chapter 2): 100 KB per SM, plus 1 KB reserved per block. Spread over the 1,536 threads of a full SM, that is only about 66 bytes per thread on average. The 32×32 tiles above use 8 KB per block, which is harmless; a kernel that wants 64 KB per block runs one block per SM. The trade is the same as with registers: on-chip storage buys reuse and costs parallelism, and the right amount is found by measurement. From Ampere on, GPUs can also copy global memory into shared memory asynchronously, without passing through registers, so that the next tile loads while the current one is used; on your card both the Ampere-style cp.async and the newer bulk-copy engine compile for sm_120 (we checked the SASS). Part II uses them.
Shared memory is fast because it is divided into 32 banks that work in parallel, each able to deliver one 4-byte word per cycle. Consecutive 4-byte words go to consecutive banks, so the bank of a word is its index modulo 32: s[0] in bank 0, s[31] in bank 31, s[32] in bank 0 again. A warp’s access is served in one pass if its 32 lanes hit 32 different banks. If several lanes want different words in the same bank, those words are fetched one after the other: an N-way bank conflict costs N passes. Lanes reading the same word do not conflict: that word is broadcast to all of them.
So the analysis is the coalescing analysis again, with a different modulus. Take the word index as a function of the lane:
s[lane]: banks 0…31, one pass.s[2*lane]: even banks only, two lanes per bank, 2-way.s[3*lane], or any odd stride: still 32 distinct banks, since an odd number has no factor in common with 32. One pass.s[32*lane]: every lane in bank 0, 32-way: the access runs at 1/32 speed.The last case is not exotic. It is what happens when a warp reads a column of a 32×32 tile, tile[lane][c]: consecutive rows are 32 words apart, so the whole column lives in one bank. And reading columns of a tile is the entire point of the transpose, and of half the loads in a matrix multiply.
Two notes for later. First, padding is not the only fix. Production kernels (CUTLASS, FlashAttention) usually swizzle instead: they permute the column index with an XOR, tile[r][c ^ r], which spreads columns over all banks without wasting memory and keeps rows 16-byte aligned for vector loads. It is the last option in the widget; Part II uses it. Second, accesses wider than 4 bytes are processed in several passes anyway (a warp’s float4 read moves 512 bytes, four times what the 32 banks deliver per cycle), and conflicts are counted within each pass, so the analysis changes slightly for vector types. Nsight Compute counts bank conflicts per instruction, and chapter 5 shows you where.
Transposing a matrix, out[c][r] = in[r][c], does no arithmetic at all. It is pure data movement, which makes it the perfect exam for this chapter: the right yardstick is a plain copy of the same bytes, and anything slower than the copy is wasted memory bandwidth. Lab 2’s experiment D runs three versions on an 8192×8192 matrix (256 MB in, 256 MB out), with a copy as the ceiling. All use blocks of 32×8 threads, each block handling a 32×32 tile (each thread moves four elements), so that a warp is always one row of 32 threads.
out[(size_t)c * R + r] = in[(size_t)r * C + c]; // c varies along the warp
The read in[r*C + c] is coalesced: c is the varying index, in the last position. The write out[c*R + r] has the varying c multiplied by R = 8,192: every lane writes to its own sector, 8,192 floats from its neighbour. One coalesced side, one side at 12.5% efficiency. No mapping of threads can fix this, because whichever index you make vary along the warp, one of the two matrices is walked by columns.
The fix is to do the transposition inside the SM, where column access is cheap. Read a 32×32 tile with coalesced row reads into shared memory, synchronize, then write the transposed tile with coalesced row writes of the output, reading the tile by columns:
blockIdx.x and blockIdx.y when it writes.template <int PAD>
__global__ void transpose_shared(const float* __restrict__ in, float* __restrict__ out, int R, int C)
{
__shared__ float tile[32][32 + PAD];
int c = blockIdx.x * 32 + threadIdx.x; // 1. load a tile, row by row
int r0 = blockIdx.y * 32 + threadIdx.y;
for (int k = 0; k < 32; k += 8)
if (r0 + k < R && c < C)
tile[threadIdx.y + k][threadIdx.x] = in[(size_t)(r0 + k) * C + c];
__syncthreads(); // the whole tile is loaded
int oc = blockIdx.y * 32 + threadIdx.x; // 2. write the mirrored tile
int or0 = blockIdx.x * 32 + threadIdx.y;
for (int k = 0; k < 32; k += 8)
if (or0 + k < C && oc < R)
out[(size_t)(or0 + k) * R + oc] = tile[threadIdx.x][threadIdx.y + k]; // column read
}
Only one barrier this time: each block handles a single tile, so nobody overwrites it (no write-after-read hazard). Both global accesses now have threadIdx.x in the last index. But look at the shared-memory read in the last line: tile[threadIdx.x][…]. The varying index is the row, so with PAD = 0 a warp reads a column of a 32-word-wide tile: section 7’s 32-way bank conflict, on every read out of the tile.
Set PAD = 1. The build output shows the cost (4224 bytes smem instead of 4096), and the column read now spreads over all 32 banks. Nothing else changes.
The copy runs near your practical DRAM ceiling. Padded should come close to it, typically 85–95% of the copy: both global sides are coalesced, and the only extra costs are the barrier and the shared-memory round trip. Naive is limited by its uncoalesced writes; the pure sector rule would say 12.5% efficiency on the write side, but in practice L2 absorbs part of the damage by merging partial writes to the same sectors from neighbouring warps, so expect something like a third to a half of the copy, not an eighth. Shared, unpadded is the instructive one: the DRAM traffic is already optimal, yet 32-way conflicts make every column read 32 passes long, which can make it little better than naive, or even worse. One extra word per row then gives the largest speedup in the lab. When your numbers come back, the gap between the last two rows is the price of bank conflicts on your card.
“Read coalesced into shared memory, do the awkward access pattern on chip, write coalesced” is one of the core moves of GPU programming. The book calls its matrix-multiply form corner turning. It reappears in matrix multiplies with a transposed operand, in layout conversions between tensor formats, in attention kernels, and in every place where the natural order of computation differs from the order in memory.
Three programs, four experiments, every one of them a prediction from this chapter. In WSL2, keep the repo inside the Linux filesystem (~/…), not under /mnt/c/: builds on the Windows drive are many times slower.
cd ~/unibo-lessons/mlsys/labs/lab02-memory
make # ptxas: the two transpose_shared kernels, 4096 vs 4224 bytes smem
make test # every kernel against a CPU reference, awkward sizes included
make bench # A latency · B1 strides · B2 offsets · C bytes in flight · D transpose
| Exp. | What it does | Prediction to write down |
|---|---|---|
| A | One thread chases pointers through 4 KB … 512 MB, plus shared memory (section 1) | where the steps of the staircase are, and their heights |
| B1 | Reads a[i * stride], stride 1 … 32 (section 3) | the useful-bandwidth curve |
| B2 | A copy shifted by 0 … 32 floats (section 4) | the cost of misalignment |
| C | float / float4 / 4× ILP copies at 17% and 100% occupancy (section 5) | which ones starve at low occupancy |
| D | 8192×8192 transpose: copy, naive, shared, padded (section 8) | each as a % of the copy |
Experiment B1 allocates a 4 GB array, so close games and video before benchmarking: under WSL2 the Windows desktop shares your GPU and its 12 GB. Paste all the PASTE: lines back (three from make test, five from make bench). Your latency staircase will replace the orders of magnitude in section 1’s table.
One thread follows a chain of pointers, j = next[j], laid out in a random cycle with one node per 128-byte line. Every load needs the previous one’s result, so nothing overlaps and no other warp hides anything: the time per step is the latency. It is the opposite of every other benchmark in the course, which is the point. Past a few hundred MB you may see the latency creep up again: that is the TLB (address translation) missing, a rung of the hierarchy nobody draws.
Add a fourth transpose that removes the conflicts with the XOR swizzle instead of padding: declare tile[32][32], store to tile[ty + k][tx ^ (ty + k)] and read from tile[tx][(ty + k) ^ tx]. Convince yourself that it is still a correct transpose, check it with make test (add it to the test list), and compare its speed with the padded version.
a[4*i] (floats).Global memory moves in 32-byte sectors; a warp’s request costs as many sectors as its 32 addresses touch. With stride 4 the lanes are 16 bytes apart, so the warp spans 512 bytes = 16 sectors for 128 useful bytes: 25% efficiency.
float a[16] declared inside a kernel sometimes fast and sometimes slow?If every index into it is a compile-time constant (for example inside fully unrolled loops), the compiler keeps it in registers. If any index is only known at run time, it must live in local memory, which is private per thread but physically in DRAM, cached in L1/L2. -Xptxas -v reports a nonzero stack frame when this happens.
x field of an array of struct { float x, y, z, w; }. What efficiency does it get, and what are the two fixes?Stride 16 bytes: 25%. Fix 1: structure of arrays, so x is contiguous (100%). Fix 2: if the kernel actually needs all four fields, load the whole struct with one 16-byte (float4) load per thread, which is also 100% efficient.
Load more bytes per instruction (float4, 16 bytes, still coalesced), or issue several independent loads per thread before using them (instruction-level parallelism, e.g. from an unrolled loop). Both raise the bytes in flight per thread, and they work even at low occupancy.
Thousands of threads share one SM’s L1, so data a warp intends to reuse is often evicted by other warps before the reuse happens. Shared memory is only overwritten when your code overwrites it, so the reuse is guaranteed, and the whole block can share what any of its threads loaded.
__syncthreads() protect against?The first (after loading the tile) protects a read-after-write dependence: no thread may read the tile before all threads have written their part. The second (after using the tile) protects a write-after-read dependence: no thread may overwrite the tile with the next phase before all threads have finished reading the current one.
s[lane], s[2*lane], s[5*lane], s[lane/4], s[32*lane]?s[lane]: none. s[2*lane]: 2-way. s[5*lane]: none (odd stride). s[lane/4]: none, groups of 4 lanes read the same word, which is broadcast. s[32*lane]: 32-way, every lane in bank 0.
float tile[32][33] make column reads conflict-free?The bank of tile[r][c] is (33·r + c) mod 32 = (r + c) mod 32. Down a column (fixed c, r = 0…31) that takes 32 different values, so the 32 lanes hit 32 different banks. With 32 columns it would be c mod 32 for every row: one bank.
The load writes tile[ty + k][tx]: along a warp only tx varies, and it is the column index, so the 32 lanes write 32 consecutive words in 32 different banks. The read tile[tx][ty + k] has the varying index in the row position, 32 words apart, which is the conflicting pattern.
Allocate it dynamically (extern __shared__ plus the third launch parameter) and raise the kernel’s limit with cudaFuncSetAttribute(kernel, cudaFuncAttributeMaxDynamicSharedMemorySize, bytes). The hard limit is 99 KB per block (100 KB per SM, 1 KB reserved per block).