← The Paper Says ARM Support. I Might Be the First to Actually Compile It

繁體中文

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 → 全部 NaN
batch size 5 → 全部 NaN
batch 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 之後。

English

The Paper Says ARM Support. I Might Be the First to Actually Compile It

Microsoft dropped something tempting last week: VibeVoice-ASR-BitNet, a speech recognition model that claims real-time transcription with just 3 CPU threads, no GPU, 1.6 to 2.3x faster than Whisper.cpp. The paper says, in plain print, that its custom SIMD kernels are “targeting both ARM and x86 platforms”.

My M1 Max is ARM. Let’s find out.

That little experiment made me, quite possibly, the first person on Earth to compile this project on ARM. Not a badge of honor. The compiler told me so.

What the model is trying to do

First, why it’s worth testing at all. Speech recognition currently lives in two worlds: small models run on CPUs but are bare-bones, while LLM-based recognizers (long-form audio, speaker separation, custom vocabulary hints) are powerful but chained to GPUs. This model tries to squeeze the second kind into the first kind’s hardware.

The approach is compression that matches the diagnosis. The team profiled the two pipeline stages and found opposite bottlenecks: the audio encoder is bound by data movement, with activations outweighing weights 16 to 1, so it gets gentle 8-bit quantization plus operator fusion to save memory round-trips. The language model is bound by weight size, reading all 1.5B parameters for every single token, so it gets the strong medicine: every weight becomes -1, 0, or +1, an 8x compression. That’s BitNet, and the authors are the same Microsoft team that started the 1-bit LLM line.

One tip for reading model cards: the pretty number in the left column of the WER table belongs to the full 7B system. This checkpoint swaps the 7B brain for a 1.5B one and then compresses it, and its own numbers sit in the right column, where Chinese meeting audio drops from 19.83 to 27.45. Vendors always put the biggest system in the most visible spot. The row for the checkpoint you’re actually downloading is yours to find.

Round one: it doesn’t compile

ggml-aarch64.c:1156:31: error: use of undeclared identifier 'VAE_ROW_BLOCK_SIZE'

Digging in: these macros are only defined inside x86 preprocessor branches. The fun part is that the language-model side has an ARM branch in its config header. The audio-encoder side just doesn’t.

This kind of error doesn’t lie. Code can claim to support any platform it wants; the compiler keeps the receipts. Nobody had ever hit build on ARM in this repo.

Added the missing macros. Next.

Round two: exclamation marks, forever

It compiles. I feed it 30 seconds of audio. The model outputs:

!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!

And keeps going. Twenty minutes in, still going.

Digging down with Claude Code, I put checkpoints on the audio encoder’s output and found something that fails the smell test instantly: the acoustic and semantic encoders, two networks with completely different weights, were producing nearly identical feature vectors. No real computation does that. The only explanation is that the matrix multiplies never ran, and what came out was the ghost of the bias terms.

The kernel source confirmed it: all seven core compute functions are wrapped in #if defined(__AVX2__) with no #else. On ARM, those seven functions compile to nothing. No error, no warning. They just quietly do nothing at all.

I wrote seven scalar fallbacks. The features came alive. The output was still exclamation marks.

Round three: the NaN fingerprint

This was the fun one. The logits were all NaN, so greedy decoding kept picking token 0, which happens to be the exclamation mark. But where did the NaN come from?

I varied the batch size and got a beautiful fingerprint:

batch size 1 → fine
batch size 2 → fine
batch size 4 → all NaN
batch size 5 → all NaN
batch size 8 → fine

1, 2, and 8 are fine; 4 and 5 explode. That pattern points straight at the answer: the upstream framework hard-rounds matrix rows to multiples of 4. On x86 that’s always safe, because x86’s parallelism constant is 4. But ARM’s constant is 8, so a remainder block of 4 rows fails the divisibility check and falls into a fallback function with mismatched semantics: values written to the wrong places, and the right places never written at all. The leftover uninitialized memory is where the NaN comes from.

The best part: a sibling function in the same file has exactly the right guard for this case, comment included, explaining why it’s needed. Same bug, fixed in one place, forgotten in the other. On x86 it can never trigger, so nobody ever noticed.

I copied the guard over, following the comment’s own pattern. Finite logits, 71 tok/s decode, overall RTF 0.75. The paper’s real-time claim actually holds on ARM.

Round four: knowing when to stop

Three bugs down, and the output went from exclamation marks to fluent multilingual gibberish. The model was talking. It just wasn’t listening.

That means at least one more numerical bug. I ran one final isolation experiment: replaced every matrix operation with a painfully slow element-by-element version whose semantics are beyond question. Bit-identical output. So the remaining bug isn’t in anything I touched; it’s further upstream, in the fused convolution operators, another stretch of hand-written code that has never been validated on ARM.

I stopped there. Not because it’s unfixable, but because fixing it wouldn’t change the conclusion: my machine already runs this model’s 7B big brother on the GPU via MLX, 4x faster and better at Chinese. The CPU edition is built for people without this hardware. The rest of the digging is someone else’s homework.

The three fixes and repro steps are packaged up and headed upstream. The repo has 13 stars. There’s a decent chance this post arrives before the issue does.

What I’m taking away

“Supports ARM” and “has been compiled on ARM” are different claims. A paper can say targeting, a model card can list NEON, but the only evidence that counts is whether that machine exists in CI.

Also: NaN isn’t scary. NaN without a fingerprint is scary. Once the batch 1/2/8-good, 4/5-bad pattern showed up, the answer surfaced on its own. Most of the debugging effort wasn’t staring at code; it was designing the one experiment that forces the bug to show itself.

As for whether 1-bit models are ready, this checkpoint’s Chinese numbers speak for themselves, and I remain skeptical. But real-time transcription on edge CPUs as a direction? Worth watching.

Postscript (the next day)

The day after I wrote this, Microsoft pushed a commit with “ARM NEON optimizations” and “fix overflow” in the message. I opened it up: all three bugs, fixed, matching what I’d found one for one. So the bugs were real and the direction was right. Their keyboard was just faster than my issue.

I pulled the new version and retested on the same machine. Compiles clean, runs faster. And the transcription is still gibberish. Round four is alive, and the evidence actually got stronger: the garbage looks identical under my scalar kernels and their NEON kernels, which points at the shared convolution path upstream of both.

The issue is filed, and this time it beat the blog post: microsoft/VibeASR.cpp#2

As for my “first person to compile it” title: shelf life, one day. Open source moves fast, and honestly, this is the best possible way to be proven wrong.

Postscript two (the day after that)

Couldn’t let it go, so I had an agent finish round four in the background. The root cause proved my own guess in the issue wrong: not the convolution pipeline, but the LM kernels. The NEON path decoded the ternary weights with a 64-element block layout while the file format packs 128 elements per unit, so every transformer layer was doing matmuls against a shuffled weight matrix. Which also explains why the output was fluent gibberish rather than noise: the model was fine, its brain was just uniformly scrambled.

With the fix, the same 30-second Mandarin clip transcribes correctly on the M1 Max at RTF 0.50. Fix and kernel diff tests are in PR #3.

Final tally: the paper’s “real-time on ARM” claim is now actually true, four bugs later.