6.2.14GPU Architecture

GPU memory bandwidth optimization

3,352 words15 min readdifficulty · medium2 backlinks

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.

Figure — GPU memory bandwidth optimization

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 w=32w = 32 threads:

  • Each thread requests bb bytes
  • Memory transaction size: TT bytes (typically 128B)
  • Coalesced case: addresses span T\leq T bytes

Transactions needed=wbT\text{Transactions needed} = \lceil \frac{w \cdot b}{T} \rceil

Best case (fully coalesced): All32 threads access consecutive floats (4 bytes each): Span=32×4=128 bytes=1 transaction\text{Span} = 32 \times 4 = 128 \text{ bytes} = 1 \text{ transaction}

Worst case (strided by 32): Thread ii accesses address A+32i×4A +32i \times 4: Span=32×32×4=4096 bytes=32 transactions\text{Span} = 32 \times 32 \times 4 = 4096 \text{ bytes} = 32 \text{ transactions}

Effective bandwidth ratio: Efficiency=Data usedData transferred=wbTransactions×T\text{Efficiency} = \frac{\text{Data used}}{\text{Data transferred}} = \frac{w \cdot b}{\text{Transactions} \times T}

Coalesced: 128128=100%\frac{128}{128} = 100\% | Strided: 1284096=3.125%\frac{128}{4096} = 3.125\%

M=B×ηM = B \times \eta

where: η=Useful bytes loadedTotal bytes transferred from DRAM\eta = \frac{\text{Useful bytes loaded}}{\text{Total bytes transferred from DRAM}}

Derivation from hardware constraints:

  1. Physical bandwidth: B=Bus width×Clock rate×2 (DDR)8 bits/byteB = \frac{\text{Bus width} \times \text{Clock rate} \times 2 \text{ (DDR)}}{\text{8 bits/byte}}
  2. Transaction overhead: Each memory request has minimum size TminT_{min}
  3. Warp divergence: When threads access non-contiguous addresses, each subset needs separate transaction
  4. Bank conflicts: In shared memory, parallel access to same bank serializes

Combined efficiency: ηtotal=ηcoalesce×ηcache×ηoccupancy\eta_{total} = \eta_{coalesce} \times \eta_{cache} \times \eta_{occupancy}

  • ηcoalesce\eta_{coalesce}: Coalescing efficiency (derived above)
  • ηcache\eta_{cache}: Cache hit rate (reduces DRAM traffic)
  • ηoccupancy\eta_{occupancy}: Fraction of time memory controllers are busy

Why banks exist: Hardware cost. To provide32-way parallel access to arbitrary addresses would require full crossbar (32×3232 \times 32 connections). Banks partition memory so each thread maps to one bank, enabling simpler hardware with 32×132 \times 1 connections per bank.

Bank assignment: For address AA and NN banks: Bank=(Aword size)modN\text{Bank} = \left(\frac{A}{\text{word size}}\right) \bmod N

Conflict rule: kk threads accessing bank bb take kk 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 + 4

Why 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 32×24=76832 \times 24 = 768 bytes of address span. That requires 768/128=6\lceil 768 / 128 \rceil = 6 transactions per field — but of the 768 bytes fetched, only 32×4=12832 \times 4 = 128 bytes are useful (efficiency 16.7%\approx 16.7\%). 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*4

Why 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: 66 fields ×6\times 6 transactions/field =36= 36 transactions
  • SoA: 66 fields ×1\times 1 transaction/field =6= 6 transactions

Reduction=366=6× fewer memory transactions\text{Reduction} = \frac{36}{6} = 6\times \text{ fewer memory transactions}

(The gap grows for wider structs: a struct spanning SS bytes per element gives 32S/128\lceil 32S/128 \rceil transactions per field for AoS vs 1 for SoA.)

2. Tiling with Shared Memory

Problem: For N×NN \times N matrices, each thread loads 2N2N elements from global memory. Total: N2×2N=2N3N^2 \times 2N = 2N^3 global element reads (many redundant).

Tiled approach:

  1. Divide matrices into T×TT \times T tiles
  2. Load one tile of AA and one tile of BB into shared memory once per block
  3. All threads in block reuse the shared data
  4. 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 NN redundant global reads into 1 global read + NN fast shared reads.

Derivation of speedup:

Without tiling: Global reads per output element=2N\text{Global reads per output element} = 2N

With T×TT \times T tiles: Tiles along the k-dimension=NT\text{Tiles along the k-dimension} = \frac{N}{T} Global reads per output element=2NT (global)+2N (shared)\text{Global reads per output element} = \frac{2N}{T} \text{ (global)} + 2N \text{ (shared)}

Global memory traffic reduction: Reduction factor=2N2N/T=T\text{Reduction factor} = \frac{2N}{2N/T} = T

For T=32T = 32: 32× less global memory traffic.

Full derivation of arithmetic intensity:

Operation count: 2N32N^3 FLOPs (each multiply-add = 2 FLOPs, done N3N^3 times).

Global data movement (tiled), counting bytes: each output element triggers 2NT\frac{2N}{T} global element reads, and each element is a 4-byte float: Bytes moved=N2×2NT×4=8N3T bytes\text{Bytes moved} = N^2 \times \frac{2N}{T} \times 4 = \frac{8N^3}{T} \text{ bytes}

Arithmetic intensity: I=FLOPsBytes=2N38N3/T=T4 FLOPs/byteI = \frac{\text{FLOPs}}{\text{Bytes}} = \frac{2N^3}{8N^3/T} = \frac{T}{4} \text{ FLOPs/byte}

For T=32T = 32: I=8I = 8 FLOPs/byte.

Compute-bound vs bandwidth-bound (Roofline): A kernel is compute-bound when its arithmetic intensity exceeds the machine balance I=peak FLOP/speak bytes/sI^* = \frac{\text{peak FLOP/s}}{\text{peak bytes/s}}. On a GPU with 20 TFLOP/s peak and 1 TB/s bandwidth: I=20×10121×1012=20 FLOPs/byteI^* = \frac{20 \times 10^{12}}{1 \times 10^{12}} = 20 \text{ FLOPs/byte}

  • Tiled (T=32T=32): I=8I = 8 FLOPs/byte <20< 20still bandwidth-bound, but 32× less traffic than naive.
  • Larger tiles / register blocking push II toward and past 2020 to become compute-bound.
  • Naive (no tiling, T=1T=1): I=0.25I = 0.25 FLOPs/byte 20\ll 20 → 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 Bank=(row×32+col)mod32=colmod32\text{Bank} = (row \times 32 + col) \bmod 32 = col \bmod 32

All threads accessing column 0 hit bank 0.

Solution: Padding

__shared__ float tile[32][33];  // Extra column

Why this works: Now address tile[row][col] = base + (row*33 + col)*4 Bank=(row×33+col)mod32\text{Bank} = (row \times 33 + col) \bmod 32

For column 0: thread ii accesses bank (i×33)mod32(i \times 33) \bmod 32. Since gcd(33,32)=1\gcd(33, 32) = 1, 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 2N2N elements, but cache holds only ~32 floats per thread. For N=1024N = 1024, 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 32×24=76832 \times 24 = 768 bytes → 768/128=6\lceil 768/128 \rceil = 6 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?
When consecutive threads in a warp access consecutive memory addresses, allowing hardware to combine multiple requests into a single wide memory transaction (e.g., 32 4-byte requests → 1 128-byte transaction).
Why does coalescing provide massive speedups?
GPU memory controllers fetch fixed-size blocks (32-128 bytes). Coalesced access uses the full block; uncoalesced access wastes bandwidth fetching data that threads don't need. Difference can be up to 32× in transactions required.
Derive the efficiency of coalesced vs strided (stride-32) access
Coalesced: 32 threads × 4 bytes = 128 bytes in 1 transaction = 100% efficiency. Strided by 32: span = 32×32×4 = 4096 bytes = 32 transactions, only 128 bytes used = 3.125% efficiency. Ratio: 32× difference.
How many transactions does an AoS layout (24-byte stride) need per field, and why?
A warp reading one float per thread spans 32×24 = 768 bytes → ceil(768/128) = 6 transactions per field. It partially coalesces (not 32 separate reads), but only 128/768 ≈ 16.7% of fetched bytes are useful.
What is the true SoA-vs-AoS speedup for a 6-field struct?
AoS: 6 fields × 6 transactions = 36 transactions per warp. SoA: 6 fields × 1 transaction = 6. Reduction = 36/6 = 6× fewer transactions (not 32×). The gap grows with wider structs.
What is the fundamental advantage of Structure-of-Arrays over Array-of-Structures?
SoA: thread i reads address A+i×4 (consecutive) → 1 coalesced transaction per field. AoS: thread i reads A+i×struct_size (strided) → several partial transactions per field, wasting bandwidth.
How does tiling reduce memory bandwidth in matrix multiply?
Without tiling: each output element loads 2N values from global memory. With T×T tiles: global reads per element = 2N/T. For T=32: 32× reduction in global traffic.
Derive the arithmetic intensity of tiled matrix multiply
FLOPs = 2N³. Bytes moved = N² × (2N/T) × 4 = 8N³/T. I = 2N³ / (8N³/T) = T/4 FLOPs/byte. For T=32, I = 8 FLOPs/byte.
What is the machine-balance (Roofline) threshold for 20 TFLOP/s peak and 1 TB/s bandwidth?
I* = peak_FLOPs / peak_bytes = 20e12 / 1e12 = 20 FLOPs/byte. A kernel is compute-bound only if its arithmetic intensity exceeds 20; below it, it is bandwidth-bound.
Is tiled matmul with T=32 (I=8) compute-bound on that machine?
No. I = 8 FLOPs/byte < I* = 20 FLOPs/byte, so it is still bandwidth-bound — but it moves 32× less traffic than naive. Larger tiles / register blocking push I past 20 to become compute-bound.
What causes bank conflicts in shared memory?
Shared memory is divided into 32 banks. When multiple threads in a warp access the same bank (but different addresses), accesses serialize. Bank = (address/word_size) mod 32. Conflict factor = number of threads hitting same bank.
How does padding fix transpose bank conflicts?
Without padding: tile[row][col] in 32×32 array → bank = col mod 32. All threads reading column 0 hit bank 0 (32-way conflict). With padding to 32×33: bank = (row×33 + col) mod 32. Since gcd(33,32)=1, consecutive threads hit different banks → no conflict.
Why is arithmetic intensity improved by tiling?
I = FLOPs/byte. Tiling increases data reuse: load data once from global memory, use T times from shared memory → reduces bytes transferred by factor T → increases I by factor T → moves toward compute-bound.
What is the bank assignment formula for a shared memory address A?
Bank = (A / word_size) mod N_banks, where N_banks = 32 typically and word_size = 4 bytes for float. This determines which of 32 parallel memory modules serves the request.
Why don't GPU caches eliminate the need for coalescing?
GPU L1 cache is ~128KB per SM shared by 1024+ threads = ~128 bytes per thread. With 32-thread warps accessing scattered addresses, cache thrashes before reuse. Cache is too small to mask poor access patterns automatically.
What is the memory transaction size on modern GPUs?
Typically 32, 64, or 128 bytes depending on architecture and memory level. NVIDIA GPUs use 128-byte L2 cache lines and 32-byte L1 sectors. Global memory coalescing targets 128-byte aligned transactions.

Concept Map

motivates

uses

consecutive addresses enable

combines into

1 transaction gives

breaks coalescing lowers

serializes access reduces

multiplies bandwidth into

defines B in

goal is 100 percent for

Memory bandwidth bottleneck

Bandwidth optimization

Memory coalescing

Warp of 32 threads

Wide memory transaction 128B

Strided access

Efficiency eta

Throughput M equals B times eta

Bank conflicts

Physical bandwidth from bus and clock

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 M=B×ηM = B \times \eta, jahan BB physical bandwidth hai aur η\eta efficiency. Tumhara code chahe kitna bhi smart ho, agar η\eta 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.

Go deeper — visual, from zero

Test yourself — GPU Architecture

Connections