軟體實作 FMA 抓出 Rust 與 musl 的次正規數錯誤,順道看 Zig 靜態配置與 Odin 型別資訊的取捨
shnatsel.github.io · 2026-09-02
2026 年 9 月 2 日,三篇工程部落格各自記錄了一個具體的底層設計案例。shnatsel 在實作向量化 FMA 時揪出 Rust 標準函式庫與 musl libc 處理次正規數的舍入錯誤,matklad 說明如何用靜態配置與固定狀態機取代動態物件池,gingerbill 則解釋 Odin 為何選擇執行期型別資訊而非編譯期型別資訊。
軟體實作 FMA,揪出 Rust 與 musl 的次正規數錯誤
FMA(fused multiply-add,融合乘加)在一次舍入內算完 a × b + c,比先乘再加、各自舍入一次的作法多保留一段精度,這對三角函數等數學函式庫的準確度很關鍵。並非所有處理器都有硬體 FMA 指令,部分 Intel 晶片與較舊的 ARM 裝置仍得靠軟體模擬。shnatsel 在為 fearless_simd 函式庫實作向量化 FMA 時做了這件事,原因是 Rust 的 std::simd 在沒有硬體 FMA 的機器上會退回慢速純量運算,而據作者估計約有 15% 的 Firefox 使用者機器不支援 AVX2。
驗證過程中,作者發現 Rust 標準函式庫的 f32::mul_add(底層對應 libm::fmaf)在處理次正規數時算錯。問題出在 compiler-builtins 專案的 fma_wide.rs 裡,用來判斷「剛好落在兩個可表示值正中間」的 halfway 邏輯只考慮了正常數值,沒把次正規數的精度差異算進去。這個錯誤已回報為 issue #1262,並有對應的修正 PR #1270。用位元 a = 0x97000800、b = 0x1cfff001、c = 0x00010002 可重現此錯誤:正確結果應是 0x00010001,軟體 FMA 卻算出 0x00010002。
同一顆次正規數 bug 也出現在 musl libc 的 fmaf() 裡,判斷式 (u.i & 0x1fffffff) != 0x10000000 可追溯到 2005 至 2011 年間的 FreeBSD 實作,已有人回報到 musl 郵件列表。同一批研究還在 Rust 編譯器裡挖出另一個獨立問題:32 位元 ARM 平台的 f128 型別有 ABI 錯誤,已列為 issue #1271。這些都不是靠推導發現,而是先寫出正確版本再逐位元比對找出來的。
fn mul_add_precise_f32(a: f32, b: f32, c: f32) -> f32 {
let (p, c) = (a as f64 * b as f64, c as f64);
let mut sum = p + c;
if sum.is_finite() {
let v = sum - p;
let err = (p - (sum - v)) + (c - v);
let bits = sum.to_bits();
if err != 0.0 && bits & 1 == 0 {
let up = sum.is_sign_negative() == err.is_sign_negative();
sum = f64::from_bits(if up { bits.wrapping_add(1) } else { bits.wrapping_sub(1) });
}
}
sum as f32
}matklad:用靜態配置與固定工作量換取可預測延遲
matklad 在 《Static Allocation, Constant Work》裡描述兩個常一起出現的設計原則。靜態配置指初始化之後就不再做任何動態記憶體配置,系統啟動時把可能用到的最大物件數量一次配置好,而不是執行期間隨需求增減,靈感來自金融帳本系統 TigerBeetle 與記憶體安全專案 Fil-C。物件池若靠動態配置與釋放週轉,很容易產生類似 use-after-free 的型別混淆錯誤。
「固定工作量」讓數量固定的一組物件在有限幾種狀態間循環,而不是被建立或銷毀,並用一個中性(sentinel)值代表「目前未使用」。下面的 Order 結構把 reserved 狀態本身設成合法的初始值,啟動時只要 @memset(orders, .reserved) 就能把整個陣列設成安全狀態,不必另外判斷某個位置是否初始化過。
const Order = struct {
id: u128,
price: u32,
count: u32,
tag: enum { bid, ask, reserved },
pub const reserved: Order = .{
.id = 0, .price = 0,
.count = 0, .tag = .reserved,
};
};這種寫法換來的不是效能高峰,而是延遲的可預測性。系統過載時只會拒絕新請求,不會因為配置失敗而整組垮掉,P100 延遲不會隨負載暴衝。固定陣列的循序掃描對 CPU cache 也比雜湊表或指標追蹤更友善,窮舉所有狀態轉移也比追蹤物件生命週期容易驗證。
gingerbill:CTTI 是指數成長,RTTI 是線性成長
gingerbill 在 《CTTI is Exponential, RTTI is Linear》裡比較兩種型別資訊策略。RTTI(runtime type information,執行期型別資訊)把每個型別的元資料存進一張表,程式在執行期查表就能寫出對任何型別通用的函式,型別數量是 N 就對應 N 筆表項,成本是線性的。CTTI(compile-time type information,編譯期型別資訊)則是替每一種實際用到的型別組合各自生成一份特化程式碼。
問題在於 CTTI 的實例化數量不只是 N,而是型別數 N 乘上操作組合數 K,也就是 N×K 甚至 Nᴷ。這個爆炸會同時發生在語意檢查、程式碼生成與執行檔體積三個地方。作者認為這是一種看不見的成本:編譯期特化常被包裝成「零成本抽象」,實際代價是拉長的編譯時間與變大的執行檔,不會直接顯示在效能量測上。他也拿 Rust 的 serde 序列化框架當對照案例。
這也是 Odin 預設選 RTTI 的原因:fmt.println 能印任何型別,靠的就是執行期讀取型別表。Odin 用 typeid 當型別的識別碼,同一個型別不管在程式的哪裡拿到的 typeid 都相同,甚至能安全地跨 DLL 邊界當純資料傳遞。Odin 也透過 base:intrinsics 提供 CTTI 路徑,但刻意讓它用起來比較麻煩,避免被濫用成預設選項。