← Running a 744B Model on a Laptop, Then Telling the Author Not to Fix That Bug

繁體中文

在 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 GB

744B 參數,在一台 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 A
A 現況(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。所以這篇的最後一個教訓其實是:小孩子才選擇性讀文件。

English

Running a 744B Model on a Laptop, Then Telling the Author Not to Fix That Bug

A project called colibri just crossed twenty thousand stars, and its opening line is this: with about 25GB of RAM you can run GLM-5.2, a 744-billion-parameter MoE model, on a consumer machine. Pure C, zero dependencies, experts streamed from disk.

My laptop has 32GB. Let’s play.

It worked. Seven seconds per token. This post isn’t about the slowness, slowness was the expected part. What’s interesting is the three counterintuitive things I measured along the way, and how the last one turned my issue to the author from “please fix this bug” into “please don’t”.

How it fits at all

The mechanism is worth understanding, because all three findings fall out of it.

Picture a consulting firm with 19456 specialists, each with a folder of about 19MB. The trick with this kind of model is that answering a single token only needs the opinion of 8 of them. In practice it walks 75 floors and asks 8 per floor, so one token pulls around 600 folders. Since you only ever use a handful at a time, you don’t need every specialist in the office. Fetch whoever you need from the warehouse next door.

The office is your RAM. The warehouse is that 429GB model file on disk. The manager who assigns the work and can never leave is about 12GB, permanently resident.

That’s where it breaks down. The office is 32GB, the manager alone eats 12GB, working space takes more, and there is almost no room left for specialists. So for every token, more than nine out of ten of those 600 folders have to be fetched from the warehouse right now.

That’s the whole story. It’s slow not because the compute is weak, but because it never stops hauling.

Confirming it’s real

The build is almost comically fast: 5.4 seconds, zero warnings. The Metal kernel tests all pass, error around 1e-6, and the expert top-8 select is bitwise identical between its serial and parallel paths. Tests like that tell you the author knows what he’s doing.

429GB downloaded, all 149 files byte-verified. First question:

› 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 GB

744 billion parameters, on a 32GB laptop, spent two minutes working out that 2+2 is 4.

Then I asked it in Traditional Chinese why MoE models can run on machines with less memory than their parameter count, capped at 128 tokens:

要理解這個問題,首先需要釐清一個核心概念:「總參數量」不等於「同時需要載入到記憶體(VRAM)的參數量」。

MoE(Mixture of Experts,混合專家)模型之所以能在記憶體遠小於其總參數量的機器上執行,核心原因在於「稀疏激活」與「動態載入」。

(In English: “To understand this, we first need to clarify a core concept: total parameter count is not the same thing as the number of parameters that must be loaded into memory at once. An MoE model can run on a machine with far less memory than its total parameter count for two main reasons: sparse activation, and loading on demand.” I left the original in place because the point of quoting it is that the quantized container still writes clean Traditional Chinese, which a translation would hide.)

Twenty minutes, then cut off at the 128-token cap. The Chinese is good: correct terminology, and it laid out its own headings. This container is a quantized build (weight precision lowered to shrink the file) and its Chinese survived intact.

While I was there I measured the hauling. Diffing iostat before and after, those twenty minutes actually read 1621GB off the disk. Working it out from the counters instead, 654.9 experts per token times 18.9MB times 128 tokens, predicts 1585GB. A 2% match.

So “12GB of experts fetched from disk per token” is not a figure of speech. It’s literal. Roughly three Blu-ray movies per token.

Surprise one: GPU acceleration does nothing here

The author ships a Metal backend that puts the expert matmuls on the GPU. On his own 128GB M4 Max it’s worth about 40%.

On this machine it’s worth 1.2%.

The profile says why:

METAL: GPU blocks 67 | CPU fallback 0 | experts on GPU 411 | gpu-wall 0.98s

The generation issues 19456 expert computations. The GPU handled 411 of them, 2%. It was busy for 0.98 seconds out of a 225-second decode.

Back to the office analogy. The Metal path batches the specialists who are already resident in the office, zero-copy straight out of the RAM slabs. Residency here is 8%. The other nine tenths arrive from the warehouse and get computed on the CPU as they land, so there is essentially nothing for the GPU to accelerate.

The counterintuitive part: to make the GPU pay off, what you need is more memory, not a faster GPU. The 128GB M4 Max gets its 40% because its office is big enough to hold specialists. The M5 Max in the docs reaches 2.06 tok/s off a 46.9GB learned pin, same reason. Metal’s benefit scales with expert residency, not with GPU throughput.

Surprise two: the tool’s own advice is wrong

There’s a doctor command that self-checks and emits tuning hints. On this machine it recommends turning speculative decoding off:

auto-tune:
DRAFT=0 low hit rate: MTP widens expert union, adds disk reads

Speculative decoding here works like this: the model ships a small helper that drafts the next token, then the real model verifies two tokens in one forward pass. When the draft is right, you saved a pass. doctor reasons that the helper widens the set of folders each token needs, which is a bad trade on a machine whose hit rate is this low.

The first half is correct. Drafting does pull 4.6% more folders per token, 608 versus 581. But following the advice:

decode tokens per forward acceptance
drafting on 224.92s 2.00 100%
DRAFT=0 262.26s 1.03 --

17% slower.

What it missed is the acceptance rate. Pulling 4.6% more folders to get two tokens per forward pass, at 100% acceptance (86% on a longer generation), is clearly worth it. The heuristic looks at hit rate alone and never checks what the drafts actually do.

The author already wrote the right thing in the README: whether speculation is a net win depends on cache warmth, so measure it. He was right. doctor just didn’t keep up.

Surprise three: do not fix this bug

This is the good one.

Reading the source, I found an OpenMP tuning block whose job is to keep the worker threads hot instead of letting them sleep. The comment is clear about why: to hold the team warm across the tiny per-expert matmul regions.

The catch is that this tuning only takes effect via a self re-exec, because libgomp reads the environment in its constructor, before main(). The comment says so itself: “setenv-in-main is ignored by the already-initialised runtime”. And that execv has exactly two branches:

#ifdef __linux__
... execv("/proc/self/exe", argv);
#endif
#ifdef __FreeBSD__
... execv("/proc/curproc/file", argv);
#endif

No macOS. It looks like an obvious gap. Add _NSGetExecutablePath(), five lines, done.

I didn’t conclude that, though. macOS uses LLVM libomp, which initializes lazily at the first parallel region, well after main() has run, so in principle the setenv calls might be picked up without any re-exec at all. Those two possibilities can only be separated by experiment, not by reading code.

So: A is the shipped behavior, B is me exporting the same four variables before launch. If the in-process setenv works, the two are identical.

r1 r2 median vs A
A as shipped (10 threads) 218.55s 219.39s 218.97s --
B same tuning exported 481.20s 490.72s 485.96s +122%
C OMP_NUM_THREADS=8 209.57s 210.01s 209.79s -4.2%
D 8 threads + tuning 233.00s 234.51s 233.76s +6.8%

Turning it on is 2.2x slower.

And A not equalling B is itself the answer: the in-process setenv really isn’t reaching the OpenMP runtime. The gap is real. It is also, right now, protecting macOS users.

Across all eight runs expert hit rate stayed between 9.9% and 10.1%, so cache state isn’t the variable, and same-config repeats differ by under 2%. These gaps are far outside noise.

Isolating one variable at a time points at the culprits precisely:

(nothing set) 218.97s --
OMP_WAIT_POLICY=active 485.76s +122%
KMP_BLOCKTIME=200 471.40s +115%
OMP_DYNAMIC=FALSE 220.78s +0.8% (noise)
GOMP_SPINCOUNT=200000 217.39s -0.7% (noise; libomp ignores GOMP_*)

Either spin-wait knob alone reproduces the entire regression. The other two are inert.

The mechanism holds up. “Keeping the team hot” means letting compute threads spin instead of sleeping. But the critical work on this machine is done by the 8 async I/O threads hauling folders, and they need cores too. Fill all 10 cores with spinning compute threads and the haulers have nowhere to run, so the hauling stalls and everything gets slower.

Rows C and D pin the mechanism down: the same tuning costs 122% at 10 threads and only 6.8% at 8. Leave two cores for the haulers and a catastrophe becomes a rounding error.

Row C is also a free 4.2% on its own. This chip has 8 performance cores plus 2 efficiency cores, and plan can’t detect that on macOS (it only knows how to ask lscpu, which always fails here), so it falls back to using all 10. But this workload barriers per expert, and the slowest thread paces the team. The fun part: adding a “physical cores” probe wouldn’t fix it either, because hw.physicalcpu also returns 10 on this chip. The number you want is hw.perflevel0.logicalcpu, which returns 8.

So what I sent upstream isn’t “please add the macOS branch”. It’s the opposite: please don’t close this gap casually, because measured, it costs low-residency Apple Silicon hosts more than 2x, unless it’s gated first.

One validation worth recording

I ran the same prompt under seven configurations, including CPU-only, Metal, and speculation disabled. All seven produced byte-identical text.

That matters. The author’s central claim is that placement, whether a specialist sits in the office or the warehouse, changes speed only, never routing decisions or weight precision. That’s a promise which is very easy to quietly break while optimizing, and it holds. Together with the 1e-6 Metal kernel agreement and the bitwise-identical expert select, this is a project built honestly.

What I took away

The thing that looks most obviously broken may be the thing protecting you. I nearly shipped “macOS is missing a branch” as-is. Five-line fix, clear mechanism, the code comment itself backing me up. What stopped it was one question: what happens if it does take effect? Two hours of A/B later, the answer was 2.2x slower.

On a low-residency host, an accelerator can’t accelerate what isn’t there. That 0.98 seconds of GPU time is the cheapest lesson in this post. Ask which layer the bottleneck is on before buying hardware. This machine is short on memory, not GPU.

Advice from a self-check tool is still a heuristic, and heuristics expire. doctor’s DRAFT=0 had a correct premise and the opposite conclusion, because it was one metric short. What a tool measures and what a tool recommends do not deserve the same trust.

I’m keeping the 400GB model file. Seven seconds per token is unusable day to day, but 744 billion parameters answering correctly on a laptop is worth pulling out to look at once in a while.

Both reports are filed: #706 is the full measurement for this hardware class, which nobody had covered before, and #707 is the “please don’t fix it”.

After filing, I found out CONTRIBUTING.md is 37 lines long and I had read the first 30. The two lines I skipped are the ones specifying what a benchmark report must include, median throughput among them. I added a follow-up comment. So the real last lesson here is that RTFM covers all 37 lines, not just the ones that fit on screen.