CUDA 讓 warp 變成一台機器
我把 CUDA 的 SM、warp、記憶體層級和 divergence 拆成一個可直接套用的心智模型,最後附可複製的檢查清單。

以前我以為 CUDA 是一堆 thread 在跑,現在我只看 warp、SM 和記憶體怎麼互相卡住。
我玩 CUDA 一陣子後,最常遇到的挫折就是:程式碼看起來很乾淨,數學也沒寫錯,launch 參數也像那麼回事,可 profiler 一打開就很難看。不是一半 warp 在等記憶體,就是幾個 thread 分支一歪,整個 warp 跟著慢下來。你以為自己在寫平行運算,結果其實是在做一台很貴的排隊機器。
我後來是被這篇文章拉回正軌的:GPU Architecture and CUDA Programming Model: Warps, Memory Hierarchy, and Thread Divergence。作者把 GPU 的結構講得很短,但我真正有收穫的是它背後那張架構圖。這篇我不是重寫原文,我是把它拆成台灣開發者可以直接拿去用的判讀方式。相關硬體背景我也會順手連到 NVIDIA CUDA Zone、CUDA C Programming Guide,還有 NVIDIA 的 occupancy 說明,方便你自己往下查。
一個 SM 不是核心,是一座小工廠
訂閱 AI 趨勢週報
每週精選模型發布、工具應用與深度分析,直送信箱。不定期,不騷擾。
不會寄垃圾信,隨時可取消。
Each SM is an independent execution engine containing: a set of CUDA cores (FP32/INT32 execution units), Tensor Cores (specialized matrix multiplication units), a warp scheduler (typically 4 per SM), a register file (shared among all active threads on the SM), L1 cache / shared memory (a fast SRAM bank, configurable split), load/store units, and special function units (SFUs) for transcendental operations (sin, cos, exp).
翻譯一下就是:GPU 不是一顆顆「大核心」在各跑各的,它是由很多個 SM 組成,每個 SM 都像一座小工廠,裡面有算術單元、記憶體搬運單元、特殊數學單元,還有排程器。真正會卡住你的,不是整張卡抽象地「忙不忙」,而是某個 SM 裡面資源夠不夠、排程順不順。

我以前也很愛把 GPU 講成「幾千個 core」,聽起來很猛,但實際上很容易把人帶歪。因為你如果真的把每個 thread 當成一個獨立 CPU process,最後就會開始疑惑:為什麼明明 thread 很多,速度卻沒上去?答案通常不是 thread 不夠多,是 SM 這座工廠塞爆了。
我現在看 kernel,第一件事不是看總 core 數,而是看每個 block、每個 thread 到底吃了多少 register、多少 shared memory,還有這些資源會不會讓 active warp 數掉下來。這些東西一掉,SM 就沒辦法拿別的 warp 來填空檔,延遲直接露餡。
實操上我會這樣做:先想清楚一個 thread 要保留哪些中間值,再估 block 會用掉多少 shared memory,最後才看 launch configuration。只要你把問題順序倒過來,很多 CUDA 的怪脾氣就會變得很合理。
- 先看 SM 資源,不要先看整張 GPU 的規格表。
- register 和 shared memory 是 occupancy 的直接成本。
- 排程單位是 warp,不是單一 thread。
Warp 才是實際執行單位
CUDA 給你 thread 這個抽象,但硬體真的在一起跑的是 32 個 thread 組成的 warp。這件事大家都知道,問題是知道不等於有真的拿來設計。很多 kernel 看起來很平行,實際上只是 source code 很漂亮,硬體執行時卻一直在同一組 warp 裡面互相拖累。
我踩過最典型的坑,就是分支寫得太自然。某些 thread 走 A 路徑,某些 thread 走 B 路徑,結果 warp scheduler 只好把兩條路都跑一遍,沒走到的 lane 全部先遮掉。你在程式碼裡看到的是「條件判斷」,在硬體眼裡看到的是「序列化」。
也就是說,如果同一個 warp 裡的 32 個 thread 做的事很像,GPU 很開心;如果它們每個人都要做不同的事,GPU 就會開始翻白眼。這也是為什麼資料排列比你想像中重要。鄰近 thread ID 最好處理鄰近資料,至少讓控制流跟資料形狀保持一致。
我之前有一個 kernel,邏輯上很乾淨,條件也很直覺,但資料分布很亂。結果 profiler 一看,branch efficiency 很醜。後來我把工作拆成兩個 pass,雖然程式看起來沒那麼「一口氣做完」,但效能直接好很多。GPU 不在乎你覺得優雅,它只在乎 warp 有沒有一起走。
- warp size 在 NVIDIA GPU 上是 32。
- 同 warp 分支不同,硬體會把路徑拆開跑。
- 資料排序常常比硬寫條件更有效。
Register 很快,但它不是免費的
原文有一句我很在意:register file 是 SM 上所有 active threads 共享的資源。這句話很容易被忽略,但它其實很要命。register 的確是最快的儲存位置之一,快到你會想一直把東西塞進去;可問題是它有限。每個 thread 用太多 register,就會壓低同一個 SM 能同時活著的 warp 數。

這就是 CUDA 很煩的地方:你想讓 thread 少去碰記憶體,就會傾向多用 register;可 register 一多,occupancy 可能掉,SM 就少了可切換的 warp。當某個 warp 去等 global memory,SM 沒有足夠的備胎可以接手,延遲就直接浮上來了。
我遇過一個很小的 refactor,沒有改演算法,甚至看不出哪裡變慢,結果 register pressure 多了一點點,整個 kernel 就掉速。這種事很像在跟編譯器玩心理戰,明明只是多留了幾個 live value,卻把排程空間吃掉。
實操寫法很單純:先看編譯輸出和 profiler 裡的 register usage,再對照 occupancy。若 register 太高,試著縮短變數生命週期、重用暫存值,或把一個超大的 kernel 拆成兩段。不是所有情況都該硬壓 register,有時候你反而該接受多跑一次 global memory,換回更多 active warp。
- register 是 SM 上最快的儲存,但不是無限的。
- register 太多,可能直接壓低 active warps。
- 有些 kernel 拆開,比硬塞在一起更快。
Shared memory 是草稿紙,不是糖果
原文把 L1 cache 和 shared memory 放在一起講,我覺得這很對。因為 shared memory 不是什麼自動出現的神秘快取,它是你自己管理的 on-chip scratchpad。你把資料搬進去,就要自己負責它值不值得;你不會因為「用了 shared memory」就自動得到加速。
我看過太多 kernel 把資料先拷進 shared memory,然後只用一次。那不是最佳化,那是儀式感。shared memory 真正有用的地方,是 block 裡的 threads 會重複讀同一批資料,或是你需要做 tiling,讓一小塊資料被多次重用,減少 global memory traffic。
如果你的存取是亂的、一次性的、或者每個 thread 讀的東西完全不一樣,shared memory 的價值就會很低。更麻煩的是,有些架構上 L1 cache 和 shared memory 還是同一塊 SRAM budget 的不同切法。你以為自己只是在加速某個 path,結果其實是在改另一個 path 的 cache 行為。
我自己的規則是:先把 global memory 存取整理成連續、coalesced 的樣子;只有當同一塊資料會被多個 thread 重用時,我才考慮搬進 shared memory。做 matrix、stencil、tile-based work 時,shared memory 很香。其他時候,常常只是多一層麻煩。
- shared memory 是 per-block 手動管理的。
- 只有在資料會被重用時,它才真的值錢。
- 亂用 shared memory 會讓 kernel 變複雜又不一定更快。
Global memory 是你最常去排隊的地方
很多人剛碰 CUDA 時,會一直盯著算術吞吐量看,這其實很容易看歪。大部分 kernel 真正卡住的地方不是算不動,而是資料搬不動。GPU 的 ALU 很兇,但如果每個 thread 都在亂序抓 global memory,SM 大部分時間都在等。
這就是 memory hierarchy 為什麼重要。離 SM 越近的東西越快,離得越遠就越慢。GPU 的做法不是讓單一 thread 一直跑到底,而是靠很多 warp 互相輪替,把等待時間藏起來。可這招有前提:你得有足夠多的 ready warp,而且你的 memory access 不能太亂。
我之前有個 kernel,一開始看起來像 compute-bound,因為算式很多。結果 profiler 一查,真正拖慢的是 memory stall。後來我把 access pattern 調順,讓 loads 更連續,效能幾乎沒改數學內容就上去了。這就是 CUDA 很現實的地方:你以為自己在優化計算,實際上常常是在修資料流。
實操上我會優先檢查三件事:thread 是否連續讀連續資料、能不能改成 structure-of-arrays、以及有沒有重複載入同一份資料。只要 global memory 的存取更整齊,很多 kernel 就會自己變快,不需要先碰更難的招式。
Divergence 會把一個 warp 拆成兩次上班
thread divergence 是大家都知道、但常常低估的那一個坑。概念很簡單:同一個 warp 裡的 thread 如果走不同分支,硬體就得把不同路徑分開執行,沒走到的 lane 先遮掉。聽起來像小事,實際上就是把平行變成分段序列化。
這件事在 CPU 上通常沒那麼致命,因為 CPU 有 branch prediction 和 out-of-order execution 這些工具可以幫忙。GPU 沒有那套玩法,GPU 靠的是吞吐量和 lockstep。你把 CPU 的寫法原封不動搬過來,然後期待 GPU 自己懂你,通常只會得到一個很慢的結果。
我不建議你幻想把所有 branch 都消掉,那太理想化。比較實際的做法是讓 branch 對 warp 友善:把相似工作排在一起,必要時先排序資料,真的差很多的 path 就拆成不同 kernel。尤其是稀有但重的分支,常常單獨處理比較乾脆,也比較不會拖累主路徑。
實操寫法:先看 hot kernel 裡的條件是不是依賴 per-thread data,再判斷這些條件在同一個 warp 內會不會分歧。如果會,就想辦法調整資料分布,或把工作拆開。若 branch 無法避免,至少讓最常走的 path 盡量一致,別讓整個 warp 為少數例外買單。
- divergence 會讓 warp 分段執行。
- CPU 的分支優化不等於 GPU 的分支優化。
- 把不同路徑拆開,常常比硬塞在同一個 kernel 裡更快。
Tensor Cores 和 SFU 是專門機器
原文提到 Tensor Cores 和 SFUs,我覺得這段很值得保留。因為 GPU 不是只有一般 CUDA core,它還有專門處理矩陣運算的 Tensor Cores,以及處理 sin、cos、exp 這類特殊函式的 SFU。這些單元存在的目的很單純:某些運算太常見了,值得做成專用硬體。
也就是說,你不能把所有 instruction 都當成同一種成本。若你的工作負載本來就是 matrix-heavy,硬體又支援 Tensor Core 路徑,那你就該先確認資料型別、layout、框架 API 能不能吃到它。若你的 kernel 大量依賴 transcendental functions,也不要把它們當成「只是比較慢的普通數學」。
我看過不少 code,功能完全正確,但效能白白浪費,原因只是它一直用 generic 的 CPU 心智在寫 GPU。這就像你買了一間有電動工具的工坊,卻堅持所有東西都用手轉螺絲起子。
實操上,我會先判斷 kernel 的主體到底是什麼:矩陣、線性代數、特殊函式、還是一般 FP32/INT32。若是矩陣密集,先去查 Tensor Core 的使用條件;若是特殊函式多,就把這些呼叫單獨 profile 出來。很多時候答案不是「再寫更巧」,而是「去用已經存在的專用硬體」。
- Tensor Cores 適合矩陣型工作。
- SFUs 處理特殊數學函式。
- 硬體有專用單元,前提是你的資料和寫法要配得上。
真正的 CUDA 心智模型,是資源談判
我後來把 CUDA 當成資源談判,而不是語法題,整件事就順很多。每個 kernel 都是在跟 threads、warps、registers、shared memory、global memory 談條件。你不是單純叫 GPU「跑這段 code」,你是在決定一個 SM 裡能塞多少工作、warp 能撐多久、又要等多久才拿得到資料。
所以我現在會用一個很土但很好用的順序來看問題:先看 memory access pattern,再看 divergence,再看 register pressure,接著看 shared memory,最後才看 occupancy。這個順序不是聖旨,但很適合拿來避免我自己亂修一通。很多 CUDA 的效能問題都很無聊、很重複,也很可以預先避免。
實作時你可以直接照這個心法走:先問 thread 在做什麼、warp 在一起做什麼、SM 還剩多少資源、資料是不是連續、分支是不是一致。只要你把這幾件事講清楚,很多 kernel 的行為就不再像玄學。
可抄的模板
# CUDA kernel 心智模型檢查表## 1) 這個工作單位是什麼?
- 一個 thread 算什麼:____________________
- 一個 warp 算什麼:______________________
- 一個 block 算什麼:______________________
## 2) 每個 thread 會留哪些變數?
- 主要 register 變數:_____________________
- register 壓力風險:______________________
- 能不能縮短 live range:__________________
## 3) 哪些資料會被重用?
- block 內會重用:_________________________
- 跨 warp 會重用:_________________________
- 最適合放哪裡:global / shared / cache
## 4) 記憶體存取有沒有整齊?
- threads 是否連續讀資料:yes / no
- writes 是否連續:yes / no
- 有沒有 scatter / gather 熱點:___________
## 5) warp 會不會分歧?
- 條件是否依賴 per-thread data:yes / no
- common path 是否明顯:yes / no
- 能不能拆 kernel:_______________________
## 6) 哪種硬體最適合做這些數學?
- 一般 FP32 / INT32:______________________
- 矩陣型工作:____________________________
- special functions:_______________________
## 7) 我先量什麼?
- occupancy:______________________________
- register usage:__________________________
- shared memory usage:_____________________
- global memory throughput:________________
- branch efficiency / divergence:__________
## 8) launch 設定
- threads per block:_______________________
- blocks per grid:_________________________
- shared memory per block:_________________
- expected active warps per SM:____________
## 9) 第一輪優化順序
- 先修 memory coalescing
- 再修 divergence
- 再降 register pressure
- shared memory 只拿來做重用
- 改完重新 profile,不要猜這份模板就是我自己在看新 kernel 時會先問的問題。它不是投影片上那種漂亮口號,而是我真的拿來擋自己手癢亂調參數的清單。很多 CUDA 的錯誤都很普通,只要你早點問對問題,就不會把時間浪費在不該動的地方。
原始來源是 CodingPancake 這篇文章;我這篇的架構拆解和實操清單是我自己的整理與延伸。硬體與工具參考我也連了 NVIDIA 官方文件:CUDA Zone、CUDA C Programming Guide、occupancy API 說明。