Chapter 7: Memory Optimisation Deep Dive
This chapter examines the three tools that dominate GPU memory performance: coalescing and the grid-stride loop, shared memory and bank conflicts, and vectorised and specialised memory paths. It ends with an optimised matrix transpose, the standard exercise in memory optimisation.
7.1 Where Memory Time Goes
From the latency table in Chapter 2, a global load costs roughly 400-800 cycles and a shared-memory access 20-30 cycles. A warp of 32 threads performing one global load each spends about 500 cycles waiting; in that time the SM could have executed roughly 16 shared-memory accesses per lane. Memory-bound kernels (the roofline test of Chapter 1) spend most of their time in this wait.
Two levers reduce it:
- Fewer transactions - coalescing, so that every fetched byte is used.
- Fewer round trips - reusing fetched data in shared memory or registers.
Everything in this chapter is one of these two levers.
7.2 Coalescing, Quantified
The hardware fetches global memory in 128-byte cache lines and services requests at 32-byte sector granularity. A warp’s load is coalesced when its 32 addresses fall in as few lines as possible.
- 32 consecutive
floats (128 bytes) - one line, one transaction. - 32
floats with stride 1 but a misaligned start - two lines. - 32
floats with stride 32 - 32 lines, or 32x the necessary traffic.
The left panel is why the global-index formula exists (Chapter 3, §3.5): it assigns consecutive threads consecutive addresses by construction. The right panel is the result of inverting that mapping, the classic column-access bug in row-major data.
The rule follows from the sector model (§2.7): consecutive thread IDs should map to consecutive addresses. The global-index formula satisfies this for 1-D arrays and row-major 2-D data.
// GOOD (coalesced): thread t reads element t of each row.
// For a row-major matrix in[row][col], col varies fastest:
float v = in[row * width + col]; // col == threadIdx.x mapped to col
// BAD (uncoalesced): thread t reads element t*width - a stride of width floats.
// 32 threads span 32 different cache lines for a large width:
float v = in[col * width + row]; // col varies slowest
7.3 The Grid-Stride Loop
A kernel launched with fewer threads than elements under-utilises the GPU; one launched with far more threads than elements wastes launch resources. The grid-stride loop decouples the launch size from the problem size:
// One grid covers the array in "rounds": each thread strides forward by the
// total number of threads (gridDim.x * blockDim.x) each iteration.
__global__ void saxpyGridStride(float alpha, const float* x, float* y, int n)
{
// Total threads in the grid:
const int stride = gridDim.x * blockDim.x;
// First element this thread owns:
int i = blockIdx.x * blockDim.x + threadIdx.x;
// March forward by 'stride' until past the end.
for (; i < n; i += stride)
{
y[i] = alpha * x[i] + y[i]; // SAXPY: single-precision A·X + Y
}
}
The launch size becomes a tuning parameter, often chosen to saturate the device, and is independent of \(n\). Each thread processes multiple elements, amortising index arithmetic and enabling per-thread data reuse. Coalescing is preserved because consecutive threads still read consecutive addresses within each iteration.
7.4 Shared Memory: The Explicit Cache
Shared memory is the programmer-managed cache of Chapter 2. Its use follows a tile pattern:
- Cooperatively load a tile of global data into shared memory (coalesced reads);
- Call
__syncthreads()to make the tile visible; - Compute from shared memory, reusing each loaded value many times;
- Call
__syncthreads()before the next tile overwrites this one.
The classic example is the matrix transpose. A naive transpose kernel reads rows (coalesced) and writes columns (uncoalesced), or vice versa; one side always pays. The shared-memory version makes both sides coalesced:
// ---------------------------------------------------------------------------
// Shared-memory tiled transpose for a WIDTH x WIDTH matrix of floats, with
// WIDTH a multiple of the tile size TILE (32 here).
//
// Stage 1 (coalesced read): each thread reads a[iy][ix] from global memory;
// consecutive threads map to consecutive ix, hence consecutive addresses.
// Stage 2 (shared memory): the tile is stored in shared as tile[ty][tx].
// Stage 3 (coalesced write): each thread writes tile[tx][ty] to a[jx][jy];
// consecutive threads (consecutive tx) map to consecutive jx within the
// same output row (jy), hence consecutive addresses in the output.
// The transpose happens in shared memory, so both global accesses are
// coalesced.
// ---------------------------------------------------------------------------
#define TILE 32
__global__ void transposeTiled(const float* in, float* out, int width)
{
// Shared tile with a padding column (see 7.5 for why +1 exists).
__shared__ float tile[TILE][TILE + 1];
// Global coordinates of this thread's element:
const int ix = blockIdx.x * TILE + threadIdx.x; // column
const int iy = blockIdx.y * TILE + threadIdx.y; // row
// Stage 1: coalesced read from global memory.
if (ix < width && iy < width)
tile[threadIdx.y][threadIdx.x] = in[iy * width + ix];
__syncthreads(); // every tile element must be visible before reads
// Transposed output coordinates:
const int jx = blockIdx.y * TILE + threadIdx.x; // column of output
const int jy = blockIdx.x * TILE + threadIdx.y; // row of output
// Stage 3: write the transposed element. Consecutive threads (x) map to
// consecutive jx columns within the same output row (jy), i.e.,
// consecutive addresses in the row-major output. Coalesced.
if (jx < width && jy < width)
out[jy * width + jx] = tile[threadIdx.x][threadIdx.y];
}
A naive kernel that performs out[j][i] = in[i][j] has threads with
consecutive IDs reading consecutive i (coalesced) but writing j-major
addresses (uncoalesced). The tile transposes the data layout in shared memory,
so both the global read and the global write are coalesced.
7.5 Bank Conflicts and Column Padding
From Chapter 2, shared memory consists of 32 banks of 4 bytes. The bank of an
address is (address / 4) mod 32. Consider the unpadded tile
float tile[32][32]:
- Row \(r\) starts at byte \(r \times 128\), so every row starts on bank \((r \times 32) \bmod 32 = 0\).
- A row read
tile[threadIdx.y][threadIdx.x]with consecutivethreadIdx.xuses all 32 banks once and is conflict-free.
A column read tile[threadIdx.x][threadIdx.y] has consecutive threads reading
addresses 32 words apart, so all 32 threads hit the same bank. This is a 32-way
bank conflict that takes 32 cycles instead of one.
Padding the tile by one column, float tile[32][33], makes row \(r\) start
at byte \(r \times 132\), hence bank \((r \times 33) \bmod 32 = r\). A
column read now visits banks 0..31 exactly once. One float of padding per row
converts a 32-cycle stall into a one-cycle access.
The padding works because the row stride of 33 words is coprime with the bank count of 32. The same coprime rule keeps the SGEMM tiles in §9.4 conflict-free.
// Padding rule:
// float tile[TILE][TILE]; // 32-way conflicts on column access
// float tile[TILE][TILE + 1]; // conflict-free column access
7.6 Vectorised Loads: float4 and Alignment
A warp loading 32 floats fetches 128 bytes in one memory transaction, but the
load instruction moves 4 bytes per lane. A 128-bit vector load moves 16 bytes
per lane, so a warp moves 512 bytes per instruction. Vectorised loads reduce
instruction count and memory requests:
// Load four floats per thread, one instruction per four floats.
__global__ void saxpyVec4(float alpha, const float4* x, float4* y, int n4)
{
const int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n4)
{
float4 xv = x[i]; // 16-byte aligned load
float4 yv = y[i];
yv.x = alpha * xv.x + yv.x; // four lanes of arithmetic per thread
yv.y = alpha * xv.y + yv.y;
yv.z = alpha * xv.z + yv.z;
yv.w = alpha * xv.w + yv.w;
y[i] = yv; // 16-byte aligned store
}
}
float4 requires 16-byte alignment. A buffer allocated with cudaMalloc is
aligned to at least 16 bytes, so converting a base pointer to float4* is
safe. A pointer offset by an odd number of floats is not aligned. If data does
not satisfy the alignment requirement, pad the allocation or handle tail
elements with scalar loads.
Vectorisation helps for three reasons: fewer instructions, fewer memory
requests, and more efficient use of the 128-bit memory path. On memory-bound
kernels, float4-style access commonly adds 20-40% throughput. The int4,
double2, and uint4 types follow the same rules.
7.7 Constant Memory
Primitive - constant memory. A 64 KB read-only memory space cached in a dedicated per-SM cache. It is optimised for broadcasts: when all threads of a warp read the same address, the hardware serves all 32 lanes in one access. When threads read different addresses, the access serialises, the inverse of shared memory’s behaviour.
// Declared at file scope, device-side:
__constant__ float g_coeffs[16];
// Host fills it with cudaMemcpyToSymbol (note: symbol, not pointer):
float h_coeffs[16] = { /* ... */ };
CHECK(cudaMemcpyToSymbol(g_coeffs, h_coeffs, sizeof(h_coeffs)));
// Kernel reads: all threads read the SAME coefficient per call - a broadcast,
// served in one access from the constant cache.
__global__ void applyCoeffs(const float* in, float* out, int n, int c)
{
const int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) out[i] = in[i] * g_coeffs[c]; // same address for all threads
}
Constant memory suits kernel parameters, coefficients, and lookup tables indexed by a value uniform across the warp. It should be avoided when each thread indexes different data; divergent constant accesses serialise and can be slower than global memory.
7.8 Texture and Surface Memory
Texture memory is a cached read-only path with two special capabilities:
spatial locality for image-like data, through caches that retain
neighbouring pixels, and hardware interpolation such as bilinear filtering.
On modern GPUs the ordinary L1/L2 caches largely match its raw bandwidth for
linear access. The remaining reasons to use a texture object are the
interpolation hardware and the cudaTextureObject API for image-like data.
For a kernel that reads images with strong 2-D locality, a texture object can be a legitimate optimisation. For linear 1-D access, coalesced global memory is equal or better. The capstone (Chapter 15) uses plain global memory for its image pipeline and operates near peak bandwidth, a useful baseline: coalescing first, specialised paths second.
7.9 Optimisation Checklist
When a kernel is memory-bound, evaluate in order:
- Coalescing. Do consecutive threads access consecutive addresses?
- Launch shape. Is the kernel a grid-stride loop sized to the device, or one thread per element?
- Data reuse. If data is reused, tile it in shared memory (§7.4) and check for bank conflicts (§7.5).
- Vectorisation. Can accesses use
float4/double2with correct alignment (§7.6)? - Uniform values. Are warp-uniform constants in constant memory (§7.7)?
- Transfer pipeline. Is the host-device path streamed with pinned memory and streams (Chapters 4 and 6)? A memory-bound kernel is not faster than the copies that feed it.
Each step is cheap to test and easy to measure (Chapter 16). Measure after each change.
Mechanism: Thread Identity and Physical Layout
Coalescing is often taught as a rule: consecutive threads should read consecutive addresses. The rule is correct, but the mechanism behind it is worth stating precisely.
The memory system fetches 128-byte cache lines and services them in 32-byte sectors. When a warp issues a load, the hardware examines all 32 addresses and satisfies them with as few line fetches as possible. Thirty-two consecutive floats span exactly 128 bytes: one line and every byte is used. Thirty-two floats with a stride of 32 words each lie in a different 128-byte region, so the hardware fetches 32 lines to deliver 32 floats. Useful data is identical; traffic is not. Uncoalesced access can multiply memory traffic by 20-30x without changing the arithmetic.
The global-index formula from Chapter 3 gives coalescing by construction:
threadIdx.x varies fastest, and the fastest-varying memory index of a
row-major array is the column. The grid-stride loop preserves this property
because consecutive threads still access consecutive addresses in every
iteration.
Shared memory has a different physical structure but the same lesson. A warp’s
shared access completes in one cycle only when its 32 addresses hit 32
different banks. A row read of float tile[32][32] hits banks 0..31 once.
A column read hits one bank 32 times. Padding the row stride from 32 to 33
words makes row-start banks cycle through 0..31, so column reads also visit
every bank once. The formula bank = (byte_address / 4) mod 32 verifies any
layout, including vectorised types and structs.
The unifying idea is that a GPU’s memory system rewards aligning thread identity with physical layout: consecutive threads to consecutive global addresses, and distinct threads to distinct shared-memory banks. Coalescing, tiling, padding, vectorisation, and constant-memory broadcasts are all ways to make that alignment exact.
Common Pitfalls
- Mapping
threadIdx.xto the slowest-varying dimension, the classic column-access bug. Put the fastest-varying thread index on the fastest-varying memory index. - Assuming
float4is safe on any pointer. It requires 16-byte alignment; an unaligned offset may compile but fault or corrupt data. - Padding only one tile dimension. If a tile is read both by row and by column,
both shared tiles need
[T][T+1]or the equivalent. - Using constant memory for divergent per-thread lookups. Constant memory is fast for broadcasts and slow when a warp reads 32 different addresses.
Check Your Understanding
When does the bank-conflict formula matter more than the "pad by one" rule?
The rule works when the row stride is coprime with 32, as with 33 words. The
formula bank = (address / 4) mod 32 verifies any layout, including wider
tiles, vectorised types, and structs, without applying a blind +1.
A warp loads 32 floats starting at byte offset 4. How many lines?
32 floats span bytes 4..131, crossing the 0..127 and 128..255 lines: two 128-byte lines. Perfectly aligned 32 floats (bytes 0..127) touch one line.
When is constant memory slower than global memory?
When threads in a warp read different addresses. The constant cache is optimised for broadcasts; divergent access serialises one address per cycle, which can be slower than a coalesced global load.
Key Takeaways
- Coalescing means touching the fewest 128-byte cache lines per warp access: consecutive threads, consecutive addresses.
- The grid-stride loop decouples launch size from problem size and preserves coalescing.
- Shared memory is the explicit cache; one column of padding removes bank conflicts.
- float4-style vectorised loads move 16 bytes per thread and require 16-byte alignment.
- Constant memory broadcasts uniform reads cheaply; divergent per-thread reads are slower than global memory.
7.10 Exercises
- A warp loads 32
floats starting at byte offset 4 (misaligned by one float). How many 128-byte lines are touched? How many would be touched if the start were aligned to 128? - Explain why padding
[TILE][TILE + 1]fixes column-access bank conflicts using the formulabank = (byte_address / 4) mod 32. - You are transposing a
1024 x 1024float matrix withTILE = 32. Count the shared-memory traffic per tile for the padded and unpadded versions (assume one column read per thread). - When would you not use constant memory for a lookup table? Give a concrete access pattern that makes it slower than global memory.