另一條平行的座標軸
這道階梯前面的每一次加速,都是從一條指令流裡再榨出更多。管線把它們的各個階段像晾衣繩般交疊;超純量與亂序執行在資料就緒時,每個週期發出好幾條不同的指令;多核心則把整條條獨立的指令流交給分開的核心。那一家子是指令層級與執行緒層級的平行——不同的工作,一起跑。這篇導覽轉了九十度,問一個新問題:如果工作完全相同,而資料是複數呢?
想像把兩個陣列逐元素相加:對一百萬個 `i` 做 `c[i] = a[i] + b[i]`。一個樸素的純量迴圈每個元素發出一個加法,外加迴圈的記帳工作,外加每次回到頂端的一個分支。然而每一次迭代跑的都是一模一樣的加法——只有運算元不同。這很浪費:我們付出代價去取出、解碼同一條指令一百萬次,只為了做一個運算的一百萬份拷貝。資料層級平行(DLP)的洞見是:一個解碼過的運算,其實能一次廣播到許多筆資料上,把所有那些取指與解碼的開銷給攤掉。
這種複數的資料從哪來?凡是真實世界成批湧來的地方都有。多媒體是一列列被同樣對待的像素、一串串的音訊取樣;科學計算是用同一道公式走過的矩陣與網格;現代機器學習則是在權重張量上汪洋般的乘加。這些工作負載淹沒在「相同運算、許多資料」的結構裡,這正是本節其餘篇章裡的硬體存在的理由。DLP 不是什麼奇技淫巧——它是我們所跑最吃力的程式最自然的形狀。
SIMD:一條指令,許多通道
硬體的答案是 SIMD——單指令多資料(Single Instruction, Multiple Data)。在Flynn 分類裡,一顆對一對運算元做一個運算的普通 CPU 是 SISD;SIMD 保留那條單一指令,卻餵給它一整個向量的運算元。想像一排收銀員,全被遞上完全相同的一行指令——「把你的兩個數字加起來」——而一盤盤的數對被推到他們面前;他們同時相加,一盤盤的和就滑出來。每一個收銀員是一條通道(lane),而這些通道步伐一致地前進:相同的運算、相同的週期、不同的資料。
為了裝下那一盤運算元,SIMD 硬體加進了寬寬的向量暫存器。一個普通的暫存器裝一個 64 位元的值,而一個向量暫存器是一只長長的箱子——比方說 512 位元——你能把它切成許多通道:八個 64 位元的倍精度數、或十六個 32 位元的浮點數、或三十二個 16 位元的數。於是一個向量加法在單一指令裡做了十六次浮點加法。關鍵在於,這不是憑空多出來的免費 ALU;晶片裡確實並排接著十六個加法器。SIMD 的勝利不是無中生有的每秒更多算術——而是把取指與解碼的成本付一次,再橫跨整個通道寬度重複使用。
Scalar: one add per instruction
a0 + b0 = c0 (decode an 'add' ... again ... again ...)
SIMD: one add across 8 lanes of a 512-bit vector register
| a0 | a1 | a2 | a3 | a4 | a5 | a6 | a7 | <- vector reg A
+ | b0 | b1 | b2 | b3 | b4 | b5 | b6 | b7 | <- vector reg B
----+----+----+----+----+----+----+----
| c0 | c1 | c2 | c3 | c4 | c5 | c6 | c7 | <- one VADD instruction
Same opcode fetched ONCE; 8 (or 16, or 32) results per issue.
Lanes never talk to each other here -- each is its own little adder.餵飽通道:跨步、聚集與散佈
寬寬的加法器若餵不進資料就毫無用處。友善的情況是連續的資料:`a[0..7]` 在記憶體裡彼此相鄰,所以一個寬載入一趟就抓進全部八條通道——而且因為那一趟拉進了一整條快取列,它漂亮地搭上了空間區域性的順風車。這正是為何 SIMD 與記憶體階層是莫逆之交:一個剛好對應到一條快取列的向量載入,是餵飽通道最便宜的方式。
真實資料不總是這麼整齊。有時你要的是每第四個元素(一張 RGBA 影像的紅色通道),那是一種跨步(strided)存取——間隔固定、規律的縫隙。更糟的是,有時索引本身就是資料,像 `c[i] = a[index[i]]`,此時每條通道都得伸向一個不同、無法預測的位址。硬體把這些命名為聚集(gather,把分散的位址收進一個向量暫存器)與散佈(scatter,把一個向量寫回分散的位址)。聚集/散佈讓 SIMD 通用得多,但要老實面對它的代價:一個碰到八條不同快取列的聚集,遠比一個連續載入慢得多,因為這些通道不再共享去記憶體的同一趟旅程。
從擴充到自動向量化(與它的極限)
你在實務中遇見 SIMD,是以加裝在既有 CPU 上的指令集擴充形式。在 x86 上是 SSE 與 AVX(AVX-512 給出上面那種 512 位元暫存器);在 Arm 上——你手機裡的那顆晶片——是 NEON,較新的 SVE 還讓向量長度可變。每一個都是一個ISA 擴充:把額外的運算碼與向量暫存器嫁接到一顆純量核心上,共用它的管線與快取。注意這帶來的二進位相容性皺褶——一個用了 AVX-512 的執行檔,在缺少那些指令的舊晶片上根本跑不起來,這正是為何軟體常常隨附多條程式碼路徑,並在啟動時挑一條。
究竟是誰在寫向量指令?理想上不是你。自動向量化(auto-vectorization)是編譯器在能證明改寫安全時,悄悄把一個純量迴圈改寫成向量指令。`c[i] = a[i] + b[i]` 這個迴圈是夢幻案例:迭代彼此獨立、資料連續、迴圈次數已知。編譯器把它一塊塊剝成比方說八個一組,每一塊發出一個 SIMD 加法,最後再收拾任何剩下的零頭元素。當它奏效時,你用同一份原始碼白白得到一個大幅的加速。
但自動向量化很脆弱,而這裡誠實很重要。編譯器必須證明那些迭代彼此獨立;如果兩個陣列參考可能別名(指向重疊的記憶體)、或迴圈帶有像 `a[i] = a[i-1] + 1` 這樣的相依、或它內部不可預測地分支,編譯器就會保守地拒絕,於是你不聲不響地拿到純量程式碼。這正是為何效能工程師學會把迴圈寫成編譯器能向量化的樣子——簡單的迴圈體、沒有別名、連續的存取——再不然,就降到手寫的內建函式(intrinsics)。SIMD 很美妙,但實務上它並非自動發生;它是一份程式碼必須遵守的契約。
GPU:當「許多資料」變成「成千上萬」
把 DLP 推到極致,你就得到圖形處理器(GPU)。CPU 的 SIMD 單元也許有十六條通道,栓在一顆聰明、獵捕延遲的核心上;而 GPU 幾乎把它所有的矽都倒進成千上萬個簡單的算術單元。這道階梯前面的比喻完美契合:CPU 是一位能在任何障礙旁即興應變的天才主廚,GPU 則是一千個各做一個小加法的實習生。對於那個橫跨一百萬元素的陣列相加,一千個實習生輾壓那位孤獨的天才——這就是作為吞吐量機器的 GPU。
GPU 跑的是 SIMD 的一個表親,後面幾篇導覽會拆解它:SIMT(單指令多執行緒,Single Instruction, Multiple Threads)。你寫的程式看起來像是針對一個資料元素的普通程式碼——一個核心程式(kernel)——而硬體跑它的成千上萬份拷貝,把它們綁成一組組(一個 warp),像 SIMD 通道般步調一致地執行。這是表達同一份 DLP 較友善的方式。我們會在 SIMT 那篇看到,這種步調一致如何既賦予 GPU 力量,又在一個 warp 裡的執行緒想走不同分支的那一刻把它絆倒。
用一個關鍵而誠實的對比作結,本節其餘篇章會把它磨利。GPU 並不只是一顆更快的 CPU。它在龐大、規律、彼此獨立的資料上壓倒性地獲勝——但它對串列、多分支、對延遲敏感的程式碼是真的很差,而那正是 CPU 的分支預測器與亂序機制生來擅長的事。一個工作負載是受限於頻寬還是受限於計算,由我們稍後會見到的屋頂線模型來刻畫。重點是:吞吐量與延遲是不同的目標,沒有單一晶片能同時贏下兩者——這正是為何現代系統把 CPU 與 GPU 配成一對,而不是二選一。