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