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

v2: expert 級 SSD 分頁(patches/0001)— 熱在 RAM 冷在 SSD,輸出與上游一致

Browse files
Files changed (2) hide show
  1. llama_server.sh +11 -1
  2. patches/0001-st-expert-pager.patch +695 -0
llama_server.sh CHANGED
@@ -318,8 +318,14 @@ build_if_needed() {
318
  command -v ninja >/dev/null && gen=(-G Ninja)
319
  mkdir -p "$LOG_DIR"
320
  # 不開 -march=native:編譯機與執行機不一定同一台,native 會在別的機器 SIGILL。
 
 
 
 
 
 
321
  cmake "${gen[@]}" -S "$SRC_DIR" -B "$SRC_DIR/build" \
322
- -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF \
323
  -DGGML_CURL=OFF -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF \
324
  -DLLAMA_BUILD_SERVER=ON -DLLAMA_BUILD_TOOLS=ON \
325
  >"$LOG_DIR/cmake.log" 2>&1 || die "cmake 失敗,見 $LOG_DIR/cmake.log"
@@ -395,6 +401,8 @@ plan_report() {
395
  threads $THREADS(配額 ${QUOTA:-無}/可用核心 $(affinity_cpus))
396
  ubatch $UBATCH ctx $CTX(模型上限 $MODEL_MAX_CTX)
397
  GPU ${GPU_MB} MiB
 
 
398
  監聽 http://0.0.0.0:$PORT
399
  環境 ST_RAM_BUDGET_MB=$RAM_BUDGET_MB ST_RESERVE_MB=$RESERVE_MB \
400
  ST_KV_RESERVE_MB=$KV_RESERVE_MB ST_THREADS=$THREADS ST_UBATCH=$UBATCH \
@@ -438,6 +446,8 @@ fi
438
 
439
  export ST_RAM_BUDGET_MB="$RAM_BUDGET_MB"
440
  export ST_RESERVE_MB="$RESERVE_MB"
 
 
441
  export ST_KV_RESERVE_MB="$KV_RESERVE_MB"
442
  export ST_THREADS="$THREADS"
443
  export ST_UBATCH="$UBATCH"
 
318
  command -v ninja >/dev/null && gen=(-G Ninja)
319
  mkdir -p "$LOG_DIR"
320
  # 不開 -march=native:編譯機與執行機不一定同一台,native 會在別的機器 SIGILL。
321
+ # -DGGML_CPU_REPACK=OFF 是硬性要求,不能拿掉:
322
+ # 開著時 ggml 會把 Q4_K 權重轉成 repack 格式放進**匿名**緩衝區,
323
+ # 權重就整個離開 mmap。這時對它 madvise(MADV_DONTNEED) 不是「讀 SSD」,
324
+ # 而是**把那塊記憶體歸零** → 輸出變亂碼,但行程看起來完全正常。
325
+ # (這個坑真的踩過,症狀是「輸出變成 'papers bases abstract' 之類的亂碼」,
326
+ # 而且 tok/s 只掉一點點 —— 非常不像出錯。st_pager.cpp 也有 in_map 檢查擋著。)
327
  cmake "${gen[@]}" -S "$SRC_DIR" -B "$SRC_DIR/build" \
328
+ -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF -DGGML_CPU_REPACK=OFF \
329
  -DGGML_CURL=OFF -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF \
330
  -DLLAMA_BUILD_SERVER=ON -DLLAMA_BUILD_TOOLS=ON \
331
  >"$LOG_DIR/cmake.log" 2>&1 || die "cmake 失敗,見 $LOG_DIR/cmake.log"
 
401
  threads $THREADS(配額 ${QUOTA:-無}/可用核心 $(affinity_cpus))
402
  ubatch $UBATCH ctx $CTX(模型上限 $MODEL_MAX_CTX)
403
  GPU ${GPU_MB} MiB
404
+ 分頁 ST_PAGER=${ST_PAGER:-1}(0 = 關閉,走上游行為)
405
+ expert 槽 $(( N_LAYER * N_EXPERT )) 個 × $(( EXPERT_BYTES / 1024 )) KiB = ${EXPERT_TOTAL_MIB} MiB
406
  監聽 http://0.0.0.0:$PORT
407
  環境 ST_RAM_BUDGET_MB=$RAM_BUDGET_MB ST_RESERVE_MB=$RESERVE_MB \
408
  ST_KV_RESERVE_MB=$KV_RESERVE_MB ST_THREADS=$THREADS ST_UBATCH=$UBATCH \
 
446
 
447
  export ST_RAM_BUDGET_MB="$RAM_BUDGET_MB"
448
  export ST_RESERVE_MB="$RESERVE_MB"
449
+ export ST_ARENA_MB="$ARENA_MB"
450
+ export ST_STATS_FILE="${ST_STATS_FILE:-$WORK_DIR/st-stats.json}"
451
  export ST_KV_RESERVE_MB="$KV_RESERVE_MB"
452
  export ST_THREADS="$THREADS"
453
  export ST_UBATCH="$UBATCH"
patches/0001-st-expert-pager.patch ADDED
@@ -0,0 +1,695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
21
+ diff --git a/src/CMakeLists.txt b/src/CMakeLists.txt
22
+ index afdaddc79..31b2c2896 100644
23
+ --- a/src/CMakeLists.txt
24
+ +++ b/src/CMakeLists.txt
25
+ @@ -18,6 +18,7 @@ set(LLAMA_CORE_SOURCES
26
+ llama-cparams.cpp
27
+ llama-grammar.cpp
28
+ llama-graph.cpp
29
+ + st_pager.cpp
30
+ llama-hparams.cpp
31
+ llama-impl.cpp
32
+ llama-io.cpp
33
+ diff --git a/src/llama-graph.cpp b/src/llama-graph.cpp
34
+ index 1112ad885..e335a9d5f 100644
35
+ --- a/src/llama-graph.cpp
36
+ +++ b/src/llama-graph.cpp
37
+ @@ -1,5 +1,7 @@
38
+ #include "llama-graph.h"
39
+
40
+ +#include "st_pager.h"
41
+ +
42
+ #include "llama-impl.h"
43
+ #include "llama-model.h"
44
+ #include "llama-batch.h"
45
+ @@ -2205,6 +2207,14 @@ ggml_tensor * llm_graph_context::build_moe_ffn(
46
+ //call early so that topk-moe can be used
47
+ ggml_build_forward_expand(gf, weights);
48
+
49
+ + // ST expert pager:在真正算 expert 之前,先知道這一層要用哪些 expert,
50
+ + // 然後把用不到的 expert 權重丟回 SSD。計算本身完全不動 ——
51
+ + // 下面仍然是 llama.cpp 自己的 mul_mat_id,所以輸出與上游一致。
52
+ + if (ggml_tensor * st_note = st::build_routing_op(ctx0, selected_experts, il,
53
+ + (int) n_expert, (int) n_expert_used)) {
54
+ + ggml_build_forward_expand(gf, st_note);
55
+ + }
56
+ +
57
+ cur = ggml_reshape_3d(ctx0, cur, n_embd, 1, n_tokens);
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 @@
65
+ #include "llama-model.h"
66
+
67
+ +#include "st_pager.h"
68
+ +
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
+
76
+ + // ST expert pager:把 MoE expert 權重記成 (layer, expert) 的檔案/記憶體區間,
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;
84
+ + size_t map_size = 0;
85
+ + int map_fd = -1;
86
+ + if (!pimpl->mappings.empty() && !ml.files.empty()) {
87
+ + map_base = pimpl->mappings[0]->addr();
88
+ + map_size = pimpl->mappings[0]->size();
89
+ + map_fd = ml.files[0]->file_id();
90
+ + }
91
+ +
92
+ + std::map<std::string, ggml_tensor *> by_name;
93
+ + for (const auto & [nm, t] : tensors_by_name) {
94
+ + by_name.emplace(nm, t);
95
+ + }
96
+ +
97
+ + if (getenv("ST_VERBOSE") && !map_base) {
98
+ + LLAMA_LOG_INFO("%s: [st] 沒有 mapping(mappings=%zu files=%zu),無法分頁\n",
99
+ + __func__, pimpl->mappings.size(), ml.files.size());
100
+ + }
101
+ + if (map_base && !st::register_model(arch_name().c_str(), map_base, map_size, map_fd, by_name)) {
102
+ + LLAMA_LOG_INFO("%s: [st] expert 分頁未啟用(arch=%s)\n", __func__, arch_name().c_str());
103
+ + }
104
+ + }
105
+ +
106
+ return true;
107
+ }
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):
118
+ +// sddqwen35a3b 做的是「把 expert 權重 pread 進自己的 arena,用自訂 ggml op
119
+ +// 重寫量化點積」。那在 22 GB 權重 / 小 RAM 機器上是必要的,代價是要自己重寫
120
+ +// Q4_K 的點積 kernel,數值正確性得另外驗證。
121
+ +// SmallThinker 只有 2.45 GiB,權重可以用 file-backed mmap 留在 SSD,
122
+ +// 所以本專案改成「**計算路徑完全不動,只把用不到的 expert 頁丟掉**」:
123
+ +// - 計算仍是 llama.cpp 自己的 kernel → 輸出與上游一致,沒有重寫 kernel 的風險。
124
+ +// - 淘汰的最小單位是「一個 expert 的三段權重」,不是 page。
125
+ +// 代價:不能像 arena 那樣用 prefetch 提前重疊 I/O,SSD 讀取是 demand-paging
126
+ +// (第一次碰到缺頁才讀)。這是本專案目前的取捨,量測見 docs。
127
+ +//
128
+ +// 淘汰的兩步驟(順序不能換,這是實測踩出來的):
129
+ +// a. madvise(MADV_DONTNEED) → 拿掉本行程 PTEs,行程總 RSS 立刻下降。
130
+ +// b. posix_fadvise(DONTNEED) → 核心的 invalidate_mapping_pages() 會跳過
131
+ +// 「仍被某個 VMA 映射」的頁,所以 (a) 必須先做;(b) 做完這些頁才真的從
132
+ +// page cache 消失,下次碰到才是真的讀 SSD。
133
+ +// 只做 (a):RSS 下降但 read_bytes 不變(頁還在 page cache)。
134
+ +// 只做 (b):page cache 下降但行程 RSS 不變(VMA 還映射著)。
135
+ +
136
+ +#include "st_pager.h"
137
+ +
138
+ +#include "ggml.h"
139
+ +#include "ggml-alloc.h"
140
+ +#include "ggml-backend.h"
141
+ +
142
+ +#include <algorithm>
143
+ +#include <array>
144
+ +#include <csignal>
145
+ +#include <cstdarg>
146
+ +#include <cstdio>
147
+ +#include <cstdlib>
148
+ +#include <cstring>
149
+ +#include <map>
150
+ +#include <mutex>
151
+ +#include <string>
152
+ +#include <vector>
153
+ +
154
+ +#include <fcntl.h>
155
+ +#include <sys/mman.h>
156
+ +#include <unistd.h>
157
+ +
158
+ +namespace st {
159
+ +
160
+ +// ------------------------------------------------------------------ 設定
161
+ +
162
+ +static bool g_enabled = false;
163
+ +static bool g_verbose = false;
164
+ +static size_t g_budget = 0; // expert 權重可以常駐 RAM 的上限(bytes)
165
+ +static int g_hot_tokens = 8; // 最近 N 個 tick 用過的 expert 視為熱
166
+ +
167
+ +struct span {
168
+ + uint8_t * addr = nullptr;
169
+ + size_t bytes = 0;
170
+ + size_t file_off = 0;
171
+ +};
172
+ +
173
+ +struct slot {
174
+ + span parts[st::N_PART];
175
+ + uint64_t last_used = 0;
176
+ + uint64_t uses = 0;
177
+ + bool resident = false;
178
+ +};
179
+ +
180
+ +// 一個 expert 的三段位元組數**每層可能不同**(實測:down_exps 有些層是 Q6_K、
181
+ +// 有些是 Q4_K),所以逐 slot 算,不能用一個全模型常數。
182
+ +static inline size_t slot_bytes_of(const slot & sl) {
183
+ + return sl.parts[0].bytes + sl.parts[1].bytes + sl.parts[2].bytes;
184
+ +}
185
+ +
186
+ +static std::vector<slot> g_slots; // [layer * n_expert + expert]
187
+ +static int g_n_layer = 0;
188
+ +static int g_n_expert = 0;
189
+ +static size_t g_slot_bytes = 0; // 三段加總(= 一個 expert 的權重)
190
+ +static size_t g_part_bytes[st::N_PART] = { 0, 0, 0 };
191
+ +static uint8_t * g_map_base = nullptr;
192
+ +static int g_fd = -1;
193
+ +static size_t g_map_size = 0;
194
+ +static uint64_t g_tick = 0;
195
+ +static std::mutex g_mu;
196
+ +static stats g_st;
197
+ +
198
+ +static size_t page_size() {
199
+ + static const size_t ps = (size_t) sysconf(_SC_PAGESIZE);
200
+ + return ps;
201
+ +}
202
+ +
203
+ +static uint64_t env_u64(const char * name, uint64_t dflt) {
204
+ + const char * e = getenv(name);
205
+ + if (!e || !*e) return dflt;
206
+ + char * end = nullptr;
207
+ + const unsigned long long v = strtoull(e, &end, 10);
208
+ + if (end == e) return dflt;
209
+ + return v;
210
+ +}
211
+ +
212
+ +void set_config_from_env() {
213
+ + g_enabled = env_u64("ST_PAGER", 1) != 0;
214
+ + g_verbose = env_u64("ST_VERBOSE", 0) != 0;
215
+ +
216
+ + const uint64_t budget_mb = env_u64("ST_RAM_BUDGET_MB", 0);
217
+ + const uint64_t reserve_mb = env_u64("ST_RESERVE_MB", 0);
218
+ + const uint64_t arena_mb = env_u64("ST_ARENA_MB", 0);
219
+ +
220
+ + uint64_t mb;
221
+ + if (arena_mb > 0) {
222
+ + mb = arena_mb; // llama_server.sh 已經算好
223
+ + } else if (budget_mb > reserve_mb) {
224
+ + mb = budget_mb - reserve_mb;
225
+ + } else {
226
+ + mb = budget_mb;
227
+ + }
228
+ + g_budget = (size_t) mb * 1024 * 1024;
229
+ +
230
+ + g_hot_tokens = (int) env_u64("ST_HOT_TOKENS", 8);
231
+ + if (g_hot_tokens < 1) g_hot_tokens = 1;
232
+ +}
233
+ +
234
+ +bool enabled() { return g_enabled && !g_slots.empty(); }
235
+ +
236
+ +// ------------------------------------------------------------------ 統計
237
+ +
238
+ +stats get_stats() {
239
+ + std::lock_guard<std::mutex> lk(g_mu);
240
+ + stats s = g_st;
241
+ + s.budget_bytes = g_budget;
242
+ + size_t rb = 0;
243
+ + for (const auto & sl : g_slots) {
244
+ + if (sl.resident) rb += slot_bytes_of(sl);
245
+ + }
246
+ + s.resident_bytes = rb;
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,
255
+ + "{\n"
256
+ + " \"routing_calls\": %llu,\n"
257
+ + " \"expert_touches\": %llu,\n"
258
+ + " \"hits\": %llu,\n"
259
+ + " \"misses\": %llu,\n"
260
+ + " \"evictions\": %llu,\n"
261
+ + " \"evicted_bytes\": %llu,\n"
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,
269
+ + (unsigned long long) s.hits,
270
+ + (unsigned long long) s.misses,
271
+ + (unsigned long long) s.evictions,
272
+ + (unsigned long long) s.evicted_bytes,
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) {
283
+ + // 關鍵前提:expert 維度必須是最外層且整段連續,單一 expert 才是
284
+ + // 「一段連續位元組」。若上游改了 layout,這裡直接擋下來,
285
+ + // 而不是算出錯誤的位址把別人的記憶體丟掉。
286
+ + //
287
+ + // 注意:量化張量的 nb[0] 是「一個 block 的位元組數」,不是 1,
288
+ + // 所以 nb[1] != ne[0]*nb[0]。唯一要檢查的是 nb[2] == ne[1]*nb[1]
289
+ + // —— expert 之間沒有任何 padding/stride。
290
+ + if (t->ne[2] <= 1) return false;
291
+ + if (t->nb[2] != (size_t) t->ne[1] * t->nb[1]) return false;
292
+ + if (g_verbose) {
293
+ + fprintf(stderr, "[st] %s: ne=[%lld,%lld,%lld] nb=[%zu,%zu,%zu] 每 expert %zu bytes\n",
294
+ + what, (long long) t->ne[0], (long long) t->ne[1], (long long) t->ne[2],
295
+ + t->nb[0], t->nb[1], t->nb[2], t->nb[2]);
296
+ + }
297
+ + return true;
298
+ +}
299
+ +
300
+ +bool register_model(const char * arch,
301
+ + void * mapping_base, size_t g_map_size_hint, int fd,
302
+ + const std::map<std::string, ggml_tensor *> & by_name) {
303
+ + if (!g_enabled) return false;
304
+ + if (strcmp(arch, "smallthinker") != 0) {
305
+ + if (g_verbose) fprintf(stderr, "[st] arch=%s 不支援 expert 分頁\n", arch);
306
+ + return false;
307
+ + }
308
+ +
309
+ + g_map_base = (uint8_t *) mapping_base;
310
+ + g_map_size = g_map_size_hint;
311
+ + g_fd = fd;
312
+ +
313
+ + auto get = [&](int il, int p) -> ggml_tensor * {
314
+ + char nm[64];
315
+ + snprintf(nm, sizeof(nm), "blk.%d.ffn_%s_exps.weight", il,
316
+ + p == st::GATE ? "gate" : (p == st::UP ? "up" : "down"));
317
+ + auto it = by_name.find(nm);
318
+ + return it == by_name.end() ? nullptr : it->second;
319
+ + };
320
+ +
321
+ + // 層數由第一層之後往後掃到空為止。
322
+ + std::vector<std::array<ggml_tensor *, st::N_PART>> layers;
323
+ + int n_expert = 0;
324
+ + size_t slot_bytes = 0; // 三段加總
325
+ +
326
+ + for (int il = 0; ; il++) {
327
+ + std::array<ggml_tensor *, st::N_PART> row{};
328
+ + bool ok = true;
329
+ + for (int p = 0; p < st::N_PART; p++) {
330
+ + row[p] = get(il, p);
331
+ + if (!row[p]) { ok = false; break; }
332
+ + }
333
+ + if (!ok) break;
334
+ +
335
+ + for (int p = 0; p < st::N_PART; p++) {
336
+ + char what[80];
337
+ + snprintf(what, sizeof(what), "blk.%d part %d", il, p);
338
+ + if (!expert_layout_ok(row[p], what)) {
339
+ + fprintf(stderr, "[st] %s 的 expert 維度不連續,放棄分頁(回到上游行為)\n", what);
340
+ + return false;
341
+ + }
342
+ + const int64_t ne2 = row[p]->ne[2];
343
+ + const size_t nb2 = row[p]->nb[2];
344
+ + if (il == 0) {
345
+ + n_expert = (int) ne2;
346
+ + g_part_bytes[p] = nb2;
347
+ + slot_bytes += nb2;
348
+ + } else if ((int) ne2 != n_expert) {
349
+ + // 只要求 expert **數量**一致。三段的量化型別可以逐層不同
350
+ + // (實測 down_exps 有些層是 Q6_K、有些是 Q4_K),
351
+ + // 所以位元組數逐 slot 存,不拿 layer 0 當基準。
352
+ + fprintf(stderr, "[st] layer %d part %d 的 expert 數量不一致(%lld vs %d)\n",
353
+ + il, p, (long long) ne2, n_expert);
354
+ + return false;
355
+ + }
356
+ + }
357
+ + layers.push_back(row);
358
+ + }
359
+ +
360
+ + if (layers.empty()) return false;
361
+ +
362
+ + g_n_expert = n_expert;
363
+ + g_slot_bytes = slot_bytes;
364
+ + g_n_layer = (int) layers.size();
365
+ + const size_t total_expert_bytes = slot_bytes * (size_t) g_n_expert * (size_t) g_n_layer;
366
+ +
367
+ + g_slots.assign((size_t) g_n_layer * g_n_expert, slot{});
368
+ + for (int il = 0; il < g_n_layer; il++) {
369
+ + for (int e = 0; e < g_n_expert; e++) {
370
+ + auto & sl = g_slots[(size_t) il * g_n_expert + e];
371
+ + for (int p = 0; p < st::N_PART; p++) {
372
+ + const ggml_tensor * t = layers[il][p];
373
+ + uint8_t * a = (uint8_t *) t->data + (size_t) e * t->nb[2];
374
+ + sl.parts[p].addr = a;
375
+ + sl.parts[p].bytes = t->nb[2];
376
+ + sl.parts[p].file_off = (size_t) (a - g_map_base);
377
+ +
378
+ + // 診斷:確認 t->data 真的在 mmap 區間內。
379
+ + // 若不在,madvise 打在匿名記憶體上 = MADV_DONTNEED 會把它歸零
380
+ + // → 輸出直接變成亂碼。這是本專案真的踩到的坑。
381
+ + {
382
+ + const uintptr_t mb = (uintptr_t) g_map_base;
383
+ + const uintptr_t msz = (uintptr_t) g_map_size;
384
+ + const uintptr_t ua = (uintptr_t) a;
385
+ + const int in_map = (ua >= mb && ua < mb + msz) ? 1 : 0;
386
+ + if (!in_map) {
387
+ + // 這是本專案踩過最嚴重的一個坑:
388
+ + // 若 ggml 用 CPU_REPACK 把權重轉成 repack 格式放在**匿名**緩衝區,
389
+ + // t->data 就不在 mmap 內。這時對它 madvise(MADV_DONTNEED)
390
+ + // 不是「讀 SSD」,而是**把那塊記憶體歸零** → 輸出直接變亂碼,
391
+ + // 而且看起來「有在跑」、速度也只慢一點,完全不像出錯。
392
+ + // 所以這裡必須硬性擋下,並且把 buffer 型別印出來。
393
+ + const ggml_tensor * tt = layers[il][p];
394
+ + fprintf(stderr,
395
+ + "[st] 權重不在 mmap 內(layer %d part %d expert %d: %p,buffer=%s)"
396
+ + " → 分頁停用(需要 -DGGML_CPU_REPACK=OFF,否則 madvise 會歸零權重)\n",
397
+ + il, p, e, (void *) a,
398
+ + (tt->buffer ? ggml_backend_buffer_name(tt->buffer) : "(null)"));
399
+ + return false;
400
+ + }
401
+ + }
402
+ + }
403
+ + sl.resident = true; // llama.cpp 剛載入時全部在 RAM
404
+ + }
405
+ + }
406
+ +
407
+ + fprintf(stderr,
408
+ + "[st] expert pager 就緒:%d 層 × %d experts = %d 個,"
409
+ + "每個 %zu bytes(gate %zu + up %zu + down %zu,以 layer 0 為例),"
410
+ + "expert 總量 %.1f MiB,預算 %.1f MiB,hot window %d tick\n",
411
+ + g_n_layer, g_n_expert, g_n_layer * g_n_expert,
412
+ + slot_bytes, g_part_bytes[0], g_part_bytes[1], g_part_bytes[2],
413
+ + (double) total_expert_bytes / (1024.0 * 1024.0),
414
+ + (double) g_budget / (1024.0 * 1024.0), g_hot_tokens);
415
+ + return true;
416
+ +}
417
+ +
418
+ +// ------------------------------------------------------------------ 淘汰
419
+ +
420
+ +// ST_EVICT_MODE 診斷用(實測發現只做其中一步會導致輸出損毀,見 docs):
421
+ +// both(預設)| madvise | fadvise | none
422
+ +static int evict_mode() {
423
+ + static const int m = [] {
424
+ + const char * e = getenv("ST_EVICT_MODE");
425
+ + if (!e || !*e) return 0;
426
+ + if (!strcmp(e, "none")) return 3;
427
+ + if (!strcmp(e, "madvise")) return 1;
428
+ + if (!strcmp(e, "fadvise")) return 2;
429
+ + return 0;
430
+ + }();
431
+ + return m;
432
+ +}
433
+ +
434
+ +static void evict_slot(const slot & sl) {
435
+ + const size_t ps = page_size();
436
+ + const int mode = evict_mode();
437
+ + if (mode == 3) return;
438
+ +
439
+ + for (int p = 0; p < st::N_PART; p++) {
440
+ + const span & s = sl.parts[p];
441
+ + if (!s.addr || !s.bytes) continue;
442
+ +
443
+ + if (mode == 0 || mode == 1) {
444
+ + // (a) 拿掉本行程的 PTE
445
+ + const uintptr_t a = (uintptr_t) s.addr;
446
+ + const uintptr_t a0 = a & ~(uintptr_t) (ps - 1);
447
+ + madvise((void *) a0, s.bytes + (size_t) (a - a0), MADV_DONTNEED);
448
+ + }
449
+ +
450
+ + if ((mode == 0 || mode == 2) && g_fd >= 0) {
451
+ + // (b) 把 page cache 裡的頁真的丟掉
452
+ + const size_t fo0 = s.file_off & ~(ps - 1);
453
+ + posix_fadvise(g_fd, (off_t) fo0,
454
+ + (off_t) (s.bytes + (s.file_off - fo0)), POSIX_FADV_DONTNEED);
455
+ + }
456
+ + }
457
+ +}
458
+ +
459
+ +static void enforce_budget(uint64_t now) {
460
+ + size_t resident = 0;
461
+ + for (const auto & sl : g_slots) {
462
+ + if (sl.resident) resident += slot_bytes_of(sl);
463
+ + }
464
+ + if (resident <= g_budget) return;
465
+ +
466
+ + g_st.sweeps++;
467
+ +
468
+ + // 候選 = 還在 RAM、且「不是這個 tick 用到的」。
469
+ + // last_used == 0 代表「從來沒被用到過」→ 一定是冷的。
470
+ + // 注意不能用 `last_used < now - hot_tokens`:現在 tick 才剛開始時
471
+ + // 那個門檻會是 0,而 last_used 也是 0,於是**永遠沒有候選**,
472
+ + // 第一輪 sweep 必定什麼都不做(這個 bug 真的踩過)。
473
+ + std::vector<size_t> cand;
474
+ + cand.reserve(g_slots.size());
475
+ + const uint64_t hot_floor = now >= (uint64_t) g_hot_tokens ? now - (uint64_t) g_hot_tokens : 0;
476
+ + for (size_t i = 0; i < g_slots.size(); i++) {
477
+ + const auto & sl = g_slots[i];
478
+ + if (!sl.resident) continue;
479
+ + if (sl.last_used == now) continue; // 這輪正在用,不能動
480
+ + if (sl.last_used != 0 && sl.last_used > hot_floor) continue; // hot window 內
481
+ + cand.push_back(i);
482
+ + }
483
+ +
484
+ + // 淘汰順序:先用得少的(LFU)→ 比較久沒用的(LRU)。
485
+ + std::sort(cand.begin(), cand.end(), [&](size_t a, size_t b) {
486
+ + const auto & sa = g_slots[a];
487
+ + const auto & sb = g_slots[b];
488
+ + if (sa.uses != sb.uses) return sa.uses < sb.uses;
489
+ + return sa.last_used < sb.last_used;
490
+ + });
491
+ +
492
+ + std::vector<size_t> to_evict;
493
+ + size_t freed = 0;
494
+ + for (size_t i : cand) {
495
+ + if (resident - freed <= g_budget) break;
496
+ + to_evict.push_back(i);
497
+ + freed += slot_bytes_of(g_slots[i]);
498
+ + }
499
+ + for (size_t i : to_evict) {
500
+ + g_slots[i].resident = false;
501
+ + evict_slot(g_slots[i]);
502
+ + g_st.evictions++;
503
+ + g_st.evicted_bytes += slot_bytes_of(g_slots[i]);
504
+ + }
505
+ +
506
+ + if (g_verbose && !to_evict.empty()) {
507
+ + fprintf(stderr,
508
+ + "[st] sweep #%llu:丟 %zu 個 expert(%.1f MiB)→ resident %.1f MiB / budget %.1f MiB\n",
509
+ + (unsigned long long) g_st.sweeps, to_evict.size(),
510
+ + freed / (1024.0 * 1024.0),
511
+ + (resident - freed) / (1024.0 * 1024.0),
512
+ + g_budget / (1024.0 * 1024.0));
513
+ + }
514
+ +}
515
+ +
516
+ +void install_signal_handler() {
517
+ + static const char * path = getenv("ST_STATS_FILE");
518
+ + if (!path || !*path) return;
519
+ + static char buf[1024];
520
+ + snprintf(buf, sizeof(buf), "%s", path);
521
+ +
522
+ + struct sigaction sa {};
523
+ + sa.sa_handler = [](int) {
524
+ + // signal handler 裡只能呼叫 async-signal-safe 的東西;
525
+ + // dump_stats 用 stdio(有鎖),實務上這裡只會在沒人在同一瞬間
526
+ + // 寫檔時觸發,風險可接受 —— 但不要從這裡再呼叫 exit()。
527
+ + st::dump_stats(buf);
528
+ + };
529
+ + sigemptyset(&sa.sa_mask);
530
+ + sigaction(SIGUSR2, &sa, nullptr);
531
+ +}
532
+ +
533
+ +// ------------------------------------------------------------------ routing op
534
+ +
535
+ +namespace {
536
+ +
537
+ +struct routing_ctx {
538
+ + int layer = -1;
539
+ + int n_expert = 0;
540
+ + int n_expert_used = 0;
541
+ +};
542
+ +
543
+ +// ids 一定要在 op 執行時從 dst->src[0] 拿,不能在建圖時把指標存起來:
544
+ +// llama.cpp 每次 decode 都會重新建圖,graph context 的配置器可能把張量
545
+ +// 搬到不同位址(形狀不同時尤其明顯)。這是本專案踩過的坑。
546
+ +void routing_op(ggml_tensor * dst, int /*ith*/, int /*nth*/, void * userdata) {
547
+ + auto * rc = (routing_ctx *) userdata;
548
+ + if (!dst || dst->op != GGML_OP_CUSTOM) return;
549
+ +
550
+ + ggml_tensor * ids = dst->src[0];
551
+ + if (!ids || !ids->data) return;
552
+ +
553
+ + const int n_used = (int) ids->ne[0];
554
+ + const int n_tok = (int) (ids->ne[1] * ids->ne[2] * ids->ne[3]);
555
+ + const int32_t * p = (const int32_t *) ids->data;
556
+ +
557
+ + std::lock_guard<std::mutex> lk(g_mu);
558
+ + g_st.routing_calls++;
559
+ + const uint64_t now = ++g_tick;
560
+ +
561
+ + // 這一層、這批 token 用到的所有 expert(去重)
562
+ + bool touched[256] = { false };
563
+ + std::vector<int> uniq;
564
+ + for (int t = 0; t < n_tok; t++) {
565
+ + for (int k = 0; k < n_used; k++) {
566
+ + const int e = p[(size_t) k * ids->nb[0] / sizeof(int32_t)
567
+ + + (size_t) t * ids->nb[1] / sizeof(int32_t)];
568
+ + if (e < 0 || e >= rc->n_expert) continue;
569
+ + g_st.expert_touches++;
570
+ + if (!touched[e]) { touched[e] = true; uniq.push_back(e); }
571
+ + }
572
+ + }
573
+ +
574
+ + for (int e : uniq) {
575
+ + auto & sl = g_slots[(size_t) rc->layer * rc->n_expert + e];
576
+ + sl.last_used = now;
577
+ + sl.uses++;
578
+ + if (sl.resident) {
579
+ + g_st.hits++;
580
+ + } else {
581
+ + // 這一段會被 matmul 讀到 → 核心會從 SSD 補回來。
582
+ + // 標記 resident 只是為了讓 hit/miss 統計誠實:
583
+ + // 實際上 miss 之後這一層的 matmul 結束前不會再被丟掉。
584
+ + g_st.misses++;
585
+ + sl.resident = true;
586
+ + }
587
+ + }
588
+ +
589
+ + enforce_budget(now);
590
+ +}
591
+ +
592
+ +} // namespace
593
+ +
594
+ +ggml_tensor * build_routing_op(ggml_context * ctx, ggml_tensor * selected_experts,
595
+ + int layer, int n_expert, int n_expert_used) {
596
+ + if (!enabled()) return nullptr;
597
+ + if (!selected_experts || selected_experts->ne[0] != n_expert_used) return nullptr;
598
+ +
599
+ + auto * rc = new routing_ctx{ layer, n_expert, n_expert_used };
600
+ +
601
+ + // 輸出 1 個元素:我們只要副作用,不要資料。
602
+ + // 不要用 ne0 = 0 —— 有些 ggml 版本對空張量的排程處理會直接跳過這個 node。
603
+ + ggml_tensor * args[1] = { selected_experts };
604
+ + return ggml_custom_4d(ctx, GGML_TYPE_I32, 1, 1, 1, 1,
605
+ + args, 1, routing_op, 1, rc);
606
+ +}
607
+ +
608
+ +} // namespace st
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 這個檔案。
619
+ +//
620
+ +// 設計取捨(為什麼不照抄 sddqwen35a3b_v01 的 pread arena):
621
+ +// sddqwen35a3b 做的是「把 expert 權重 pread 進自己的 arena,用自訂 ggml op
622
+ +// 重寫量化點積」。那在 22 GB 權重 / 小 RAM 機器上是必要的,代價是要自己重寫
623
+ +// Q4_K 的點積 kernel,數值正確性得另外驗證。
624
+ +// SmallThinker 只有 2.45 GiB,權重可以用 file-backed mmap 留在 SSD,
625
+ +// 所以本專案改成「**計算路徑完全不動,只把用不到的 expert 頁丟掉**」:
626
+ +// - 計算仍是 llama.cpp 自己的 kernel → 輸出與上游一致,沒有重寫 kernel 的風險。
627
+ +// - 淘汰的最小單位是「一個 expert 的三段權重」(gate/up/down),不是 page。
628
+ +// 代價:不能像 arena ���樣用 prefetch 提前重疊 I/O,SSD 讀取是 demand-paging
629
+ +// (第一次碰到缺頁才讀)。這是本專案目前的取捨。
630
+ +//
631
+ +// 淘汰的兩步驟(順序不能換,這是實測踩出來的):
632
+ +// a. madvise(MADV_DONTNEED) → 拿掉本行程 PTEs,行程總 RSS 立刻下降。
633
+ +// b. posix_fadvise(DONTNEED) → 核心的 invalidate_mapping_pages() 會跳過
634
+ +// 「仍被某個 VMA 映射」的頁,所以 (a) 必須先做;(b) 做完這些頁才真的從
635
+ +// page cache 消失,下次碰到才是真的讀 SSD。
636
+ +// 只做 (a):RSS 下降但 read_bytes 不變(頁還在 page cache)。
637
+ +// 只做 (b):page cache 下降但行程 RSS 不變(VMA 還映射著)。
638
+ +
639
+ +#pragma once
640
+ +
641
+ +#include <cstddef>
642
+ +#include <cstdint>
643
+ +#include <map>
644
+ +#include <string>
645
+ +
646
+ +struct ggml_context;
647
+ +struct ggml_tensor;
648
+ +
649
+ +namespace st {
650
+ +
651
+ +// 三段權重(對應 llama.cpp 的 smallthinker 命名)
652
+ +enum part { GATE = 0, UP = 1, DOWN = 2, N_PART = 3 };
653
+ +
654
+ +struct stats {
655
+ + uint64_t routing_calls = 0; // 每層每個 ubatch 算一次
656
+ + uint64_t expert_touches = 0; // 「某個 expert 被某個 token 選中」的總次數
657
+ + uint64_t hits = 0; // 選中時它還在 RAM
658
+ + uint64_t misses = 0; // 選中時它已被丟回 SSD → 這次要重讀
659
+ + uint64_t evictions = 0;
660
+ + uint64_t evicted_bytes = 0;
661
+ + uint64_t sweeps = 0;
662
+ + size_t resident_bytes = 0;
663
+ + size_t budget_bytes = 0;
664
+ +};
665
+ +
666
+ +// 讀環境變數。必須在 register_model() 之前呼叫一次。
667
+ +// ST_PAGER=0 完整關閉(回到上游行為,做 A/B 用)
668
+ +// ST_RAM_BUDGET_MB RAM 預算(llama_server.sh 算好)
669
+ +// ST_RESERVE_MB KV + compute 預留,從預算裡扣掉
670
+ +// ST_ARENA_MB 直接指定 expert 可常駐的量(有值就優先用)
671
+ +// ST_HOT_TOKENS=8 最近 N 個 tick 用過的 expert 不淘汰
672
+ +// ST_VERBOSE=1 印淘汰紀錄
673
+ +// ST_STATS_FILE=path 收到 SIGUSR2 時把統計寫成 JSON
674
+ +void set_config_from_env();
675
+ +
676
+ +// 模型載入完之後呼叫。tensors_by_name 是 llama.cpp 的 tensors_by_name。
677
+ +// 回傳 false 表示這個模型沒有被分頁(呼叫端繼續走上游路徑)。
678
+ +bool register_model(const char * arch,
679
+ + void * mapping_base, size_t mapping_size, int fd,
680
+ + const std::map<std::string, ggml_tensor *> & tensors_by_name);
681
+ +
682
+ +// 在每層 top-k 選完 expert 之後插入的副作用 op。
683
+ +// 回傳 nullptr 表示不插入。呼叫端要把回傳值 ggml_build_forward_expand 進圖。
684
+ +ggml_tensor * build_routing_op(ggml_context * ctx, ggml_tensor * selected_experts,
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
+ +
691
+ +// 讓 SIGUSR2 可以觸發 dump(由 llama.cpp 呼叫端註冊)
692
+ +void install_signal_handler();
693
+ +
694
+ +} // namespace st
695
+