--- 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 --bin --fast python3 tools/determinism_check.py --model --bin --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 與其相依套件保留各自授權條款。