JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

CPU 對 GPU:挑對工具

你現在懂了 SIMD、向量暫存器與 SIMT 的 warp。本節最後這篇導覽退後一步,問工程師真正的問題:給定一份工作,它該在哪裡跑?我們會建起屋頂線的圖像,把獵捕延遲的 CPU 與渴求吞吐量的 GPU 對照起來,並誠實面對 GPU 真的很差的那類程式碼。

兩台機器,兩個目標

到了現在,你桌上那兩顆晶片該感覺像不同的物種,而非不同的速度。CPU 生來是要把條工作鏈盡快做完——把回應時間壓到最低。這正是為何它把電晶體花在亂序執行、深層的分支預測與大快取上:全是為了讓一條單一、彎彎曲曲、滿是相依的指令流衝向終點而設的機器。GPU 下的是相反的賭注。它幾乎不在乎任何單一項目花多久;它在乎的是每秒的總工作量——吞吐量——所以它把矽倒進成千上萬條簡單的通道,並靠著總有另一個 warp 待命可跑來把延遲藏起來。

把那個老比喻清楚地放在心裡:CPU 是一位能在食譜裡任何障礙旁即興應變的天才主廚,GPU 則是一千個各做一個小加法的實習生。要主廚跑一場全是相同加法的馬拉松,實習生輾壓他;要實習生照著一份彎彎繞繞、滿是「如果醬汁油水分離,改做這個」的食譜,他們就潰散,而主廚只是聳聳肩順勢應變。沒有誰比較好——它們回答的是不同的問題。這整篇導覽的本領,就是學會辨認你的程式碼在問哪一個問題。

屋頂線:你的工作是挨餓還是忙碌?

要決定一份工作該歸何處,我們需要一個關於工作本身、而非晶片的數字。那個數字是 算術強度(arithmetic intensity):你每從記憶體搬一個位元組,做了多少算術。把它定義為浮點運算數除以碰到的位元組數。像 `c[i] = a[i] + b[i]` 這樣的迴圈讀 8 個位元組、寫 4 個(單精度)只為做一個加法——強度約 0.1 flops/位元組,幾乎是純粹的資料搬運。把兩個大矩陣相乘,則把每個載入的值在一整列工作裡重複使用——強度落在數十或數百。同樣的硬體,對記憶體系統的需求卻天差地遠。

屋頂線模型(roofline model)把這變成一張圖。把可達到的效能(flops/秒)對著算術強度畫出來。有兩道天花板。左邊那道斜屋頂是頻寬極限:如果你每位元組做的算術很少,你的速度就被記憶體能多快餵你所封頂——尖峰頻寬乘以強度。右邊那道平屋頂是計算極限:一旦強度夠高,你受限的就是晶片的尖峰算術,而非它的記憶體。兩道屋頂相交之處是脊點(ridge point)。你的核心程式落在這些屋頂底下某處,而它撞上哪一道屋頂,會告訴你該修什麼。

  performance
  (flops/s)
     ^
 peak|............______________________  <- compute roof (flat)
     |          /:
     |         / :        matmul lives here
     |        /  :        (compute-bound)
     |       /   :  * 
     |      /    :
     |     / <-- bandwidth roof (slope = peak bandwidth)
     |    /:
     |   / :  *  array-add lives here
     |  /  :     (bandwidth-bound)
     +-/---+---------------------------> arithmetic intensity
          ridge                          (flops per byte)

  Left of the ridge: memory-bound -> raise intensity / reuse data.
  Right of the ridge: compute-bound -> the GPU's thousands of lanes shine.
屋頂線的草圖。脊點以左,你被記憶體頻寬餓著;脊點以右,你受限於純算術。同一份程式碼在一顆晶片上可能受限於頻寬、在另一顆上受限於計算,因為每台機器有自己的屋頂。

GPU 大獲全勝之時

GPU 在工作極度平行(embarrassingly parallel)時發光:成千上萬個彼此獨立的項目、對每個做相同的運算,而每位元組有足夠的算術讓通道忙碌而非挨餓。這正是前面幾篇導覽裡的資料層級平行,放大來看。稠密線性代數、影像與影片濾鏡、網格上的物理、以及神經網路核心那場乘加風暴,全都舒舒服服地坐在屋頂線脊點的右側,那裡 GPU 的成千上萬條通道正是整件事的重點。餵這樣一個核心程式足夠多的執行緒,硬體就會在某個 warp 卡住時換進另一個來把記憶體延遲藏起來——延遲被藏住,吞吐量被推到最大。

但純算術只是勝利的一半;另一半是頻寬。GPU 把它眾多的通道配上非常寬、非常快的記憶體——想想堆疊在晶片旁的高頻寬記憶體——正是因為若非如此,成千上萬條通道會挨餓。這是 SIMD 教過的同一課,如今放到行星尺度:加法器很容易,餵飽它們才是工程。GPU 在一個受限於頻寬的核心程式上的優勢,往往與其說在於 flops,不如說在於那條通往記憶體的粗管。從屋頂線上讀出來:撐起受頻寬限制的程式碼的,是一道更陡的頻寬屋頂,而非一道更高的計算屋頂。

GPU 是錯誤工具之時

現在來到誠實的那一半。GPU 對串列、多分支、對延遲敏感的程式碼是真的很差,假裝不是這樣會白白浪費真金白銀。串列:如果第 N 步相依於第 N-1 步,你沒辦法把工作攤在成千上萬條通道上——這條鏈有的是長度,不是寬度,你那 1000 個實習生裡有 999 個閒站著,只有一個在做事。CPU 帶著它兇悍的亂序引擎,正是為了讓一條單一的相依鏈衝刺而生。多分支:回想 SIMT 那篇的分支發散——當一個 warp 裡的執行緒走上不同路徑,硬體會把兩條路徑都跑一遍,再把不該跑的通道遮掉。滿是無法預測的 `if` 的程式碼,會把 GPU 拖向它尖峰的一小部分。

對延遲敏感:GPU 靠手上有大量其他工作來把延遲藏起來。如果一份任務很小、或必須此刻立即回應——處理一次按鍵、跑一個短查詢、在一毫秒內反應——就沒有一列列的 warp 可換進來,於是 GPU 的整套策略崩潰,你剩下的是一顆緩慢、低時脈的核心。而且在 GPU 跑起來之前,你還得先繳一筆稅:主機—裝置傳輸。資料必須從 CPU 記憶體越過一條 PCIe 連結進到 GPU 記憶體,結果再傳回來。如果核心程式很快,這趟拷貝可能比你卸載出去的計算還貴——這是把程式碼「加速」到反而更慢的經典手法。

真正的答案:兩個都用

標題要你挑一個工具,但成熟的答案很少是「非此即彼」。真實的系統是異質的:CPU 跑程式的串列脊椎——多分支的控制邏輯、一部分接一部分的決策、那些必須當下回應的部分——再把龐大、規律、平行的整片整片算術交給 GPU。CPU 是主機(host),負責調度;GPU 是裝置(device),埋頭磨完吞吐量的工作。這正是驅動整個後 Dennard 故事的同一種分工:當時脈在 2005 年前後不再變快,業界轉向多核心,再轉向專門化的引擎,各自做它最拿手的事。

帶走一個心智模型。讓工作的形狀去配機器的形狀:一條彎彎曲曲的相依鏈想要獵捕延遲的 CPU;一片由相同、獨立、算術密集的工作組成的汪洋,想要吞吐量型的 GPU。屋頂線告訴你是挨餓還是忙碌;算術強度與傳輸成本告訴你那趟越過 PCIe 的旅程值不值得。挑對工具不是對某顆晶片的忠誠——而是誠實地讀懂工作,而最有野心的程式到頭來往往兩種工具都需要,協同運作。