Paper 說支援 ARM,我大概是第一個真的去編譯的人
微軟上週丟出一個蠻誘人的東西:VibeVoice-ASR-BitNet,號稱不用 GPU、3 條 CPU thread 就能即時語音辨識,比 Whisper.cpp 快 1.6 到 2.3 倍。論文白紙黑字寫著 custom SIMD kernels「targeting both ARM and x86 platforms」。
我的 M1 Max 就是 ARM。玩玩看。
結果這一玩,我大概成了地球上第一個在 ARM 上編譯這個專案的人。不是什麼榮譽,是編譯器直接告訴我的。
這模型想做什麼
先講它為什麼值得測。現在的語音辨識分兩個世界:小模型能跑 CPU 但陽春,LLM 型的辨識(聽得懂長錄音、分得出講者、能塞術語提示)功能全但綁死 GPU。這個模型想把第二種塞進第一種的硬體。
做法是對症下藥的壓縮。他們先量出管線兩段的體質完全相反:前段的聲音編碼器卡在資料搬運,流過的資料量是權重的 16 倍,所以用溫和的 8-bit 加算子融合,少跑幾趟記憶體。後段的語言模型卡在權重太肥,每吐一個字都要把 15 億參數讀一遍,所以下猛藥,每個權重只留 -1、0、+1 三種值,壓成八分之一。這就是 BitNet,作者正是微軟提出 1-bit LLM 的那個團隊。
一個讀 model card 的小心得:WER 表左欄那個漂亮數字是 7B 大系統的,這個 checkpoint 是 7B 換成 1.5B 小腦再壓縮的版本,自己的數字在右欄,中文會議語料從 19.83 掉到 27.45。廠商的表格永遠把最大的系統放最顯眼的位置,checkpoint 自己的那一行要自己找。
第一關:編譯直接炸
ggml-aarch64.c:1156:31: error: use of undeclared identifier 'VAE_ROW_BLOCK_SIZE'追進去發現,這些巨集只在 x86 的條件編譯分支裡有定義。有趣的是,語言模型那側的設定檔有寫 ARM 分支,偏偏聲音編碼器這側的忘了。
這種錯誤騙不了人。程式碼可以宣稱支援任何平台,編譯器不會。這個 repo 從來沒有人在 ARM 上按下過 build。
補上缺的巨集,過關。
第二關:輸出全是驚嘆號
編譯過了,跑 30 秒音檔,模型輸出:
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!而且一路輸出到天荒地老,20 分鐘停不下來。
跟 Claude Code 一路往下挖,先在聲音編碼器的輸出插檢查點,發現一個一眼假的現象:acoustic 和 semantic 兩個編碼器,權重完全不同,輸出的特徵向量卻幾乎一模一樣。正常運算下這不可能,唯一的解釋是矩陣乘法根本沒算,輸出只剩 bias 的殘影。
翻 kernel 原始碼,答案很直白:七個核心運算函式全部包在 #if defined(__AVX2__) 裡面,沒有 #else。在 ARM 上,這七個函式編譯出來是空的。不報錯,不警告,就是安靜地什麼都不做。
補了七個 scalar 版本,特徵有值了。但輸出還是驚嘆號。
第三關:NaN 的指紋
這關最好玩。logits 全部是 NaN,於是 greedy decoding 永遠選到第 0 號 token,就是那個驚嘆號。但 NaN 從哪來?
改變 batch 大小做實驗,得到一組漂亮的指紋:
batch size 1 → 正常batch size 2 → 正常batch size 4 → 全部 NaNbatch size 5 → 全部 NaNbatch size 8 → 正常1、2、8 好,4、5 炸。這個 pattern 直接指向答案:上游框架把矩陣運算的列數硬取整到 4 的倍數,在 x86 上這永遠安全,因為 x86 的平行度參數就是 4。但 ARM 的平行度參數是 8,於是 4 列的餘數塊通不過整除檢查,掉進一個語意完全不匹配的 fallback 函式:值寫錯位置,該寫的位置永遠沒寫到,殘留的未初始化記憶體就是 NaN 的來源。
最妙的是,另一個平行的函式裡有人加過完全對應的防護,還留了註解解釋為什麼要防。同一個 bug,一個地方修了,另一個地方忘了。x86 上永遠不會觸發,所以沒人發現。
照著那段註解的樣式補上防護,logits 正常了,decode 速度 71 tok/s,整體 RTF 0.75。論文宣稱的 real-time,在 ARM 上其實做得到。
第四關:停損
修完三個 bug,轉錄輸出從驚嘆號變成了流利的多語胡言亂語。模型在講話,但沒有在聽。
這代表還有至少一個數值 bug。我做了最後一個隔離實驗:把所有矩陣運算換成逐元素的笨版本(慢到哭但語意絕對正確),輸出跟我修的版本 bit 級相同。所以剩下的 bug 不在我碰過的地方,在更上游的卷積融合算子區,那裡也是一片從沒在 ARM 驗證過的手寫程式碼。
到這裡我停手了。不是修不動,是修好了也不改變結論:我家裡已經跑著它的 7B 老大哥(MLX 走 GPU,快它 4 倍,中文更準),這個 CPU 版是為沒有這種硬體的人設計的。繼續挖是別人的作業。
三個 bug 的修補和重現步驟都整理好了,準備發給上游。repo 才 13 個 star,可能這篇文比 issue 先到。
學到什麼
「支援 ARM」和「在 ARM 上編譯過」是兩件事。paper 可以寫 targeting,model card 可以列 NEON,但唯一可信的證據是 CI 裡有沒有那台機器。
另外,NaN 不可怕,可怕的是不留指紋的 NaN。這次 batch 1/2/8 好、4/5 炸的 pattern 一出來,答案就自己浮上來了。debug 的大半功夫不在盯程式碼,在設計那個能逼 bug 現形的實驗。
至於 1-bit 模型到底行不行,這個 checkpoint 的中文數字擺在那裡,我持保留態度。但邊緣 CPU 即時聽寫這個方向,值得繼續看。
後記(隔天)
寫完這篇的第二天,微軟推了一個 commit,訊息寫著 ARM NEON optimizations 和 fix overflow。點開一看,三個 bug 全修了,跟我挖到的一一對上。所以那些 bug 是真的,方向也是對的,只是他們的鍵盤比我的 issue 快。
我拉了新版在同一台機器重測。編譯直接過,速度更快了。然後,轉錄還是亂碼。第四關還在,而且證據反而更完整:亂碼在我的 scalar 實作和他們的 NEON 實作下長得一模一樣,指向共同的上游卷積路徑。
issue 發出去了,這次不用擔心比文章慢:microsoft/VibeASR.cpp#2
至於「第一個編譯的人」這個頭銜,保鮮期一天。開源世界跑得很快,這大概是被打臉的方式裡最好的一種。
後記二(又隔天)
不甘心,讓 agent 在背景把第四關也打完了。root cause 打臉了我自己在 issue 裡的猜測:不在卷積管線,在 LM kernel。NEON 路徑用 64 元素的區塊佈局去解三值權重,但檔案格式是 128 元素一組,等於每一層 transformer 都在用「洗過牌的權重矩陣」算矩陣乘法。這也解釋了為什麼輸出是流利的胡言亂語而非雜訊:模型本身是好的,只是它的腦被均勻打亂了。
修好之後,同一段 30 秒中文演講在 M1 Max 上轉出了正確的內容,RTF 0.50。修法和 kernel 對拍測試都在 PR #3。
所以最終回收:paper 宣稱的「ARM 即時聽寫」,現在真的成立了,四個 bug 之後。