File size: 10,830 Bytes
f279c31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
faea13d
 
f279c31
 
 
 
 
 
 
faea13d
 
f279c31
 
 
 
 
faea13d
 
f279c31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
faea13d
f279c31
833ef4d
f279c31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833ef4d
f279c31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833ef4d
f279c31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
833ef4d
faea13d
 
 
 
f279c31
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
---
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 <path>/llama-cli --model <path>/model.gguf \
    --budget 1024 --budget 512 --budget 256 --out validate/ab-test.json

# 本機 SSD 讀取能力
python3 tools/io_probe.py --model <path>/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 **不含**權重。