File size: 14,581 Bytes
1e54449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
f279c31
1e54449
 
 
 
 
 
 
 
 
 
f279c31
1e54449
f279c31
 
 
 
1e54449
 
 
 
 
 
 
 
 
 
 
 
370fce7
1e54449
370fce7
1e54449
370fce7
 
f279c31
370fce7
f279c31
 
 
1e54449
370fce7
1e54449
 
 
 
370fce7
 
 
 
 
1e54449
370fce7
 
 
 
f279c31
370fce7
 
 
 
 
1e54449
370fce7
1e54449
370fce7
 
 
 
 
 
 
1e54449
370fce7
 
1e54449
833ef4d
 
 
 
 
 
 
 
 
 
1e54449
 
370fce7
 
 
 
 
 
 
 
 
 
 
 
833ef4d
370fce7
 
833ef4d
370fce7
833ef4d
 
370fce7
 
 
 
1e54449
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
a035243
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
# STATUS — SmallThinker-4B-A0.6B:熱參數在 RAM、冷參數到 SSD

進度真相來源。**每次改完程式、編譯過、驗證過就更新本檔並 `./sync.sh`。**

## 一句話

`Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF` 的 `SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf`
(2,630,212,704 bytes = 2508 MiB)跑在 llama.cpp 上,權重永遠是 SSD 上的
file-backed `mmap`;RAM 只留「熱的」expert 權重 + KV + compute buffer,
用不到的 expert 會被**明確丟回 SSD**(`madvise` + `posix_fadvise`)。

## 模型事實(全部從 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 |

**expert 權重總量 2092.5 MiB / 2508 MiB = 83.4%。** 也就是說「熱在 RAM、冷在 SSD」
這件事基本上就是「expert 權重怎麼放」。

每個 expert 是三段連續位元組(`blk.N.ffn_{gate,up,down}_exps.weight`,
expert 維度是最外層):

| 段 | shape | 每個 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,294,784 B = 2.19 MiB** |

共 32 × 32 = 1024 個 expert,整體平均 2.04 MiB/個(因為 16 層的 down 是 Q4_K)。

逐層實測(`tools` 之外,用 gguf-py 直接算):Q6_K down 的層 70.03 MiB/層、
Q4_K down 的層 60.75 MiB/層;Q6_K 的層號是 0,1,2,3,6,9,12,15,18,21,24,27,28,29,30,31。

⚠️ **三段的量化型別逐層可能不同**(實測有些層的 `down_exps` 是 Q4_K 而非 Q6_K,
`nb[2` 就不同)。所以「每個 expert 多大」必須**逐 slot 算**,
拿 layer 0 當全模型常數會算錯。

## 硬約束

- **行程總 RSS**(含 file-backed mmap 的權重頁)要受 RAM 預算約束。
  只看匿名記憶體會嚴重低估 —— 這是本專案第一版的錯。
- **VmSwap = 0**。
- **輸出必須與上游逐字相同**。這是分頁機制唯一不能妥協的正確性條件。

## 目前狀態(2026-10-07)

**兩個目標都达成了**:`validate/ab-test.json` → `all_passed: true`。

| 設定 | peak 總 RSS | swap | SSD 重讀 | 命中率 | 淘汰次數 | 輸出逐字相同 |
| --- | --- | --- | --- | --- | --- | --- |
| `ST_PAGER=0`(上游行為) | **2.565 GiB** | 0 | — | — | — | 基準 |
| 預算 1024 MiB | **1.301 GiB** | 0 | 2317 MiB | 57.4% | 2894 | ✅ |
| 預算 512 MiB | **0.833 GiB** | 0 | 2316 MiB | 55.1% | 4572 | ✅ |
| 預算 256 MiB | **0.596 GiB** | 0 | 2320 MiB | 53.7% | 5625 | ✅ |
| 預算 128 MiB | **0.661 GiB** | 0 | 2317 MiB | 54.1% | 5298 | ✅ |

同一份 `llama-cli`、同一個 prompt、同一個 seed,四種設定的輸出**完全相同**:

> Hello! 😊 I'm DeepSeek-R1, your friendly AI assistant. I'm here to help with all
> kinds of questions, learning, and creative tasks

`--budget 128` 的 RSS 反而比 `256` 高一點:預算已經逼近「hot window 內必須
保留的量」(`ST_HOT_TOKENS=8` × 32 層 × 4 experts/層 ≈ 66 MiB 的下限),
再往下壓沒有意義,反而讓命中率掉下來。

### 端到端(`llama_server.sh --verify`,ST_RAM_BUDGET_MB=512)

證據:`validate/last-run.json`

| 項目 | 值 |
| --- | --- |
| peak 總 RSS | **1.090 GiB**(非 expert 權重 410 MiB + expert 分頁 510 MiB + KV/compute/sampler) |
| VmSwap | **0** |
| expert 常駐 / 預算 | **510.3 / 512.0 MiB** ✅ |
| 命中率 | 41.2% |
| `read_bytes`(整個行程) | 2440 MiB |
| chat 輸出 | `A **MoE (Model Parallelism) layer** enables a neural network to` |

### 設計取捨(為什麼不照抄 sddqwen35a3b_v01 的 pread arena)

| | sddqwen35a3b_v01 | 本專案 |
| --- | --- | --- |
| 權重怎麼進 RAM | `pread` 進自己配置的 arena | 留在 llama.cpp 自己的 mmap |
| MoE 計算 | **自訂 ggml op**,自己重寫 Q4_K 點積 | **不動**,用上游 `mul_mat_id` |
| 輸出正確性 | 要另外驗證 kernel 寫對沒有 | 逐字等於上游,結構上不可能錯 |
| SSD 讀取 | 預取可與計算重疊 | demand paging(第一次碰到缺頁才讀) |
| 程式碼量 | 約 2800 行 | 約 550 行 |

取捨很清楚:**放棄 I/O 預取,換取「計算路徑完全沒動過」的保證。**
模型只有 2.45 GiB、SSD 讀取 300+ MB/s,demand paging 的penalty 可接受。

## 本機 SSD 讀取能力(實測,`validate/io-probe.json`)

| threads | 1 | 2 | 4 | 8 |
| --- | --- | --- | --- | --- |
| 循序讀取 | 337 MB/s | 667 MB/s | 1328 MB/s | 2558 MB/s |

4K 隨機讀 ≈ 1767 IOPS。檔案系統是 **overlayfs**。
單執行緒 337 MB/s 是「每個 decode step 重讀 4 experts × 32 層」這個負載的基準,
SSD 讀取頻寬是本模型 decode 速度的主要上限。

## 踩過的坑(全部有實測證據,不要重蹈)

| 坑 | 真相 | 怎麼知道的 |
| --- | --- | --- |
| **`ml.init_mappings()` 的 prefetch 參數寫反** | 開著分頁時本來要關掉 prefetch(否則 `MAP_POPULATE` 會在**載入那一瞬間**把整個 2.45 GiB fault 進 page cache,峰值立刻爆掉),我卻寫成 `init_mappings(!paging_active())` → 分頁開著時反而 prefetch=ON。症狀:peak RSS 2.6 GiB,而且**後面的 sweep 明明有在跑、帳面 resident 也對** —— 只看分頁統計完全看不出問題 | 在 `load_tensors` 三個時點印 `VmRSS`:載入前 83 MB → `load_all_data` 前 **2652 MB** → 載入後 2646 MB。整個 2.5 GiB 是在載入時進去的 |
| **`will_page()` 不能看「權重載入了沒」** | `init_mappings()` 在 `load_all_data()` 之前,那時 `register_model()` 還沒跑過,所以 `enabled()` 一定是 false → 判斷會永遠回「不分頁」→ 回到 `MAP_POPULATE` | 同上症狀(改了之後峰值立刻掉到 0.83 GiB) |
| **開 `GGML_CPU_REPACK` 時權重不在 mmap 內** | ggml 把 Q4_K 轉成 repack 格式放進**匿名**緩衝區。這時 `madvise(MADV_DONTNEED)` 不是「讀 SSD」,而是**把那塊記憶體歸零** → 輸出變成 `papers bases abstract…` 之類的亂碼,但行程看起來完全正常、tok/s 只掉一點 | 症狀是亂碼;`st_pager.cpp` 現在有 `in_map` 檢查會擋下並印出 buffer 型別(`CPU_REPACK`) |
| **`fscanf("%63[^:]: %llu")` 讀 `/proc/self/io` 永遠讀不到 `read_bytes`** | 沒有開頭空格時 `%[^:]` 會把上一行的 `\n` 讀進 key,`strcmp` 永遠比不到 | 量到 `read_bytes = 0`,但 C 測試明明有讀 |
| **子行程退出後才讀 `/proc/<pid>/io`** | 行程一退出 `/proc/<pid>` 就消失,外部量測得到 `{}`,看起來像「完全沒 I/O」 | `io_delta: {}`。SSD 讀取量只能由**行程自己**在 `atexit` 寫出來 |
| **`fscanf(" %63[^:]: %lld")` 的空格不能省** | 對比上面兩條:同一個檔案,同一個 key,一個有空格一個沒有,結果差 100% | 純 C 測試 `/tmp/ev6.c` 兩種寫法並排跑 |
| **用 peak RSS 判定分頁有沒生效** | 峰值取決於 prefetch 有沒有關,跟 prefill 長度無關(實測 prompt 2 token 與 211 token 的峰值幾乎一樣) | `rss_timeline_mib` |
| **`nb[2] != ne[0]*nb[0]` 檢查會誤判量化張量** | 量化張量 `nb[0]` 是**一個 block** 的位元組數,不是 1。只要檢查 `nb[2] == ne[1]*nb[1]` | 「layer 0 不連續」誤報 |
| **第一輪 sweep 永遠什麼都不做** | 條件寫成 `last_used < now - hot_tokens`,第一輪時門檻是 0 而 `last_used` 也是 0,`0 < 0` 為假 | sweep 計數有跳、但 resident 不降 |
| **拿 layer 0 的 `nb[2]` 當全模型常數** | `down_exps` 逐層量化型別不同(實測 layer 4 是 Q4_K、layer 0 是 Q6_K) | 「layer 4 part 2 形狀不一致」 |
| **`os.pread()` 回傳資料本身,不是讀到的位元組數** | 寫成 `got += n` 會得到 `TypeError: int + bytes`,而**執行緒裡的例外不會讓主程式失敗** → `io_probe` 印出 `bytes: 0、0.015 秒、0.0 MB/s`,看起來像「SSD 完全沒速度」 | `bytes: 0` 搭配「只花 15 ms」這個不合理組合才察覺 |
| **外部取樣 RSS 抓不到 decode 階段** | 一次 generation 的樣本數太少。改由**行程自己**在 sweep 裡讀 `/proc/self/status` | 兩邊數字對不起來時才發現 |

(本專案被「量測方式/語意/時序」教訓共 6 次:① 只看外部取樣的 RSS
② 讀 `/proc/self/io` 的格式錯 ③ 在行程退出後才讀它 ④ 把 prefetch 語意寫反
⑤ 分層時把「有打算分頁」和「已註冊分頁」混為一談 ⑥ 在執行緒裡把 `pread` 的
回傳值當成位元組數,例外被吞掉後看起來像「量到 0」。
共同特徵都是「看起來有在做事,但量錯了地方」。每次都是靠實測抓到的。)

,不要重蹈)

| 坑 | 真相 | 怎麼知道的 |
| --- | --- | --- |
| **開 `GGML_CPU_REPACK` 時權重不在 mmap 內** | ggml 把 Q4_K 轉成 repack 格式放進**匿名**緩衝區。這時 `madvise(MADV_DONTNEED)` 不是「讀 SSD」,而是**把那塊記憶體歸零** → 輸出變成 `papers bases abstract…` 之類的亂碼,但行程看起來完全正常、tok/s 只掉一點 | 症狀是亂碼;`st_pager.cpp` 現在有 `in_map` 檢查會直接擋下並印出 buffer 型別 |
| **`fscanf("%63[^:]: %llu")` 讀 `/proc/self/io` 永遠讀不到 `read_bytes`** | 沒有開頭空格時 `%[^:]` 會把上一行的 `\n` 讀進 key,`strcmp` 永遠比不到 | 量到 `read_bytes = 0`,但 C 測試明明有讀 |
| **子行程退出後才讀 `/proc/<pid>/io`** | 行程一退出 `/proc/<pid>` 就消失,外部量測得到 `{}`,看起來像「完全沒 I/O」 | `io_delta: {}`。SSD 讀取量只能由**行程自己**在 `atexit` 寫出來 |
| **用 peak RSS 判定分頁有沒生效** | peak 由 prefill 決定,而 prefill 的工作集 ≈ 整個模型。要看 decode 階段 | `rss_timeline_mib` |
| **`nb[2] != ne[0]*nb[0]` 檢查會誤判量化張量** | 量化張量 `nb[0]` 是**一個 block** 的位元組數,不是 1。只要檢查 `nb[2] == ne[1]*nb[1]` | 「layer 0 不連續」誤報 |
| **第一輪 sweep 永遠什麼都不做** | 條件寫成 `last_used < now - hot_tokens`,第一輪時門檻是 0 而 `last_used` 也是 0,`0 < 0` 為假 | sweep 計數有跳、但 resident 不降 |
| **拿 layer 0 的 `nb[2]` 當全模型常數** | `down_exps` 逐層量化型別不同 | 「layer 4 part 2 形狀不一致」 |

## 常用指令

```bash
# 完整流程(自動抓 llama.cpp / 套 patch / 編譯 / 下載權重 / 啟動 / 驗證)
ST_HOME=~/.cache/smallthinker4b ./llama_server.sh --verify

# 只看推導出來的參數,不動任何東西
./llama_server.sh --plan

# A/B 驗證(baseline vs 各種預算)
python3 tools/ab_test.py --bin <llama-cli> --model <gguf> \
    --budget 1024 --budget 512 --budget 256 --out validate/ab-test.json

# 對照組:關掉分頁
ST_PAGER=0 ./llama_server.sh

# 手動壓低 RAM 預算
ST_RAM_BUDGET_MB=512 ./llama_server.sh

# 上傳(最重要的一步)
./sync.sh "改了什麼"
```

## 關鍵環境變數

| 變數 | 預設 | 說明 |
| --- | --- | --- |
| `ST_PAGER` | `1` | `0` = 關閉分頁(上游行為,做 A/B 用) |
| `ST_RAM_BUDGET_MB` | 自動偵測 | RAM 總額 |
| `ST_RESERVE_MB` | 0 | KV + compute 預留,從預算扣掉 |
| `ST_ARENA_MB` | 0 | 直接指定 expert 可常駐量(有值優先) |
| `ST_HOT_TOKENS` | `8` | 最近 N 個 tick 用過的 expert 不淘汰 |
| `ST_VERBOSE` | `0` | 印每次淘汰 |
| `ST_STATS_FILE` | — | 行程退出時把統計寫成 JSON(`SIGUSR2` 也會寫) |
| `ST_EVICT_MODE` | `both` | 診斷用:`both`/`madvise`/`fadvise`/`none` |

## 檔案地圖

| 檔案 | 用途 |
| --- | --- |
| `llama_server.sh` | 一鍵啟動 + 自動推導參數 |
| `patches/0001-st-expert-pager.patch` | llama.cpp 改動(expert 分頁) |
| `tools/ab_test.py` | A/B 驗證(RSS / swap / SSD 讀取 / 輸出比對) |
| `tools/verify_run.py` | 對 llama-server 打一次 chat 並量記憶體 |
| `tools/io_probe.py` | 實測本機 SSD 讀取能力(**換機器必跑**) |
| `tools/drop_model_cache.py` | 量測前丟掉模型檔的 page cache |
| `validate/` | 驗證證據 |
| `sync.sh` | 推回 HF(崩潰後的唯一保險) |
---

## 2026-10-07 重開:方向修正(使用者明確要求)

**使用者指示:**「我要的是 **hot expert 放記憶體**,**不是 page 轉換**」。

因此 `patches/0001-st-expert-pager.patch`(v1–v10 的做法)**方向被否決**:
它讓權重留在 llama.cpp 自己的 file-backed mmap,用
`madvise(MADV_DONTNEED)` + `posix_fadvise(DONTNEED)` 把冷 expert **逐出 page cache**。
那確實是「page 轉換」:hot expert 只是恰好還在 page cache 裡,不是被程式**放進 RAM**。

### 修正後的設計(本次目標)

| 參數 | 住哪 |
| --- | --- |
| **啟動參數**(attn / norm / router / token_embd / output,410.19 MiB) | **匿名 RAM**(`use_mmap=false` 走 pread→匿名緩衝區,不依賴 page cache) |
| **hot experts** | **RAM arena**(程式自己配置、`pread` 進來的**實體 RAM**) |
| **cold experts** | **SSD**(留在 GGUF 檔案裡,只在需要時 `pread` 進 arena 槽位) |

即:RAM arena + 槽位淘汰 + 自訂 MoE op(執行期才知道哪些 expert 被選到,
所以算權重時必須由 pager 提供 arena 裡的 bytes)。
**這是 `HelloSun/sddqwen35a3b_v01` 的做法,也是本專案現在要採用的做法。**

### 為什麼不能沿用參考專案的 commit

參考專案 `sddqwen35a3b_v01` 的 base commit 是 `836d57176dc699a726c55418e4f96b8ca628e1bf`。
實測在該 commit 上 **原生路徑(`SDQ_PAGER=0`)對 smallthinker 就已經是亂碼**
(輸出 `and and the and all achieving and`),pager 路徑同樣是亂碼。
→ 該 commit 的 smallthinker 支援有問題,**不能**用它當 base。

**本專案 base commit 改用 `b9acf138a1e28ce1fc23b5a4fc4b12444b50f7ea`**(v1–v10 用過、
輸出正確的那個)。

### v11 基線(vanilla,尚未套 arena patch)

`validate/baseline-vanilla.json`

| 項目 | 值 |
| --- | --- |
| 輸出 | `Hello! I'm DeepSeek-R1, your friendly AI assistant. 😊 ...` ✅ |
| decode | **82.1 tok/s**(16 threads,權重全在 page cache) |
| peak 總 RSS | **2.674 GiB**(= 整份 2.45 GiB 權重都在 RAM 裡) |
| swap | 0 |

這是 arena 版本的**正確性基準**:同一 prompt / temp 0 / seed,輸出必須逐字相同。