minicpm47woek_EPQ / docs /DESIGN.md
HelloSun's picture
EPM: expert paging CPU inference for MiniCPM-V-4.7-35B-A3B Q4_K_M
12eb3a5 verified
|
Raw History Blame Contribute Delete
6.12 kB

設計:記憶體帳目、SSD→RAM→CPU 資料流

這個模型在 8 GiB 裡能跑,不是因為它小,而是因為19.71 GiB 的權重裡有 18.16 GiB 從來不需要同時在 RAM 裡。

1. 權重帳目

MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf,21,166,255,008 B = 19.71 GiB, 架構 qwen35moe,40 個 block。

項目 大小
路由專家權重(40 層 × 256 個 × 3 個投影) 18.16 GiB
非專家常駐權重(embedding / lm_head / 注意力 / SSM / shared expert) 1.54 GiB
單一 token 的專家工作集(40 層 × top-8) 14.53 MiB 平均 / 15.56 MiB 最大

單一 token 在解碼時需要 320 個 layer-expert,但因為槽位分兩種尺寸,正確的說法是 160 個大槽位 + 160 個小槽位。8 GiB 的 arena 塞得下 3714 個槽位,其中約四成是 大槽位(20/40 的層用大槽位),所以命中率可以到 70% 以上;2 GiB 的 arena 只有 332 個槽位湊不出這個組合,命中率崩到 22.7%(見 README 的 budget 曲線)。

兩種 expert 大小

這個模型的 ffn_down_exps 量化型別逐層不同:

層 ffn_down_exps 每個 expert 位元組
20 層 Q6_K 2,039,808 B
20 層 Q4_K 1,769,472 B

ffn_gate_exps 與 ffn_up_exps 全部是 Q4_K,每個 589,824 B。

平均下來每個 layer-expert 是 1.95 MiB,因此 arena 的槽位分成兩種尺寸: 小 1.69 MiB × 2420 + 大 1.95 MiB × 806(8 GiB 預算下)。

這一點是這個模型相對於參考實作最重要的結構差異:參考的 Qwen3.6-35B-A3B 是 37 層 1,900,544 B + 3 層 2,039,808 B,槽位比例完全不同。槽位尺寸必須從 模型 metadata 推導,不能寫死。

混合架構

qwen35moe.block_count = 40,但只有 10 層是全注意力 (full_attention_interval = 4),另外 30 層是 SSM / Mamba 層 (ssm.* 張量,recurrent_layers 40 個元素全為 1)。

這影響兩件事:

  1. 正確性:模型 metadata 裡因此多了一個 BOOL 陣列,參考實作的 GGUF 標頭 解析器會在這裡錯位(見 CORRECTNESS.md §3.2)。
  2. 效能:SSM 層的計算可以平行,不像注意力那樣受限於序列維度,所以 執行緒數的結論與參考實作相反(見 NEGATIVE_RESULTS.md)。

2. 資料流

GGUF 檔(19.71 GiB)
   │
   │  非專家權重:mmap,乾淨的 file-backed 頁
   │  路由專家權重:自訂 buffer type,只回報 4 KB,
   │               真正的內容留在檔案裡(pager 用 pread 管理)
   ▼
SSD → RAM:pager 在需求觸發時把選中的 expert 讀進 arena 槽位
   │
   ▼
RAM → CPU:ggml 自己的量化點積(ggml_vec_dot_q4_K_q8_K / q6_K_q8_K)

路由本身(logits / softmax / top-k / 權重正規化)仍然由 ggml 算完, build_moe_ffn 只在「把被選到的 8 個 expert 算出來並加權求和」這一段 交給 sdq_build_moe。

3. 實作的關鍵決定

三段並行讀取

一個 expert 的 gate / up / down 在檔案上互不相鄰。循序讀是 3 × 延遲的鏈, 改成三段同時讀之後,一層 8 個全 miss 的 expert 明顯變快。

需求觸發的 LRU 淘汰 + pin

acquire() 是「先讀完再 publish」。reserve_slot() 會在資料還沒讀進來之前 就把 (layer, expert) 記進 page 表,一旦 pread 失敗,中斷槽就會留在表裡卻是 舊資料,之後的 probe() 會把它當成命中 → 靜默算錯。成本相同,但不能這麼做。

arena sizing 扣的是「匿名 RSS」

statm 的 resident 把 mmap 進來的模型權重頁也算成已用,但那些是乾淨的 file-backed 頁,記憶體吃緊時核心會直接回收,既不佔用寶貴的實體 RAM 也不會產生 swap I/O。把它算進「已用」會讓 arena 被無謂地縮水。

arena = 預算 − 匿名 RSS − SDQ_RESERVE_MB

RLIMIT_DATA 只當上界,不當預算來源:它算的是匿名虛擬記憶體,而 llama.cpp 會配置大量「映射了但永遠不會 touch」的圖與 compute buffer。

8 GiB 下的 reserve 校正

SDQ_RESERVE_MB 必須逐模型、逐 ctx/ubatch 校正。本模型在 ctx=4096 / ubatch=128 下量到的:

reserve arena 槽位 峰值匿名 RSS 8 GiB 內?
128 MiB — — 4042.6 MiB 是(餘 53 MiB,太緊)
256 MiB — — 3918.1 MiB 是(餘 178 MiB)
384 MiB — — 3795.7 MiB 是(餘 300 MiB)
512 MiB 6.35 GiB 3714 7799 MiB 是(餘 446 MiB)
640 MiB 6.23 GiB 3644 7623.0 MiB 是(餘 292 MiB)
768 MiB 6.11 GiB 3573 7498.5 MiB 是
1024 MiB 5.87 GiB 3432 7251.6 MiB 是

峰值匿名 RSS 幾乎與 reserve 成 1:1 反比(每減 128 MiB,RSS 多約 123 MiB), 因為省下來的預留全部變成 arena。512 MiB 是這個模型在 8 GiB 下的選擇。

換 RAM 預算時 reserve 也要跟著換。本 repo 的量測腳本用一條比例規則:

reserve = clamp(RAM 預算 / 16, 128 MiB, 1024 MiB)

在 8 GiB 重現 512 MiB,在 4 GiB 給出 256 MiB(餘 178 MiB)。 用固定的 640 MiB 跑 4 GiB 會直接超出上限。

4. 輸出不直接寫 dst

同一個 token 的 8 個 expert 會撞在一起,所以每個執行緒一份累加緩衝, 最後一道 barrier 之後歸約。同時每個 expert 算完立刻 release() 它的槽位 —— 不放的話 prefill 一層會把整層用到的 expert 全部釘住(最多 256 個), 大槽位不夠時會中止。

5. 這個設計沒有做的事

  • 不碰路由。 路由必須完整算完才知道要哪些 expert,而路由本身很小。
  • 不用 swap。 swap 的延遲不可預測,而且本專案的 no-swap 是核心保證。
  • 不做壓縮。 權重已經是 Q4_K_M,再壓就是品質換速度,不在本專案範圍。
  • 不處理視覺。 這個 GGUF 的 vision projector 在另一個檔案 (mmproj-model-f16.gguf),本路徑只做文字推論。