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

向量處理器與 SIMD 擴充指令

第一篇指南給了你 SIMD 的核心想法:一道運算、許多資料。現在來認識真正執行它的機器——長長的向量暫存器與通道、螺進普通 CPU 裡的 SSE/AVX/NEON 擴充、會替你嘗試(卻常失敗)的自動向量化編譯器,以及 GPU 這部千名實習生的吞吐量引擎,它在「寬」的工作上快得驚人,在分支密集的序列程式上卻是真的不行。

一道運算、許多資料在哪裡才真正划算

上一篇指南埋下了種子:資料層級平行 指的是對一整個陣列的值套用完全相同的運算,而元素之間沒有相依。在打造機器之前,我們得誠實面對這個模式究竟出現在哪裡——因為那是本篇一切的前提。它住在 多媒體 裡(替每個像素加上同樣的亮度、把同樣的增益混進每個音訊取樣),住在 科學計算 裡(兩個向量相乘、推進模擬格點的每一格),也住在 機器學習 裡(一次矩陣乘法就是數百萬對彼此獨立的乘加)。它們共同的形狀,是一個各回合彼此不交談的迴圈。

當這個形狀存在時,一次處理一個元素就太浪費了:你付出抓取與解碼一道指令的全部開銷,卻只為了把一對數字相加,然後再來一次、再一次。SIMD——單指令、多資料——的解法是發出一道指令,一口氣處理許多元素。你把指令的開銷攤提到整個包裹上,硬體一次平行地把比方說八對數字相加,而純量機器得跑八次各自獨立的加法。這就是它的全部賣點,也是為什麼這種平行風格加到 CPU 上很便宜、而每個結果的成本更便宜。

向量處理器:暫存器、通道,以及記憶體如何餵養它們

經典的化身是 向量處理器。普通的暫存器檔每個暫存器只裝一個純量,而 向量暫存器 裝的是一整排元素——想像一個暫存器不是單一個盒子,而是一個有比方說 8、16 或 64 個格位的托盤。單一道向量指令,例如「向量加」,會把整個托盤串流穿過加法器。硬體被組織成一條條 通道(lane):算術單元的多份平行副本,每一份咀嚼托盤的一個格位。八條通道,意思是八個加法在同一個時脈週期裡並肩發生,就像八位收銀員同時為八位顧客結帳,而不是一位收銀員伺候一條排隊人龍。

這裡有一種美麗的經濟。一次抓取、一次解碼、一次發出——接著是一長串有用的算術。在純量迴圈裡主宰一切的控制開銷被攤得很薄,而且處理器事先就確切知道有多少元素要來,於是能不斷餵飽各條通道與 管線,免去純量迴圈每回合的分支開銷。真正的向量機器還帶著一個 向量長度 暫存器,讓一道指令能處理只裝了一半的托盤——也就是陣列尾端那個無法被通道數整除的零頭。

但通道再快,也只能跟記憶體填滿它們的速度一樣快。最簡單的情形是連續區塊——元素一個接一個排著,一次有效率的掃描就載入。真實資料更雜亂,所以向量機器支援兩種彆扭的存取模式。跨步(strided)存取每隔 k 個抓一個(比方說每隔三個值,當你從交錯的 RGB 像素裡只讀一個色彩通道時)。聚集/散布(gather/scatter)依一個索引陣列給出的任意位址清單,讀寫各個元素——很靈活,但每個元素可能落在不同的快取列裡,所以會比乾淨的連續載入慢上許多。誠實的規則是:你的資料越分散,你實際收割到的 SIMD 承諾就越少。

SIMD 擴充:SSE、AVX、NEON——以及那個替你嘗試的自動向量化器

今天很少有人會買一台純粹的向量超級電腦。取而代之,向量的點子被螺進了普通 CPU,化身為 SIMD 擴充:在既有指令集上加進一組超寬暫存器與指令。在 Intel 與 AMD 上,你會遇到 SSE(128 位元暫存器,一次四個 32 位元浮點數)以及它的後繼者 AVX 與 AVX-512(256 與 512 位元暫存器,一次八或十六個浮點數)。在 Arm 晶片上——你的手機、一台 Mac——對應的是 NEON(以及更新的 SVE)。各處都是同一個想法:一個暫存器裝著一小撮固定數量的值,一道指令對整撮運算。這些是 軟硬體介面 的一部分——它們拓寬了 ISA 這份契約,讓軟體能指名要求平行的算術。

那麼,究竟是誰發出這些指令?理想上是編譯器,透過 自動向量化:你寫一個普通的純量迴圈,自動向量化器 認出這個模式,把它改寫成每道 SIMD 指令處理好幾個元素。當它奏效時簡直是魔法——你那段樸素的 C 迴圈悄悄地跑得寬上四或八倍,原始碼一個字都不必改。但它很脆弱,而誠實地交代它的極限正是本節的重點。編譯器必須能證明各回合真正彼此獨立;只要它一證不出來,就會放棄而發出緩慢的純量碼,而且往往不告訴你。

// vectorizes cleanly: independent, contiguous, countable
for (i = 0; i < n; i++)
    c[i] = a[i] + b[i];        // 8 adds per AVX instruction

// will NOT vectorize: each element depends on the previous
for (i = 1; i < n; i++)
    a[i] = a[i-1] + a[i];      // loop-carried dependence

// may NOT vectorize: aliasing unknown (do a and b overlap?)
void f(float *a, float *b, int n) {
    for (i = 0; i < n; i++) a[i] = b[i] * 2.0f;
}
上面的迴圈可向量化;中間有跨回合相依;下面卡住,因為編譯器無法證明兩個指標不重疊。

GPU:一千名實習生,而不是一位天才

把 SIMD 的想法推到極致,你就抵達了 GPU圖形處理器 生來就是為了替數百萬個像素上色,每一個都是一筆獨立的小計算——正是第一節那個無相依的形狀。所以 GPU 不是擺幾顆寬大的 CPU 核心,而是塞進數千個小而簡單的算術單元。最貼切的比喻是:CPU 是一位才華洋溢的天才,什麼都能做,包括棘手的序列推理,而且很快;GPU 則是一千名實習生,每位只會做一筆小小的加總,但一千位同時動手。對於對的問題,這一千名實習生會把那位孤獨的天才碾壓。

這讓 GPU 成為一部 吞吐量處理器:它不試圖快速完成任何單一工作,而是試圖每秒完成一大疊工作。吞吐量機器 容忍漫長的記憶體延遲,靠的單純是手上排著太多獨立的工作,於是每當一群工作卡住等記憶體時,硬體就立刻切換到另一群準備好的工作。CPU 把它的電晶體預算花在快取與精巧的亂序機制上,好讓單一串流跑得快;GPU 卻把預算花在純粹的算術寬度與眾多平行工作群上,好讓所有那些單元都忙著。這是把矽下在哪裡的兩種不同賭注。

你不會像寫 CPU 迴圈那樣寫 GPU 程式。程式設計模型(NVIDIA 上的 CUDA,以及他處類似的模型)要你寫一個短短的函式——稱為「核心」(kernel)——描述對單一個元素該做什麼,然後一口氣把它在數千個元素上發動。硬體把這數千份副本鋪展到它眾多的單元上。這與資料平行的形狀契合得很美,但它逼你把問題表達成「同一個小函式,獨立地跑上極多次」。真正具有這種結構的問題會一飛沖天;不具有的問題,無論你怎麼瞇眼端詳,都不合身。

CPU 對 GPU,以及框住兩者的屋頂線

這裡是事情誠實的核心,也是本篇最想斬除的誤解:GPU 不只是更快的 CPU。它是一種不同的賭注。GPU 在寬廣、規則、獨立的算術上極為出色——卻在序列、分支密集、對延遲敏感的程式上真的很差,也就是那種充滿 if 判斷、控制流難以預測、每一步都得馬上要前一步答案的鏈式程式。那種序列程式,正是 CPU 的亂序執行與分支預測被打造來啃食的對象。把這種程式交給 GPU,那一千名實習生大多會閒坐著,只有少數幾位慢慢爬過一團它們從未被設計來面對的糾結。

任一顆晶片究竟能不能觸及它的巔峰,取決於一張單一而清晰的圖,叫做 屋頂線模型(roofline model)。把可達到的效能對「算術強度」作圖——算術強度是有用的算術運算量與從記憶體搬動的位元組量之比。在低強度(每位元組沒幾個浮點運算,像是把兩個大陣列加一次)時,你是 受記憶體限制:一道由記憶體頻寬決定的斜「屋頂」把你壓住,更快的算術單元一點忙也幫不上,因為你正餓著沒資料。在高強度(每位元組許多浮點運算,像是密集矩陣乘法,把每個載入的值重複使用許多次)時,你撞上的是巔峰運算那道平坦的「屋頂」。這個模型在你動手最佳化之前就告訴你,你實際上抵著的是哪一面牆。

還有兩個誠實的但書替這一切收尾。其一,把工作送上 GPU,意味著要把資料經由一條相對狹窄的連結,從主記憶體複製到 GPU 自己的記憶體;對一個小工作來說,那次傳輸可能花得比省下的還多,所以只有當算術大到足以淹沒那次複製時,GPU 才會贏。其二,多核那一階的 Amdahl 定律 依然當家:哪怕你的程式只有一小片頑固地保持序列,那一片就替你的總加速比設下上限,無論你往平行的部分丟多少通道或實習生。大規模平行是一把對準某一種特定形狀問題的利刃——而不是一顆通用的「變快」按鈕。