6.2.14 · HinglishGPU Architecture

GPU memory bandwidth optimization

3,316 words15 min readRead in English

6.2.14 · Hardware › GPU Architecture

Overview

GPU memory bandwidth optimization ek aisi practice hai jisme memory access patterns ko is tarah structure kiya jaata hai ki GPU cores aur memory ke beech data transfer rates maximize ho sakein — kyunki memory bandwidth aksar GPU computing ka primary performance bottleneck hoti hai.

Figure — GPU memory bandwidth optimization

Fundamental problem yeh hai: Modern GPUs mein hazaaron cores hote hain lekin limited memory buses hoti hain. Ek single NVIDIA A100 mein 6,912 CUDA cores hain jo ~2 TB/s memory bandwidth share karte hain. Agar har core independently data request kare, toh memory system congested ho jaata hai. Smart access patterns se 10-100× speedups achieve kiye ja sakte hain.

Core Concepts

Yeh kyun exist karta hai: GPU memory controllers data ko bade chunks mein read karte hain (32-128 bytes). Jab thread 0 address A read karta hai, thread 1 A+4 read karta hai, thread 2 A+8 read karta hai, etc., toh hardware 32 alag-alag 4-byte reads ki jagah ek 128-byte block fetch karta hai.

Coalesced bandwidth ki derivation:

threads ke warp ke liye:

  • Har thread bytes request karta hai
  • Memory transaction size: bytes (typically 128B)
  • Coalesced case: addresses bytes span karte hain

Best case (fully coalesced): Saare 32 threads consecutive floats access karte hain (4 bytes each):

Worst case (strided by 32): Thread address access karta hai:

Effective bandwidth ratio:

Coalesced: | Strided:

jahaan:

Hardware constraints se derivation:

  1. Physical bandwidth:
  2. Transaction overhead: Har memory request ka minimum size hota hai
  3. Warp divergence: Jab threads non-contiguous addresses access karein, toh har subset ko alag transaction chahiye
  4. Bank conflicts: Shared memory mein, same bank ka parallel access serialize ho jaata hai

Combined efficiency:

  • : Coalescing efficiency (upar derive ki gayi)
  • : Cache hit rate (DRAM traffic reduce karta hai)
  • : Fraction of time jab memory controllers busy hain

Banks kyun exist karte hain: Hardware cost ki wajah se. 32-way parallel access arbitrary addresses ke liye provide karna full crossbar ( connections) maangega. Banks memory ko partition karte hain taaki har thread ek bank se map ho, jisse simpler hardware ke saath connections per bank possible ho.

Bank assignment: Address aur banks ke liye:

Conflict rule: Bank access karne waale threads 1 cycle ki jagah cycles lete hain.

Broadcasting exception: Agar saare threads same address read karein, toh hardware 1 cycle mein broadcast karta hai (koi conflict nahi).

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

Yeh slow kyun hai: Thread 0 address A read karta hai, thread 1 A+24 read karta hai, thread 2 A+48 read karta hai. Jo field threads chahte hain (x) woh har 24 bytes par scattered hai. Hardware phir bhi 128-byte lines fetch karta hai, lekin ek warp jo ek float per thread read kar raha hai woh ab bytes ka address span touch karta hai. Iska matlab hai transactions per field — lekin fetch kiye gaye 768 bytes mein se sirf bytes useful hain (efficiency ). Toh AoS partially coalesce karta hai; yeh 32 alag reads mein fully serialize nahi hota, lekin ~5/6 bandwidth waste karta hai.

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

Yeh fast kyun hai: Thread 0 A read karta hai, thread 1 A+4 read karta hai, thread 2 A+8 read karta hai. Addresses consecutive hain, isliye hardware 1 transaction per warp per field mein combine kar deta hai.

Speedup calculation: 6 fields ke liye, per warp:

  • AoS: fields transactions/field transactions
  • SoA: fields transaction/field transactions

(Yeh gap wider structs ke liye barhta hai: ek struct jo per element bytes span karta hai, AoS ke liye transactions per field deta hai vs SoA ke liye 1.)

2. Tiling with Shared Memory

Problem: matrices ke liye, har thread global memory se elements load karta hai. Total: global element reads (bahut saare redundant hain).

Tiled approach:

  1. Matrices ko tiles mein divide karo
  2. ka ek tile aur ka ek tile shared memory mein ek baar per block load karo
  3. Block ke saare threads shared data reuse karte hain
  4. Next tile par move karo

Yeh step kyun? Shared memory global memory se ~100× faster hoti hai aur ek block ke andar share hoti hai. Tiles cooperatively load karke, hum redundant global reads ko 1 global read + fast shared reads mein convert karte hain.

Speedup ki derivation:

Tiling ke bina:

tiles ke saath:

Global memory traffic reduction:

ke liye: 32× kam global memory traffic.

Arithmetic intensity ki poori derivation:

Operation count: FLOPs (har multiply-add = 2 FLOPs, baar done).

Global data movement (tiled), bytes count karte hue: har output element global element reads trigger karta hai, aur har element ek 4-byte float hai:

Arithmetic intensity:

ke liye: FLOPs/byte.

Compute-bound vs bandwidth-bound (Roofline): Ek kernel compute-bound hota hai jab uski arithmetic intensity machine balance se zyada ho. Ek GPU par jo 20 TFLOP/s peak aur 1 TB/s bandwidth deta hai:

  • Tiled (): FLOPs/byte still bandwidth-bound, lekin naive se 32× kam traffic.
  • Larger tiles / register blocking ko ke paas aur usse aage push karte hain taaki compute-bound ho jaaye.
  • 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!

Conflict kyun hota hai: Jab thread 0 tile[0][0] read karta hai, thread 1 tile[1][0] read karta hai, .., thread 31 tile[31][0] read karta hai. Saare column 0 access karte hain, jo same bank (bank 0) se map hota hai → 32-way conflict → 32× slower.

Bank mapping: Address tile[row][col] = base + (row*32 + col)*4 bytes

Column 0 access karne waale saare threads bank 0 hit karte hain.

Solution: Padding

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

Yeh kyun kaam karta hai: Ab address tile[row][col] = base + (row*33 + col)*4

Column 0 ke liye: thread bank access karta hai. Kyunki , consecutive threads different banks se map hote hain → koi conflict nahi!

Cost: 3% memory overhead (33/32) → 32× speedup transpose mein.

Yeh sahi kyun lagta hai: CPUs par, caches working sets ke relative large hoti hain aur achha automatic optimization provide karti hain.

Reality yeh hai: GPU caches per-thread bahut chhoti hain (128 KB L1 per SM jo 1024+ threads share karte hain = 128 bytes per thread). 32-thread warps lockstep mein execute hone se, agar threads scattered addresses access karein, toh cache data reuse se pehle hi thrash ho jaati hai.

Example: Matrix multiply bina tiling ke. Har thread ko elements chahiye, lekin cache sirf ~32 floats per thread hold kar sakti hai. ke liye, 99.7% accesses cache miss karte hain → bandwidth-bound.

Fix: Deliberate data reuse ke liye explicitly shared memory use karo. Automatic caching par depend mat karo.

Yeh sahi kyun lagta hai: Addresses strided hain (har 24 bytes), toh lagta hai kuch bhi coalesce nahi hota.

Reality yeh hai: Hardware hamesha full 128-byte cache lines fetch karta hai. Ek warp jo ek float per thread 24-byte-stride struct se read kar raha hai woh bytes span karta hai → transactions, 32 nahi. Yeh partially coalesce karta hai lekin ~83% bandwidth waste karta hai (768 bytes mein se sirf 128 bytes useful).

Fix: 1 transaction/field ke liye SoA use karo. AoS ki asli penalty wasted bandwidth hai (efficiency ~17%), "32× more transactions" nahi.

Reality yeh hai: Memory transactions aligned hone chahiye 32/64/128-byte boundaries par. Agar array address 0x102 se start ho (misaligned), toh consecutive accesses bhi boundaries par split ho jaate hain.

Example: Array 0x102 se start hoti hai. Warp 0x102-0x181 (128 bytes) read karta hai. Yeh 0x100 aur 0x180 boundaries span karta hai → 1 ki jagah 2 transactions.

Fix: cudaMalloc (aligned pointers return karta hai) ya __align__ attribute use karo:

__align__(128) float data[N];

Active Recall Practice

Recall Feynman Explanation (Ek 12 saal ke bachche ko explain karo)

Ek library imagine karo jisme 1000 students hain aur ek librarian. Agar har student random shelves se books maange, toh librarian thak ke idhar-udhar bhaagta rahega. Lekin agar ek hi row mein baithe students sab ek hi shelf se books maangein, toh librarian ek cart laata hai jise sab share karte hain — bahut faster!

GPUs us library jaisi hain. Unke paas hazaaron "students" (threads) hain jo ek saath kaam karte hain. Memory library shelves hai. Agar threads randomly data maangein, toh memory system overwhelm ho jaata hai. Lekin agar nearby threads nearby data maangein, toh memory system sab kuch ek trip mein deliver kar sakti hai.

Kuch tricks hain: (1) data organize karo taaki nearby threads ko nearby data chahiye (jaise students ko unki shelf ke hisaab se arrange karna), (2) ek chhhoti fast "desk" (shared memory) use karo taaki same room ke students ke beech data share ho, (3) ensure karo ki students sab ek saath same drawer se grab na karein (bank conflicts). Yeh tricks GPU programs ko 10-100× faster banati hain!

Connections

  • GPU-Warp-Scheduling: coalescing warps ke andar kaam karta hai (32 threads)
  • Cache-Hierarchy: L1/L2 caches explicit optimization ko complement karte hain, replace nahi karte
  • Roofline-Model: bandwidth optimization programs ko compute-bound regime ki taraf move karta hai
  • Parallel-Algorithm-Design: memory access patterns algorithm choice ko influence karte hain
  • CUDA-Shared-Memory: banking aur conflicts ke hardware details
  • Memory-Latency-Hiding: high occupancy latency tolerance allow karta hai

Summary

GPU memory bandwidth optimization coalescing (threads ko consecutive addresses access karaana), tiling (fast shared memory mein data reuse karna), aur bank conflicts avoid karne (shared memory access patterns structure karna) par center hoti hai. Fundamental trade-off yeh hai: data layout mein chhote changes 10-100× speedups dete hain kyunki memory bandwidth GPU ka scarcest resource hai. Har optimization ek hardware truth se derive hoti hai ki 128 bytes load karne ki cost wahi hai jo 4 bytes load karne ki — isliye threads ki requests ko saath pack karo.


#flashcards/hardware

GPUs mein memory coalescing kya hai?
Jab warp ke consecutive threads consecutive memory addresses access karte hain, jisse hardware multiple requests ko ek single wide memory transaction mein combine kar sake (jaise 32 4-byte requests → 1 128-byte transaction).
Coalescing massive speedups kyun deta hai?
GPU memory controllers fixed-size blocks (32-128 bytes) fetch karte hain. Coalesced access poora block use karta hai; uncoalesced access bandwidth waste karta hai un data ko fetch karke jo threads ko chahiye nahi. Difference required transactions mein 32× tak ho sakta hai.
Coalesced vs strided (stride-32) access ki efficiency derive karo
Coalesced: 32 threads × 4 bytes = 128 bytes in 1 transaction = 100% efficiency. Strided by 32: span = 32×32×4 = 4096 bytes = 32 transactions, sirf 128 bytes used = 3.125% efficiency. Ratio: 32× difference.
Ek AoS layout (24-byte stride) ko per field kitne transactions chahiye, aur kyun?
Ek warp jo ek float per thread read karta hai woh 32×24 = 768 bytes span karta hai → ceil(768/128) = 6 transactions per field. Yeh partially coalesce karta hai (32 alag reads nahi), lekin fetch kiye gaye bytes ka sirf 128/768 ≈ 16.7% useful hai.
6-field struct ke liye asli SoA-vs-AoS speedup kya hai?
AoS: 6 fields × 6 transactions = 36 transactions per warp. SoA: 6 fields × 1 transaction = 6. Reduction = 36/6 = 6× fewer transactions (32× nahi). Yeh gap wider structs ke saath barhta hai.
Structure-of-Arrays ka Array-of-Structures par fundamental advantage kya hai?
SoA: thread i address A+i×4 read karta hai (consecutive) → 1 coalesced transaction per field. AoS: thread i A+i×struct_size read karta hai (strided) → per field kaafi partial transactions, bandwidth waste karta hai.
Tiling matrix multiply mein memory bandwidth kaise reduce karta hai?
Bina tiling ke: har output element global memory se 2N values load karta hai. T×T tiles ke saath: per element global reads = 2N/T. T=32 ke liye: global traffic mein 32× reduction.
Tiled matrix multiply ki arithmetic intensity derive karo
FLOPs = 2N³. Bytes moved = N² × (2N/T) × 4 = 8N³/T. I = 2N³ / (8N³/T) = T/4 FLOPs/byte. T=32 ke liye, I = 8 FLOPs/byte.
20 TFLOP/s peak aur 1 TB/s bandwidth ke liye machine-balance (Roofline) threshold kya hai?
I* = peak_FLOPs / peak_bytes = 20e12 / 1e12 = 20 FLOPs/byte. Ek kernel compute-bound tabhi hoga jab uski arithmetic intensity 20 se zyada ho; usse neeche, woh bandwidth-bound hai.
Kya us machine par tiled matmul T=32 (I=8) compute-bound hai?
Nahi. I = 8 FLOPs/byte < I* = 20 FLOPs/byte, isliye yeh still bandwidth-bound hai — lekin naive se 32× kam traffic move karta hai. Larger tiles / register blocking I ko 20 se aage push karte hain taaki compute-bound ban sake.
Shared memory mein bank conflicts kyun hote hain?
Shared memory 32 banks mein divided hoti hai. Jab ek warp ke multiple threads same bank (lekin different addresses) access karte hain, toh accesses serialize ho jaate hain. Bank = (address/word_size) mod 32. Conflict factor = same bank hit karne waale threads ki sankhya.
Padding transpose bank conflicts kaise fix karta hai?
Bina padding ke: tile[row][col] 32×32 array mein → bank = col mod 32. Column 0 read karne waale saare threads bank 0 hit karte hain (32-way conflict). 32×33 padding ke saath: bank = (row×33 + col) mod 32. Kyunki gcd(33,32)=1, consecutive threads alag banks hit karte hain → koi conflict nahi.
Tiling se arithmetic intensity kyun improve hoti hai?
I = FLOPs/byte. Tiling data reuse badhata hai: global memory se ek baar data load karo, shared memory se T baar use karo → transferred bytes factor T se reduce hote hain → I factor T se increase hota hai → compute-bound ki taraf move hota hai.
Shared memory address A ke liye bank assignment formula kya hai?
Bank = (A / word_size) mod N_banks, jahaan N_banks = typically 32 aur word_size = float ke liye 4 bytes. Yeh decide karta hai ki 32 parallel memory modules mein se kaun sa request serve karega.
GPU caches coalescing ki zaroorat kyun khatam nahi karti?
GPU L1 cache ~128KB per SM hai jo 1024+ threads share karte hain = ~128 bytes per thread. 32-thread warps ke scattered addresses access karne se, cache reuse se pehle thrash ho jaati hai. Cache itni chhhoti hai ki poor access patterns automatically mask nahi kar sakti.
Modern GPUs par memory transaction size kya hai?
Typically 32, 64, ya 128 bytes, architecture aur memory level ke hisaab se. NVIDIA GPUs 128-byte L2 cache lines aur 32-byte L1 sectors use karte hain. Global memory coalescing 128-byte aligned transactions target karta hai.

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