每個人都應該了解 SIMD

每個人都應該了解 SIMD

什麼是 SIMD 以及它為何有效

SIMD 讓 CPU 可以同時對多個數據元素執行單一指令,將逐位元組(byte-wise)的迴圈轉變為逐區塊(chunk-wise)的迴圈,並提供與向量寬度相匹配的線性加速(例如:ARM NEON 為 4×,AVX2 為 8×,AVX-512 為 16×)。當你經常處理數百、數千或數百萬個數值時,這非常值得投入。

常見的五步驟模式

大多數「一次處理 N 個值」的 SIMD 程式碼都遵循五個步驟:將任何需要的常數廣播(broadcast)到向量中、每次以一個向量寬度的區塊進行輸入迴圈、執行逐通道(lane-wise)操作、將向量結果還原(reduce)為標量決策或遮罩(mask),最後以處理剩餘部分或不支援的 CPU 的標量尾部(scalar tail)結束。

實際範例:Ghostty 控制字元掃描

在 Ghostty 中,一個用於掃描解碼後的碼位(codepoints)以尋找第一個值 ≤ 0xF 的迴圈,是使用 Zig 語言並採用這五步驟模式編寫的。該程式碼廣播 0xF,載入 u32 向量,對其進行比較,使用 @reduce 進行還原以查看是否全部大於該值,使用 @bitCast@ctz 尋找第一個失敗的通道,然後回退到原始的標量迴圈。這在 ARM NEON 上可實現高達 4× 的吞吐量,AVX2 為 8×,AVX-512 為 16×,而在 AVX2 Intel 桌機上的實際加速約為 5×。

步驟 1:廣播常數

結論:你創建一個向量類型,並將比較值填充(splat)到每個通道中,以便 CPU 可以同時比較多個值。 Zig 程式碼片段顯示了 const threshold: V = @splat(0xF);,這會將 0xF 複製到向量的所有通道中。

步驟 2:一次載入一個向量

結論:以等於向量寬度的區塊來遍歷輸入,每次迭代載入一個完整的向量。 迴圈條件 while (end + lanes <= cps.len) : (end += lanes) 確保僅處理完整的向量,每次載入 lanes 個元素。

步驟 3:執行 SIMD 操作

結論:將操作(例如 >)應用於整個向量;CPU 會在所有通道中並行執行它。 表達式 values > threshold 透過單一 CPU 指令產生一個布林值向量,每個通道一個。

步驟 4:還原向量結果

結論:結合每個通道的結果(例如使用 @reduce)來決定是否繼續,或使用位元技巧定位第一個不匹配項。 @reduce(.And, greater_than_threshold) 用於判斷是否每個通道都通過;如果不是,@bitCast 會將布林向量轉換為位元遮罩,~mask 將其反轉,而 @ctz 則計算尾隨零的數量以找到第一個失敗通道的索引。

步驟 5:以標量尾部結束

結論:在向量迴圈之後,對剩餘元素或不支援 SIMD 的 CPU 執行原始的標量迴圈。 最後的 while (end < cps.len and cps[end] > 0xF) end += 1; 處理任何剩餘部分,並在 simd.lanes(u32) 返回 null 時作為回退方案。

為什麼編譯器不一定能做到這一點

結論:雖然編譯器可以對簡單迴圈進行自動向量化(auto-vectorize),但它們經常錯失機會且不可預測,因此顯式(explicit)的 SIMD 能提供可靠且可預測的加速。 自動向量化適用於沒有複雜控制流的常規算術迴圈,但生產環境的編譯器經常錯失向量化的機會。當一個迴圈的重要性足以帶來 5× 的增益時,你會希望向量化是顯式的,而不是受到無關程式碼編輯或編譯器更新所導致的隱形變化影響。

每個人都應該了解 SIMD

結論:識別出這種模式可以讓你放心地決定何時應用 SIMD;這五步驟模式易於學習,且可在不同語言間移植。 你不需要編寫像 simdutfsimdjson 中那樣奇特的演算法就能獲益;常見的情況要簡單得多,一旦你掌握了這些步驟,只需幾行程式碼即可表達。

社群見解與反對意見

結論:Hacker News 的評論者指出,SIMD 並非總是必需的,並強調了識別瓶頸、面向數據的設計、宏輔助工具,以及即使依賴編譯器或 AI,了解 SIMD 限制的重要性。

  • 「並非每個人都需要了解 SIMD。機械共鳴(Mechanical sympathy)是軟體架構師減少日後重工的重要被動優勢,但我認為基準測試(benchmarking)和能夠識別瓶點是更重要的日常技能。」 – @boricj
  • 「我喜歡 SIMD,但在使用 SIMD 等技術對程式碼進行超優化之前,請務必仔細考慮你的數據結構和存取模式。」 – @Rendello
  • 「每個人都應該了解 SIMD,這樣你才知道何時可以請你那位精通此道的優秀朋友 Al 來幫你正確地使用它。」 – @magarnicle
  • 「我猜我不理解『reduce』這個步驟。看起來你必須小心不要『抵消』掉 SIMD 帶來的所有好處。當然,它可以並行比較 8 個值,但如果你必須輪流查看這 8 個答案,你就回到了原點。範例中的 @reduce() 函數是一個特殊的向量函數,能在一『步』內告訴你是否所有值都為真嗎?」 – @losvedir
  • 「99% 的開發者應該直接忽略 SIMD。大多數專案都有很多提升效能的低垂果實(low hanging fruit),但卻還沒有人有時間去解決它們。」 – @andix
  • 「我學習 SIMD 時遇到的最大障礙是內建函數(intrinsics)奇怪的命名慣例。」 – @ktimespi
  • 「為了支持這個論點,即使你沒有計劃親自編寫 SIMD 或打算『直接讓 AI 來做』,了解什麼在 SIMD 中可以變快也是很重要的...」 – @derf_
  • 「我的編譯器知道 SIMD。然而,了解 SIMD 的限制可能有助於避免那些無法優化為使用 SIMD 的計算。」 – @gblargg
  • 「我認為如果有處理細節的宏(macros),會有更多開發者使用 SIMD。」 – @taylodl
  • 「比以往任何時候都更容易,有了 RISC-V Vector (RVV),它是 RVA23 的一部分。」 – @snvzz
  • 「我會稍微改寫標題為『每個人都應該知道何時 SIMD 沒有發生』。現代編譯器在向量化方面非常出色,直到它們突然失效,並且通常會因為假設或單一數據依賴的分支而回退到標量程式碼。學習檢查編譯器的優化報告可能更有價值。」 – @kiaansaraiya

這些觀點強化了本文的建議:學習基礎的 SIMD 模式以便識別何時適用,但也要進行測量、考慮數據佈局,並在適當時依賴編譯器或輔助工具。

Sources