在 32GB 筆電上跑 744B 參數,然後叫作者不要修那個 bug
有個叫 colibri 的專案最近衝到兩萬顆星,開頭第一句話是:只要約 25GB 記憶體,就能在消費級電腦上執行 GLM-5.2,744B 參數的 MoE 模型。純 C、零相依、專家從硬碟串流。
我的筆電有 32GB。玩玩看。
結果它真的跑起來了,一個字 7 秒。這篇要講的不是慢,慢是可以預期的。有意思的是過程中量到三件跟直覺相反的事,其中最後一件讓我發 issue 給作者的時候,寫的不是「請修這個 bug」,而是「拜託不要修」。
它憑什麼塞得進去
先講原理,這部分值得懂,因為後面三個發現全部從它衍生。
想像一間顧問公司,裡面有 19456 位專科顧問,每位的資料夾約 19MB。這種模型的特別之處是,每回答一個字只需要問其中 8 位的意見(實際上要跑 75 個樓層、每層問 8 位,所以一個字大約調 600 多份資料夾)。既然一次只用到極少數,那就不必把全部顧問請進辦公室,需要誰再從隔壁倉庫搬進來就好。
辦公室是記憶體,倉庫是硬碟上那 429GB 的模型檔。負責分派工作、不能離開的那位主管約 12GB,常駐辦公室。
問題就在這裡。辦公室 32GB,主管一個人占掉 12GB,作業空間再扣掉一些,幾乎沒有位子留給顧問。所以每回答一個字,600 多份資料夾裡有九成以上要現去倉庫搬。
這就是全部的故事。速度慢不是因為運算不夠力,是因為一直在搬東西。
先確認它是真的
編譯快得有點誇張,5.4 秒,零警告。Metal kernel 的對拍測試全過,誤差在 1e-6 等級,專家選路的序列版和平行版是位元相同。這種測試寫得出來,說明作者知道自己在幹什麼。
模型 429GB 下載完,149 個檔案逐一比對,位元全對。第一個問題丟給它:
› What is 2+2?◆ 2 + 2 = 4
prefill 13 tokens in 74.36s | decode 7 tokens in 58.24s (0.12 tok/s)expert hit rate 7.6% | RSS 6.29 GB744B 參數,在一台 32GB 的筆電上,花了兩分鐘算出 2+2 等於 4。
然後換繁體中文問它 MoE 為什麼能在小記憶體機器上跑,上限設 128 個字:
要理解這個問題,首先需要釐清一個核心概念:「總參數量」不等於「同時需要載入到記憶體(VRAM)的參數量」。
MoE(Mixture of Experts,混合專家)模型之所以能在記憶體遠小於其總參數量的機器上執行,核心原因在於「稀疏激活」與「動態載入」。以下用繁體中文為您詳細解釋其運作原理:
跑了 20 分鐘,然後在 128 字上限處被切斷。中文品質是好的,術語正確、還自己排了標題結構。這個容器是壓縮過的版本(把權重精度壓低讓檔案變小),中文能力沒被打壞。
順便量一下搬運量。用 iostat 對拍前後,這 20 分鐘從硬碟真的讀了 1621GB。我另外用顧問人數乘資料夾大小推算應該是 1585GB,差 2%。
所以「每產生一個字要從硬碟搬 12GB」不是比喻,是字面上的事實。差不多是三部藍光電影。
意外一:顯示晶片加速等於沒用
作者提供了一個 Metal 後端,讓顯示晶片幫忙算專家的矩陣乘法。他自己在 128GB 的 M4 Max 上量到快 40%。
我在這台開起來,只快 1.2%。
翻 profile 才知道為什麼:
METAL: GPU blocks 67 | CPU fallback 0 | experts on GPU 411 | gpu-wall 0.98s整段生成要跑 19456 次專家運算,顯示晶片只處理了 411 次,占 2%。全程只忙了 0.98 秒,而 decode 花了 225 秒。
原因回到辦公室那個比喻。Metal 路徑處理的是已經常駐在辦公室裡的顧問(零複製直接從記憶體 slab 讀),而這台辦公室的常駐率只有 8%。其他九成是剛從倉庫搬進來、當場算完就丟掉的,根本沒有東西給顯示晶片加速。
這個結論反直覺的地方是:要讓顯示晶片派上用場,你需要的是更大的記憶體,不是更強的顯示晶片。作者那台 128GB 的能快 40%,是因為辦公室夠大放得下顧問;文件裡另一台 M5 Max 靠 46.9GB 的常駐快取衝到 2.06 tok/s,同一個道理。Metal 的收益跟常駐率成正比,跟 GPU 算力沒什麼關係。
意外二:軟體自己的調校建議是錯的
這軟體有個 doctor 命令做自我檢查,會給調校建議。它在這台機器上建議關掉投機解碼:
auto-tune: DRAFT=0 low hit rate: MTP widens expert union, adds disk reads投機解碼是這樣運作的:模型附了一個小幫手先猜下一個字,然後讓正式模型一次驗證兩個字,猜對就省一次工。doctor 的理由是,這個小幫手會讓每個字需要調的資料夾變多,在你這種命中率極低的機器上不划算。
前半段沒錯,我實測搶答確實讓每個字多調 4.6% 的資料夾(608 對 581)。但照它的建議關掉之後:
decode 每次驗證產出 猜對率搶答開(預設) 224.92s 2.00 個字 100%DRAFT=0 262.26s 1.03 個字 --慢了 17%。
它漏看的是猜對率。多調 4.6% 的資料夾換來一次做兩個字,而猜對率是 100%(換長一點的題目是 86%),明顯划算。doctor 的判斷只看了命中率一個指標,沒去看實際的接受率。
這其實是作者自己在 README 裡寫過的:投機解碼是否划算取決於快取熱度,請自己實測。他寫對了,只是 doctor 的啟發式沒跟上。
意外三:這個 bug 千萬不要修
這件最有意思。
我在讀原始碼的時候,看到一段 OpenMP 調校,作用是讓運算執行緒保持隨時待命不要睡著。註解寫得很清楚,是為了在細碎的 per-expert 矩陣乘法之間維持熱度。
問題是這段調校靠 self re-exec 才會生效,因為 libgomp 在 main() 之前就把環境變數讀完了,程式碼註解自己也說明「setenv-in-main is ignored by the already-initialised runtime」。而那個 execv 只有兩個分支:
#ifdef __linux__ ... execv("/proc/self/exe", argv);#endif#ifdef __FreeBSD__ ... execv("/proc/curproc/file", argv);#endif沒有 macOS。看起來就是個該補的漏洞,補上 _NSGetExecutablePath() 就好,五行的事。
但我沒直接下結論。因為 macOS 用的是 LLVM libomp,它在第一個 parallel region 才初始化,那時候 main() 裡的 setenv 早就跑完了,理論上可能根本不需要 re-exec 就會生效。這兩種可能要靠實驗分辨,不能靠讀程式碼。
所以做了對照:A 是現況,B 是我自己在外面把同一組變數 export 進去。如果內部的 setenv 有效,兩組應該一樣。
r1 r2 平均 vs AA 現況(10 執行緒) 218.55s 219.39s 218.97s --B 外部 export 同一組調校 481.20s 490.72s 485.96s +122%C OMP_NUM_THREADS=8 209.57s 210.01s 209.79s -4.2%D 8 執行緒 + 調校 233.00s 234.51s 233.76s +6.8%打開之後慢了 2.2 倍。
而 A 不等於 B 這件事本身就是答案:內部的 setenv 確實沒有到達 OpenMP runtime。這個漏洞是真的,但它目前是在保護 macOS 使用者。
八組跑下來,專家命中率全部落在 9.9% 到 10.1% 之間,所以快取狀態不是變數;同一組配置跑兩輪差異在 2% 以內,這些落差遠超噪音。
單變數隔離之後,兇手指得更清楚:
(什麼都不設) 218.97s --OMP_WAIT_POLICY=active 485.76s +122%KMP_BLOCKTIME=200 471.40s +115%OMP_DYNAMIC=FALSE 220.78s +0.8%(噪音)GOMP_SPINCOUNT=200000 217.39s -0.7%(噪音,libomp 不讀 GOMP_*)兩個忙等旋鈕各自單獨都足以造成全部退化,另外兩個完全無害。
機制說得通。所謂保持待命的做法是讓運算執行緒空轉不睡,但這台機器最關鍵的工作是搬資料的那 8 個 I/O 執行緒,它們也需要核心才能動。運算執行緒佔滿全部 10 顆核心空轉,搬運工就沒核心可用,搬不動了,整體反而更慢。
C 和 D 那兩列剛好把這個機制釘死:同樣的調校,10 執行緒下慢 122%,只讓 8 顆空轉、留 2 顆給搬運工,就只慢 6.8%。
順帶說 C 這一列本身也是免費的 4.2%。這顆晶片有 8 顆快核心加 2 顆省電慢核心,而 plan 在 macOS 上查不到核心配置(它只會用 lscpu,Mac 上必然失敗),退而全用 10 顆。但這種運算是大家做完一起等,最慢的那個決定整組節奏。有趣的是,就算補上物理核心數的偵測也修不到這件事,因為 hw.physicalcpu 在 M1 Max 也回 10。要的是 hw.perflevel0.logicalcpu,才會回 8。
所以我給作者的回報不是「請補上 macOS 分支」,是反過來的:千萬不要順手把這個漏洞補好,實測會讓 Apple Silicon 低常駐率的機器慢一倍以上,除非先加判斷條件。
一件值得記的驗證
我用七種不同配置跑同一個問題,包含純 CPU、開 Metal、關掉投機解碼。七次產生的文字,一個位元都不差。
這件事有意義。作者的核心宣稱是「顧問放在辦公室還是倉庫,只影響速度,不改變路由決策與權重精度」。這是很容易在最佳化過程中偷偷破掉的承諾,而它成立。加上 Metal kernel 對拍到 1e-6、專家選路位元相同這些測試,這是個做得很誠實的專案。
學到什麼
看起來最該修的東西,可能正在保護你。這次差一點就直接寫「macOS 少了一個分支」發出去,五行的修法、機制清楚、程式碼註解都幫我背書。真正擋下來的是一個問句:如果它生效了,會怎樣?花了兩小時做對照,答案是慢 2.2 倍。
在低常駐率的機器上,加速器加速不了不存在的東西。Metal 那 0.98 秒是整篇最便宜的教訓。買硬體之前先問瓶頸在哪一層,這台機器缺的是記憶體不是 GPU。
自我檢查工具的建議也是啟發式,也會過期。doctor 的 DRAFT=0 前提正確、結論相反,因為它少看一個指標。工具給的建議跟工具給的量測,可信度不是一回事。
那 400GB 的模型檔我留著。一個字 7 秒沒辦法日常用,但 744B 參數在筆電上正確回答問題這件事,值得偶爾拿出來看一眼。
兩則回報都發出去了:#706 是這台機器的完整實測(這個硬體等級先前沒人補過),#707 是那個「拜託不要修」。
發完才發現 CONTRIBUTING 只有 37 行,而我只讀了前 30 行,漏掉的最後兩行剛好就是 benchmark 報告的規範,要求中位數吞吐量。補了一則 comment。所以這篇的最後一個教訓其實是:小孩子才選擇性讀文件。