File size: 16,819 Bytes
12eb3a5 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 | ---
license: apache-2.0
library_name: gguf
base_model: openbmb/MiniCPM-V-4.7-35B-A3B
pipeline_tag: text-generation
tags:
- minicpm
- qwen35moe
- gguf
- moe
- cpu
- low-memory
- expert-offloading
- no-swap
---
# EPM:MiniCPM-V-4.7-35B-A3B 的 8 GiB 專家分頁 CPU 推論
在 **8 GiB RAM、沒有 swap** 的條件下,用 CPU 跑 19.71 GiB 的
`MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf`。
同一個模型、同樣的 8 GiB 上限之下,**未修改的 llama.cpp 連模型都載不進去**
(它要配置一個 14.4 GB 的 CPU_REPACK buffer,在載入階段就失敗)。EPM 把路由
專家權重留在儲存裝置,只把當前選中的專家分頁載入受限的 CPU arena:
**5.83 tok/s,峰值匿名 RSS 7799 MiB,VmSwap 全程 0 MiB。**
這個 repo 收錄實作(`cpp/` + `patches/`)、量測工具與全部證據。
**不包含模型權重。**
> **名稱說明**:EPM = Expert Paging for MiniCPM。本專案**不是**論文中的 SDQ
> (Sparse Decomposed Quantization):沒有 2:4 結構化稀疏跳零計算,也不宣稱
> GPU 稀疏加速。`sdq/` Python package、`sdq_*` C++ 檔案與 `SDQ_*` 環境變數是
> 歷史 implementation identifier,保留以免破壞既有驗證工具;對外產品名稱與
> 執行入口是 EPM / `epq-chat`。
---
## 快速開始
需求:Linux x86-64、C++ 工具鏈、CMake、Git、Python 3、19.71 GB 磁碟空間。
已測 llama.cpp commit `9d7d5049804be03fce6575cc9f49c058b3c48788`。
```bash
python3 demo.py --once
```
從頭開始時,腳本會取得本 repo 原始碼、從 `openbmb/MiniCPM-V-4.7-35B-A3B-gguf`
下載 GGUF、套用 patch、編譯,然後跑一句示範。模型已下載的話:
```bash
python3 demo.py --model-path /path/to/MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf \
--once --ctx 4096 --threads 8 --n-predict 48
```
互動式聊天:
```bash
python3 demo.py --model-path /path/to/MiniCPM-V-4_7-35B-A3B-Q4_K_M.gguf \
--ctx 4096 --threads 8
```
**記憶體預算與 reserve**:8 GiB 目標用 `--ram-budget-mb 8192` +
`SDQ_RESERVE_MB=512`(這個模型在 ctx=4096 / ubatch=128 下校正出來的值,
見 `docs/DESIGN.md` §3)。換 RAM 預算要重新校正。
此 GGUF 不含 vision projector,本路徑只支援文字推論。已驗證的 context 是 4K;
模型原生支援 262 K,但在這個 RAM 預算下沒有驗證過。
---
## 在 8 GiB / no-swap 下量到的結果
**每次執行都由 `tools/strict8g.py` 管治**:所有**匿名**記憶體映射被 `mlock()`
鎖住(被鎖的頁永遠不會被寫到 swap,所以 no-swap 是核心保證的),
實體常駐量有硬上限,超標立刻 `SIGKILL`,另外 `RLIMIT_DATA` 當第二道閘門。
**`strict_ok=False` 的樣本一律不採用。**
### 標配(N=9,配對交錯,提示詞中英交替)
| 指標 | 值 |
| --- | ---: |
| **Decode tok/s(中位數)** | **5.83** |
| 單輪全距 | 5.19 – 6.63(抖動 25%) |
| Expert cache hit rate | 68.68% |
| SSD 讀取 / decode token | 214.1 MB |
| Arena | 6.35 GiB(3714 slots) |
| **峰值匿名 RSS** | **7799 MiB** / 8192 MiB 上限 |
| **峰值 VmSwap** | **0.000 MiB** |
| strict_ok | 9/9 |
條件:2× Intel Xeon Platinum 8559C(AVX-512 / AMX),`ctx=4096`、`ubatch=128`、
8 threads、greedy、48 requested tokens。
> 這是**共享主機**,同一組設定的單輪抖動可達 25%。數字足以支撐「能不能跑」
> 與「大概多快」,不足以支撐 5% 等級的宣稱。
### 同一個 8 GiB 上限下的傳統 llama.cpp
| 路徑 | 結果 |
| --- | --- |
| 傳統 llama.cpp `llama-completion`(**未套 patch**) | **無法載入模型** |
| EPM `epq-chat` | **5.83 tok/s**,峰值匿名 RSS 7799 MiB,VmSwap 0 |
失敗點:
```
ggml_aligned_malloc: insufficient memory (attempted to allocate 14400.00 MB)
ggml_backend_buft_alloc_buffer_n_default: failed to allocate CPU_REPACK buffer of size 15099494400
llama_model_load: error loading model: unable to allocate CPU_REPACK buffer
```
對照必須在**同一個上限**下建立。先量「兩邊都沒記憶體限制」再比較,是把 RAM
這個變數漏掉了。
### Pager budget 曲線(實體上限固定 8 GiB,只有 arena 大小在變,N=5)
| Budget | Arena | 槽位 | 命中率 | SSD MB/token | Decode tok/s | 峰值匿名 RSS | VmSwap |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 2 GiB | 0.57 GiB | 332 | 22.72% | 526.2 | 4.32 | 1842 MiB | 0 |
| 3 GiB | 1.53 GiB | 895 | 50.19% | 340.0 | 5.16 | 2828 MiB | 0 |
| 4 GiB | 2.50 GiB | 1459 | 59.81% | 275.8 | 5.48 | 3817 MiB | 0 |
| 5 GiB | 3.46 GiB | 2023 | 64.88% | 240.8 | 5.82 | 4805 MiB | 0 |
| 6 GiB | 4.43 GiB | 2587 | 67.52% | 222.2 | 5.38 | 5793 MiB | 0 |
| 7 GiB | 5.39 GiB | 3150 | 68.60% | 214.0 | 5.10 | 6779 MiB | 0 |
| **8 GiB** | **6.35 GiB** | **3714** | **70.74%** | **198.9** | **5.45** | **7766 MiB** | **0** |
2 GiB 只有 332 個槽位。單一 token 的工作集是 320 個 layer-expert,但**槽位還分兩種
尺寸**:20 層用大槽位、20 層用小槽位,所以每個 token 需要的是 160 個大槽位加
160 個小槽位,而不是 320 個任一尺寸的槽位。2 GiB 之下 arena 只有 0.57 GiB,
湊不出這個組合,命中率因此崩到 22.7%(8 GiB 之下 3714 個槽位裡約四成是大槽位,
命中率回到 70.7%)。
命中率在 8 GiB 這個端點**仍然在爬升**(8 GiB 70.7% → 16 GiB 77.5%)→
**瓶頸是記憶體上限,不是命中率飽和。**
> 這一欄與上面標配的命中率(68.68%)不同是正常的:這裡是 N=5、提示詞輪替不同,
> 標配是 N=9。兩者都受同一個 8 GiB 上限管治。
### RAM 擴張曲線:4 / 8 / 16 / 64 GiB
`--ram-budget-mb` 從 4 GiB 拉到 64 GiB。每次執行都由 `tools/strict8g.py` 管治,
36/36 輪 `strict_ok=True`,`VmSwap` 全程 0。
**兩點方法學說明,讀表前請先看:**
1. **這條曲線不用 `mlock`。** `mlock()` 會強制整個映射常駐 —— 對一個 60 GiB 的
arena,它會讓 RSS 直接變成 60 GiB,量到的就不是真實機器的行為。no-swap 的
核心保證保留給 8 GiB 這個**部署目標**(那一點另外有完整 mlock 證據,見
[`docs/STRICT_8GIB.md`](docs/STRICT_8GIB.md))。
2. **arena 不預先碰滿。** 全部路由專家權重只有 18.16 GiB,所以預先碰觸上限就是
18.17 GiB;超出來的部分等到真的用到才 fault in。這就是為什麼 64 GiB 預算的
「峰值 RSS」會停在約 24.0 GiB 而不是 62 GiB —— **那個數字才是真實的記憶體佔用。**
結構指標(槽位、命中率、SSD 讀取量、峰值 RSS)都是設定的直接函數,不受執行抖動
影響,四個點都列。吞吐另外做 **N=9 配對交錯** 並用 Welch t 檢定。
| Budget | Arena | 槽位 | 命中率 | SSD MB/token | 平均 tok/s | 單輪標準差 | 峰值 RSS | VmSwap |
| ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| 4 GiB | 2.74 GiB | 1600 | 61.50% | 275.4 | 5.43 ± 0.37 | 0.37 | 3963 MiB | 0 |
| **8 GiB** | **6.36 GiB** | **3715** | **71.98%** | **199.1** | **6.33 ± 0.33** | 0.33 | **7669 MiB** | **0** |
| 16 GiB | 13.59 GiB | 7943 | 77.54% | 157.9 | 6.28 ± 0.41 | 0.41 | 15076 MiB | 0 |
| 64 GiB | 59.89 GiB | 35003 | 77.54% | 157.6 | 6.32 ± 0.51 | 0.51 | 23969 MiB | 0 |
| 比較 | 結果 | Welch t | p | 判定 |
| --- | ---: | ---: | ---: | --- |
| **4 GiB 比 8 GiB** | 5.43 → 6.33(**+16.6%**) | **−5.44** | **0.0001** | **統計顯著** |
| 8 GiB 比 16 GiB | 6.33 → 6.28(−0.8%) | −0.30 | 0.76 | 無法分辨 |
| 8 GiB 比 64 GiB | 6.33 → 6.32(−0.2%) | −0.05 | 0.96 | 無法分辨 |
#### 這條曲線的重點
* **4 GiB → 8 GiB 是真實且顯著的提升:+16.6%**(t = −5.44,p = 0.0001)。4 GiB 時
命中率只有 61.5%、SSD/token 275.4 MB(8 GiB 的 1.4 倍),因為 arena 湊不出
「160 大 + 160 小」的工作集。
* **8 GiB 以上再加記憶體完全沒有可量測的收益**:8 vs 16(t = −0.30, p = 0.76)與
8 vs 64(t = −0.05, p = 0.96)全部無法分辨,而記憶體用量是 2 倍到 8 倍。
結構指標也說明原因:命中率在 **16 GiB 就飽和在 77.54%**,SSD/token 飽和在她
~158 MB。
* **真正的記憶體需求飽和在約 24.0 GiB 峰值**(18.16 GiB 全部專家權重 + 1.54 GiB
非專家權重 + KV cache / compute buffer / runtime)。
* **部署結論:8 GiB 就是對的容量。** 它拿到飽和命中率的 93%(72.0% 對 77.5%),
而且是整條曲線裡 throughput 顯著優於更低設定、又與所有更高設定打平的唯一一點。
> 逐輪原始資料:`bench/results/ram_sweep_n9.json`(N=9)與
> `bench/results/ram_sweep.json`(N=3,含 32 GiB 點)。
---
## 記憶體怎麼配置的
模型 metadata:每個 MoE 層 256 個路由專家,每個 token 選 8 個。
GGUF 21,166,255,008 B,其中路由專家權重 **18.16 GiB**、非專家常駐權重
**1.54 GiB**。
EPM 把未啟用的專家留在檔案裡,依需求把選中的專家載入記憶體 arena。關鍵實作:
* **三段並行讀取**:一個 expert 的 gate/up/down 在檔案上互不相鄰,循序讀是
3 × 延遲的鏈;改成三段同時讀之後明顯變快。
* **大小分級槽位**:**這個模型有兩種 expert 大小** —— 20 層的 `ffn_down_exps`
是 Q6_K(2,039,808 B/expert),另外 20 層是 Q4_K(1,769,472 B/expert)。
依分佈分大小槽位,而不是全部按最大的配。
* **需求觸發的 LRU 淘汰**,加上對「本 token 使用中」的 pin 保護。
* **讀取與計算重疊**(mode C),每個 expert 算完立刻釋放槽位。
* **跨層預取預設關閉**:它是負結果(見 `docs/NEGATIVE_RESULTS.md`)。
### Arena sizing
```
arena = RAM 預算 − 匿名 RSS − SDQ_RESERVE_MB
```
1. **扣的是「匿名 RSS」,不是「總 RSS」。** `statm` 的 resident 把 mmap 進來的
模型權重頁也算成已用,但那些是乾淨的 file-backed 頁,核心會回收。
2. **`RLIMIT_DATA` 只當上界,不當預算來源。** 它算的是匿名**虛擬**記憶體,
而 llama.cpp 會配置大量「映射了但永遠不會 touch」的 buffer。
`SDQ_RESERVE_MB` 是**逐模型、逐 ctx/ubatch 校正**的。本模型 ctx=4096 /
ubatch=128 下,512 MiB 留 446 MiB 餘量。峰值匿名 RSS 幾乎與 reserve 成 1:1
反比。**換 RAM 預算必須重新校正** —— 用固定的 512 MiB 跑 4 GiB 會超出上限。
完整校正表見 [`docs/DESIGN.md`](docs/DESIGN.md) §3。
---
## 正確性
```bash
python3 -m sdq.verify --model <gguf> --bin <epq-chat> --fast
python3 tools/determinism_check.py --model <gguf> --bin <epq-chat> --runs 5 -n 48
```
| 層 | 內容 | 結果 |
| --- | --- | --- |
| 1 kernel | 單一 expert 前向 vs NumPy 純重算(L0 E3 / L17 E200 / L33 E7 / **L39 E100**) | 全 PASS,max abs diff ≤ 3.7e-09 |
| 2 整層 | 真實路由輸入 vs NumPy,L0 / L39 | L0 PASS(1.1e-08);L39 PASS(8.8e-05,落在單一 Q8_K LSB 翻轉上界 3.2e-02 之內) |
| 2b 預取壓力 | 預取開 + 極小 arena 強制回收,兩種槽位配置 | 4/4 PASS |
| 3 端到端 A/B | vs llama.cpp 原生 `mul_mat_id` | **在 8 GiB 上不可能執行** —— 需要整個 19.71 GiB 模型常駐 |
決定性:**5 次執行 × 48 token × 248,144 vocab 的 logits 全部逐位元組相同。**
過程中抓到並修正的問題包括一個 GGUF 標頭解析在 **BOOL 陣列**上錯位的 bug
(`qwen35moe.attention.recurrent_layers`,這個混合架構模型才有)。
L39 那一組 8.8e-05 的差異被完整追查到根因,並證明是 **Q8_K 量化邊界**造成、
不是 pager 的錯誤:把 C++ 端自己的 SiLU 中間值餵回 NumPy 參考,輸出立刻從
7.9e-04 掉到 **6.7e-08**。完整證據鏈見 [`docs/CORRECTNESS.md`](docs/CORRECTNESS.md) §4。
---
## 這個模型特有的兩件事
相對於同系列的 Qwen3.6-35B-A3B 實作,本模型有兩個結構差異,
**移植時必須重新推導、不能照抄**:
1. **兩種 expert 大小**(20 層 2,039,808 B / 20 層 1,769,472 B)。槽位尺寸必須
從 metadata 推導。參考實作是 37 層 + 3 層,比例完全不同。
2. **混合架構**:40 層裡只有 10 層是全注意力,30 層是 SSM 層。直接後果是
**執行緒數的結論相反** —— 這裡 12 執行緒比 8 快約 6.5%,參考實作則是
8 之後開始變慢。SSM 層不受序列維度限制,能吃滿更多執行緒。
另外模型 metadata 裡多了一個 BOOL 陣列(`recurrent_layers`),
參考實作的 GGUF 標頭解析器會在那裡錯位。
---
## 其他結果
### 執行緒數
| threads | 4 | 8 | 10 | 12 | 16 |
| --- | ---: | ---: | ---: | ---: | ---: |
| 平均 tok/s | 4.65 | 5.96 | 6.16 | **6.35** | 6.27 |
4 → 8 是真實且顯著的提升(+28%)。8 之後的差異都在單輪抖動之內。
預設 8,**建議 12**(理由見上一節)。
### 預取(負結果)
跨層預取 5.95 ± 0.37、僅 end_token 5.78 ± 0.33、關閉 6.03 ± 0.44 ——
**差異遠小於單輪抖動,統計上無法分辨**,而且多數輪次 SSD 讀取量上升。
**預設關閉。**
### 儲存裝置會改變結論
本機是本地 NVMe。慢速儲存上 I/O 排程是瓶頸,預取與並行讀取才有意義;
快速 NVMe 上命中率(arena 大小)才是瓶頸。**本 repo 的 tok/s 不能直接套用到
慢速儲存的機器上。** 請先跑 `tools/io_probe.py` 再決定要最佳化哪一邊。
---
## 這個量測**沒有**證明的事
1. **不是 cgroup 驗證。** 這個環境的 cgroup2 掛載唯讀、沒有 `CAP_SYS_ADMIN`。
目前的保證是「kernel `RLIMIT_DATA` + 取樣殺行程」與「mlock 匿名頁」兩條腿。
2. **主機 swap 是開啟的。** no-swap 由 `mlock` 讓它不可能發生,不是把主機的
swap 關掉。
3. **`brk()` 主堆疊未被鎖**(glibc 內部直接下 syscall)。大型配置都走 `mmap`。
4. **共享主機**,單輪抖動可達 25%。
5. **只驗到 4K context**;模型原生支援 262 K。
6. **只做文字推論**,vision projector 是另一個檔案。
7. **儲存裝置比歷史量測的機器快一到兩個數量級。**
完整方法與證據見 [`docs/STRICT_8GIB.md`](docs/STRICT_8GIB.md)。
---
## Repository layout
| 路徑 | 內容 |
| --- | --- |
| `cpp/` | C++ expert pager(`sdq_pager.*`、`sdq_moe.cpp`、`sdq_buft.cpp`、`sdq_cli.cpp`、`sdq_mtp.cpp`) |
| `patches/` | 把 pager 接進 llama.cpp 的 patch(針對固定 commit `9d7d5049`) |
| `sdq/` | 設定、受管治的基準測量、實驗、驗證、HF sync |
| `tools/` | 8 GiB/no-swap 管治、GGUF 索引、決定性檢查、儲存 probe |
| `bench/` | 量測腳本與 `bench/results/` 原始輸出(JSON) |
| `docs/` | 見下 |
文件:
| 文件 | 內容 |
| --- | --- |
| [`docs/STRICT_8GIB.md`](docs/STRICT_8GIB.md) | **8 GiB / no-swap 的方法、證據與限制**(先讀這份) |
| [`docs/DESIGN.md`](docs/DESIGN.md) | 記憶體帳目、SSD→RAM→CPU 設計、reserve 校正 |
| [`docs/CORRECTNESS.md`](docs/CORRECTNESS.md) | 三層驗證、決定性證據、Q8_K 量化邊界分析 |
| [`docs/NEGATIVE_RESULTS.md`](docs/NEGATIVE_RESULTS.md) | 試過但沒留下的最佳化 |
---
## 重現
```bash
# 受管治的單次執行
python3 tools/strict8g.py --limit-mb 8192 --record /tmp/one.json -- \
epq-chat -m model.gguf -p "..." -n 48 -c 4096 -t 8 -b 128 \
--temp 0 --no-chat --no-interactive --ram-budget-mb 8192
# 標配基準測量(9 輪,配對交錯)
python3 -m sdq.strict_bench --model model.gguf --bins base=./build/bin/epq-chat \
--rounds 9 --strict-mb 8192 --label headline
# 正確性與決定性
python3 -m sdq.verify --model model.gguf --bin ./build/bin/epq-chat --fast
python3 tools/determinism_check.py --model model.gguf --bin ./build/bin/epq-chat --runs 5 -n 48
# 曲線與負結果
python3 bench/run_budget_curve.py --model model.gguf --bin ./build/bin/epq-chat --rounds 5
python3 bench/run_ramsweep.py --model model.gguf --bin ./build/bin/epq-chat --rounds 3
python3 bench/run_n9.py --model model.gguf --bin ./build/bin/epq-chat --rounds 9
python3 bench/run_negative.py --which prefetch --model model.gguf --bin ./build/bin/epq-chat
python3 bench/run_negative.py --which threads --model model.gguf --bin ./build/bin/epq-chat
# 儲存裝置特性、路由重疊、GGUF 結構
python3 tools/io_probe.py --model model.gguf
python3 tools/route_overlap.py --model model.gguf
python3 tools/gguf_index.py model.gguf --json bench/expert_index.json
```
---
## License
MiniCPM-V base model 由 upstream 發布;請以 upstream model card 的目前條款為準。
本 repository 包含推論軟體與量測結果,**不包含模型權重**。
llama.cpp 與其相依套件保留各自授權條款。 |