Go 1.27 引入實驗性平台無關 SIMD 套件
TL;DR
Go 1.27 發布了一個實驗性的、平台無關的 simd 套件,抽象化固定大小的向量類型,讓開發者可以撰寫一次的向量程式碼,即可在 AVX/AVX2/AVX512(amd64)、NEON(arm64)和 WebAssembly 上以接近組合語言的效能執行,同時在沒有 SIMD 支援的 CPU 上會自動降級為快速的軟體模擬。
為何需要可移植的 SIMD 層?
不同 CPU 家族以截然不同的方式暴露 SIMD:
- 向量寬度可能是固定的(例如 wasm、PowerPC、s390x 上的 128 位元)或可變的(RISC‑V V‑延伸、ARM SVE)。
- 掩碼語意不同:有些架構有專用的掩碼暫存器(AVX‑512、RVV),有些使用向量布林值(NEON、AVX2),有些則完全沒有掩碼。
- 指令集不僅因家族而異,也因功能旗標而異(AVX 對 AVX2 對 AVX‑512、NEON 對 SVE 等)。
由於 Go 早期的 archsimd 套件必須直接暴露這些細節,撰寫可移植的 SIMD 程式碼需要針對各架構的條件判斷與手動撰寫組合語言。新的 simd 套件將固定大小的向量從類型系統中移除,僅實作所有支援平台都能執行的操作交集,並以高效模擬填補差異。
如何啟用實驗性 API
在建置或執行程式時,設定環境變數 GOEXPERIMENT=simd,就像舊的 archsimd 實驗一樣。編譯器會根據程式啟動時偵測到的硬體,將 simd 類型的呼叫重寫為架構特定的實作或模擬的備用方案。
核心設計原則
- 交集優先 API – 僅公開所有目標平台共用的操作。缺少的原語透過映射至現有 SIMD 指令的模擬提供。
- 接近組合語言的效能 – 當原始操作符合硬體能力時,產生的程式碼速度與手寫組合語言相當。
- 平順降級 – 在沒有 SIMD 支援的 CPU 上(或設定
GODEBUG=simd=0時),相同程式碼會使用純 Go 實作執行。 - 可讀性 – 類型命名為
simd.Uint8s、simd.Float32s等,方法語法如同一般 Go 方法,讓程式碼對人類與大型語言模型都易於理解。
範例:向量化內積
func innerProduct(x, y []float32) float32 {
var acc simd.Float32s
var i int
for i = 0; i < len(x)-acc.Len()+1; i += acc.Len() {
u := simd.LoadFloat32s(x[i : i+acc.Len()])
v := simd.LoadFloat32s(y[i : i+acc.Len()])
acc = u.MulAdd(v, acc) // acc += u*v
}
if i < len(x) {
u, _ := simd.LoadFloat32sPart(x[i:])
v, _ := simd.LoadFloat32sPart(y[i:])
acc = u.MulAdd(v, acc)
}
return simd.ReduceSum(acc) // Go 1.28 新增
}
迴圈每次處理 acc.Len() 個元素,其中 acc.Len() 是執行時期偵測到的向量寬度(128、256 或 512 位元)。相同原始碼在現代 x86 上編譯為 AVX‑512,在 Apple Silicon 上為 NEON,或在 SIMD 不可用時為純 Go 迴圈。
支援的操作(Go 1.27)
下表總結目前可用的方法。V 表示向量類型,M 表示掩碼類型,E 表示標量元素類型。
載入 / 廣播
LoadV([]E) V– 將完整切片載入至向量。LoadVPart([]E) (V, int)– 載入部分切片,回傳實際載入的元素數量。BroadcastV(E) V– 將標量廣播至所有通道。
儲存 / 字串表示
Store([]E)– 將向量寫回切片。StorePart([]E) int– 儲存盡可能多的通道。String() string– 人類可讀的表示方式。
算術(選用)
Add、Sub、Mul、Div(僅浮點數)、Neg、Abs(僅浮點數)。MulAdd– 浮點數的融合乘加運算。IfElse(mask, y)– 在mask為真時,從y選取元素。Masked(mask)– 在掩碼為假時,將通道歸零。
布林與掩碼
- 向量上的
And、Or、Xor、AndNot。 Mask類型(Mask8s、Mask16s…)支援And、Or、String、ToIntWs。
比較運算
Equal、NotEqual、Greater、GreaterEqual、Less、LessEqual– 產生與元素寬度相同的掩碼。
轉換與重塑
ConvertToFloatW、ConvertToIntW、ConvertToUintW。ToBits、ReshapeToUint*、BitsToFloatW、BitsToIntW– 零成本的重新解釋。
移位與旋轉(僅整數向量)
ShiftAllLeft/Right、RotateAllLeft/Right,適用於 16、32 和 64 位元通道。
注意: 第一版缺少泛型歸約(
ReduceSum)和部分以掩碼為中心的工具;這些功能預計於 Go 1.28 加入。
與架構特定程式碼的橋接
當需要的操作尚未納入可移植 API 時,開發者可透過 ToArch() 和 FromArch 助手回退至 archsimd 套件。以下為 amd64 上缺少 Int8s.OnesCount 實作的範例:
//go:build goexperiment.simd && amd64
func OnesCount(v simd.Int8s) simd.Int8s {
switch x := v.ToArch().(type) {
case archsimd.Int8x16:
// 使用查表技巧處理 AVX/AVX2。
lut := archsimd.LoadInt8x16Array(&popcnt4x16)
mask := archsimd.BroadcastInt8x16(0x0f)
lo := x.And(mask)
hi := x.ToBits().ReshapeToUint16s().ShiftAllRight(4).
ReshapeToUint8s().BitsToInt8().And(mask)
return simd.Int8sFromArch(lut.PermuteOrZero(lo).Add(lut.PermuteOrZero(hi)))
default:
return OnesCountEmulated(v)
}
}
編譯器會在編譯時期專門化此型別判斷,因為二進位檔在編譯時已知目標平台存在的 archsimd 類型。
使用 GODEBUG 進行執行時期控制
開發者可強制設定特定向量寬度或完全模擬以供測試:
GODEBUG=simd=0– 始終使用模擬路徑。GODEBUG=simd=128、256、512– 要求該寬度;若不可用則恐慌。GODEBUG=simd=+128(或+256、+512) – 即使缺少某些可選指令也允許該寬度;使用不支援的指令會在執行時期恐慌。
實作注意事項
編譯器執行 AST 重寫,為所有提及 simd 類型的函式產生大小專用的複本。這些複本位於 simd/internal/bridge,並以具體的 archsimd 類型實例化(例如 Int8x16)。分派僅在程式啟動時發生一次,之後熱門迴圈會執行專用版本,不再需要分支。加入對 simd 類型的虛擬參考(例如 var _ simd.Uint64s)可將分派點提升至周圍迴圈,確保使用專用版本。
社群反應(選摘 HN 評論)
"在這種情況下,可移植的 SIMD 比非可移植的 SIMD 慢約 11 %,但兩者都比非 SIMD 快約 5 倍。" – ImJasonH(wasm 上的基準測試)
"C++ 正在引入
std::simd;我完全支持以最少的內建指令撰寫向量程式碼。" – beached_whale
"沒有人要求這個功能,但他們花時間把它做對了,並持續推動 Go 成為記憶體安全、高階的系統語言。" – u8
"這為已經支援多核心的 Go 專案開啟了許多低階效能的大門。很少有語言提供標準函式庫的 SIMD 支援。" – qprofyeh
"介面轉換和型別判斷看起來效率不高,但編譯器會將其專門化;判斷在啟動時就解決,而非每次迴圈執行。" – vlovich123
未來展望
- Go 1.28 將為
archsimd加入 SVE 支援,並最終納入可移植的simd套件。 - 計畫新增
OnesCount等新操作、更豐富的掩碼工具、歸約原語和向量混洗。 - 功能變異旗標將讓程式碼在僅缺少少數可選指令的硬體上執行(例如 Raspberry Pi 的 NEON 不含 PMULL)。
- 一篇關於
archsimd內部實作的專題部落格文章即將推出。
總結
Go 的實驗性 SIMD 層消除了歷史上的架構特定組合語言障礙,提供一個乾淨、大小無關的 API,可在多種 CPU 上實現接近原生的效能,同時確保在任何平台上的功能降級。其設計理念與 C++ 的 Highway 相呼應,並預見未來如 SVE 等擴充,使 Go 成為高階、跨平台高效能工作負載的更具吸引力的選擇。
Sources
相關
- 專案
- 專案
- 專案
- 專案