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。
python3 demo.py --once
從頭開始時,腳本會取得本 repo 原始碼、從 openbmb/MiniCPM-V-4.7-35B-A3B-gguf
下載 GGUF、套用 patch、編譯,然後跑一句示範。模型已下載的話:
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
互動式聊天:
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。
兩點方法學說明,讀表前請先看:
- 這條曲線不用
mlock。mlock()會強制整個映射常駐 —— 對一個 60 GiB 的 arena,它會讓 RSS 直接變成 60 GiB,量到的就不是真實機器的行為。no-swap 的 核心保證保留給 8 GiB 這個部署目標(那一點另外有完整 mlock 證據,見docs/STRICT_8GIB.md)。 - 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
- 扣的是「匿名 RSS」,不是「總 RSS」。
statm的 resident 把 mmap 進來的 模型權重頁也算成已用,但那些是乾淨的 file-backed 頁,核心會回收。 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 §3。
正確性
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 §4。
這個模型特有的兩件事
相對於同系列的 Qwen3.6-35B-A3B 實作,本模型有兩個結構差異, 移植時必須重新推導、不能照抄:
- 兩種 expert 大小(20 層 2,039,808 B / 20 層 1,769,472 B)。槽位尺寸必須 從 metadata 推導。參考實作是 37 層 + 3 層,比例完全不同。
- 混合架構: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 再決定要最佳化哪一邊。
這個量測沒有證明的事
- 不是 cgroup 驗證。 這個環境的 cgroup2 掛載唯讀、沒有
CAP_SYS_ADMIN。 目前的保證是「kernelRLIMIT_DATA+ 取樣殺行程」與「mlock 匿名頁」兩條腿。 - 主機 swap 是開啟的。 no-swap 由
mlock讓它不可能發生,不是把主機的 swap 關掉。 brk()主堆疊未被鎖(glibc 內部直接下 syscall)。大型配置都走mmap。- 共享主機,單輪抖動可達 25%。
- 只驗到 4K context;模型原生支援 262 K。
- 只做文字推論,vision projector 是另一個檔案。
- 儲存裝置比歷史量測的機器快一到兩個數量級。
完整方法與證據見 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 |
8 GiB / no-swap 的方法、證據與限制(先讀這份) |
docs/DESIGN.md |
記憶體帳目、SSD→RAM→CPU 設計、reserve 校正 |
docs/CORRECTNESS.md |
三層驗證、決定性證據、Q8_K 量化邊界分析 |
docs/NEGATIVE_RESULTS.md |
試過但沒留下的最佳化 |
重現
# 受管治的單次執行
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 與其相依套件保留各自授權條款。
Model tree for HelloSun/minicpm47woek
Base model
openbmb/MiniCPM-V-4.7-35B-A3B