GPU memory bandwidth optimization
Overview
GPU memory bandwidth optimization is the practice of structuring memory access patterns to maximize data transfer rates between GPU cores and memory, critical because memory bandwidth is often the primary performance bottleneck in GPU computing.

The fundamental problem: Modern GPUs have thousands of cores but limited memory buses. A single NVIDIA A100 has 6,912 CUDA cores sharing a ~2 TB/s memory bandwidth. If each core requests data independently, the memory system becomes congested. Smart access patterns can achieve 10-100× speedups.
Core Concepts
Why it exists: GPU memory controllers read data in large chunks (32-128 bytes). When thread0 reads address A, thread 1 reads A+4, thread 2 reads A+8, etc., the hardware fetches one 128-byte block instead of 32 separate 4-byte reads.
Derivation of coalesced bandwidth:
For a warp of threads:
- Each thread requests bytes
- Memory transaction size: bytes (typically 128B)
- Coalesced case: addresses span bytes
Best case (fully coalesced): All32 threads access consecutive floats (4 bytes each):
Worst case (strided by 32): Thread accesses address :
Effective bandwidth ratio:
Coalesced: | Strided:
where:
Derivation from hardware constraints:
- Physical bandwidth:
- Transaction overhead: Each memory request has minimum size
- Warp divergence: When threads access non-contiguous addresses, each subset needs separate transaction
- Bank conflicts: In shared memory, parallel access to same bank serializes
Combined efficiency:
- : Coalescing efficiency (derived above)
- : Cache hit rate (reduces DRAM traffic)
- : Fraction of time memory controllers are busy
Why banks exist: Hardware cost. To provide32-way parallel access to arbitrary addresses would require full crossbar ( connections). Banks partition memory so each thread maps to one bank, enabling simpler hardware with connections per bank.
Bank assignment: For address and banks:
Conflict rule: threads accessing bank take cycles instead of 1.
Broadcasting exception: If all threads read the same address, hardware broadcasts in1 cycle (no conflict).
Optimization Techniques
1. Array-of-Structures vs Structure-of-Arrays
Array of Structures (AoS):
struct Particle { float x, y, z, vx, vy, vz; };
Particle particles[N];
// Thread i accesses:
float x = particles[i].x; // Address: base + i*24 + 0
float y = particles[i].y; // Address: base + i*24 + 4Why this is slow: Thread 0 reads address A, thread 1 reads A+24, thread 2 reads A+48. The field the threads want (x) is scattered every 24 bytes. The hardware still fetches 128-byte lines, but a warp reading one float per thread now touches bytes of address span. That requires transactions per field — but of the 768 bytes fetched, only bytes are useful (efficiency ). So AoS partially coalesces; it is not fully serialized into 32 separate reads, but it wastes ~5/6 of the bandwidth.
Structure of Arrays (SoA):
struct Particles {
float x[N], y[N], z[N];
float vx[N], vy[N], vz[N];
};
Particles particles;
// Thread i accesses:
float x = particles.x[i]; // Address: base_x + i*4
float y = particles.y[i]; // Address: base_y + i*4Why this is fast: Thread 0 reads A, thread 1 reads A+4, thread 2 reads A+8. Addresses are consecutive, so hardware combines into 1 transaction per warp per field.
Speedup calculation: For 6 fields, per warp:
- AoS: fields transactions/field transactions
- SoA: fields transaction/field transactions
(The gap grows for wider structs: a struct spanning bytes per element gives transactions per field for AoS vs 1 for SoA.)
2. Tiling with Shared Memory
Problem: For matrices, each thread loads elements from global memory. Total: global element reads (many redundant).
Tiled approach:
- Divide matrices into tiles
- Load one tile of and one tile of into shared memory once per block
- All threads in block reuse the shared data
- Move to next tile
Why this step? Shared memory is ~100× faster than global memory and shared within a block. By loading tiles cooperatively, we convert redundant global reads into 1 global read + fast shared reads.
Derivation of speedup:
Without tiling:
With tiles:
Global memory traffic reduction:
For : 32× less global memory traffic.
Full derivation of arithmetic intensity:
Operation count: FLOPs (each multiply-add = 2 FLOPs, done times).
Global data movement (tiled), counting bytes: each output element triggers global element reads, and each element is a 4-byte float:
Arithmetic intensity:
For : FLOPs/byte.
Compute-bound vs bandwidth-bound (Roofline): A kernel is compute-bound when its arithmetic intensity exceeds the machine balance . On a GPU with 20 TFLOP/s peak and 1 TB/s bandwidth:
- Tiled (): FLOPs/byte → still bandwidth-bound, but 32× less traffic than naive.
- Larger tiles / register blocking push toward and past to become compute-bound.
- Naive (no tiling, ): FLOPs/byte → heavily bandwidth-bound.
3. Avoiding Bank Conflicts in Shared Memory
Naive approach:
__shared__ float tile[32][32];
// Load: thread i reads column i → coalesced
tile[threadIdx.y][threadIdx.x] = input[...];
// Store: thread i writes row i → each thread different bank
output[...] = tile[threadIdx.x][threadIdx.y]; // CONFLICT!Why conflict occurs: When thread 0 reads tile[0][0], thread 1 reads tile[1][0], .., thread 31 reads tile[31][0]. All access column 0, which maps to the same bank (bank 0) → 32-way conflict → 32× slower.
Bank mapping: Address tile[row][col] = base + (row*32 + col)*4 bytes
All threads accessing column 0 hit bank 0.
Solution: Padding
__shared__ float tile[32][33]; // Extra columnWhy this works: Now address tile[row][col] = base + (row*33 + col)*4
For column 0: thread accesses bank . Since , consecutive threads map to different banks → no conflict!
Cost: 3% memory overhead (33/32) → 32× speedup in transpose.
Why it feels right: On CPUs, caches are large relative to working sets and provide good automatic optimization.
The reality: GPU caches are small per-thread (128 KB L1 per SM shared by 1024+ threads = 128 bytes per thread). With 32-thread warps executing in lockstep, if threads access scattered addresses, the cache gets thrashed before data can be reused.
Example: Matrix multiply without tiling. Each thread needs elements, but cache holds only ~32 floats per thread. For , 99.7% of accesses miss the cache → bandwidth-bound.
The fix: Explicitly use shared memory for deliberate data reuse. Don't rely on automatic caching.
Why it feels right: The addresses are strided (every 24 bytes), so it seems nothing coalesces.
The reality: The hardware always fetches full 128-byte cache lines. A warp reading one float per thread from a 24-byte-stride struct spans bytes → transactions, not 32. It partially coalesces but wastes ~83% of the bandwidth (only 128 of 768 bytes useful).
The fix: Use SoA to collapse to 1 transaction/field. The real penalty of AoS is wasted bandwidth (efficiency ~17%), not "32× more transactions."
The reality: Memory transactions must be aligned to 32/64/128-byte boundaries. If array starts at address 0x102 (misaligned), even consecutive accesses split across boundaries.
Example: Array starts at 0x102. Warp reads 0x102-0x181 (128 bytes). This spans boundaries 0x100 and 0x180 → 2 transactions instead of 1.
The fix: Use cudaMalloc (returns aligned pointers) or __align__ attribute:
__align__(128) float data[N];Active Recall Practice
Recall Feynman Explanation (Explain to a 12-year-old)
Imagine a library with 1000 students and one librarian. If each student asks for books from random shelves, the librarian runs around exhausted. But if students sitting in the same row all ask for books from the same shelf, the librarian brings one cart that everyone shares – much faster!
GPUs are like that library. They have thousands of "students" (threads) all working at once. The memory is the library shelves. If threads ask for data randomly, the memory system gets overwhelmed. But if nearby threads ask for nearby data, the memory system can deliver everything in one trip.
There are tricks: (1) organize data so nearby threads need nearby data (like arranging students by their shelf), (2) use a small fast "desk" (shared memory) to share data between students in the same room, (3) make sure students don't all grab from the same drawer at once (bank conflicts). These tricks make GPU programs 10-100× faster!
Connections
- GPU-Warp-Scheduling: coalescing works within warps (32 threads)
- Cache-Hierarchy: L1/L2 caches complement but don't replace explicit optimization
- Roofline-Model: bandwidth optimization moves programs toward compute-bound regime
- Parallel-Algorithm-Design: memory access patterns influence algorithm choice
- CUDA-Shared-Memory: hardware details of banking and conflicts
- Memory-Latency-Hiding: high occupancy allows latency tolerance
Summary
GPU memory bandwidth optimization centers on coalescing (making threads access consecutive addresses), tiling (reusing data in fast shared memory), and avoiding bank conflicts (structuring shared memory access patterns). The fundamental trade-off: small changes in data layout yield 10-100× speedups because memory bandwidth is the GPU's scarcest resource. Every optimization derives from the hardware truth that loading 128 bytes costs the same as loading 4 bytes – so pack threads' requests together.
#flashcards/hardware
What is memory coalescing in GPUs?
Why does coalescing provide massive speedups?
Derive the efficiency of coalesced vs strided (stride-32) access
How many transactions does an AoS layout (24-byte stride) need per field, and why?
What is the true SoA-vs-AoS speedup for a 6-field struct?
What is the fundamental advantage of Structure-of-Arrays over Array-of-Structures?
How does tiling reduce memory bandwidth in matrix multiply?
Derive the arithmetic intensity of tiled matrix multiply
What is the machine-balance (Roofline) threshold for 20 TFLOP/s peak and 1 TB/s bandwidth?
Is tiled matmul with T=32 (I=8) compute-bound on that machine?
What causes bank conflicts in shared memory?
How does padding fix transpose bank conflicts?
Why is arithmetic intensity improved by tiling?
What is the bank assignment formula for a shared memory address A?
Why don't GPU caches eliminate the need for coalescing?
What is the memory transaction size on modern GPUs?
Concept Map
Hinglish (regional understanding)
Intuition Hinglish mein samjho
Hinglish (regional understanding)
Intuition Hinglish mein samjho
Dekho, GPU ke andar hazaaron cores hote hain jo ek saath kaam karte hain, lekin memory tak pahunchne ka rasta limited hota hai — jaise ek warehouse jahan bahut saare workers hain par delivery trucks kam. Toh yahan asli intuition yeh hai ki agar tumhare threads memory se data aise mangte hain ki consecutive threads consecutive addresses access karein, tab hardware un sabko ek single wide transaction mein combine kar leta hai. Isko bolte hain memory coalescing. Jab yeh properly hota hai, ek warp ke 32 threads sirf ek 128-byte transaction mein sara data le aate hain — 100% efficiency. Lekin agar threads randomly ya strided pattern mein access karein, toh wahi data laane ke liye 32 alag transactions lagti hain, aur efficiency gir kar 3% tak aa jaati hai. Yani same hardware par tumhe 10-100x slowdown mil sakta hai sirf access pattern galat hone se.
Ab yeh matter kyun karta hai? Kyunki GPU computing mein bottleneck aksar compute nahi, balki memory bandwidth hota hai. Cores toh fast hain, par unhe feed karne ke liye data time par nahi pahunche toh woh idle baithe rahenge. Formula simple hai: actual throughput , jahan physical bandwidth hai aur efficiency. Tumhara code chahe kitna bhi smart ho, agar kam hai toh tumhe full hardware speed kabhi nahi milegi. Isliye shared memory mein bhi banks ka concept aata hai — memory ko 32 banks mein baanta jaata hai taaki parallel access ho sake, par agar do threads same bank ke different addresses mangein toh bank conflict ho jaata hai aur access serialize ho jaata hai (k threads = k cycles).
Toh regional student ke liye takeaway yeh hai: GPU programming mein sirf algorithm sahi hona kaafi nahi — data memory mein kaise arrange hai aur threads use kaise access karte hain, yeh performance ki asli kunji hai. Jab bhi tum CUDA kernel likho, socho ki "kya mere consecutive threads consecutive memory chhu rahe hain?" Agar haan, toh coalescing ka fayda milega aur tumhara code full bandwidth ke kareeb chalega. Yeh chhoti si soch tumhare program ko 10x ya usse zyada tez bana sakti hai, bina koi extra hardware ke.