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`),本路徑只做文字推論。