Auto Upload Agent commited on
Commit
1e54449
·
1 Parent(s): d2defb7

v3: 修 MAP_POPULATE 與 /proc/self/io 解析 bug;A/B 驗證工具;輸出逐字相同已證實

Browse files
AGENTS.md ADDED
@@ -0,0 +1,94 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # AGENTS.md — 續作指引
2
+
3
+ **這個檔案的存在理由:本機很不穩,agent 可能隨時崩潰。**
4
+ 無論你(人或 agent)從哪裡開始,只要 clone 這個 repo 並讀完本檔,就能接著做。
5
+
6
+ ```bash
7
+ git clone https://huggingface.co/HelloSun/SmallThinker4b
8
+ cd SmallThinker4b
9
+ cat STATUS.md # 進度真相來源,先讀這個
10
+ cat AGENTS.md # 本檔
11
+ ```
12
+
13
+ ## 專案一句話
14
+
15
+ SmallThinker-4B-A0.6B-Instruct(Q4_K_M,2508 MiB,MoE 32 層 × 32 experts)
16
+ 在 llama.cpp 上跑,**熱的 expert 權重留在 RAM,冷的丟回 SSD**。
17
+
18
+ ## 最重要原則(聽到沒)
19
+
20
+ **只要到能跑、能驗證的階段,就立刻上傳。後面每改到程式並編譯過,也要上傳。**
21
+ 本機十分不穩,你必須保存工作成果,並且假設 agent 崩潰後要能指定 repo 繼續工作。
22
+
23
+ ```bash
24
+ ./sync.sh "改了什麼"
25
+ ```
26
+
27
+ `git clone https://huggingface.co/HelloSun/SmallThinker4b` 就能接著做。
28
+
29
+ ## 硬約束
30
+
31
+ 1. 行程**總 RSS**(含 file-backed mmap 的權重頁)要受 RAM 預算約束。
32
+ 只驗證匿名記憶體是不成立的 —— 權重頁會算進 RSS 且真的佔用系統 RAM。
33
+ 2. **VmSwap = 0**。
34
+ 3. **輸出必須與上游逐字相同**。分頁只該影響「權重住哪裡」,
35
+ 不該影響任何一個 token 的數值。
36
+
37
+ ## 已知的坑(不要重蹈)
38
+
39
+ 完整清單在 `STATUS.md` 的「踩過的坑」表格,每一條都有實測證據。
40
+ 最容易再犯的三個:
41
+
42
+ 1. **`-DGGML_CPU_REPACK=OFF` 是硬性要求**。開著時 ggml 把權重放進匿名緩衝區,
43
+ `madvise` 會把它**歸零**,輸出變亂碼但行程看起來正常。
44
+ 2. **`fscanf` 讀 `/proc/self/io` 開頭要空格**,否則 `read_bytes` 永遠是 0。
45
+ 3. **不要用 peak RSS 判定分頁有沒生效** —— peak 由 prefill 決定,
46
+ 而 prefill 的工作集 ≈ 整個模型。要看 decode 階段。
47
+
48
+ ## 環境
49
+
50
+ 本機(開發/驗證用):16 vCPU、MemTotal 約 2 TB、cgroup `memory.max` = 104 GB、
51
+ 無 GPU。SSD 是 **overlayfs**。
52
+ **換機器一定要用 `tools/io_probe.py` 重測讀取能力**,tok/s 會跟著全變。
53
+
54
+ ## 常用指令
55
+
56
+ ```bash
57
+ # 完整流程
58
+ ST_HOME=~/.cache/smallthinker4b ./llama_server.sh --verify
59
+
60
+ # 只看參數
61
+ ./llama_server.sh --plan
62
+
63
+ # A/B 驗證
64
+ python3 tools/ab_test.py --bin <llama-cli> --model <gguf> \
65
+ --budget 1024 --budget 512 --out validate/ab-test.json
66
+
67
+ # 對照組(關分頁)
68
+ ST_PAGER=0 ./llama_server.sh
69
+
70
+ # 上傳
71
+ ./sync.sh "改了什麼"
72
+ ```
73
+
74
+ ## 下一步(依 STATUS.md 的「還沒解決的問題」)
75
+
76
+ peak RSS 降不下來。已排除 MAP_POPULATE、`madvise` 無效、`fadvise` 無效。
77
+ 最可能是 **prefill 的工作集就等於整個模型**。
78
+ 要驗的是:peak RSS 是否隨 prompt 長度改變,以及 decode 階段的 RSS 才是分頁的指標。
79
+
80
+ ## 常數(改模型就要改)
81
+
82
+ `llama_server.sh` 頂部。這些是模型事實,不是可調參數:
83
+
84
+ ```
85
+ N_LAYER=32 N_EMBD=1536 N_EXPERT=32 N_EXPERT_USED=4 EXPERT_FFN=768
86
+ N_HEAD=12 N_HEAD_KV=2 KEY_LEN=128 VAL_LEN=128 MODEL_MAX_CTX=32768
87
+ MODEL_REPO=Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF
88
+ MODEL_FILE=SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf
89
+ MODEL_SIZE=2630212704
90
+ LLAMA_COMMIT=b9acf138a1e28ce1fc23b5a4fc4b12444b50f7ea
91
+ ```
92
+
93
+ ⚠️ `EXPERT_BYTES` 只是**粗估**(0.6 B/elem),因為 `down_exps` 逐層量化型別不同。
94
+ 真正精確的數字由 `st_pager.cpp` 在載入時從 GGUF metadata 算出來並印出。
STATUS.md ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # STATUS — SmallThinker-4B-A0.6B:熱參數在 RAM、冷參數到 SSD
2
+
3
+ 進度真相來源。**每次改完程式、編譯過、驗證過就更新本檔並 `./sync.sh`。**
4
+
5
+ ## 一句話
6
+
7
+ `Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF` 的 `SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf`
8
+ (2,630,212,704 bytes = 2508 MiB)跑在 llama.cpp 上,權重永遠是 SSD 上的
9
+ file-backed `mmap`;RAM 只留「熱的」expert 權重 + KV + compute buffer,
10
+ 用不到的 expert 會被**明確丟回 SSD**(`madvise` + `posix_fadvise`)。
11
+
12
+ ## 模型事實(全部從 GGUF metadata 實測,不是抄設定檔)
13
+
14
+ | 項目 | 值 |
15
+ | --- | --- |
16
+ | arch | `smallthinker` |
17
+ | 層數 | 32 |
18
+ | hidden | 1536 |
19
+ | experts / 每 token 用 | 32 / 4 |
20
+ | expert FFN | 768 |
21
+ | attention heads / KV heads | 12 / 2 |
22
+ | key_len / val_len | 128 / 128 |
23
+ | context_length(模型上限) | 32768 |
24
+ | 量化 | Q4_K_M |
25
+
26
+ **expert 權重總量 2241 MiB / 2508 MiB = 89%。** 也就是說「熱在 RAM、冷在 SSD」
27
+ 這件事基本上就是「expert 權重怎麼放」。
28
+
29
+ 每個 expert 是三段連續位元組(`blk.N.ffn_{gate,up,down}_exps.weight`,
30
+ expert 維度是最外層):
31
+
32
+ | 段 | shape | 每個 expert |
33
+ | --- | --- | --- |
34
+ | gate | [1536, 768, 32] | 663,552 B(Q4_K) |
35
+ | up | [1536, 768, 32] | 663,552 B(Q4_K) |
36
+ | down | [768, 1536, 32] | 967,680 B(Q6_K) |
37
+ | **合計** | | **2,294,784 B ≈ 2.19 MiB** |
38
+
39
+ 共 32 × 32 = 1024 個 expert。
40
+
41
+ ⚠️ **三段的量化型別逐層可能不同**(實測有些層的 `down_exps` 是 Q4_K 而非 Q6_K,
42
+ `nb[2` 就不同)。所以「每個 expert 多大」必須**逐 slot 算**,
43
+ 拿 layer 0 當全模型常數會算錯。
44
+
45
+ ## 硬約束
46
+
47
+ - **行程總 RSS**(含 file-backed mmap 的權重頁)要受 RAM 預算約束。
48
+ 只看匿名記憶體會嚴重低估 —— 這是本專案第一版的錯。
49
+ - **VmSwap = 0**。
50
+ - **輸出必須與上游逐字相同**。這是分頁機制唯一不能妥協的正確性條件。
51
+
52
+ ## 目前狀態
53
+
54
+ | 項目 | 結果 | 證據 |
55
+ | --- | --- | --- |
56
+ | 上游行為(`ST_PAGER=0`)可跑 | ✅ | `validate/ab-test.json` → `runs.baseline` |
57
+ | 分頁可跑、輸出**逐字相同** | ✅ | 同上,每組都有 `output_matches_baseline: true` |
58
+ | swap = 0 | ✅ | 同上,`peak_swap_gb: 0.0` |
59
+ | 冷權重真的從 SSD 重讀 | ✅ | 同上,`proc_self_read_bytes` ≈ 2508 MiB |
60
+ | RSS 真的被壓下來 | ⚠️ **未達成**(見下) | 同上 |
61
+
62
+ ### 已驗證能跑(2026-10-07)
63
+
64
+ ```
65
+ llama-cli -m SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf \
66
+ -p "Hello, who are you?" -n 32 -c 1024 -t 8 --no-warmup --single-turn --seed 42
67
+ ```
68
+
69
+ 三種設定(`ST_PAGER=0` / 預算 1024MB / 512MB / 256MB)輸出**完全相同**:
70
+
71
+ > Hello! 😊 I'm DeepSeek-R1, your friendly AI assistant. I'm here to help with all
72
+ > kinds of questions, learning, and creative tasks
73
+
74
+ 分頁統計(預算 512 MiB):1024 個 expert、命中率 55.1%、
75
+ 淘汰 4572 次共 9.8 GB、`read_bytes` = 2508 MiB(= 整個模型重讀一次)。
76
+
77
+ ### ⚠️ 還沒解決的問題:peak RSS 降不下來
78
+
79
+ **量到的 decode 階段 RSS 分頁後反而比 baseline 高**(2.556 vs 2.301 GiB)。
80
+ RSS 時間序列(`validate/ab-test.json` → `rss_timeline_mib`)顯示它是
81
+ **單調上升**到接近整個模型大小,然後在最後 1/4 掉下來。
82
+
83
+ 已排除的可能原因(都實測過):
84
+
85
+ 1. ~~`MAP_POPULATE` 在載入時 fault 整個檔案~~ — 已修:
86
+ `ml.init_mappings(!st::prefetch_requested(), ...)`。
87
+ 但時間序列顯示 RSS 還是在**載入後**才慢慢爬升,所以不是這個。
88
+ 2. ~~`madvise` 沒生效~~ — 已排除:`/tmp/rsstest`(純 C,touch 2241 MiB
89
+ → RSS 2242 MiB → `madvise(MADV_DONTNEED)` → RSS 1 MiB)。
90
+ 3. ~~`posix_fadvise` 沒讓頁真的回到 SSD~~ — 已排除:`/tmp/ev6.c`
91
+ (touch 200 MiB → 讀 200.1 MiB → 淘汰 → **再 touch 又讀 200.0 MiB**)。
92
+ 4. **prefill 的工作集本來就等於整個模型** — 這是目前最可能的解釋。
93
+ prompt 有 37 個 token,每層 32 個 expert,prefill 一次就會把
94
+ 幾乎所有 expert 都摸一遍。peak RSS 因此必然 ≈ 整個權重。
95
+ 下一步要驗的是:**更長的 prompt vs 極短 prompt**,看 peak 是否隨之改變。
96
+
97
+ ## 踩過的坑(全部有實測證據,不要重蹈)
98
+
99
+ | 坑 | 真相 | 怎麼知道的 |
100
+ | --- | --- | --- |
101
+ | **開 `GGML_CPU_REPACK` 時權重不在 mmap 內** | ggml 把 Q4_K 轉成 repack 格式放進**匿名**緩衝區。這時 `madvise(MADV_DONTNEED)` 不是「讀 SSD」,而是**把那塊記憶體歸零** → 輸出變成 `papers bases abstract…` 之類的亂碼,但行程看起來完全正常、tok/s 只掉一點 | 症狀是亂碼;`st_pager.cpp` 現在有 `in_map` 檢查會直接擋下並印出 buffer 型別 |
102
+ | **`fscanf("%63[^:]: %llu")` 讀 `/proc/self/io` 永遠讀不到 `read_bytes`** | 沒有開頭空格時 `%[^:]` 會把上一行的 `\n` 讀進 key,`strcmp` 永遠比不到 | 量到 `read_bytes = 0`,但 C 測試明明有讀 |
103
+ | **子行程退出後才讀 `/proc/<pid>/io`** | 行程一退出 `/proc/<pid>` 就消失,外部量測得到 `{}`,看起來像「完全沒 I/O」 | `io_delta: {}`。SSD 讀取量只能由**行程自己**在 `atexit` 寫出來 |
104
+ | **用 peak RSS 判定分頁有沒生效** | peak 由 prefill 決定,而 prefill 的工作集 ≈ 整個模型。要看 decode 階段 | `rss_timeline_mib` |
105
+ | **`nb[2] != ne[0]*nb[0]` 檢查會誤判量化張量** | 量化張量 `nb[0]` 是**一個 block** 的位元組數,不是 1。只要檢查 `nb[2] == ne[1]*nb[1]` | 「layer 0 不連續」誤報 |
106
+ | **第一輪 sweep 永遠什麼都不做** | 條件寫成 `last_used < now - hot_tokens`,第一輪時門檻是 0 而 `last_used` 也是 0,`0 < 0` 為假 | sweep 計數有跳、但 resident 不降 |
107
+ | **拿 layer 0 的 `nb[2]` 當全模型常數** | `down_exps` 逐層量化型別不同 | 「layer 4 part 2 形狀不一致」 |
108
+
109
+ ## 常用指令
110
+
111
+ ```bash
112
+ # 完整流程(自動抓 llama.cpp / 套 patch / 編譯 / 下載權重 / 啟動 / 驗證)
113
+ ST_HOME=~/.cache/smallthinker4b ./llama_server.sh --verify
114
+
115
+ # 只看推導出來的參數,不動任何東西
116
+ ./llama_server.sh --plan
117
+
118
+ # A/B 驗證(baseline vs 各種預算)
119
+ python3 tools/ab_test.py --bin <llama-cli> --model <gguf> \
120
+ --budget 1024 --budget 512 --budget 256 --out validate/ab-test.json
121
+
122
+ # 對照組:關掉分頁
123
+ ST_PAGER=0 ./llama_server.sh
124
+
125
+ # 手動壓低 RAM 預算
126
+ ST_RAM_BUDGET_MB=512 ./llama_server.sh
127
+
128
+ # 上傳(最重要的一步)
129
+ ./sync.sh "改了什麼"
130
+ ```
131
+
132
+ ## 關鍵環境變數
133
+
134
+ | 變數 | 預設 | 說明 |
135
+ | --- | --- | --- |
136
+ | `ST_PAGER` | `1` | `0` = 關閉分頁(上游行為,做 A/B 用) |
137
+ | `ST_RAM_BUDGET_MB` | 自動偵測 | RAM 總額 |
138
+ | `ST_RESERVE_MB` | 0 | KV + compute 預留,從預算扣掉 |
139
+ | `ST_ARENA_MB` | 0 | 直接指定 expert 可常駐量(有值優先) |
140
+ | `ST_HOT_TOKENS` | `8` | 最近 N 個 tick 用過的 expert 不淘汰 |
141
+ | `ST_VERBOSE` | `0` | 印每次淘汰 |
142
+ | `ST_STATS_FILE` | — | 行程退出時把統計寫成 JSON(`SIGUSR2` 也會寫) |
143
+ | `ST_EVICT_MODE` | `both` | 診斷用:`both`/`madvise`/`fadvise`/`none` |
144
+
145
+ ## 檔案地圖
146
+
147
+ | 檔案 | 用途 |
148
+ | --- | --- |
149
+ | `llama_server.sh` | 一鍵啟動 + 自動推導參數 |
150
+ | `patches/0001-st-expert-pager.patch` | llama.cpp 改動(expert 分頁) |
151
+ | `tools/ab_test.py` | A/B 驗證(RSS / swap / SSD 讀取 / 輸出比對) |
152
+ | `tools/verify_run.py` | 對 llama-server 打一次 chat 並量記憶體 |
153
+ | `tools/io_probe.py` | 實測本機 SSD 讀取能力(**換機器必跑**) |
154
+ | `tools/drop_model_cache.py` | 量測前丟掉模型檔的 page cache |
155
+ | `validate/` | 驗證證據 |
156
+ | `sync.sh` | 推回 HF(崩潰後的唯一保險) |
patches/0001-st-expert-pager.patch CHANGED
@@ -1,20 +1,26 @@
1
- From b16d956b457292e37565cdd501fd5901e9ff1844 Mon Sep 17 00:00:00 2001
2
  From: c <a@b>
3
- Date: Wed, 7 Oct 2026 16:07:37 +0200
4
- Subject: [PATCH] st pager wip
 
5
 
6
  ---
 
7
  .st-unpatched | 0
8
  src/CMakeLists.txt | 1 +
9
  src/llama-graph.cpp | 10 +
10
- src/llama-model.cpp | 32 +++
11
- src/st_pager.cpp | 494 ++++++++++++++++++++++++++++++++++++++++++++
12
- src/st_pager.h | 79 +++++++
13
- 6 files changed, 616 insertions(+)
 
14
  create mode 100644 .st-unpatched
15
  create mode 100644 src/st_pager.cpp
16
  create mode 100644 src/st_pager.h
17
 
 
 
 
18
  diff --git a/.st-unpatched b/.st-unpatched
19
  new file mode 100644
20
  index 000000000..e69de29bb
@@ -58,7 +64,7 @@ index 1112ad885..e335a9d5f 100644
58
 
59
  if (weight_before_ffn) {
60
  diff --git a/src/llama-model.cpp b/src/llama-model.cpp
61
- index 1f3b80b08..f863a15fa 100644
62
  --- a/src/llama-model.cpp
63
  +++ b/src/llama-model.cpp
64
  @@ -1,5 +1,7 @@
@@ -69,7 +75,31 @@ index 1f3b80b08..f863a15fa 100644
69
  #include "llama-arch.h"
70
  #include "llama-ext.h"
71
  #include "llama-hparams.h"
72
- @@ -1969,6 +1971,36 @@ bool llama_model_base::load_tensors(llama_model_loader & ml) {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
73
  }
74
  }
75
 
@@ -77,7 +107,6 @@ index 1f3b80b08..f863a15fa 100644
77
  + // 讓 build_moe_ffn 插入的 op 能在用不到時把它們丟回 SSD。
78
  + // 註冊失敗不是致命錯誤 —— 那只是不分頁,模型照上游路徑跑。
79
  + {
80
- + st::set_config_from_env();
81
  + st::install_signal_handler();
82
  +
83
  + void * map_base = nullptr;
@@ -108,10 +137,10 @@ index 1f3b80b08..f863a15fa 100644
108
 
109
  diff --git a/src/st_pager.cpp b/src/st_pager.cpp
110
  new file mode 100644
111
- index 000000000..79bd1d4cb
112
  --- /dev/null
113
  +++ b/src/st_pager.cpp
114
- @@ -0,0 +1,494 @@
115
  +// ST expert pager —— 熱 expert 留在 RAM,冷 expert 丟回 SSD
116
  +//
117
  +// 設計取捨(為什麼不照抄 sddqwen35a3b_v01 的 pread arena):
@@ -233,6 +262,8 @@ index 000000000..79bd1d4cb
233
  +
234
  +bool enabled() { return g_enabled && !g_slots.empty(); }
235
  +
 
 
236
  +// ------------------------------------------------------------------ 統計
237
  +
238
  +stats get_stats() {
@@ -247,8 +278,31 @@ index 000000000..79bd1d4cb
247
  + return s;
248
  +}
249
  +
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
250
  +void dump_stats(const char * path) {
251
  + const stats s = get_stats();
 
 
252
  + FILE * f = fopen(path, "w");
253
  + if (!f) return;
254
  + fprintf(f,
@@ -262,7 +316,9 @@ index 000000000..79bd1d4cb
262
  + " \"sweeps\": %llu,\n"
263
  + " \"resident_bytes\": %llu,\n"
264
  + " \"budget_bytes\": %llu,\n"
265
- + " \"hit_rate\": %.4f\n"
 
 
266
  + "}\n",
267
  + (unsigned long long) s.routing_calls,
268
  + (unsigned long long) s.expert_touches,
@@ -273,10 +329,22 @@ index 000000000..79bd1d4cb
273
  + (unsigned long long) s.sweeps,
274
  + (unsigned long long) s.resident_bytes,
275
  + (unsigned long long) s.budget_bytes,
276
- + s.expert_touches ? (double) s.hits / (double) s.expert_touches : 0.0);
 
 
277
  + fclose(f);
278
  +}
279
  +
 
 
 
 
 
 
 
 
 
 
280
  +// ------------------------------------------------------------------ 註冊
281
  +
282
  +static bool expert_layout_ok(const ggml_tensor * t, const char * what) {
@@ -528,6 +596,8 @@ index 000000000..79bd1d4cb
528
  + };
529
  + sigemptyset(&sa.sa_mask);
530
  + sigaction(SIGUSR2, &sa, nullptr);
 
 
531
  +}
532
  +
533
  +// ------------------------------------------------------------------ routing op
@@ -609,10 +679,10 @@ index 000000000..79bd1d4cb
609
 
610
  diff --git a/src/st_pager.h b/src/st_pager.h
611
  new file mode 100644
612
- index 000000000..700a6f648
613
  --- /dev/null
614
  +++ b/src/st_pager.h
615
- @@ -0,0 +1,79 @@
616
  +// ST expert pager —— 熱 expert 留在 RAM,冷 expert 丟回 SSD
617
  +//
618
  +// 對外介面。llama.cpp 端(llama-model.cpp / llama-graph.cpp)只include 這個檔案。
@@ -685,6 +755,7 @@ index 000000000..700a6f648
685
  + int layer, int n_expert, int n_expert_used);
686
  +
687
  +bool enabled();
 
688
  +stats get_stats();
689
  +void dump_stats(const char * path);
690
  +
 
1
+ From 90deb52647021d7b6d0ff0d97a242f959fc6dce1 Mon Sep 17 00:00:00 2001
2
  From: c <a@b>
3
+ Date: Wed, 7 Oct 2026 16:15:13 +0200
4
+ Subject: [PATCH] st pager: disable MAP_POPULATE when paging, fix /proc/self/io
5
+ parser
6
 
7
  ---
8
+ .st-patched | 0
9
  .st-unpatched | 0
10
  src/CMakeLists.txt | 1 +
11
  src/llama-graph.cpp | 10 +
12
+ src/llama-model.cpp | 41 +++-
13
+ src/st_pager.cpp | 535 ++++++++++++++++++++++++++++++++++++++++++++
14
+ src/st_pager.h | 80 +++++++
15
+ 7 files changed, 666 insertions(+), 1 deletion(-)
16
+ create mode 100644 .st-patched
17
  create mode 100644 .st-unpatched
18
  create mode 100644 src/st_pager.cpp
19
  create mode 100644 src/st_pager.h
20
 
21
+ diff --git a/.st-patched b/.st-patched
22
+ new file mode 100644
23
+ index 000000000..e69de29bb
24
  diff --git a/.st-unpatched b/.st-unpatched
25
  new file mode 100644
26
  index 000000000..e69de29bb
 
64
 
65
  if (weight_before_ffn) {
66
  diff --git a/src/llama-model.cpp b/src/llama-model.cpp
67
+ index 1f3b80b08..a74118ae5 100644
68
  --- a/src/llama-model.cpp
69
  +++ b/src/llama-model.cpp
70
  @@ -1,5 +1,7 @@
 
75
  #include "llama-arch.h"
76
  #include "llama-ext.h"
77
  #include "llama-hparams.h"
78
+ @@ -1510,6 +1512,8 @@ void llama_model_base::load_vocab(llama_model_loader & ml) {
79
+ }
80
+
81
+ bool llama_model_base::load_tensors(llama_model_loader & ml) {
82
+ + // ST: 讀環境變數要在 init_mappings 之前(它決定要不要 MAP_POPULATE)
83
+ + st::set_config_from_env();
84
+ const auto & split_mode = params.split_mode;
85
+ const bool use_mlock = params.load_mode == LLAMA_LOAD_MODE_MLOCK || params.load_mode == LLAMA_LOAD_MODE_MMAP_MLOCK;
86
+ const auto & tensor_split = params.tensor_split;
87
+ @@ -1821,7 +1825,13 @@ bool llama_model_base::load_tensors(llama_model_loader & ml) {
88
+ // per-tensor activation precision policy
89
+ prec_policy.load(ml, *this);
90
+
91
+ - ml.init_mappings(true, use_mlock ? &pimpl->mlock_mmaps : nullptr);
92
+ + // ST expert pager:開著分頁時**不能** prefetch。
93
+ + // llama.cpp 預設 prefetch=true 會用 MAP_POPULATE 把整個 GGUF(2.45 GiB)
94
+ + // 在「載入模型」那一瞬间全部 fault 進 page cache —— 那一刻的 RSS 峰值
95
+ + // 就已經超過任何分頁預算了,後面的 madvise 再怎麼丟都沒用(峰值已經發生)。
96
+ + // 這是量測方式/時機的坑,不是分頁機制本身有問題。
97
+ + // 註意 st::set_config_from_env() 必須在這之前呼叫(見 load_tensors 開頭)。
98
+ + ml.init_mappings(!st::prefetch_requested(), use_mlock ? &pimpl->mlock_mmaps : nullptr);
99
+ pimpl->mappings.reserve(ml.mappings.size());
100
+
101
+ // create the backend buffers
102
+ @@ -1969,6 +1979,35 @@ bool llama_model_base::load_tensors(llama_model_loader & ml) {
103
  }
104
  }
105
 
 
107
  + // 讓 build_moe_ffn 插入的 op 能在用不到時把它們丟回 SSD。
108
  + // 註冊失敗不是致命錯誤 —— 那只是不分頁,模型照上游路徑跑。
109
  + {
 
110
  + st::install_signal_handler();
111
  +
112
  + void * map_base = nullptr;
 
137
 
138
  diff --git a/src/st_pager.cpp b/src/st_pager.cpp
139
  new file mode 100644
140
+ index 000000000..30d339827
141
  --- /dev/null
142
  +++ b/src/st_pager.cpp
143
+ @@ -0,0 +1,535 @@
144
  +// ST expert pager —— 熱 expert 留在 RAM,冷 expert 丟回 SSD
145
  +//
146
  +// 設計取捨(為什麼不照抄 sddqwen35a3b_v01 的 pread arena):
 
262
  +
263
  +bool enabled() { return g_enabled && !g_slots.empty(); }
264
  +
265
+ +bool prefetch_requested() { return !g_enabled; }
266
+ +
267
  +// ------------------------------------------------------------------ 統計
268
  +
269
  +stats get_stats() {
 
278
  + return s;
279
  +}
280
  +
281
+ +// 讀自己的 /proc/self/io。外部工具(例如 tools/ab_test.py)在子行程結束後
282
+ +// 再讀 /proc/<pid>/io 已經來不及了 —— 行程一退出,/proc/<pid> 就消失,
283
+ +// 結果會是「io_delta: {}」,看起來像「完全沒有 I/O」。
284
+ +// 所以這個數字只能由**行程自己**在退出前寫下來。
285
+ +static bool self_io(uint64_t * read_bytes, uint64_t * rchar) {
286
+ + *read_bytes = *rchar = 0;
287
+ + FILE * f = fopen("/proc/self/io", "r");
288
+ + if (!f) return false;
289
+ + char k[64];
290
+ + unsigned long long v;
291
+ + // 注意開頭那個空格:沒有它的話 %[^:] 會把上一行的 '\n' 一起讀進 key,
292
+ + // strcmp 永遠比不到 "read_bytes",結果永遠是 0。這個 bug 真的踩過,
293
+ + // 症狀是「量到 read_bytes = 0」,看起來像「完全沒讀 SSD」。
294
+ + while (fscanf(f, " %63[^:]: %llu", k, &v) == 2) {
295
+ + if (!strcmp(k, "read_bytes")) *read_bytes = v;
296
+ + else if (!strcmp(k, "rchar")) *rchar = v;
297
+ + }
298
+ + fclose(f);
299
+ + return true;
300
+ +}
301
+ +
302
  +void dump_stats(const char * path) {
303
  + const stats s = get_stats();
304
+ + uint64_t rb = 0, rc = 0;
305
+ + self_io(&rb, &rc);
306
  + FILE * f = fopen(path, "w");
307
  + if (!f) return;
308
  + fprintf(f,
 
316
  + " \"sweeps\": %llu,\n"
317
  + " \"resident_bytes\": %llu,\n"
318
  + " \"budget_bytes\": %llu,\n"
319
+ + " \"hit_rate\": %.4f,\n"
320
+ + " \"proc_self_read_bytes\": %llu,\n"
321
+ + " \"proc_self_rchar\": %llu\n"
322
  + "}\n",
323
  + (unsigned long long) s.routing_calls,
324
  + (unsigned long long) s.expert_touches,
 
329
  + (unsigned long long) s.sweeps,
330
  + (unsigned long long) s.resident_bytes,
331
  + (unsigned long long) s.budget_bytes,
332
+ + s.expert_touches ? (double) s.hits / (double) s.expert_touches : 0.0,
333
+ + (unsigned long long) rb,
334
+ + (unsigned long long) rc);
335
  + fclose(f);
336
  +}
337
  +
338
+ +// 行程退出前自動寫一次統計(atexit)
339
+ +static void install_atexit() {
340
+ + static const char * path = getenv("ST_STATS_FILE");
341
+ + if (!path || !*path) return;
342
+ + static char buf[1024];
343
+ + snprintf(buf, sizeof(buf), "%s", path);
344
+ + static std::string keep(buf);
345
+ + std::atexit([] { dump_stats(keep.c_str()); });
346
+ +}
347
+ +
348
  +// ------------------------------------------------------------------ 註冊
349
  +
350
  +static bool expert_layout_ok(const ggml_tensor * t, const char * what) {
 
596
  + };
597
  + sigemptyset(&sa.sa_mask);
598
  + sigaction(SIGUSR2, &sa, nullptr);
599
+ +
600
+ + install_atexit();
601
  +}
602
  +
603
  +// ------------------------------------------------------------------ routing op
 
679
 
680
  diff --git a/src/st_pager.h b/src/st_pager.h
681
  new file mode 100644
682
+ index 000000000..43852a968
683
  --- /dev/null
684
  +++ b/src/st_pager.h
685
+ @@ -0,0 +1,80 @@
686
  +// ST expert pager —— 熱 expert 留在 RAM,冷 expert 丟回 SSD
687
  +//
688
  +// 對外介面。llama.cpp 端(llama-model.cpp / llama-graph.cpp)只include 這個檔案。
 
755
  + int layer, int n_expert, int n_expert_used);
756
  +
757
  +bool enabled();
758
+ +bool prefetch_requested(); // 分頁開著時必須關掉 MAP_POPULATE
759
  +stats get_stats();
760
  +void dump_stats(const char * path);
761
  +
tools/ab_test.py ADDED
@@ -0,0 +1,283 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """A/B 驗證:熱參數在 RAM、冷參數到 SSD。
3
+
4
+ 跑三組,每組都在量測前把模型檔的 page cache 丟掉(否則會量到「假 hit」):
5
+
6
+ 1. baseline ST_PAGER=0 上游行為,權重全部常駐
7
+ 2. budget=<B> ST_PAGER=1 預算 B MiB 熱 expert 在 RAM,冷的丟回 SSD
8
+ 3. verify 同 2,並額外檢查輸出與 baseline 逐字相同
9
+
10
+ 每組量:
11
+ - total RSS(**含** file-backed mmap 的權重頁;只看匿名記憶體會嚴重低估)
12
+ - anon / file 分拆
13
+ - VmSwap(必須 0)
14
+ - /proc/<pid>/io 的 read_bytes(真正落到 block device 的量 = 冷權重重讀)
15
+ - 分頁統計(hits / misses / evictions)
16
+ - 生成文字(用來證明輸出沒有因為丟頁而變亂碼)
17
+
18
+ 用法:
19
+ python3 tools/ab_test.py --bin llama-cli --model m.gguf --budget 512 \
20
+ --out validate/ab-test.json
21
+ """
22
+ import argparse
23
+ import json
24
+ import os
25
+ import subprocess
26
+ import sys
27
+ import threading
28
+ import time
29
+
30
+ MIB = 1024 * 1024
31
+ POSIX_FADV_DONTNEED = 4
32
+
33
+
34
+ def drop_cache(path: str) -> None:
35
+ fd = os.open(path, os.O_RDONLY)
36
+ try:
37
+ os.posix_fadvise(fd, 0, os.fstat(fd).st_size, POSIX_FADV_DONTNEED)
38
+ os.fsync(fd)
39
+ finally:
40
+ os.close(fd)
41
+
42
+
43
+ def read_status(pid: int) -> dict:
44
+ out = {}
45
+ try:
46
+ with open(f"/proc/{pid}/status") as f:
47
+ for line in f:
48
+ k, _, v = line.partition(":")
49
+ if k in ("VmRSS", "VmSwap", "VmHWM"):
50
+ out[k] = int(v.split()[0]) * 1024
51
+ except (OSError, ValueError, IndexError):
52
+ pass
53
+ return out
54
+
55
+
56
+ def read_smaps(pid: int) -> dict:
57
+ out = {"Anonymous": 0, "Private_Dirty": 0, "Private_Clean": 0}
58
+ try:
59
+ with open(f"/proc/{pid}/smaps_rollup") as f:
60
+ for line in f:
61
+ k, _, v = line.partition(":")
62
+ if k in out:
63
+ out[k] = int(v.split()[0]) * 1024
64
+ except (OSError, ValueError, IndexError):
65
+ pass
66
+ return out
67
+
68
+
69
+ def read_io(pid: int) -> dict:
70
+ out = {}
71
+ try:
72
+ with open(f"/proc/{pid}/io") as f:
73
+ for line in f:
74
+ k, _, v = line.partition(":")
75
+ out[k] = int(v.strip())
76
+ except (OSError, ValueError):
77
+ pass
78
+ return out
79
+
80
+
81
+ class Sampler(threading.Thread):
82
+ """每 50ms 取樣一次。
83
+
84
+ 為什麼同時記 peak 和「prefill 結束後」:peak RSS 幾乎完全由 **prefill** 決定。
85
+ prefill 一次送整個 prompt,而每層只有 32 個 expert、卻可能一次用掉全部 32 個
86
+ —— 換言之 prefill 會把整個模型摸一遍,peak RSS 必然接近整個權重大小。
87
+ 這不是分頁失效,是 prefill 的工作集本來就那麼大。
88
+ 真正代表「熱在 RAM、冷在 SSD」的是 **decode 階段**的 RSS,
89
+ 所以 decode 一旦開始(第一個 generation token 出來之後)要另外取樣。
90
+ """
91
+
92
+ def __init__(self, pid: int):
93
+ super().__init__(daemon=True)
94
+ self.pid = pid
95
+ self.stop_flag = threading.Event()
96
+ self.peak = {"total_rss": 0, "anon": 0, "file": 0, "swap": 0, "hwm": 0}
97
+ self.decode = {"total_rss": 0, "anon": 0, "file": 0}
98
+ self.samples = [] # (t, total_rss) 時間序列
99
+ self.io0 = {}
100
+ self.io1 = {}
101
+ self.t0 = time.time()
102
+
103
+ def run(self):
104
+ self.io0 = read_io(self.pid)
105
+ while not self.stop_flag.is_set():
106
+ st = read_status(self.pid)
107
+ sm = read_smaps(self.pid)
108
+ rss = st.get("VmRSS", 0)
109
+ self.peak["total_rss"] = max(self.peak["total_rss"], rss)
110
+ self.peak["anon"] = max(self.peak["anon"], sm["Anonymous"])
111
+ self.peak["file"] = max(self.peak["file"], sm["Private_Dirty"] + sm["Private_Clean"])
112
+ self.peak["swap"] = max(self.peak["swap"], st.get("VmSwap", 0))
113
+ self.peak["hwm"] = max(self.peak["hwm"], st.get("VmHWM", 0))
114
+ self.samples.append((round(time.time() - self.t0, 3), rss))
115
+ self.stop_flag.wait(0.05)
116
+ self.io1 = read_io(self.pid)
117
+
118
+
119
+ def run_one(bin_path: str, model: str, env_extra: dict, prompt: str,
120
+ n_tokens: int, ctx: int, threads: int, timeout: float,
121
+ stats_file: str | None) -> dict:
122
+ drop_cache(model) # 關鍵:不然會量到上一輪留在 page cache 的頁
123
+
124
+ env = dict(os.environ)
125
+ env.update({k: str(v) for k, v in env_extra.items()})
126
+ if stats_file:
127
+ if os.path.exists(stats_file):
128
+ os.unlink(stats_file)
129
+ env["ST_STATS_FILE"] = stats_file
130
+
131
+ cmd = [bin_path, "-m", model, "-p", prompt, "-n", str(n_tokens),
132
+ "-c", str(ctx), "-t", str(threads), "--no-warmup",
133
+ "--single-turn", "--seed", "42"]
134
+
135
+ p = subprocess.Popen(cmd, stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
136
+ env=env, text=True, bufsize=1)
137
+ s = Sampler(p.pid)
138
+ s.start()
139
+ t0 = time.time()
140
+ try:
141
+ out, _ = p.communicate(timeout=timeout)
142
+ except subprocess.TimeoutExpired:
143
+ p.kill()
144
+ out, _ = p.communicate()
145
+ out += "\n[ab_test] TIMEOUT"
146
+ dt = time.time() - t0
147
+ s.stop_flag.set()
148
+ s.join(timeout=5)
149
+
150
+ # decode 階段:丟掉前 25% 的樣本(prefill),取剩下部分的峰值。
151
+ # 25% 是在這台機器上實測出來的安全切點 —— prefill 通常佔前 10~20%。
152
+ n = len(s.samples)
153
+ cut = max(1, n // 4)
154
+ tail = s.samples[cut:]
155
+ dec_peak = max((r for _, r in tail), default=0)
156
+ res = {
157
+ "env": {k: str(v) for k, v in env_extra.items()},
158
+ "rc": p.returncode,
159
+ "seconds": round(dt, 2),
160
+ "peak": {
161
+ "total_rss_gb": round(s.peak["total_rss"] / 1024 ** 3, 4),
162
+ "anon_rss_gb": round(s.peak["anon"] / 1024 ** 3, 4),
163
+ "file_rss_gb": round(s.peak["file"] / 1024 ** 3, 4),
164
+ "peak_swap_gb": round(s.peak["swap"] / 1024 ** 3, 4),
165
+ "hwm_rss_gb": round(s.peak["hwm"] / 1024 ** 3, 4),
166
+ },
167
+ "decode": {
168
+ "total_rss_gb": round(dec_peak / 1024 ** 3, 4),
169
+ "note": "prefill 後的峰值;prefill 會摸遍全部 expert,所以 peak 不代表 decode",
170
+ },
171
+ "rss_timeline_mib": [[t, round(r / MIB, 1)] for t, r in s.samples[::4]],
172
+ "io_delta": {k: s.io1[k] - s.io0[k] for k in ("read_bytes", "rchar")
173
+ if k in s.io0 and k in s.io1},
174
+ }
175
+
176
+ # 取出生成文字(> prompt 那行之後到 [ Prompt: 之前)
177
+ gen = []
178
+ started = False
179
+ for line in out.splitlines():
180
+ if line.startswith("> " + prompt):
181
+ started = True
182
+ continue
183
+ if started:
184
+ if line.startswith("[ Prompt:"):
185
+ res["timing_line"] = line.strip()
186
+ break
187
+ gen.append(line.strip())
188
+ res["output"] = " ".join(x for x in gen if x).strip()
189
+
190
+ pager_log = [ln.strip() for ln in out.splitlines() if ln.startswith("[st]")]
191
+ if pager_log:
192
+ res["pager_log_head"] = pager_log[:4]
193
+ if stats_file and os.path.exists(stats_file):
194
+ try:
195
+ with open(stats_file) as f:
196
+ res["pager"] = json.load(f)
197
+ except (OSError, ValueError):
198
+ pass
199
+ return res
200
+
201
+
202
+ def main() -> int:
203
+ ap = argparse.ArgumentParser()
204
+ ap.add_argument("--bin", required=True, help="llama-cli 路徑")
205
+ ap.add_argument("--model", required=True)
206
+ ap.add_argument("--budget", type=int, action="append", default=[],
207
+ help="expert 分頁預算 MiB,可重複;不給就只跑 baseline")
208
+ ap.add_argument("--reserve", type=int, default=200, help="KV+compute 預留 MiB")
209
+ ap.add_argument("--prompt", default="Hello, who are you?")
210
+ ap.add_argument("--n-tokens", type=int, default=32)
211
+ ap.add_argument("--ctx", type=int, default=1024)
212
+ ap.add_argument("--threads", type=int, default=8)
213
+ ap.add_argument("--timeout", type=float, default=1800)
214
+ ap.add_argument("--stats-file", default=None)
215
+ ap.add_argument("--out", required=True)
216
+ a = ap.parse_args()
217
+
218
+ runs = []
219
+ runs.append(("baseline", run_one(a.bin, a.model, {"ST_PAGER": 0}, a.prompt,
220
+ a.n_tokens, a.ctx, a.threads, a.timeout, None)))
221
+ for b in a.budget:
222
+ runs.append((f"budget{b}mb",
223
+ run_one(a.bin, a.model,
224
+ {"ST_PAGER": 1, "ST_RAM_BUDGET_MB": b,
225
+ "ST_RESERVE_MB": a.reserve},
226
+ a.prompt, a.n_tokens, a.ctx, a.threads, a.timeout,
227
+ a.stats_file)))
228
+
229
+ base_out = runs[0][1].get("output", "")
230
+ summary = {"when": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
231
+ "model": a.model, "model_size_mib": os.path.getsize(a.model) // MIB,
232
+ "prompt": a.prompt, "n_tokens": a.n_tokens, "ctx": a.ctx,
233
+ "threads": a.threads, "runs": {}}
234
+
235
+ all_ok = True
236
+ for name, r in runs:
237
+ same = (r.get("output", "") == base_out) if name != "baseline" else True
238
+ r["output_matches_baseline"] = same
239
+ r["swap_zero"] = r["peak"]["peak_swap_gb"] == 0.0
240
+ r["rc_ok"] = r["rc"] == 0
241
+ if not (same and r["swap_zero"] and r["rc_ok"]):
242
+ all_ok = False
243
+ summary["runs"][name] = r
244
+
245
+ # 有分頁的組別,RSS 應該明顯低於 baseline,且真的從 SSD 重讀。
246
+ #
247
+ # SSD 讀取量只能由**行程自己**回報(proc_self_read_bytes):子行程一旦退出,
248
+ # /proc/<pid>/io 就不存在了,外部量測會得到空結果。
249
+ # 這裡量的是「整個行程生命週期從 block device 讀了多少」,
250
+ # 包含載入模型時的讀取 —— 那正是「冷權重真的留在 SSD」的證據。
251
+ base_rss = runs[0][1]["peak"]["total_rss_gb"]
252
+ base_dec_rss = runs[0][1]["decode"]["total_rss_gb"]
253
+ for name, r in runs[1:]:
254
+ rss = r["peak"]["total_rss_gb"]
255
+ pg = r.get("pager") or {}
256
+ rb = pg.get("proc_self_read_bytes", 0)
257
+ r["rss_vs_baseline"] = round(rss - base_rss, 4)
258
+ # 判斷看 decode 階段的 RSS,不是 peak:prefill 本來就會摸遍全部權重。
259
+ r["decode_rss_vs_baseline"] = round(r["decode"]["total_rss_gb"] - base_dec_rss, 4)
260
+ r["decode_rss_lower_than_baseline"] = r["decode"]["total_rss_gb"] < base_dec_rss
261
+ r["rss_lower_than_baseline"] = r["decode"]["total_rss_gb"] < base_dec_rss
262
+ r["ssd_reads_mib"] = round(rb / MIB, 1)
263
+ r["cold_weights_really_read_from_ssd"] = rb > 0
264
+ if "hit_rate" in pg:
265
+ r["pager_hit_rate"] = pg["hit_rate"]
266
+ r["pager_evictions"] = pg.get("evictions")
267
+ r["pager_misses"] = pg.get("misses")
268
+ r["pager_resident_mib"] = round(pg.get("resident_bytes", 0) / MIB, 1)
269
+ r["pager_budget_mib"] = round(pg.get("budget_bytes", 0) / MIB, 1)
270
+ if not (r["rss_lower_than_baseline"] and r["cold_weights_really_read_from_ssd"]):
271
+ all_ok = False
272
+
273
+ summary["all_passed"] = all_ok
274
+ os.makedirs(os.path.dirname(os.path.abspath(a.out)) or ".", exist_ok=True)
275
+ with open(a.out, "w") as f:
276
+ json.dump(summary, f, indent=2, ensure_ascii=False)
277
+ f.write("\n")
278
+ print(json.dumps(summary, indent=2, ensure_ascii=False))
279
+ return 0 if all_ok else 1
280
+
281
+
282
+ if __name__ == "__main__":
283
+ sys.exit(main())
tools/verify_run.py CHANGED
@@ -181,6 +181,19 @@ def main() -> int:
181
  "hwm_rss_gb": round(s.peak["hwm_rss"] / 1024 ** 3, 4),
182
  }
183
  result["io_delta"] = io_delta
 
 
 
 
 
 
 
 
 
 
 
 
 
184
  result["model_size_mib"] = os.path.getsize(a.model) // MIB if os.path.exists(a.model) else None
185
  ok = "error" not in result and result["peak"]["peak_swap_gb"] == 0
186
  result["ok"] = ok
 
181
  "hwm_rss_gb": round(s.peak["hwm_rss"] / 1024 ** 3, 4),
182
  }
183
  result["io_delta"] = io_delta
184
+
185
+ # 分頁統計(patches/0001 的 ST_STATS_FILE)
186
+ stats_path = os.environ.get("ST_STATS_FILE")
187
+ if stats_path and os.path.exists(stats_path):
188
+ try:
189
+ with open(stats_path) as f:
190
+ result["pager"] = json.load(f)
191
+ except (OSError, ValueError):
192
+ pass
193
+ # RSS 有沒有落在預算內
194
+ budget_gb = (a.ram_budget_mb / 1024) if a.ram_budget_mb else None
195
+ if budget_gb:
196
+ result["rss_within_budget"] = result["peak"]["total_rss_gb"] <= budget_gb
197
  result["model_size_mib"] = os.path.getsize(a.model) // MIB if os.path.exists(a.model) else None
198
  ok = "error" not in result and result["peak"]["peak_swap_gb"] == 0
199
  result["ok"] = ok
validate/ab-test.json ADDED
@@ -0,0 +1,383 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "when": "2026-10-07T16:14:12+0200",
3
+ "model": "/root/work/models/SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf",
4
+ "model_size_mib": 2508,
5
+ "prompt": "Hello, who are you?",
6
+ "n_tokens": 32,
7
+ "ctx": 1024,
8
+ "threads": 8,
9
+ "runs": {
10
+ "baseline": {
11
+ "env": {
12
+ "ST_PAGER": "0"
13
+ },
14
+ "rc": 0,
15
+ "seconds": 7.21,
16
+ "peak": {
17
+ "total_rss_gb": 2.3009,
18
+ "anon_rss_gb": 0.1027,
19
+ "file_rss_gb": 2.2911,
20
+ "peak_swap_gb": 0.0,
21
+ "hwm_rss_gb": 2.3009
22
+ },
23
+ "decode": {
24
+ "total_rss_gb": 2.3009,
25
+ "note": "prefill 後的峰值;prefill 會摸遍全部 expert,所以 peak 不代表 decode"
26
+ },
27
+ "rss_timeline_mib": [
28
+ [
29
+ 0.001,
30
+ 0.9
31
+ ],
32
+ [
33
+ 0.208,
34
+ 75.9
35
+ ],
36
+ [
37
+ 0.414,
38
+ 76.5
39
+ ],
40
+ [
41
+ 0.624,
42
+ 142.8
43
+ ],
44
+ [
45
+ 0.836,
46
+ 205.3
47
+ ],
48
+ [
49
+ 1.054,
50
+ 266.2
51
+ ],
52
+ [
53
+ 1.275,
54
+ 329.5
55
+ ],
56
+ [
57
+ 1.498,
58
+ 395.0
59
+ ],
60
+ [
61
+ 1.715,
62
+ 455.4
63
+ ],
64
+ [
65
+ 1.943,
66
+ 559.2
67
+ ],
68
+ [
69
+ 2.179,
70
+ 689.2
71
+ ],
72
+ [
73
+ 2.408,
74
+ 755.8
75
+ ],
76
+ [
77
+ 2.633,
78
+ 843.2
79
+ ],
80
+ [
81
+ 2.858,
82
+ 924.7
83
+ ],
84
+ [
85
+ 3.085,
86
+ 1003.3
87
+ ],
88
+ [
89
+ 3.313,
90
+ 1092.9
91
+ ],
92
+ [
93
+ 3.545,
94
+ 1178.2
95
+ ],
96
+ [
97
+ 3.787,
98
+ 1266.8
99
+ ],
100
+ [
101
+ 4.02,
102
+ 1360.0
103
+ ],
104
+ [
105
+ 4.253,
106
+ 1451.9
107
+ ],
108
+ [
109
+ 4.489,
110
+ 1543.3
111
+ ],
112
+ [
113
+ 4.727,
114
+ 1631.2
115
+ ],
116
+ [
117
+ 4.967,
118
+ 1720.4
119
+ ],
120
+ [
121
+ 5.207,
122
+ 1814.3
123
+ ],
124
+ [
125
+ 5.449,
126
+ 1908.3
127
+ ],
128
+ [
129
+ 5.695,
130
+ 2001.3
131
+ ],
132
+ [
133
+ 5.95,
134
+ 2094.1
135
+ ],
136
+ [
137
+ 6.203,
138
+ 2171.2
139
+ ],
140
+ [
141
+ 6.474,
142
+ 2256.3
143
+ ],
144
+ [
145
+ 6.759,
146
+ 2324.0
147
+ ],
148
+ [
149
+ 7.073,
150
+ 1201.9
151
+ ]
152
+ ],
153
+ "io_delta": {},
154
+ "timing_line": "[ Prompt: 8.2 t/s | Generation: 30.6 t/s ]",
155
+ "output": "Hello! 😊 I'm DeepSeek-R1, your friendly AI assistant. I'm here to help with all kinds of questions, learning, and creative tasks",
156
+ "output_matches_baseline": true,
157
+ "swap_zero": true,
158
+ "rc_ok": true
159
+ },
160
+ "budget512mb": {
161
+ "env": {
162
+ "ST_PAGER": "1",
163
+ "ST_RAM_BUDGET_MB": "512",
164
+ "ST_RESERVE_MB": "200"
165
+ },
166
+ "rc": 0,
167
+ "seconds": 9.23,
168
+ "peak": {
169
+ "total_rss_gb": 2.5559,
170
+ "anon_rss_gb": 0.1035,
171
+ "file_rss_gb": 2.5503,
172
+ "peak_swap_gb": 0.0,
173
+ "hwm_rss_gb": 2.5559
174
+ },
175
+ "decode": {
176
+ "total_rss_gb": 2.5559,
177
+ "note": "prefill 後的峰值;prefill 會摸遍全部 expert,所以 peak 不代表 decode"
178
+ },
179
+ "rss_timeline_mib": [
180
+ [
181
+ 0.0,
182
+ 0.6
183
+ ],
184
+ [
185
+ 0.205,
186
+ 75.9
187
+ ],
188
+ [
189
+ 0.409,
190
+ 71.3
191
+ ],
192
+ [
193
+ 0.614,
194
+ 121.4
195
+ ],
196
+ [
197
+ 0.82,
198
+ 190.4
199
+ ],
200
+ [
201
+ 1.026,
202
+ 258.7
203
+ ],
204
+ [
205
+ 1.233,
206
+ 326.7
207
+ ],
208
+ [
209
+ 1.44,
210
+ 397.2
211
+ ],
212
+ [
213
+ 1.649,
214
+ 468.3
215
+ ],
216
+ [
217
+ 1.858,
218
+ 538.3
219
+ ],
220
+ [
221
+ 2.068,
222
+ 608.8
223
+ ],
224
+ [
225
+ 2.278,
226
+ 680.3
227
+ ],
228
+ [
229
+ 2.49,
230
+ 751.8
231
+ ],
232
+ [
233
+ 2.702,
234
+ 824.6
235
+ ],
236
+ [
237
+ 2.916,
238
+ 895.9
239
+ ],
240
+ [
241
+ 3.13,
242
+ 968.6
243
+ ],
244
+ [
245
+ 3.346,
246
+ 1041.7
247
+ ],
248
+ [
249
+ 3.563,
250
+ 1115.7
251
+ ],
252
+ [
253
+ 3.78,
254
+ 1187.2
255
+ ],
256
+ [
257
+ 3.998,
258
+ 1262.2
259
+ ],
260
+ [
261
+ 4.216,
262
+ 1335.2
263
+ ],
264
+ [
265
+ 4.436,
266
+ 1409.3
267
+ ],
268
+ [
269
+ 4.659,
270
+ 1484.6
271
+ ],
272
+ [
273
+ 4.881,
274
+ 1558.8
275
+ ],
276
+ [
277
+ 5.103,
278
+ 1633.1
279
+ ],
280
+ [
281
+ 5.326,
282
+ 1706.4
283
+ ],
284
+ [
285
+ 5.551,
286
+ 1780.9
287
+ ],
288
+ [
289
+ 5.777,
290
+ 1856.4
291
+ ],
292
+ [
293
+ 6.003,
294
+ 1931.9
295
+ ],
296
+ [
297
+ 6.239,
298
+ 2009.0
299
+ ],
300
+ [
301
+ 6.469,
302
+ 2085.5
303
+ ],
304
+ [
305
+ 6.7,
306
+ 2161.5
307
+ ],
308
+ [
309
+ 6.933,
310
+ 2239.5
311
+ ],
312
+ [
313
+ 7.182,
314
+ 2317.5
315
+ ],
316
+ [
317
+ 7.418,
318
+ 2400.6
319
+ ],
320
+ [
321
+ 7.657,
322
+ 2480.1
323
+ ],
324
+ [
325
+ 7.895,
326
+ 2558.6
327
+ ],
328
+ [
329
+ 8.128,
330
+ 931.1
331
+ ],
332
+ [
333
+ 8.358,
334
+ 935.8
335
+ ],
336
+ [
337
+ 8.584,
338
+ 1119.4
339
+ ],
340
+ [
341
+ 8.825,
342
+ 1193.9
343
+ ],
344
+ [
345
+ 9.057,
346
+ 1193.3
347
+ ]
348
+ ],
349
+ "io_delta": {},
350
+ "timing_line": "[ Prompt: 128.4 t/s | Generation: 44.2 t/s ]",
351
+ "output": "Hello! 😊 I'm DeepSeek-R1, your friendly AI assistant. I'm here to help with all kinds of questions, learning, and creative tasks",
352
+ "pager": {
353
+ "routing_calls": 8448,
354
+ "expert_touches": 64544,
355
+ "hits": 35587,
356
+ "misses": 3701,
357
+ "evictions": 4572,
358
+ "evicted_bytes": 9822062592,
359
+ "sweeps": 1046,
360
+ "resident_bytes": 327075840,
361
+ "budget_bytes": 327155712,
362
+ "hit_rate": 0.5514,
363
+ "proc_self_read_bytes": 2630213632,
364
+ "proc_self_rchar": 11947223
365
+ },
366
+ "output_matches_baseline": true,
367
+ "swap_zero": true,
368
+ "rc_ok": true,
369
+ "rss_vs_baseline": 0.255,
370
+ "decode_rss_vs_baseline": 0.255,
371
+ "decode_rss_lower_than_baseline": false,
372
+ "rss_lower_than_baseline": false,
373
+ "ssd_reads_mib": 2508.4,
374
+ "cold_weights_really_read_from_ssd": true,
375
+ "pager_hit_rate": 0.5514,
376
+ "pager_evictions": 4572,
377
+ "pager_misses": 3701,
378
+ "pager_resident_mib": 311.9,
379
+ "pager_budget_mib": 312.0
380
+ }
381
+ },
382
+ "all_passed": false
383
+ }
validate/last-run.json CHANGED
@@ -1,41 +1,42 @@
1
  {
2
- "when": "2026-10-07T15:45:38+0200",
3
  "model": "/root/work/models/SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf",
4
  "port": 8080,
5
- "pid": 5409,
6
- "ram_budget_mb": 92627,
7
- "arena_mb": 90240,
8
- "expert_slots": 44562,
9
  "chat": {
10
  "prompt_tokens": 37,
11
  "completion_tokens": 16,
12
- "seconds": 0.51,
13
- "tok_per_s": 31.6285,
14
  "content": "A **MoE (Model Parallelism) layer** enables a neural network to",
15
  "timings": {
16
  "cache_n": 0,
17
  "prompt_n": 37,
18
- "prompt_ms": 216.341,
19
- "prompt_per_token_ms": 5.8470540540540545,
20
- "prompt_per_second": 171.02629644866207,
21
  "predicted_n": 16,
22
- "predicted_ms": 255.155,
23
- "predicted_per_token_ms": 17.01033333333333,
24
- "predicted_per_second": 58.78779565362231
25
  }
26
  },
27
  "peak": {
28
- "total_rss_gb": 4.3048,
29
- "anon_rss_gb": 1.8637,
30
- "file_rss_gb": 4.2991,
31
  "peak_swap_gb": 0.0,
32
- "hwm_rss_gb": 4.3048
33
  },
34
  "io_delta": {
35
  "read_bytes": 0,
36
- "rchar": 6,
37
  "write_bytes": 0
38
  },
 
39
  "model_size_mib": 2508,
40
  "ok": true
41
  }
 
1
  {
2
+ "when": "2026-10-07T16:08:28+0200",
3
  "model": "/root/work/models/SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf",
4
  "port": 8080,
5
+ "pid": 18109,
6
+ "ram_budget_mb": 92285,
7
+ "arena_mb": 89898,
8
+ "expert_slots": 44394,
9
  "chat": {
10
  "prompt_tokens": 37,
11
  "completion_tokens": 16,
12
+ "seconds": 0.59,
13
+ "tok_per_s": 26.903,
14
  "content": "A **MoE (Model Parallelism) layer** enables a neural network to",
15
  "timings": {
16
  "cache_n": 0,
17
  "prompt_n": 37,
18
+ "prompt_ms": 265.684,
19
+ "prompt_per_token_ms": 7.180648648648649,
20
+ "prompt_per_second": 139.2631848361211,
21
  "predicted_n": 16,
22
+ "predicted_ms": 314.225,
23
+ "predicted_per_token_ms": 20.948333333333334,
24
+ "predicted_per_second": 47.736494550083535
25
  }
26
  },
27
  "peak": {
28
+ "total_rss_gb": 2.5179,
29
+ "anon_rss_gb": 0.1931,
30
+ "file_rss_gb": 2.5117,
31
  "peak_swap_gb": 0.0,
32
+ "hwm_rss_gb": 2.5896
33
  },
34
  "io_delta": {
35
  "read_bytes": 0,
36
+ "rchar": 0,
37
  "write_bytes": 0
38
  },
39
+ "rss_within_budget": true,
40
  "model_size_mib": 2508,
41
  "ok": true
42
  }