A GPU is the memory-hierarchy lesson of the matrix-multiplication post with part of the cache taken out of the hardware's hands and placed in yours. The CPU hides latency with large caches and speculation; the GPU has caches too, but relies mainly on keeping thousands of threads resident and switching between them while loads are in flight. The consequences are that the layout of memory accesses across a group of threads matters as much as the layout within one thread, that there is a small, fast memory you must fill explicitly, and that how many threads can be resident at once (occupancy) is a resource budget you can compute by hand.
WARNING
This laptop has no NVIDIA GPU and no CUDA toolkit, so the kernels in this post were written and reasoned about but not compiled or run here. There are no timings. Every quantity I use is either an architectural constant from the CUDA C Programming Guide (warp size, bank count, sector size) or a worked example whose assumptions are stated next to it. Treat the code as a careful draft and run it through
nvccandcompute-sanitizerbefore trusting it.
The execution model
A kernel launch names a grid of blocks, each block a 1-, 2- or 3-D array of threads. The programmer sees threads and blocks; the hardware sees warps of 32 threads that execute one instruction at a time in lockstep (SIMT), and streaming multiprocessors (SMs) onto which whole blocks are placed.
Three rules fall out of this picture and drive everything else in the post:
- Threads in a warp share an instruction stream. A branch that splits a warp is executed both ways with masks, so divergence inside a warp costs real time; divergence between warps costs nothing.
- Threads can synchronise and share fast memory only within a block, because only a block is guaranteed to be co-resident on one SM.
- An SM holds several blocks at once and interleaves their warps. A warp that is waiting on a load is simply not scheduled; a different warp runs. Latency is hidden mainly by having enough warps, not by caches.
Five memories, and who manages them
| Memory | Scope | Lifetime | Allocated by | Role |
|---|---|---|---|---|
| Registers | one thread | the thread | compiler | operands; spills go to "local" memory in DRAM |
| Shared memory | one block | the block | __shared__ or dynamic launch parameter | a software-managed cache, 32 banks of 4 bytes |
| L1 cache | one SM | hardware | hardware | caches global loads (whether by default depends on the architecture); 32-byte unit |
| L2 cache | whole device | hardware | hardware | caches global memory; 128-byte lines, 32-byte sectors |
| Global memory | whole device and the host | cudaMalloc/cudaFree | the program | device DRAM; every kernel's inputs and outputs |
The important asymmetry is between the two fast memories of an SM. L1, like the CPU's caches, is filled by hardware on a miss and evicts by its own policy; shared memory is filled by instructions you write. That is a loss of convenience and a gain of control: a tile you stage in shared memory stays there until you overwrite it, with no replacement policy to second-guess.
Coalescing: the warp is the unit of memory access
When a warp executes a load, the hardware does not issue 32 independent requests. It looks at the 32 addresses, groups them into 32-byte sectors (four sectors make a 128-byte L2 line) and fetches each distinct sector once. If thread base + 4t and base is 32-byte aligned, the 32 addresses fall into exactly four sectors and every fetched byte is used; shift base by one element and the same 128 bytes straddle five sectors, 160 bytes moved for 128 used. If thread base + 32t, every address lands in its own sector and the warp pulls 1024 bytes to use 128 of them.
A useful way to score an access pattern is its efficiency,
which is threadIdx.x must index the column (the fastest-varying dimension), exactly the stride-1 rule that made the i-k-j loop order win on the CPU. A warp walking down a column of a row-major matrix is the GPU form of the stride-B in the textbook i-j-k loop.
Shared memory and bank conflicts
Shared memory is split into 32 banks, each 4 bytes wide, and word tile[ty][tx] along tx touches 32 consecutive words, one per bank, and finishes in one pass. A warp reading tile[tx][ty] along tx with a 32-wide tile touches words
The classic fix is to declare the tile as tile[32][33]. The row stride becomes 33 words,
A tiled matrix multiplication
The naive kernel assigns one thread per element of A[row*N + k] read is a broadcast (same address) and the B[k*N + col] read is coalesced, so the access pattern is fine, but every element of
-
__global__ void matmul_naive(const float *A, const float *B, float *C, int N) { int row = blockIdx.y * blockDim.y + threadIdx.y; int col = blockIdx.x * blockDim.x + threadIdx.x; if (row >= N || col >= N) return; float acc = 0.f; for (int k = 0; k < N; k++) acc += A[row * N + k] * B[k * N + col]; C[row * N + col] = acc; }Two 4-byte loads per fused multiply-add:
flops per bytes requested from the memory system, an arithmetic intensity of flop/byte before any cache help. -
#define TILE 32 __global__ void matmul_tiled(const float *A, const float *B, float *C, int N) { __shared__ float As[TILE][TILE]; __shared__ float Bs[TILE][TILE + 1]; // +1 pad keeps column reads conflict-free int tx = threadIdx.x, ty = threadIdx.y; int row = blockIdx.y * TILE + ty; int col = blockIdx.x * TILE + tx; float acc = 0.f; for (int k0 = 0; k0 < N; k0 += TILE) { // cooperative, coalesced loads: tx is the fastest-varying index for both As[ty][tx] = (row < N && k0 + tx < N) ? A[row * N + k0 + tx] : 0.f; Bs[ty][tx] = (k0 + ty < N && col < N) ? B[(k0 + ty) * N + col] : 0.f; __syncthreads(); // the whole tile is in before anyone reads it #pragma unroll for (int k = 0; k < TILE; k++) acc += As[ty][k] * Bs[k][tx]; // As: broadcast across the warp; Bs: stride 1 __syncthreads(); // everyone is done before the tile is overwritten } if (row < N && col < N) C[row * N + col] = acc; }Each thread now loads two floats per tile step and performs
TILEmultiply-adds out of shared memory: flops per bytes, an intensity of flop/byte against global memory, 32 times the naive kernel.
In roofline terms (the first post of this series), and counting the loads the source issues, tiling moves the kernel TILE times further to the right:
Whether 8 flop/byte is past the ridge depends on the GPU: the ridge is that device's own ratio of peak arithmetic to memory bandwidth, and on a part whose single-precision peak in flop/s is more than eight times its bandwidth in bytes per second, the tiled kernel is still on the bandwidth side. What tiling guarantees is the factor of 32, not the crossing.
The two __syncthreads() calls are load-bearing. The first guarantees that the tile is complete before any thread reads it; the second guarantees that no thread starts loading the next tile while a slower warp is still reading the current one. Dropping the second is the classic race that produces results that are usually right. The pad on Bs is not needed for the read pattern in this kernel (Bs[k][tx] is already a row read) but it makes the kernel safe to transpose later without rediscovering bank conflicts.
The host side: launch, timing and an occupancy query
dim3 block(TILE, TILE); // 1024 threads = 32 warps
dim3 grid((N + TILE - 1) / TILE, (N + TILE - 1) / TILE);
cudaEvent_t t0, t1;
cudaEventCreate(&t0); cudaEventCreate(&t1);
cudaEventRecord(t0);
matmul_tiled<<<grid, block>>>(dA, dB, dC, N);
cudaEventRecord(t1);
cudaEventSynchronize(t1);
float ms; cudaEventElapsedTime(&ms, t0, t1);
int blocksPerSM;
cudaOccupancyMaxActiveBlocksPerMultiprocessor(&blocksPerSM, matmul_tiled, TILE * TILE, 0);
cudaDeviceProp prop; cudaGetDeviceProperties(&prop, 0);
printf("%d blocks/SM, %d of %d warps resident\n", blocksPerSM,
blocksPerSM * TILE * TILE / 32, prop.maxThreadsPerMultiProcessor / 32);
Kernel launches are asynchronous, so timing with host clocks around the launch measures nothing; events recorded in the same stream bracket the work on the device. Compile with nvcc -O3 -arch=sm_XX --ptxas-options=-v to see the registers and shared memory per block that the occupancy calculation below depends on.
A block reduction
Summing
- Each thread starts by summing many elements in a grid-stride loop, so the number of blocks is decoupled from
and the per-thread work amortises launch and reduction overhead. - The block then reduces in shared memory with sequential addressing: at step
, thread addssdata[t + s]intosdata[t]. Active threads are contiguous, so whole warps retire together and no warp diverges until fewer than 32 threads remain, and the addresses are consecutive, so there are no bank conflicts. - The last 32 values are reduced inside one warp with
__shfl_down_sync, which exchanges registers directly without shared memory or a barrier.
__inline__ __device__ float warp_reduce(float v)
{
for (int offset = 16; offset > 0; offset >>= 1)
v += __shfl_down_sync(0xffffffffu, v, offset);
return v;
}
__global__ void reduce_sum(const float *in, float *partial, int n)
{
extern __shared__ float sdata[];
unsigned tid = threadIdx.x;
unsigned i = blockIdx.x * (blockDim.x * 2) + tid;
unsigned stride = gridDim.x * blockDim.x * 2;
float v = 0.f;
for (; i < (unsigned)n; i += stride) { // grid-stride: many elements per thread
v += in[i];
if (i + blockDim.x < (unsigned)n) v += in[i + blockDim.x];
}
sdata[tid] = v;
__syncthreads();
for (unsigned s = blockDim.x / 2; s > 32; s >>= 1) { // sequential addressing
if (tid < s) sdata[tid] += sdata[tid + s];
__syncthreads();
}
if (tid < 32) { // final warp in registers
v = sdata[tid] + sdata[tid + 32];
v = warp_reduce(v);
if (tid == 0) partial[blockIdx.x] = v;
}
}
The kernel produces one partial sum per block; a second launch with a single block (or a final atomicAdd) finishes. It assumes a one-dimensional block whose size is a power of two from 64 up to the device's limit, and blockDim.x * sizeof(float) bytes of dynamic shared memory, as in reduce_sum<<<blocks, 256, 256 * sizeof(float)>>>(in, partial, n); the finishing launch needs the same kind of block, however few partial sums remain. Outside that range the tree breaks: with 32 threads sdata[tid + 32] reads past the end of the array, and with 96 threads some values are added twice, so 96 ones sum to 112. The interleaved-addressing variant that textbooks show first, if (tid % (2*s) == 0), is correct but leaves most lanes of every warp idle from the first step. Its usual repair, index = 2*s*tid, keeps the active threads contiguous but strides through shared memory, which brings bank conflicts: two-way at the first step and worse after. Sequential addressing avoids both. Mark Harris's reduction slides, linked below, walk through seven versions of this kernel; the code above is close to the last one without its template unrolling, and with a warp shuffle, which the slides predate, in place of their unrolled last warp.
Occupancy as a budget
Occupancy is the fraction of an SM's warp slots that are filled: resident warps divided by the maximum. Blocks are placed whole, so the budget is counted in blocks, and the binding resource is whichever runs out first:
Here
A worked example with stated, illustrative assumptions: an SM with
- If the compiler needs
, one block needs registers and only fits: one block, 32 warps, 50 % occupancy, with 24,576 registers left unused because half a block cannot run. - If
, registers allow blocks, shared memory , block slots 32 and warp slots : two blocks, 64 warps, 100 %.
Eight registers per thread decide whether the SM is half empty. This is why --ptxas-options=-v, __launch_bounds__ and the -maxrregcount flag exist, and why a block of 1024 threads is often a worse choice than 256: smaller blocks pack into the leftover space more flexibly.
TIP
High occupancy is a means, not the goal. It buys latency hiding, but a kernel can also hide latency with more independent work per thread (instruction-level parallelism) and fewer resident warps, and the register-heavy version is often faster because the extra registers hold reused tiles. Vasily Volkov's talk linked below makes that case with measurements; the matching intuition from the CPU side is that keeping data in registers beats re-reading it from any cache.
Mapping back to the CPU posts
| CPU idea (earlier posts) | GPU counterpart |
|---|---|
| stride-1 inner loop (i-k-j order) | threadIdx.x indexes the fastest-varying dimension (coalescing) |
| cache blocking so a tile stays in L1 | staging a tile in shared memory, bounded by __syncthreads |
| padding per-thread counters to separate lines | padding a shared tile to [32][33] to spread banks |
| independent accumulators to cover FMA latency | many resident warps, or more ILP per thread (occupancy) |
| roofline arithmetic intensity | the same ratio; tiling multiplies it by the tile width |
The vocabulary is different and the numbers are different, but the questions a profiler answers are the same: how many bytes moved for the useful work done, and how much of the machine was waiting.
Further reading
- CUDA C Programming Guide: the execution model, the memory hierarchy and the compute-capability tables that give the exact limits for each architecture.
- CUDA C Best Practices Guide: coalescing, shared-memory and occupancy guidance with the official terminology.
- Mark Harris, Optimizing Parallel Reduction in CUDA: seven versions of the reduction kernel and why each is faster than the last.
- How to Access Global Memory Efficiently in CUDA C/C++ Kernels and Using Shared Memory in CUDA C/C++, two short posts with measured stride and bank-conflict experiments.
- CUDA Pro Tip: Occupancy API Simplifies Launch Configuration, the API used in the host code above.
- Vasily Volkov, Better Performance at Lower Occupancy, GTC 2010.