昨天提到,當一個 Warp 在等 memory 的時候,GPU 可以先去執行其他 ready 的 Warp,用這種方式把等待時間藏起來。
但這邊其實會冒出另一個問題:
為什麼拿個資料還需要等?
原因很簡單:GPU 裡面的資料,並不是全部都放在同一個地方。
有些 memory 離運算單元很近,速度很快,但容量很小;有些地方可以放非常多資料,但要把資料搬過來,就得花更多時間。
這就是今天要看的 GPU Memory Hierarchy。
先看一張很簡化的圖:

越靠近 SM 裡面的運算單元,通常速度越快,但能放的東西也越少。
今天先抓三個比較重要的地方就好:
Register、Shared Memory、Global Memory。
L1 / L2 Cache 先知道它們存在,之後有需要再回來看。
Register 可以把它想成 Thread 手上正在使用的資料。
例如:
float x = a[i];
float y = b[i];
float z = x + y;
像 x、y、z 這類正在計算的值,編譯器通常會盡量放在 Register 裡。
Register 很快,但數量有限。
所以它很適合放「現在馬上要用」的東西,不可能拿來塞整個模型。
另一個極端就是 Global Memory。
模型權重、很大的 Tensor、KV Cache 等資料,大部分都需要放在這裡。
在資料中心 GPU 上,這通常就是我們常聽到的 HBM;其他 GPU 也可能使用 GDDR。
它的容量比 Register、Shared Memory 大非常多,但問題也很直接:
離運算單元比較遠。
如果每做一點計算,就要一直跑去 Global Memory 拿資料,GPU 很多時間就會花在等資料,而不是做運算。
所以 GPU 很會算,不代表程式就一定跑得快。
中間還有一個非常重要的 Shared Memory。
它位在 SM 上,同一個 Block 裡的 Threads 可以一起使用。
假設現在有一份資料,很多 Threads 都會重複用到。
如果沒有 Shared Memory,可能會變成:
Thread 0 ──→ Global Memory
Thread 1 ──→ Global Memory
Thread 2 ──→ Global Memory
Thread 3 ──→ Global Memory
每個 Thread 都自己跑去拿一次。
但如果我們先把資料搬到 Shared Memory:
Global Memory
↓
搬進來一次
↓
Shared Memory
↓ ↓ ↓ ↓
T0 T1 T2 T3
同一個 Block 裡的 Threads 就可以重複使用這份資料。
這個概念其實很重要:
與其一直去很遠的地方拿同一份資料,不如搬近一點,再重複使用。
之後講 GEMM optimization 的時候,我們會真的看到怎麼把 Matrix 切成一小塊一小塊,搬進 Shared Memory 再做計算。
FlashAttention 背後也有很類似的想法:盡量減少昂貴的資料搬移。
這也是我今天覺得最重要的一個觀念。
一開始很容易覺得:
GPU optimization = 想辦法讓乘法、加法算得更快。
但很多時候,真正卡住我們的反而不是「算」,而是:
資料搬得不夠快。
例如一個運算只需要很少的計算,卻得從 Global Memory 搬大量資料進來,那就算 GPU 有再多運算單元,也只能在那邊等。
所以很多我們之後會看到的 LLM Infra 技術,其實都跟 Data Movement 有關。
像是:
看起來是完全不同的技術,但背後其實常常都在回答同一個問題:
怎麼讓資料不要搬那麼多次?