minicpm47woek_EPQ / README.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
16.8 kB
---
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 與其相依套件保留各自授權條款。