如何用 MLX 在 Apple Silicon 上建構裝置端語音識別
OnType 的裝置端語音識別由 mlx-swift-asr驅動。這是我們打造的一個開源 Swift 庫,用來在 Apple Silicon 上原生執行 Qwen3-ASR。 沒有 Python 執行時,沒有子程序橋接,也沒有雲端,只有 Swift → MLX → Neural Engine。
這篇文章會講清楚推理鏈路是怎麼工作的、那些讓它“感覺像即時響應”的延遲工程, 以及我們一路踩過的一些不明顯的坑。
為什麼是 Qwen3-ASR,為什麼是 MLX
Qwen3-ASR 是阿里巴巴 Qwen 團隊推出的語音識別模型。我們釋出的是 0.6B 引數版本,量化到 6 bit 後,磁碟佔用大約 400MB。它採用 Whisper 風格的音訊編碼器(Conv2d → Transformer),再接入使用 Grouped Query Attention、RoPE 和 SwiGLU 活化函數的 Qwen3 文字解碼器。
Apple 的 MLX 框架是專門為 Apple Silicon 上的機器學習推理設計的。它相對於通用執行時的關鍵優勢在於: MLX 理解 M 系列晶片的統一記憶體架構。CPU、GPU 和 Neural Engine 共享同一塊記憶體集區,不需要在主機記憶體和裝置記憶體之間反覆複製資料。 對於一個即時 ASR 管線來說,音訊會持續流入,中間表示也會立刻被消費, 這就直接消掉了一整類延遲開銷。
我們使用的是 mlx-swift,也就是原生 Swift 繫結,因此整條路徑都留在 Apple 的生態裡:Swift → MLX → Metal → Neural Engine。 整個過程中沒有 Python 直譯器。
推理管線
mlx-swift-asr 裡的音訊轉文字路徑分成五個階段:
階段 1:Mel 頻譜圖
16kHz 的原始音訊會被轉換成 128 bin 的 log-mel spectrogram。 引數與 WhisperFeatureExtractor 完全一致:400 樣本 FFT 視窗(25ms), 160 樣本 hop(10ms),以及 Slaney 風格的 mel filterbank 歸一化。
Hann window 和 mel filterbank 矩陣會在啟動時計算一次, 並快取為靜態屬性。這只是一個小最佳化(每次轉寫大約能省 10ms), 但在流式管線裡,每個 chunk 都會走這條路徑,積少成多。
一個不太明顯的相容性細節是:我們會丟掉最後一個 STFT frame, 以匹配 PyTorch 的 torch.stft(center=True) 行為。 frame 數量差一個,就會讓編碼器發生維度不匹配。 這種 bug 最煩的地方在於模型不會直接崩掉,它只是吐出垃圾結果, 要排查非常久。
階段 2:音訊編碼
Mel 頻譜圖會送入音訊塔,也就是三層 stride-2 Conv2d 加一個 Transformer 編碼器。這個階段會把時間維壓縮大約 8 倍,並把音訊特徵投影到文字解碼器的隱藏維度。
這裡還有一個細節:Conv2d 權重在模型載入時,需要從 PyTorch 的 OIHW 佈局轉置到 MLX 的 OHWI 佈局。權重清洗步驟會自動處理這件事。
階段 3:構造 Prompt
編碼後的音訊特徵會按 Qwen3 的 chat template 格式併入文字 prompt。 prompt 裡的音訊佔位 token 會透過基於 cumsum 的索引方案, 替換成真實的編碼器輸出 embedding。這和多模態 LLM 處理影象 token 的架構是同一種思路。
階段 4:透過雙緩衝產生 token
這裡才是最有意思的效能工程部分。自迴歸解碼一次只生成一個 token。 最樸素的做法是:前向計算、取出 token、再做下一次前向。這樣是序列的。 GPU 在算的時候 CPU 在等,CPU 在取 token 的時候 GPU 又閒著。
我們用了雙緩衝的 asyncEval 模式,把 GPU 和 CPU 工作重疊起來:
- 在提取目前 token 之前,先排隊下一次前向計算
- 呼叫
item()取出目前 token ID。這個操作會強制 GPU 同步,但這時 GPU 其實已經在平行計算下一輪 logits 了 - 等到我們真正需要下一輪 logits 時,它們往往已經算好了
程式碼讀起來其實很反直覺:你要先準備下一步輸入 embedding, 把 forward pass 和 asyncEval 排進去,然後再提取目前 token。但正是這種流水線化,隱藏掉了原本會主導解碼耗時的同步延遲。
階段 5:Token 解碼與清理
產生出的 token ID 會透過 BPE 解碼,再經過輸出解析,去掉特殊 token, 同時提取檢測到的語言。清洗後的文字接著進入我們的 逆文字規範化引擎,最終才到達游標位置。
Metal 預熱:5 秒冷啟動
MLX 會在執行時編譯 Metal compute kernel,也就是 JIT 編譯。 這導致應用程式啟動後的第一次轉寫會承擔大約 5 秒的 shader 編譯開銷。 從第二次開始才會很快。
這是整個庫裡影響最大的一處效能陷阱。沒有預熱時,第一次真實轉寫對 8 秒音訊往往需要 5 到 8 秒(大概只有 0.5 倍於即時的速度)。 做完預熱後,每次轉寫都能跑到 4 到 6 倍於即時的速度。
我們的預熱策略裡有幾個不明顯的點:
- 用噪聲,不用靜音。 靜音會產生接近 0 的 mel 值,不一定能覆蓋所有 kernel 路徑。低幅隨機噪聲能保證整條管線都跑過更豐富的計算分支。
- 用真實長度的音訊。 我們用 8 秒音訊做預熱, 這樣 batched Conv2d 編碼器會處理大約 8 個 chunk,更接近真實批次維度。 如果只用 2 秒音訊(大約 2 個 chunk),第一次真實轉寫在更大 batch 尺寸下仍可能觸發額外的 Metal pipeline state 編譯。
- 首次執行使用非零 temperature。 如果用 greedy decoding(temperature=0),模型在噪聲輸入下會立刻輸出 EOS, 從而一個 token 都不生成,導致自迴歸解碼迴圈的 kernel 根本沒編譯。 temperature=1.0 則會強制產生 token,把完整解碼路徑走一遍。
- 做兩輪預熱。 第一輪使用 temperature sampling 編譯所有 kernel,第二輪使用 greedy decoding 驗證快速路徑。 然後清掉記憶體快取,但保留 shader 快取。
基準資料
以下資料來自 M1 Pro 上、6 bit 量化的 Qwen3-ASR 0.6B。 這裡統計的是純推理耗時,不包含模型載入和 Metal 預熱:
| 音訊時長 | 推理時間 | RTF | 速度 |
|---|---|---|---|
| 1.58s | 0.27s | 0.172 | 5.8 倍於即時 |
| 2.56s | 0.41s | 0.159 | 6.3 倍於即時 |
| 0.98s | 0.26s | 0.264 | 3.8 倍於即時 |
| 1.19s | 0.29s | 0.239 | 4.2 倍於即時 |
典型 RTF 範圍是 0.15 到 0.27,也就是 3.7 到 6.3 倍於即時速度。 這和 Python 版 MLX 實作相比也很有競爭力,但我們是一個原生 Swift 庫, 沒有任何 Python 開銷。
對於 OnType 的即時語音輸入場景,這意味著 ASR 引擎處理語音的速度比語音到達的速度還快。等使用者鬆開熱鍵時, 大部分音訊其實已經被轉寫完了,通常只剩最後一個 chunk 需要處理, 耗時不到 300 毫秒。
防護欄
有幾種安全機制,是我們踩坑之後才學會必須加上的:
- 基於時長的 token 上限。 最大產生長度被限制為
ceil(audioDuration × 20) + 64個 token。 沒有這個限制時,某些異常輸入會讓模型在不輸出 EOS 的情況下產生成千上萬個 token, 導致 GPU 一直空轉。 - 重複檢測。 如果同一個 token 連續重複 10 次, 我們就直接停止。這能抓住模型陷入死迴圈的退化輸出。
- 最小音訊長度。 短於一個 FFT 視窗的音訊 (400 樣本,也就是 25ms)會直接回傳空結果。 沒有這個檢查的話,STFT 裡的 reflection padding 會因為 range 非法而崩潰。
- MLX 錯誤轉換。 MLX 預設會對 GPU 錯誤直接
fatalError。我們會用withError包裹關鍵路徑,把這些錯誤轉成 Swift 的throws, 這樣應用就能在 HUD 裡展示錯誤,而不是直接崩掉。
開源
mlx-swift-asr 採用 MIT 協議,地址在 github.com/ontypehq/mlx-swift-asr。它是一個獨立 Swift package。只要把它加進你的 Package.swift,你就能得到一個三行 API 的裝置端語音識別:
let stt = try await Qwen3ASRSTT.loadWithWarmup(from: modelDirectory)
let result = try await stt.transcribe(file: audioURL)
print(result.text) // "Hello, world."
print(result.rtf) // 0.17 (5.8x real-time)如果你在做任何需要 Apple Silicon 上語音識別的東西,比如語音輸入、 轉寫工具或無障礙產品,可以試試看。基準也可以透過 swift test --filter Benchmark 復現。
試試 OnType,看看 mlx-swift-asr 在真實產品裡的效果: 按住鍵、說話、鬆開就能輸入。裝置端、即時、私密。