vatt'ghern jaskier's ballads

一支手寫的 SIMD kernel 可能塞著「hundreds upon hundreds of bespoke unsafe blocks」——每個 intrinsic call 各包一層 unsafe,安全性無從審起。新的做法不是把這些 unsafe 藏到抽象底下,而是讓型別系統證明 CPU 能力、把整段不安全壓進唯一一個 macro:你要 audit 的只剩「this one macro」,不是上百個。

讓 Rust 的 SIMD 不再到處 unsafe

寫平台 SIMD intrinsic 在 Rust 裡向來醜——作者對那段程式碼的評語直接是「It's hideous. And the whole thing is wrapped in unsafe!」。問題不在於難寫,而在於 unsafe 的散布方式:一個跨 x86、ARM 的數值 kernel,光是 _mm256_add_ps 這類 intrinsic 的呼叫點就可能上百個,每一個按語言規則都得包在 unsafe 裡。先前那些「safe SIMD」crate 多半只是把這些 unsafe 藏到抽象底下,沒有真的消掉它——表面乾淨,底下仍有大量 unsafe 潛伏。這篇文章講的,是一套讓 unsafe「even on the inside」也收斂到單一可審點的組裝方式,由四個彼此咬合的型別系統機制構成。

四個機制依序是:不可偽造的 zero-sized CPU 能力 token、Rust 1.87 起編譯器自己追蹤 target feature、把唯一 unsafe 封進一個 macro、以及泛型 SIMD 型別 f32x8<L: Level> 對每個指令集 level 各給一份運算實作。最後留一個沒解掉的張力:operator overloading 的 a + b 跟保留 inlining 效益之間,作者自己也說不確定能怎麼辦。先看這套組裝到底把「要審多少 unsafe」這個量壓到哪裡——下面這張圖讓你拖動 kernel 規模,看兩種寫法的審查面積怎麼分岔。

先把「為什麼 intrinsic 一定 unsafe」這件事講清楚,後面四層才有對照。SIMD intrinsic 例如 _mm256_add_ps,對應的是一條只有在 CPU 支援 AVX2 時才存在的機器指令;放到一台沒有 AVX2 的機器上執行,得到的不是慢、是 illegal instruction 直接掛掉。Rust 的型別系統無法在編譯期知道「跑這支 binary 的那台機器有沒有 AVX2」——同一份 binary 可能被搬到 server、筆電、容器裡任何一代 CPU 上跑。所以 Rust 把這類 intrinsic 一律標成 unsafe:呼叫它等於跟編譯器簽一份「我保證執行時 feature 真的在」的切結書,責任轉嫁給程式設計師。問題就出在「每個 intrinsic 都要簽一次」——一支認真的數值 kernel,加法、乘法、shuffle、load、store 全是 intrinsic,再乘上 x86 與 ARM 兩套指令集各一份,切結書的份數很快堆到上百。這套組裝要做的,是讓你只簽一次、而且簽在一個看得清楚的地方。

拖動滑桿改變 kernel 的 intrinsic 呼叫數——比較兩種寫法要 audit 的 unsafe 區塊數 · 1 條曲線 vs 1 條水平線

240
縱軸是「需要人工 audit 的 unsafe 區塊數」。naive 線是 y = N(每個 intrinsic 一個 unsafe);macro 線恆為 1(整段不安全封在單一 macro)。數字依文章對問題與方案的描述計算,非實測 benchmark。

不可偽造的 token:把「這台機器有 AVX2」變成型別

第一塊地基是一個小到不能再小的型別。它的定義是 pub struct Avx2(());——一個包了 unit 的 newtype,唯一的 field 是 private。private 這件事是重點:module 外面的任何人都無法憑空 Avx2(()) 寫出一個值,只能透過 module 提供的建構路徑拿到它。而那條路徑唯一的入口,是 runtime 的 CPU feature detection:

pub struct Avx2(());

fn detect_avx2() -> Option<Avx2> {
    if is_x86_feature_detected!("avx2") {
        Some(Avx2(()))      // 偵測到才發證
    } else {
        None
    }
}

於是這個值本身就是一張證明:你手上若有一個 Avx2,就代表程式在執行期確認過這台機器支援 AVX2。它無法被偽造,因為偽造它的唯一方法是繞過 detect_avx2 去寫 Avx2(()),而 private field 把這條路堵死了。型別系統不知道「AVX2 是什麼」,但它知道「這個值只能從那個函式流出來」,這就夠了——能力檢查被編碼成型別的存在性。

第二個關鍵性質是成本。作者明說:「because it's a zero-sized type, passing this token around has no runtime overhead」。Avx2(()) 是 zero-sized type,在記憶體裡不佔任何 byte,把它當參數傳進千百層函式呼叫,編譯出來不會多一個 register、不會多一條 mov 指令。這就是為什麼可以放心讓每一個 SIMD 運算都吃一個 token 參數——證明在型別層走遍全程,在機器碼層完全消失。

值得停一下體會這個設計的精巧:型別系統在這裡完全不理解「AVX2」的語意,它對指令集、暫存器寬度、SIMD 一無所知。它只懂一件事——某個型別的值,能不能在這個 scope 被建構出來。把 field 設成 private,等於宣告「這個值只能由我這個 module 發放」;把唯一的發放路徑接到 runtime detection,等於宣告「拿到這個值代表偵測通過」。整個能力系統就靠 Rust 最基本的可見性規則撐起來,沒有用到任何特殊的 compiler magic。這也是為什麼這個 pattern 不必等語言加新功能就能用——它把一個執行期的事實(CPU 支援某 feature)借型別的存在性,搬進編譯器能推理的世界。這個技巧不是新發明,pulp 這個 crate 已經用 CPU feature token 用了好幾年;新的地方在後面三層怎麼接上它,把「token 內部那層 wrapper」也做安全。

Rust 1.87:編譯器自己追蹤 target feature

有了 token 還不夠。intrinsic 之所以 unsafe,本質原因是:在一台沒有 AVX2 的機器上執行 _mm256_add_ps 會直接觸發 illegal instruction。Rust 用 #[target_feature(enable = "avx2")] 這個標記告訴編譯器「這個函式假設 AVX2 存在」,但在 1.87 之前,即使函式帶了這個標記,函式體內呼叫 intrinsic 仍然得自己再包一層 unsafe——編譯器沒把「函式已宣告 feature」跟「intrinsic 需要 feature」這兩件事連起來。

2025 年 5 月 15 日發布的 Rust 1.87.0 補上了這條連線。官方 release note 的原文是:「Most std::arch intrinsics that are unsafe only due to requiring target features to be enabled are now callable in safe code that has those features enabled.」翻成白話:只因為「需要某個 target feature」才被標成 unsafe 的那些 intrinsic,現在在「已經 enable 該 feature」的安全程式碼裡可以直接呼叫,不必再寫 unsafe。注意官方用的是「Most」而不是「All」——這是一個有範圍的改善,不是全面解除。

#[target_feature(enable = "avx2")]
fn add_avx2(a: __m256, b: __m256) -> __m256 {
    _mm256_add_ps(a, b)   // 1.87 起:函式已標 avx2,這行不必 unsafe
}

看起來 unsafe 消失了,但作者立刻補一句誠實的話:「This only shifts the unsafe up a layer.」unsafe 沒有不見,它從 intrinsic 的呼叫點往上搬到了函式的呼叫點——現在不安全的不是 _mm256_add_ps 這一行,而是「在不確定有沒有 AVX2 的情況下去呼叫 add_avx2」這個動作。呼叫一個帶 #[target_feature] 的函式本身才是 unsafe 的來源。1.87 給的是地基,不是屋頂——它讓 kernel 內部可以脫掉 unsafe,但「誰來保證進入這個函式時 feature 真的可用」這個問題還懸著。

這個「往上搬一層」的設計其實是對的方向,不是退而求其次。把 unsafe 留在 intrinsic 呼叫點,意味著每一條 intrinsic 都是一個獨立的不安全來源,散在 kernel 各處;把它搬到函式邊界,意味著一整支 kernel 不管裡面有多少條 intrinsic,只共用一個不安全來源——進入這個函式這件事。審查的單位從「每條指令」變成「每個 feature gate 的入口」,數量級立刻下來。但「搬一層」本身不會自動讓入口安全,它只是把問題收斂到一個值得認真處理的地方。誰來保證進入時 feature 可用?答案就是前一節那張不可偽造的 token——它在型別層攜帶「偵測通過」這個事實。下一層要做的,就是把 token 跟這個 #[target_feature] 入口接在一起,讓「有證明才能進」這件事由型別系統強制執行。

一個 macro:把唯一的 unsafe 封起來

把前兩層接起來:token 是「feature 可用」的證明,#[target_feature] 函式是「假設 feature 可用」的程式碼。中間缺的那一步——「拿著證明、安全地進入假設」——就是整套設計收斂 unsafe 的地方。作法是寫一個 macro,把這一步封進去:要 token 當參數(逼呼叫者先拿到證明)、內含唯一的 unsafe 區塊、區塊裡呼叫一個帶 #[target_feature] 的內層函式。

fn add_avx2(token: Avx2, a: __m256, b: __m256) -> __m256 {
    // SAFETY: token 的存在證明 avx2 可用,進入內層函式因此安全
    unsafe { inner(token, a, b) }

    #[target_feature(enable = "avx2")]
    fn inner(token: Avx2, a: __m256, b: __m256) -> __m256 {
        _mm256_add_ps(a, b)
    }
}

讀這段 code 要抓的是責任怎麼分配。外層 add_avx2 是普通的安全函式,它收一個 Avx2 token——這個型別簽名等於在編譯期強制:沒有 token 的人根本叫不動它,而 token 只能從 runtime detection 流出來。函式體裡那個 unsafe 區塊是整段唯一的不安全點,它的 SAFETY 註解只需要論證一件事:token 的存在保證了 feature 可用,所以進入內層 #[target_feature] 函式是安全的。內層的 inner#[target_feature(enable = "avx2")],享受 1.87 的好處——裡面的 intrinsic 不必再寫 unsafe。三層各司其職:token 管證明、unsafe 區塊管「把證明兌現成進入許可」、target_feature 函式管實際運算。

整套方案的賣點就在這裡。作者寫道:「you only ever need to review and audit this one macro, not hundreds upon hundreds of bespoke unsafe blocks」。原本散落在上百個呼叫點、各自不同、各自可能寫錯的 unsafe,被 macro 收成一份。你的 reviewer 只要把這一個 macro 看對——確認「有 token 才能進、token 只能從 detection 拿到」這條鏈成立——其餘所有用 macro 生成的 SIMD 運算就都是安全的。這就是標題「even on the inside」的意思:不只 API 表面安全,連抽象的內部也只有一個可審點。對照前作的價值在這裡才看得出來:pulp 早就有 token,但它的 intrinsic wrapper 是一條條手寫的 unsafe——換句話說,pulp 把「表面」做安全了,「內部」每個 wrapper 仍是獨立的不安全來源、仍會偶爾寫錯。macro 方案的差別是把那一層也收斂掉。

下面的 tab 把這條收斂鏈攤成四步,可以一格一格切換看每一步的程式碼長相——從最原始的滿地 unsafe,到 token,到 macro 封裝,再到後面要講的泛型運算。

// 滿地 unsafe:每個 intrinsic 自己包一層
unsafe { let s = _mm256_add_ps(a, b); }
unsafe { let p = _mm256_mul_ps(s, c); }
unsafe { _mm256_storeu_ps(out, p); }
// ……再乘上 x86 / ARM 每個平台一份

要審的 unsafe 數量:hundreds upon hundreds。每一個都可能把 feature 假設寫錯。

// token 是不可偽造的 ZST 證明
pub struct Avx2(());

fn detect_avx2() -> Option<Avx2> {
    if is_x86_feature_detected!("avx2") { Some(Avx2(())) }
    else { None }
}

token 把「這台機器有 AVX2」變成型別層的存在性。傳遞它 no runtime overhead,因為它是 zero-sized。

fn add_avx2(token: Avx2, a: __m256, b: __m256) -> __m256 {
    // SAFETY: token 證明 avx2 可用
    unsafe { inner(token, a, b) }

    #[target_feature(enable = "avx2")]
    fn inner(token: Avx2, a: __m256, b: __m256) -> __m256 {
        _mm256_add_ps(a, b)
    }
}

整段不安全收進這一個 macro。要審的 unsafe 數量:this one macro

pub trait Level {}

pub struct f32x8<L: Level> {
    data: [f32; 8],
    token: L,
}

impl std::ops::Add for f32x8<Avx2> {
    type Output = Self;
    fn add(self, rhs: Self) -> Self::Output { /* avx2 path */ }
}

把 token 塞進泛型型別參數,a + b 對 Avx2 與 fallback 各有一份最佳實作——使用端寫的是普通加法。

泛型 SIMD 型別:f32x8<L: Level> 與每個 level 的運算

token 解決了「證明」,macro 解決了「審查面積」,但使用端還是得自己拿著 add_avx2(token, a, b) 這種帶 token 的函式呼叫到處跑。下一層把 token 收進型別參數,讓使用者寫回普通的 a + b。作法是一個泛型 marker trait Level,跟一個帶 level 參數的向量型別:

pub trait Level {}

pub struct f32x8<L: Level> {
    data:  [f32; 8],
    token: L,          // level 證明跟著資料走
}

impl std::ops::Add for f32x8<Avx2> {
    type Output = Self;
    fn add(self, rhs: Self) -> Self::Output { /* 用 avx2 path */ }
}
// 另對 f32x8<Fallback> 給一份純量實作

f32x8<L> 把八個 f32 打包成一個向量,型別參數 L 記住它是哪個 instruction-set level 的證明。對每個 level 各寫一份 impl Addf32x8<Avx2>+ 走 AVX2 路徑、f32x8<Fallback>+ 退回純量。這就是「multi-versioning」——同一段高階演算法用泛型寫一次,編譯器替每個 level 各 monomorphize 一份最佳碼。使用者面看到的是乾淨的 a + b,背後的 token 與 macro 都被泛型機制吸收了。

把 token 收進型別參數 L 而不是當一般欄位傳,這個選擇有它的好處。型別參數是編譯期的東西,f32x8<Avx2>f32x8<Fallback> 是兩個不同的型別,編譯器在 monomorphize 時就替它們各生成一份專屬機器碼——AVX2 那份直接是緊湊的向量指令,Fallback 那份是純量迴圈,兩者之間沒有任何 runtime 的 if 判斷。dispatch 的決策被前移到「你拿什麼 level 的 token 建構向量」那一刻,之後整條運算鏈都是該 level 的專屬碼。這跟「每次運算都 runtime 檢查一次 CPU feature」的傳統做法是兩個世界:傳統做法把 branch 留在 hot loop 裡,泛型做法把 branch 消滅在型別實例化的瞬間。代價是要對每個 level 各維護一份 impl,而且 level 集合是離散、封閉的——你不能在跑的時候動態長出一個新 level。下面這張圖把這四層機制怎麼咬合、a + b 怎麼依 level dispatch 攤開來看。

四層機制如何把不可偽造 token 一路接到 a + b dispatch · 4 個機制 + 2 條 level 分支

runtime detection ——> token ——> 泛型型別 ——> a + b dispatch ——> 唯一 unsafe runtime detection is_x86_feature_detected! 偵測到才發證,否則 None token (ZST) Avx2(()) 不可偽造、傳遞零成本 generic type f32x8<L: Level> token 收進型別參數 L a + b 使用端寫普通加法 impl Add for f32x8<Avx2> monomorphize 成 avx2 path impl Add for f32x8<Fallback> 退回純量實作 底層:唯一 unsafe macro(兩條 path 共用,只此一處要 audit)
四層咬合:detection 發 token、token 進 f32x8<L> 的型別參數、a + b 依 level 各自 monomorphize、兩條 path 最終都落到同一個封了唯一 unsafe 的 macro。

歷史與現況:從 pulp 到 fearless_simd v0.5

這套組裝不是憑空長出來的。CPU feature token 這個點子,pulp 這個 crate 已經用了好幾年;但作者點出它的不完整:pulp「relied on handwritten unsafe wrappers around intrinsics, and occasionally got them wrong」——token 機制有了,可是包 intrinsic 的那層仍是手寫 unsafe,偶爾還寫錯。simdeez 走的路線類似,也是替多個 instruction set 各生成一份實作。而 safe_unaligned_simd 處理的是另一個切面(unaligned load/store 的安全包裝),原版的 unsafe 量偏高。這些前作各補一塊,但沒有人把「連內部也只剩一個可審 unsafe」這件事做齊。

點欄位標題排序 · 4 個 crate × 4 欄

safe SIMD 生態的四個前後作——token 機制、intrinsic wrapper 的安全程度、處理範圍各不同。點欄位標題排序。
crate CPU feature token intrinsic wrapper 定位 / 文章評語
pulp有(用了多年)手寫 unsafetoken 機制成熟,但「occasionally got them wrong」——wrapper 仍是手寫 unsafe。
simdeez類似機制多版本生成替多個 instruction set 各生成一份實作,路線與 fearless_simd 相近。
safe_unaligned_simd原版 unsafe 量高專注 unaligned load/store 的安全包裝,功能後被 fearless_simd 以更低 unsafe 量整合。
fearless_simdmarker value + Simd traitkernel! macro 封裝v0.5:safe SIMD with ergonomic multi-versioning;f32x8/f64x4 等實作 + 與 *。

這些前作的關係不是互相取代,而是各補一塊拼圖。token 是 pulp 證明可行的、編譯器層的 target_feature 追蹤是 Rust 1.87 補上的、把唯一 unsafe 封進 macro 是讓「內部」也安全的最後一塊,泛型 f32x8<L> 則是把這些接成乾淨 API 的黏著層。一個生態要走到「safe SIMD even on the inside」,這幾塊得同時到位——任何一塊缺了,要嘛使用者面不夠乾淨、要嘛內部仍漏 unsafe、要嘛效能打折。這也解釋了為什麼這篇文章現在才出現,而不是三年前:它需要的最後一塊地基(1.87 的 intrinsic 安全化)才剛落地不久。

把這套設計收成 production crate 的是 fearless_simd,目前版本 0.5.0,自我定位是「safe SIMD with ergonomic multi-versioning in Rust」。它的 token 機制以 marker value 實作 Simd trait,提供 f32x8f64x4i32x8 等泛型 packed vector 型別,並實作標準算術 trait——文件明說這些型別「can be added together using +, multiplied by a scalar using *」,背後「implemented as efficiently as possible using SIMD instructions」。支援的 level 是離散集合:Sse4_2 對應 x86-64-v2、Avx2 對應 x86-64-v3,外加純量 Fallback。要混用高階運算與平台特定 intrinsic 時,它公開 dispatch! macro(依 level 選實作)跟 kernel! macro——依文件,當 portable API 不夠用時,你可以透過 kernel!() macro 安全地呼叫平台特定的 intrinsic,也就是前面講的「把唯一 unsafe 封進 macro」在真實 crate 裡的對應物。

沒解掉的張力:a + b 與 inlining 不可兼得

到這裡聽起來很完美:安全、零成本、使用端寫普通 a + b。但有個 caveat 把整套設計拉回現實,而且作者沒有假裝它不存在。根源是 inlining 規則:「The compiler cannot inline a function that has a #[target_feature] annotation into one that doesn't.」帶 #[target_feature] 的函式無法被 inline 進一個沒有該標記的函式。

對 SIMD 來說 inlining 不是可有可無的優化——它是效能本身。SIMD 的價值在於把連續的向量運算融成緊湊的指令流,一旦每個 a + b 都變成一次真正的 function call(push 參數、跳轉、回返),向量化帶來的好處會被 call overhead 吃掉一大塊。要量化這個傷害:一條 AVX2 加法本身可能只有一兩個 cycle,但一次未被 inline 的函式呼叫,光是參數搬進暫存器、跳轉、回返、可能的 register spill,就能輕鬆吃掉十幾個 cycle。把運算包成函式呼叫,等於在每條一兩 cycle 的指令外面套一層十幾 cycle 的殼——向量化算出來的加速被殼直接吞掉。更糟的是它阻斷了下游優化:編譯器看不穿函式邊界,就沒辦法把相鄰的幾個 a + b 合併、沒辦法把載入跟運算交錯排程、沒辦法把迴圈展開。inlining 一斷,後面一整串優化全部失效。

所以這個 inlining 限制直接威脅到整套抽象的存在理由——一個讓你寫得安全又漂亮、但跑起來比手寫 unsafe 慢一截的抽象,沒有人會用。具體的約束是:「whenever you call a + b on SIMD types, you have to do it from a function with either #[inline(always)] or #[target_feature] on it」——你呼叫 a + b 的那個函式,本身必須帶 #[inline(always)]#[target_feature] 其中之一,inline 鏈才接得起來。這是一個會洩漏出去的抽象:理想上使用者不該需要知道底下有 #[target_feature] 這回事,但這個 inlining 規則逼著他在自己的函式上掛標記。漂亮的 a + b 語法守住了,代價是呼叫脈絡的一個隱性紀律——忘了掛,編譯器不會報錯,只會默默生出慢的碼。下面這張圖把「呼叫端有沒有帶對標記」這件事的後果並排攤開。

切換呼叫端有沒有帶 #[inline(always)] / #[target_feature]——看 a + b 能不能 inline · 2 種呼叫脈絡

do_stuff() { c = a + b; } ↑ 你寫的呼叫端 do_stuff() 無 #[inline(always)] / #[target_feature] add_avx2() #[target_feature] 內含 SIMD intrinsic 結果:無法 inline ——> 真正的 function call 每個 a + b 都付一次 call overhead,向量化效益被吃掉
規則:#[target_feature] 函式不能被 inline 進無標記的函式。呼叫端自己帶 #[inline(always)]#[target_feature],inline 鏈才接得起來。

對要動手的工程師,這個限制有兩個務實的讀法。第一,它把「安全」跟「快」分成兩件要分別確認的事——換用 fearless_simd 之類的 crate 確實把 unsafe 的 audit 面積壓到一個 macro,但效能不是自動到手的,得確認 hot path 上呼叫 a + b 的每一層都掛了對的標記。第二,這個紀律無法靠編譯器報錯來提醒,只能靠 benchmark 抓——忘了掛標記,程式照樣編得過、跑得對,只是慢。所以導入這類 crate 的正確姿勢是:先讓它正確,再用 profiler 確認 inline 鏈沒斷,兩步分開做。把這件事跟前面的審查面積放在一起看,這套設計給的交換很清楚——拿確定的安全收益,換一個需要主動守住的效能紀律。

有沒有出路?部分有。作者提到 Struct Target Features RFC:「The Struct Target Features RFC solves this for add_avx2(token, a, b) and add<S: Simd>(token, a, b) but I don't see a path to a + b just yet.」對於顯式傳 token 的函式呼叫形式,RFC 能解;但對 operator overloading 的 a + b,作者明說「目前還看不到路」。而且他不只是說現在沒解,他懷疑這是根本性的:「I'm not sure what can be done about this. The limitation seems quite fundamental for any approach that implements a + b with SIMD.」——任何想用 a + b 實作 SIMD 的方案,可能都會撞到同一面牆。這是 hedge,不是定論,但它把整套設計的邊界劃得很清楚:你可以拿到安全與乾淨語法,但要保住效能,呼叫端的 inlining 紀律得自己守。

What this enables:一支跨平台的 SIMD kernel,原本散在上百個呼叫點的 unsafe,現在收斂成單一一個可審的 macro——配上 Rust 1.87 的 target_feature 追蹤、不可偽造的 ZST token 與 f32x8<L: Level> 泛型,fearless_simd v0.5 把它變成 cargo add 就能用的東西;代價是 a + b 的呼叫端得自己守住 inlining 紀律,這道牆作者自己也還沒看到繞過去的路。