從 SIMD 到 SIMT:GPU 安靜的把戲
你已經見過兩種「對許多資料做同一個運算」的方法。單純的 SIMD 要程式設計者用向量去思考:一條指令明確地指名一個寬寬的向量暫存器,硬體把它套用到各個通道上。前一篇把 GPU 描繪成一台吞吐量機器——一千個實習生各自做一個小小的加法,而不是一個天才 CPU 一個一個飛快地算完。但它究竟怎麼在沒有一千個獨立控制單元的情況下跑這一千個實習生?這就是本篇要回答的問題,而答案有個名字:SIMT,單指令、多執行緒。
SIMT 的高明之處在於換了一個視角。你不再寫一條向量指令,而是寫一個看起來像純量的小程式——一個 kernel(核心函式)——它描述一個執行緒要做的工作,彷彿世界上只有它這一個執行緒。然後你發射這個程式的成千上萬份複本,每一份都拿到自己的執行緒索引,於是知道哪一片資料是自己的。要把兩個各一百萬元素的陣列相加?你只為單一個 `i` 寫下 `c[i] = a[i] + b[i]`,發射一百萬個執行緒,讓第 `i` 號執行緒處理第 `i` 個元素。向量性從你的程式碼裡消失,搬進了硬體裡。
這裡有個誠實的玄機,而它正是重點所在。那成千上萬個執行緒在底層並非真正彼此獨立。硬體把它們以固定大小的束抓起來,讓一束裡的每個執行緒步調一致地(lockstep)一起跑,全部在同一時刻執行同一條指令,只是資料不同。所以它寫起來像許多獨立的執行緒,跑起來卻像一條寬寬的 SIMD 指令。這個「束」就是本篇一切的關鍵單位——在 NVIDIA 硬體上,它叫做 warp。
Warp:以 32 為一束的步調一致
一個 warp 是一群執行緒——在 NVIDIA GPU 上是 32 個——硬體把它們當成一個整體來排程與執行。它們共用單一個程式計數器、單一次指令擷取:當這個 warp 執行加法時,32 個執行緒全部做加法;當它執行載入時,32 個全部載入。你可以把一個 warp 想成「一條 SIMD 指令穿上了 32 件執行緒形狀的戲服」。GPU 之所以這麼做,理由非常經濟。擷取與解碼一條指令是昂貴的,所以把一次擷取攤到 32 個通道上,意味著晶片幾乎把全部電晶體都花在算術上、幾乎沒花在控制上——這正好和你先前研究的亂序執行 CPU 相反。
Warp 也是 GPU 隱藏記憶體延遲的方法,這是它的第二項超能力。當某個 warp 發出一個必須跑去晶片外記憶體的載入——那是好幾百個週期之遠——排程器不會停下來等它。它只是把那個 warp 停泊一旁,切換到另一個準備好的 warp,而且切換成本是零,因為每個 warp 的狀態本來就住在那個巨大的暫存器檔裡。只要在途中的 warp 夠多,總會有一個準備好可以計算,而其他的還在等記憶體,於是算術單元始終忙碌。CPU 用預測與推測去對抗延遲;GPU 則乾脆用一大堆備用的 warp 把延遲淹沒掉。
分支發散:那個讓你付雙倍代價的 if 判斷
現在來看地基上的裂縫。一個 warp 的 32 個執行緒共用一個程式計數器,所以它們任何時刻只能停在一條指令上。在你的 kernel 撞上一個 `if`、而它的條件對某些執行緒為真、對另一些為假之前,這都沒問題。這時執行緒們對「要往哪走」不再有共識——而單一個程式計數器沒辦法同時指向兩個地方。這就是 分支發散,它是 GPU 程式設計裡最重要的單一效能概念。
硬體誠實卻略帶痛楚的解法,是把兩條路徑一條接一條地都跑一遍,並把當下不該活動的執行緒遮罩掉(mask)。它先跑 `then` 那一側,把條件為真的執行緒打開、其餘關掉(它們的結果丟棄)。接著把遮罩翻轉,跑 `else` 那一側。直到兩側都跑完,執行緒們才重新會合(reconverge)、再一起前進。所以一個從中間裂開的 warp,得依序為兩條分支付帳——工作量加倍,而被遮罩掉的那些通道就乾坐著空轉,把時間燒在什麼都沒做上。
Warp of 8 lanes (real warps are 32). Each runs:
if (x > 0) { A; } else { B; }
lane: 0 1 2 3 4 5 6 7
x > 0 ? T F T T F F T F
step 1 A: [A] . [A] [A] . . [A] . <- 'else' lanes idle
step 2 B: . [B] . . [B] [B] . [B] <- 'then' lanes idle
\__________ both run, in sequence __________/
Result: this warp takes time(A) + time(B), not max of the two.
A warp where ALL lanes agree runs only one side -> no penalty.有兩點能讓這件事保持誠實。第一,發散是以 warp 為單位、不是全域的:如果一個 warp 裡的 32 個執行緒剛好對分支取得共識,這個 warp 就只全速跑被選中的那一側,不付任何代價。傷害純粹來自同一個 warp 裡的執行緒意見不合時。第二,這不是 CPU 上那種分支預測問題——GPU 不是猜一個方向再回滾;它確定性地把兩側都跑一遍。代價不是預測失敗的清空,而是實實在在被序列化的工作。迴圈次數依資料而定時也會以同樣方式發散:warp 會一直迴圈到最後一個執行緒做完為止,而早做完的就乾坐著空轉。
順著機器的紋理寫程式
懂了 warp,你就能寫出順著硬體紋理流動、而不是逆著它的程式碼。第一課是把資料安排好,讓同一個 warp 裡的執行緒走同一個分支。如果你非得依某個條件把工作分開,試著讓這個切分落在 warp 的邊界上——例如把元素排序,使所有「為真」的情況聚在一起——這樣每個 warp 都是一致的、永不發散。對於兩側都很便宜的小而平衡的 `if`,直接跑兩邊往往比為了避開發散所花的力氣還划算,所以該量測、而不是害怕每一個分支。
同一副 warp 眼鏡也解釋了為什麼記憶體存取樣式如此要緊。當一個 warp 的 32 個執行緒讀取 32 個相鄰的位址時,硬體能把它們融合成一個寬寬的記憶體交易——這叫合併存取(coalescing),是空間區域性穿上了 GPU 的衣服。反之,當這些執行緒讀取四散的位址時(一次gather),warp 那一次載入就碎裂成許多筆獨立交易,吞吐量隨之崩塌。所以「一個 warp 裡的執行緒應該碰相鄰的資料」就是記憶體那一階「對快取友善的程式碼」一課的 GPU 表親:規則的形狀一樣,只是單位從一條快取行換成了一個 warp。
這如何銜接,以及接下來是什麼
退一步看,SIMT 就不再是行話。它是一樁誠實的交易:讓程式設計者寫簡單的、每執行緒的純量程式碼,而讓硬體藉由把執行緒以步調一致的束來跑,重新賺回 SIMD 的效率。CUDA 程式設計模型不過是這樁交易的詞彙——執行緒分組成區塊(block)、區塊組成網格(grid)、每個執行緒拿到一個索引。在底下,這些執行緒乘著 warp,warp 們藉由互相替換來隱藏延遲,而整台機器把電晶體都灌進算術裡,因為 SIMT 的結構讓它省下了「每個執行緒一個控制單元」的開銷。
- 把 kernel 寫成純量的、每執行緒的程式碼;發射成千上萬份複本,每份各有自己的索引——這就是 SIMT 的視角。
- 硬體以步調一致的束(warp,NVIDIA 是 32 個)來跑執行緒:一次擷取、一個程式計數器、多份資料。
- 一個把 warp 劈開的 if 判斷會發散:兩側依序各跑一遍、各有一半通道被遮罩掉,於是工作量加倍。
- 為 warp 而寫:讓一個 warp 裡的執行緒走同一個分支、並讀取相鄰的記憶體(合併存取)。
帶著一句誠實的話往前走:GPU 不是一台更快的 CPU,它是一樁不同的交易。當成千上萬個執行緒對「該做什麼」取得共識、並碰觸整齊相鄰的資料時,它令人嘆為觀止;而對於序列、多分支、對延遲敏感、warp 會發散、又沒有資料層級平行性可餵的程式碼,它則真的很差。這個階段的最後一篇會把這場取捨攤開來講——把 GPU 的吞吐量對上 CPU 的低延遲長處,並用 roofline(屋頂線)這個概念告訴你,某個工作負載究竟在哪一個上面真的跑得比較快。