--- license: apache-2.0 library_name: llama-cpp-python tags: - gguf - llama.cpp - moe - ssd-offload - smallthinker - expert-paging - low-ram base_model: - Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF pipeline_tag: text-generation --- # SmallThinker-4B-A0.6B — 熱參數在 RAM、冷參數在 SSD 在 llama.cpp 上跑 [`Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF`](https://huggingface.co/Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF) 的 `SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf`(2,630,212,704 bytes = 2508 MiB), **expert 級 SSD 分頁**:權重永遠是 SSD 上的 file-backed `mmap`,RAM 只留熱的 expert 權重,用不到的 expert 會被明確丟回 SSD。 **計算路徑完全沒動過** —— 仍然是 llama.cpp 自己的 `mul_mat_id`, 所以輸出與上游**逐字元相同**。這是本專案唯一不妥協的正確性條件。 ```bash ./llama_server.sh # port 8080 ./llama_server.sh --plan # 只印推導出來的參數 ./llama_server.sh --verify # 啟動 + 打一次 chat + 量記憶體 ST_RAM_BUDGET_MB=256 ./llama_server.sh # 手動壓低 RAM 預算 ST_PAGER=0 ./llama_server.sh # 關閉分頁,回到上游行為 ``` --- ## 實測結果 本機:16 vCPU、**無 GPU**、檔案系統 **overlayfs**、2026-10-07。 完整證據:[`validate/ab-test.json`](validate/ab-test.json)(`all_passed: true`)、 [`validate/last-run.json`](validate/last-run.json)。 ### 記憶體 vs 分頁預算 同一份 `llama-cli`、同一個 prompt、同一個 seed(`--seed 42`)、ctx 1024、8 threads: | 設定 | peak 總 RSS | 匿名 | 檔案對映 | swap | SSD 重讀 | 命中率 | 淘汰次數 | | --- | --- | --- | --- | --- | --- | --- | --- | | `ST_PAGER=0`(上游) | **2.565 GiB** | 0.103 | 2.559 | **0** | — | — | — | | 預算 1024 MiB | **1.301 GiB** | 0.103 | 1.300 | **0** | 2317 MiB | 57.4% | 2,894 | | 預算 512 MiB | **0.833 GiB** | 0.103 | 0.836 | **0** | 2316 MiB | 55.1% | 4,572 | | 預算 256 MiB | **0.596 GiB** | 0.103 | 0.592 | **0** | 2320 MiB | 53.7% | 5,625 | | 預算 128 MiB | **0.661 GiB** | 0.103 | 0.657 | **0** | 2317 MiB | 54.1% | 5,298 | **總 RSS 從 2.565 GiB 降到 0.596 GiB(−76.8%),swap 全程 0。** > 量的是**總 RSS**(含 file-backed mmap 的權重頁),不是只有匿名記憶體 —— > 權重頁會算進 RSS 且真的佔用系統 RAM。只看匿名記憶體會嚴重低估(本實測 0.103 GiB)。 > 峰值由**模型載入**決定,不是 prefill:實測 prompt 2 token 與 211 token 的峰值幾乎一樣。 `預算 128 MiB` 反而比 `256 MiB` 高一點:預算已逼近 hot window 內必須保留的下限 (`ST_HOT_TOKENS=8` × 32 層 × 4 experts/層),再壓沒有意義,命中率也沒比較好。 ### 速度代價 | 設定 | prefill | decode | 耗時 | | --- | --- | --- | --- | | `ST_PAGER=0`(上游,權重全在 RAM) | 178.7 t/s | **55.4 t/s** | 9.06 s | | 預算 1024 MiB | 7.8 t/s | **22.9 t/s** | 7.85 s | | 預算 512 MiB | 7.9 t/s | **21.6 t/s** | 7.67 s | | 預算 256 MiB | 8.1 t/s | **19.7 t/s** | 7.88 s | | 預算 128 MiB | 7.9 t/s | **20.9 t/s** | 8.13 s | **用約 2.4~2.8× 的 decode 速度換 3~4.3× 的記憶體。** 這是 demand paging 的必然代價: 被丟掉的 expert 下次要用時必須重新讀 SSD。 ### 本機 SSD 讀取能力([`validate/io-probe.json`](validate/io-probe.json)) | threads | 1 | 2 | 4 | 8 | | --- | --- | --- | --- | --- | | 循序讀取 | 337 MB/s | 667 MB/s | 1328 MB/s | 2558 MB/s | 4K 隨機讀 ≈ 1767 IOPS。**換機器務必用 `tools/io_probe.py` 重測**, decode 速度幾乎完全由它決定。 ### 端到端(`llama_server.sh --verify`,`ST_RAM_BUDGET_MB=512`) | 項目 | 值 | | --- | --- | | peak 總 RSS | 1.090 GiB | | VmSwap | **0** | | expert 常駐 / 預算 | **510.3 / 512.0 MiB** ✅ | | 分頁命中率 | 41.2% | | `read_bytes`(整個行程) | 2.44 GiB | | chat 輸出 | `A **MoE (Model Parallelism) layer** enables a neural network to` | 1.090 GiB 的組成:非 expert 權重 410 MiB(常駐)+ expert 分頁 510 MiB + KV / compute buffer / 執行檔(llama-server 開 4 個 slot)。 --- ## 模型事實(全部從 GGUF metadata 實測,不是抄設定檔) | 項目 | 值 | | --- | --- | | arch | `smallthinker` | | 層數 | 32 | | hidden | 1536 | | experts / 每 token 用 | 32 / 4 | | expert FFN | 768 | | attention heads / KV heads | 12 / 2 | | key_len / val_len | 128 / 128 | | context_length(模型上限) | 32768 | | 量化 | Q4_K_M | | 檔案 | 2,630,212,704 bytes(2508 MiB) | ### 權重組成 | | 位元組 | 佔檔案 | | --- | --- | --- | | **expert 權重(1024 個)** | 2,194,145,280(**2092.5 MiB**) | **83.4%** | | 其餘(embedding / attention / norms / router) | 430,065,024(410.2 MiB) | 16.6% | expert 佔 83.4% —— 所以「熱在 RAM、冷在 SSD」這件事基本上就是 「expert 權重怎麼放」。 **每個 expert 是三段連續位元組**(`blk.N.ffn_{gate,up,down}_exps.weight`, expert 維度是最外層 `ne[2]`,所以單一 expert 是一段連續區間): | 段 | shape | layer 0 每個 expert | | --- | --- | --- | | gate | [1536, 768, 32] | 663,552 B(Q4_K) | | up | [1536, 768, 32] | 663,552 B(Q4_K) | | down | [768, 1536, 32] | 967,680 B(**Q6_K**) | | 合計 | | 2.19 MiB | ⚠️ **`down_exps` 的量化型別逐層不同**:32 層裡有 **16 層是 Q6_K、16 層是 Q4_K** (層號 0,1,2,3,6,9,12,15,18,21,24,27,28,29,30,31 是 Q6_K)。 所以「一個 expert 多大」必須**逐層算**:Q6_K 層 70.03 MiB/層、Q4_K 層 60.75 MiB/層, 平均 2.04 MiB/個。拿 layer 0 當全模型常數會把總量高估 148 MiB(7%)。 --- ## 機制 ``` llama.cpp 正常載入(權重留在 SSD 的 file-backed mmap) │ ├─ 每層 top-k 選完 expert 之後,插入一個 ggml custom op │ ├─ 讀出這一層這批 token 用到哪些 expert,標成「熱」 │ └─ 若 resident 超過預算 → 用 LFU→LRU 順序淘汰最冷的 │ a. madvise(MADV_DONTNEED) 拿掉本行程 PTE → RSS 下降 │ b. posix_fadvise(DONTNEED) 丟掉 page cache → 下次真的讀 SSD │ └─ 計算本身:完全不動,仍是上游 ggml_mul_mat_id ``` 淘汰的兩步驟**順序不能換**: 只做 (a) → RSS 下降但 `read_bytes` 不變(頁還在 page cache); 只做 (b) → page cache 下降但行程 RSS 不變(VMA 還映射著)。 (核心的 `invalidate_mapping_pages()` 會跳過仍被 VMA 映射的頁,所以必須先 (a)。) ### 與 `HelloSun/sddqwen35a3b_v01` 的取捨 | | sddqwen35a3b_v01 | 本專案 | | --- | --- | --- | | 權重怎麼進 RAM | `pread` 進自己配置的 arena | 留在 llama.cpp 自己的 mmap | | MoE 計算 | **自訂 ggml op**,自己重寫 Q4_K 點積 | **不動**,用上游 `mul_mat_id` | | 輸出正確性 | 要另外驗證 kernel 寫對沒有 | 逐字等於上游,結構上不可能錯 | | SSD 讀取 | 預取可與計算重疊 | demand paging(碰到缺頁才讀) | | 程式碼量 | ~2800 行 | ~550 行 | **放棄 I/O 預取,換取「計算路徑完全沒動過」的保證。** --- ## 環境變數 | 變數 | 預設 | 說明 | | --- | --- | --- | | `ST_PAGER` | `1` | `0` = 關閉分頁(上游行為,做 A/B 用) | | `ST_RAM_BUDGET_MB` | 自動偵測 | expert 權重可以常駐 RAM 的上限 | | `ST_RESERVE_MB` | `0` | KV + compute 預留,從預算扣掉 | | `ST_ARENA_MB` | `0` | 直接指定 expert 常駐量(有值優先) | | `ST_HOT_TOKENS` | `8` | 最近 N 個 tick 用過的 expert 不淘汰 | | `ST_VERBOSE` | `0` | 印每次淘汰 + 實際 RSS | | `ST_STATS_FILE` | — | 行程退出時把統計寫成 JSON(`SIGUSR2` 也會寫) | | `ST_EVICT_MODE` | `both` | 診斷用:`both`/`madvise`/`fadvise`/`none` | ## ⚠️ 必看的坑 1. **`-DGGML_CPU_REPACK=OFF` 是硬性要求**(`llama_server.sh` 已自動加上)。 開著時 ggml 把 Q4_K 轉成 repack 格式放進**匿名**緩衝區,權重就離開 mmap。 這時 `madvise(MADV_DONTNEED)` 不是「讀 SSD」,而是**把那塊記憶體歸零** → 輸出變成 `papers bases abstract…` 之類的亂碼,但行程看起來完全正常、 tok/s 只掉一點。patch 裡也有 `in_map` 檢查會擋下並印出 buffer 型別。 2. **分頁開著時一定要關掉 `MAP_POPULATE`**,否則整個 2.45 GiB 會在 「載入模型」那一瞬間全部 fault 進 page cache,峰值立刻爆掉 —— 而且分頁統計看起來完全正常(sweep 有跑、帳面 resident 也對),光看統計抓不到。 完整清單(每條都有實測證據)在 [`STATUS.md`](STATUS.md) 的「踩過的坑」表格。 ## 檔案 | 檔案 | 用途 | | --- | --- | | [`llama_server.sh`](llama_server.sh) | 一鍵啟動(自動抓 llama.cpp / 套 patch / 編譯 / 下載權重) | | [`patches/0001-st-expert-pager.patch`](patches/0001-st-expert-pager.patch) | llama.cpp 改動 | | [`tools/ab_test.py`](tools/ab_test.py) | A/B 驗證(RSS / swap / SSD 讀取 / 輸出比對) | | [`tools/verify_run.py`](tools/verify_run.py) | 對 llama-server 打 chat 並量記憶體 | | [`tools/io_probe.py`](tools/io_probe.py) | 實測本機 SSD 讀取能力(**換機器必跑**) | | [`tools/drop_model_cache.py`](tools/drop_model_cache.py) | 量測前丟掉模型檔的 page cache | | [`STATUS.md`](STATUS.md) | **進度真相來源** | | [`AGENTS.md`](AGENTS.md) | 續作指引(假設 agent 崩潰後只看這兩個檔案) | ## 重現 ```bash git clone https://huggingface.co/HelloSun/SmallThinker4b cd SmallThinker4b # 端到端驗證 ST_RAM_BUDGET_MB=512 ./llama_server.sh --verify # A/B 驗證(baseline vs 各種預算) python3 tools/ab_test.py --bin /llama-cli --model /model.gguf \ --budget 1024 --budget 512 --budget 256 --out validate/ab-test.json # 本機 SSD 讀取能力 python3 tools/io_probe.py --model /model.gguf --out validate/io-probe.json ``` ## 進度與續作 工作成果保存在這個 repo(`./sync.sh "改了什麼"` 即可上傳)。 **假設 agent 崩潰**:clone 這個 repo → 讀 `STATUS.md` → 讀 `AGENTS.md` → 接著做。 本機很不穩,**每改完程式並編譯過就上傳,不要累積**。 尚未做(見 `STATUS.md` 的「下一步」):I/O 預取(讓 SSD 讀取與計算重疊)、 KV cache 也放 SSD、限制 prefill 批次大小以壓低 prefill 階段峰值。 ## 授權 本 repo 的程式碼(`llama_server.sh` / `patches/` / `tools/`)沿用 llama.cpp 的 MIT。模型權重屬於 [`Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF`](https://huggingface.co/Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF) (Apache-2.0),本 repo **不含**權重。