每個人都該懂 SIMD
SIMD(單指令多資料) 給人一種很複雜的印象。我遇過許多非常優秀的軟體工程師,他們認為 SIMD 太過複雜而難以學習,或是將其視為僅適用於極致高效能軟體的利基最佳化,在日常程式設計中派不上用場。
我認為這種想法是錯的。SIMD 其實可以很簡單易懂,而常見用來加速單純 for 迴圈、一次處理 N 個值的 SIMD 程式碼,幾乎都遵循相同的固定模式。一旦掌握基礎,撰寫 SIMD 就跟寫 for 迴圈一樣簡單。而當它不再簡單時,通常就代表現在先跳過它是個好主意。
每一位開發者至少都應該掌握到這個程度的 SIMD。
本文以 Zig 作為範例,但內容適用於任何程式語言。不同程式語言對 SIMD 指令的支援程度各不相同,我希望未來有更多程式語言能提供這些通用的概念。
我很討厭現在每篇文章都得加這句聲明,但我還是想註明:本文完全由人工撰寫,沒有使用任何 AI 輔助。
背景:什麼是 SIMD?
如果你已經知道 SIMD 是什麼,可以直接跳過這一節。
SIMD 讓 CPU 能夠同時對多個值進行平行運算。舉例來說,CPU 不必一次只比較一個位元組,而是能用單一指令同時比較 4、8 甚至更多位元組。
如果你在程式碼中曾看過像這樣的迴圈:
for (byte in bytes) { /* ... */ }
for (character in string) { /* ... */ }
for (value in array) { /* ... */ }這就是使用 SIMD 的機會。SIMD 會將它們轉變成這樣:
for (8 byte chunk in bytes) { /* ... */ }這會帶來與平行度直接對應的局部加速:你處理資料的速度會變成 4 倍、8 倍,甚至更快。
要讓這樣的最佳化真正划算,唯一的實際條件是你需要經常處理足夠大量的位元組。如果你用這些 for 迴圈處理的資料永遠只有幾個或幾十個位元組,那就沒什麼價值。但如果是對數百、數千、數百萬個位元組進行迭代,效益就會非常巨大。
以上就是基礎概念。像 simdutf 和 simdjson 這類專案則將此發揮到極致,使用了難以理解的 SIMD 技巧。但你不需要寫出那樣的演算法也能從 SIMD 中獲益。一般常見的情況要簡單得多。
常見的模式
常見的「一次處理 N 個值」的 SIMD 程式碼都遵循相同的五個步驟:
- 廣播所需的任何常數,並初始化向量累加器(如果有的話)。
- 每次以一個向量寬度的區塊為單位,對輸入進行迴圈。
- 在所有通道上平行執行比較或算術運算。
- 依需求縮減或儲存向量結果。
- 用純量尾段處理剩餘的元素。純量尾段其實就是你向量化之前的一般迴圈,只是它只處理無法填滿完整向量的剩餘部分。
當你愈常這樣做,就會愈自然地將每個 for 迴圈拆解成這五個步驟,撰寫 SIMD 也就變得幾乎跟寫純量迴圈一樣自然。
實際範例
讓我們來看看一個來自 Ghostty 的實際範例。我們會先看純量實作,再看 SIMD 實作,然後將它對應回上述的常見模式。
我有一個已解碼的碼位(codepoint)切片,想要一路消耗直到遇到小於或等於 0xF 的值(即 C0 控制字元)。2 終端機中大部分都是要印出的一般字元,因此我們試著將它們批次處理在一起。所以這個迴圈的目的,就是盡可能快速地找到下一段可列印區間的結尾。
純量迴圈只有一行:
while (end < cps.len and cps[end] > 0xF) end += 1;它一次只處理一個碼位,非常容易理解。
以下是沒有使用特定 CPU 內建函式3、也沒有註解的通用向量版本,稍後我會詳細解說。
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;
const greater_than_threshold = values > threshold;
if (@reduce(.And, greater_than_threshold)) continue;
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;
}
}
while (end < cps.len and cps[end] > 0xF) end += 1;多了 12 行程式碼。
這可以將迴圈的處理量提升最多 4 倍(使用 ARM NEON,包含 Apple Silicon)、8 倍(使用 AVX2,多數現代 x86 CPU)以及 16 倍(使用 AVX-512,部分 Intel CPU 與 AMD Zen 4 及更新的處理器)。
在 AVX2 的 Intel 桌機上,從終端機程式到最終終端機狀態的實際端對端處理量,這帶來了大約 5 倍的加速。你總會因為 SIMD 程式碼周圍的其他開銷而損失一部分理想的加速幅度,但是……那仍然是 5 倍!
好吧,我理解對於不熟悉這些概念的人來說,那 12 行程式碼看起來會非常陌生。所以現在讓我們退一步,一步步解說,並將其直接對應到前面提到的模式。
步驟 1:廣播常數
讓我們從前三行開始:
if (simd.lanes(u32)) |lanes| {
const V = @Vector(lanes, u32);
const threshold: V = @splat(0xF);simd.lanes(u32) 是 Ghostty 中的一個輔助函式,會回傳目標 CPU 一次能處理的 u32 數值數量。這些個別的數值稱為通道。在 ARM 上會回傳 4,AVX2 回傳 8,AVX-512 則回傳 16。如果目標平台沒有我們想使用的向量尺寸,它會回傳 null,我們就會跳過所有這些程式碼,完全不做 SIMD 處理。
@Vector(lanes, u32) 會建立向量型別。如果 lanes 是 8,那麼 V 就是一個包含八個 u32 值的單一數值,CPU 可以對其進行平行運算。依此類推。
最後,我們需要將每個值與 0xF 進行比較。向量比較需要在兩側都是向量,因此 @splat(0xF) 會將 0xF 複製、也就是廣播到每一個通道中。結果會是一個看起來像這樣的向量:
{ 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }這就是步驟 1:準備向量型別並廣播任何常數。有些演算法還會在這裡初始化向量累加器,但這個演算法不需要。
步驟 2:一次處理一個向量
接下來,我們一次對一個完整的向量進行迴圈:
while (end + lanes <= cps.len) : (end += lanes) {
const values: V = cps[end..][0..lanes].*;如果 lanes 是 8,我們只在至少剩下八個值時才進入迴圈。在迴圈內,我們將那八個值載入到向量 values 中。在每次迴圈結束時,end += lanes 會一次前進八個值,而不是一個。
需要完整向量的這個要求很重要。如果只剩下五個值,就無法載入一個八通道的向量。有各種技巧可以處理這種情況,但我們採取簡單的做法,透過純量尾段來處理,稍後會在步驟 5 中說明。
這就是步驟 2:每次以一個向量寬度的區塊為單位,載入並遍歷輸入。你可以在這裡看到通道數量帶來的加速!
步驟 3:執行 SIMD 運算
現在我們來執行比較:
const greater_than_threshold = values > threshold;values 和 threshold 都是向量,因此這會對應到一個向量運算(也就是一條實際的向量 CPU 指令)。那個 > 會將 values 中每個通道的值與 threshold 中對應通道的值進行比較。如果有八個通道,這就等同於執行八次純量比較 cps[end] > 0xF,但它只用一條 CPU 指令就完成了。4
結果是另一個每個通道各有一個布林值的向量。從概念上來看,它像是這樣:
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
threshold: { 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF, 0xF }
greater_than_threshold: { true, true, true, false, true, true, true, true }這就是真正的 SIMD 運算。沒有明確的內層迴圈。> 運算子會同時平行地套用到每一個通道。
比較只是其中一個例子。這也可以是加法、乘法、最小值、最大值,或向量型別支援的任何其他運算。重點是程式碼仍然維持相同的模式。
步驟 4:縮減向量結果
現在我們有了一個布林向量,但原本的迴圈需要知道第一個小於或等於 0xF 的值的位置。
首先,來處理所有值都大於 0xF 的常見情況:
if (@reduce(.And, greater_than_threshold)) continue;@reduce(.And, ...) 會使用 and 將所有布林值組合起來,並回傳單一布林值。如果每個通道都是 true,我們就 continue 並處理下一個向量。在我們的範例中,通道 3 是 false,因此 @reduce 會回傳 false,我們就會往下執行以找出究竟是哪個通道失敗。
如果有任何通道是 false,我們就需要精確找出是哪個通道失敗:
const mask: std.meta.Int(.unsigned, lanes) = @bitCast(greater_than_threshold);
end += @ctz(~mask);
break;@bitCast 會將布林向量轉換成每個通道對應一個位元的整數。位元為 1 表示該值大於 0xF,為 0 則表示不是。我們將遮罩反向,讓失敗的比較變成 1,然後 @ctz 會計算第一個失敗之前的零位元數量。該數量就是第一個失敗通道的索引。
我們將該索引加到 end 上並 break,因為我們已經找到了控制字元。
使用與步驟 3 相同的值,我們可以看到每個通道的轉換過程:
values: { 0x41, 0x42, 0x43, 0x0A, 0x44, 0x45, 0x46, 0x47 }
greater_than_threshold: { true, true, true, false, true, true, true, true }
mask: { 1, 1, 1, 0, 1, 1, 1, 1 }
~mask: { 0, 0, 0, 1, 0, 0, 0, 0 }@ctz(~mask) 會在第一個 1 之前數到三個零位元,因此回傳 3。將 3 加到 end 上,就會指向包含 0x0A 的通道 3,也就是第一個控制字元。
這就是步驟 4:將向量結果縮減成原始演算法所需的任何形式。這也是在不同演算法之間變化最大的步驟。加總可能會將向量累加器縮減成單一數值;轉換可能會將整個向量儲存到輸出緩衝區。我們的掃描則是將向量轉換為位元遮罩,以便找到特定的通道。
步驟 5:以純量尾段收尾
在向量迴圈之後,我們執行一開始那個完全相同的純量迴圈:
while (end < cps.len and cps[end] > 0xF) end += 1;如果輸入長度不是向量寬度的整數倍,這段迴圈就會處理剩餘的值。舉例來說,一個八通道的向量迴圈會為這個迴圈留下零到七個值。這就稱為純量尾段。
這個迴圈同時也處理 simd.lanes(u32) 回傳 null 的 CPU。在這種情況下,我們會跳過所有 SIMD 程式碼,由純量迴圈處理整個輸入。原本的實作同時作為備援方案與尾段處理。
這就是步驟 5。它就只是一般的迴圈。
回顧:常見的模式
讓我們將整個實作對應回五個步驟:
@splat(0xF)將比較值廣播到每一個通道。while迴圈一次載入lanes個值。values > threshold平行比較每一個通道。@reduce、@bitCast和@ctz找出第一個失敗的比較。- 原本的純量迴圈處理剩餘部分與不支援的 CPU。
步驟 4 的細節一開始需要花些時間理解,但整體架構非常直觀。而且步驟 1、2、3 和 5 在完全不同的演算法中,往往看起來幾乎一模一樣。
每當你看到 for (byte in bytes),你就會將它對應到這個模式。
為什麼編譯器不能代勞?
有時候它是可以的!編譯器可以 auto-vectorize(自動向量化) 簡單的迴圈,特別是沒有複雜控制流程的規則算術迴圈。在手動撰寫 SIMD 之前,你應該永遠先以最佳化選項編譯純量版本,看看編譯器產生了什麼。
但編譯器在能 auto-vectorize 的範圍上受到嚴重限制,整體表現也相當不佳。Auto-vectorization 數十年來一直是編譯器研究中活躍的領域,而近期的研究仍然從「正式環境的編譯器經常錯失向量化機會」這一觀察出發。我不認為這個問題會很快消失。
更重要的是,當這個迴圈重要到讓我在意 5 倍加速時,我希望向量化是明確且可預測的。我不希望一個不相關的程式碼變更或編譯器更新,就悄悄地讓它變回純量迴圈。
每個人都該懂 SIMD
每一位開發者都應該能夠辨識出這樣的機會,更重要的是,不應該對 SIMD 感到畏懼。如果你看到一個熱點迴圈正在掃描、比較、計數或轉換大量連續資料,你就應該能夠想像以一個向量寬度為單位來處理它。
本文展示了這些常見情況都遵循非常規則的模式,你很快就能習慣。而且有了良好的語言支援,你不需要懂任何組合語言或特定 CPU 的細節,就能獲得輕鬆的效能提升。
每個人都應該至少懂 SIMD 到能夠做到這件事的程度。5
註腳
像 simdutf 和 simdjson 這類非常出色的專案,使用了極其複雜的 SIMD 技巧來達成目標。但這並不是我所認定的「日常 SIMD」。↩
C0 控制字元的範圍超過
0xF。這是 Ghostty 在這個特定程式碼路徑中所使用的切分點;ESC 與其他控制序列的處理在其他地方進行。↩通用向量移除的是特定 CPU 的語法,而非特定 CPU 的程式碼產生。Zig 仍然會將這些操作降為目標平台所啟用的指令集。Ghostty 在無法選擇支援的向量寬度時,會退回純量程式碼。↩
比較本身是一次向量運算。載入向量、縮減結果以及定位失敗的通道則需要額外的指令。重點在於我們一次進行了多個比較。↩
本文基於我曾在 Lobsters 上發表的一則留言。↩
隨機一篇部落格