c commited on
Commit
faea13d
·
0 Parent(s):
Files changed (7) hide show
  1. .gitignore +4 -0
  2. README.md +27 -0
  3. llama_server.sh +456 -0
  4. sync.sh +83 -0
  5. tools/drop_model_cache.py +51 -0
  6. tools/io_probe.py +122 -0
  7. tools/verify_run.py +200 -0
.gitignore ADDED
@@ -0,0 +1,4 @@
 
 
 
 
 
1
+ .hf_token
2
+ __pycache__/
3
+ *.pyc
4
+ validate/*.tmp
README.md ADDED
@@ -0,0 +1,27 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # SmallThinker-4B-A0.6B — 熱參數在 RAM、冷參數在 SSD
2
+
3
+ 用 llama.cpp 跑 `Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF` 的
4
+ `SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf`(2.45 GiB,MoE:32 層 × 32 experts,
5
+ 每 token 用 4 個)。權重永遠以 file-backed `mmap` 留在 SSD,RAM 只放熱的權重
6
+ (expert arena)+ KV + compute buffer。
7
+
8
+ ```bash
9
+ ./llama_server.sh # port 8080
10
+ ./llama_server.sh --plan # 只印推導出來的參數
11
+ ./llama_server.sh --verify # 啟動 + 打一次 chat + 量記憶體
12
+ ST_RAM_BUDGET_MB=512 ./llama_server.sh # 手動壓低 RAM 預算做實驗
13
+ ```
14
+
15
+ 進度真相來源:[STATUS.md](STATUS.md)。續作指引:[AGENTS.md](AGENTS.md)。
16
+
17
+ ## 檔案
18
+
19
+ | 檔案 | 用途 |
20
+ | --- | --- |
21
+ | `llama_server.sh` | 一鍵啟動(自動抓 llama.cpp / 套 patch / 編譯 / 下載權重) |
22
+ | `patches/` | llama.cpp 改動(expert 分頁) |
23
+ | `tools/verify_run.py` | 打一次 chat request 並量 total RSS / swap / SSD 讀取 |
24
+ | `tools/io_probe.py` | 實測本機 SSD 讀取能力(**換機器必跑**) |
25
+ | `tools/drop_model_cache.py` | 量測前丟掉模型檔的 page cache |
26
+ | `validate/` | 驗證證據(JSON) |
27
+ | `sync.sh` | 推回 HF(崩潰後的唯一保險) |
llama_server.sh ADDED
@@ -0,0 +1,456 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # ==============================================================================
3
+ # SmallThinker-4B-A0.6B-Instruct — 熱參數在 RAM、冷參數在 SSD 的啟動器
4
+ #
5
+ # ./llama_server.sh # port 8080
6
+ # ./llama_server.sh --port 9000 # 換 port
7
+ # ./llama_server.sh --plan # 只印推導出來的參數,不動任何東西
8
+ # ./llama_server.sh --verify # 啟動後打一次 /v1/chat/completions 並量記憶體
9
+ #
10
+ # 這個檔案假設「環境還沒建立」:什麼都沒有時,它會自己抓 llama.cpp、套 patch、
11
+ # 編譯、把權重抓回來,最後啟動 llama-server。已經建立好的話就跳過對應步驟。
12
+ #
13
+ # 三個必須先講清楚的前提(不然這個設計看起來很怪):
14
+ #
15
+ # 1. **模型與量化不變。** 權重永遠是 Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF 的
16
+ # SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf,2630212704 bytes(2.45 GiB),
17
+ # 永遠只做 SSD 上的 file-backed mmap。RAM 少的機器**不會**換一個小一點的 GGUF。
18
+ #
19
+ # 2. **跟著機器變的是「RAM 放得下多少權重」。** RAM 越大 → arena 越大 → 命中率越高
20
+ # → SSD 讀取越少。分頁器自己會算,這裡只需要把 RAM 預算餵對(ST_RAM_BUDGET_MB)。
21
+ #
22
+ # 3. **不打開 swap。** 匿名記憶體超出預算就是 crash,而不是安靜地 swap 掉。
23
+ # ==============================================================================
24
+ set -uo pipefail
25
+
26
+ SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)
27
+
28
+ # ------------------------------------------------------------------ 設定
29
+ # 這些是「這個模型的事實」,不是可以調的參數;要改改這裡,不要在呼叫時傳參數。
30
+ HF_REPO="${ST_HF_REPO:-HelloSun/SmallThinker4b}"
31
+ LLAMA_REPO="${ST_LLAMA_REPO:-https://github.com/ggml-org/llama.cpp.git}"
32
+ # patch 是針對這個 commit 生成的。換 commit 一定要重新產生 patch。
33
+ LLAMA_COMMIT="${ST_LLAMA_COMMIT:-b9acf138a1e28ce1fc23b5a4fc4b12444b50f7ea}"
34
+
35
+ MODEL_REPO="Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF"
36
+ MODEL_FILE="SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf"
37
+ MODEL_SIZE=2630212704 # bytes,驗證下載用
38
+
39
+ MODEL_ARCH="smallthinker"
40
+ N_LAYER=32 # smallthinker.block_count
41
+ N_EMBD=1536 # smallthinker.embedding_length
42
+ N_EXPERT=32 # smallthinker.expert_count
43
+ N_EXPERT_USED=4 # smallthinker.expert_used_count
44
+ EXPERT_FFN=768 # smallthinker.expert_feed_forward_length
45
+ N_HEAD=12 # smallthinker.attention.head_count
46
+ N_HEAD_KV=2 # smallthinker.attention.head_count_kv
47
+ KEY_LEN=128 # smallthinker.attention.key_length
48
+ VAL_LEN=128 # smallthinker.attention.value_length
49
+ MODEL_MAX_CTX=32768 # smallthinker.context_length
50
+
51
+ # 單一 expert 權重 = gate + up + down 三段,元素數 3 * N_EMBD * EXPERT_FFN。
52
+ # Q4_K 每元素實際約 0.5625 B/elem(GGUF metadata 實測),這裡保守用 0.6。
53
+ EXPERT_ELEMS=$(( 3 * N_EMBD * EXPERT_FFN ))
54
+ EXPERT_BYTES=$(( EXPERT_ELEMS * 6 / 10 ))
55
+ EXPERT_TOTAL_MIB=$(( N_LAYER * N_EXPERT * EXPERT_BYTES / 1048576 ))
56
+
57
+ # 工作目錄:原始碼、patch、模型、執行檔都放這裡。
58
+ WORK_DIR="${ST_HOME:-${XDG_CACHE_HOME:-$HOME/.cache}/smallthinker4b}"
59
+ SRC_DIR="$WORK_DIR/llama.cpp"
60
+ BIN_DIR="$WORK_DIR/bin"
61
+ MODEL_DIR="${ST_MODEL_DIR:-$WORK_DIR/models}"
62
+ PATCH_DIR="$SCRIPT_DIR/patches"
63
+ LOG_DIR="$WORK_DIR/logs"
64
+
65
+ # ------------------------------------------------------------------ 參數
66
+ PORT=8080
67
+ PLAN_ONLY=0
68
+ VERIFY=0
69
+ while [ $# -gt 0 ]; do
70
+ case "$1" in
71
+ --port) PORT="${2:?--port 需要一個數字}"; shift ;;
72
+ --plan) PLAN_ONLY=1 ;;
73
+ --verify) VERIFY=1 ;;
74
+ -h|--help) sed -n '2,20p' "$0"; exit 0 ;;
75
+ *) die() { printf '[st4b] 未知參數:%s(只有 --port / --plan / --verify)\n' "$1" >&2; exit 2; }; die "$1" ;;
76
+ esac
77
+ shift
78
+ done
79
+
80
+ say() { printf '[st4b] %s\n' "$*" >&2; }
81
+ step() { printf '\033[1;34m==>\033[0m %s\n' "$*" >&2; }
82
+ die() { printf '[st4b] ERROR: %s\n' "$*" >&2; exit 1; }
83
+
84
+ command -v git >/dev/null || die "需要 git"
85
+ command -v cmake >/dev/null || die "需要 cmake(apt install cmake build-essential)"
86
+ command -v curl >/dev/null || die "需要 curl"
87
+ command -v python3 >/dev/null || die "需要 python3"
88
+
89
+ # ======================================================== 1. 偵測系統資源
90
+ # 放在最前面:後面所有步驟(尤其是 RAM 預算)都要用到。
91
+ MIB=1048576
92
+
93
+ meminfo_kb() {
94
+ awk -v k="$1:" '$1==k {print $2; exit} END{}' /proc/meminfo 2>/dev/null || echo 0
95
+ }
96
+
97
+ cgroup_mem_limit() {
98
+ local v
99
+ if [ -r /sys/fs/cgroup/memory.max ]; then
100
+ v=$(tr -d '[:space:]' </sys/fs/cgroup/memory.max)
101
+ case "$v" in ''|max) ;; *) printf '%s' "$v"; return 0 ;; esac
102
+ fi
103
+ if [ -r /sys/fs/cgroup/memory/memory.limit_in_bytes ]; then
104
+ v=$(tr -d '[:space:]' </sys/fs/cgroup/memory/memory.limit_in_bytes)
105
+ if [ -n "$v" ] && [ "$v" -lt 4611686018427387904 ] 2>/dev/null; then
106
+ printf '%s' "$v"; return 0
107
+ fi
108
+ fi
109
+ return 1
110
+ }
111
+
112
+ cgroup_mem_used() {
113
+ if [ -r /sys/fs/cgroup/memory.current ]; then tr -d '[:space:]' </sys/fs/cgroup/memory.current
114
+ elif [ -r /sys/fs/cgroup/memory/memory.usage_in_bytes ]; then tr -d '[:space:]' </sys/fs/cgroup/memory/memory.usage_in_bytes
115
+ else echo 0
116
+ fi
117
+ }
118
+
119
+ cgroup_cpu_quota() {
120
+ local q p
121
+ if [ -r /sys/fs/cgroup/cpu.max ]; then
122
+ read -r q p < /sys/fs/cgroup/cpu.max
123
+ if [ "$q" != "max" ] && [ -n "${p:-}" ] && [ "$p" -gt 0 ] 2>/dev/null; then
124
+ echo $(( q / p )); return 0
125
+ fi
126
+ return 1
127
+ fi
128
+ return 1
129
+ }
130
+
131
+ affinity_cpus() { nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1; }
132
+
133
+ gpu_mb() {
134
+ command -v nvidia-smi >/dev/null 2>&1 || { echo 0; return; }
135
+ nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \
136
+ | awk '{s+=$1} END{print s+0}'
137
+ }
138
+
139
+ # --- RAM -------------------------------------------------------------------
140
+ # 容器裡 /proc/meminfo 的 MemAvailable 是**宿主**的餘量,直接用會高估,
141
+ # 必須跟 cgroup 上限取較小值。這個坑在 sddqwen35a3b_v01 踩過一次。
142
+ AVAIL_KB=$(meminfo_kb MemAvailable)
143
+ [ "${AVAIL_KB:-0}" -gt 0 ] || AVAIL_KB=$(meminfo_kb MemFree)
144
+ AVAIL_BYTES=$(( ${AVAIL_KB:-0} * 1024 ))
145
+ if LIMIT=$(cgroup_mem_limit); then
146
+ FREE=$(( LIMIT - $(cgroup_mem_used) ))
147
+ [ "$FREE" -lt 0 ] 2>/dev/null && FREE=0
148
+ AVAIL_BYTES=$(( AVAIL_BYTES < FREE ? AVAIL_BYTES : FREE ))
149
+ fi
150
+ [ "$AVAIL_BYTES" -gt 0 ] || die "讀不到可用的 RAM"
151
+ USABLE_MB=$(( AVAIL_BYTES / MIB ))
152
+ CGROUP_MB=$( { cgroup_mem_limit || echo 0; } | awk -v m=$MIB '{printf "%d", $1/m}')
153
+
154
+ # --- CPU -------------------------------------------------------------------
155
+ CPUS=$(affinity_cpus)
156
+ QUOTA=$(cgroup_cpu_quota || true)
157
+ [ -n "${QUOTA:-}" ] && [ "$QUOTA" -lt "$CPUS" ] 2>/dev/null && CPUS="$QUOTA"
158
+ # SmallThinker 每層每 token 只有 n_expert_used=4 個 expert 可以平行,
159
+ # threads 超過 4 之後只是多開執行緒搶同一批 CPU。
160
+ THREADS=$(( CPUS > 8 ? 8 : CPUS ))
161
+ [ "$THREADS" -lt 1 ] && THREADS=1
162
+
163
+ GPU_MB=$(gpu_mb)
164
+
165
+ # --- 推導啟動參數 -----------------------------------------------------------
166
+ # ST_RAM_BUDGET_MB 可以手動壓低做實驗(冷權重真的留在 SSD);沒給就自動偵測。
167
+ CTX=${ST_CTX:-4096}
168
+ UBATCH=${ST_UBATCH:-512}
169
+ # KV:每個 token = N_LAYER * N_HEAD_KV * (KEY_LEN + VAL_LEN) 個元素,f16 = 2 bytes。
170
+ KV_ELEMS=$(( N_LAYER * N_HEAD_KV * (KEY_LEN + VAL_LEN) ))
171
+ KV_ESTIMATE_MB=$(( CTX * KV_ELEMS * 2 / MIB ))
172
+ KV_RESERVE_MB=${ST_KV_RESERVE_MB:-$KV_ESTIMATE_MB}
173
+ [ "$KV_RESERVE_MB" -lt "$KV_ESTIMATE_MB" ] && KV_RESERVE_MB="$KV_ESTIMATE_MB"
174
+ COMPUTE_RESERVE_MB=${ST_COMPUTE_RESERVE_MB:-$(( UBATCH * 2 + 800 ))}
175
+ RESERVE_MB=$(( KV_RESERVE_MB + COMPUTE_RESERVE_MB ))
176
+
177
+ # 非 expert 的權重(embedding / attention / norms / router)必須常駐 RAM,
178
+ # 否則每 token 都要重讀。粗估 = 模型總大小 − expert 總量。
179
+ NON_EXPERT_MIB=$(( $(stat -c %s "$MODEL_DIR/$MODEL_FILE" 2>/dev/null || echo $MODEL_SIZE) / MIB - EXPERT_TOTAL_MIB ))
180
+ [ "$NON_EXPERT_MIB" -lt 0 ] && NON_EXPERT_MIB=0
181
+
182
+ if [ -n "${ST_RAM_BUDGET_MB:-}" ]; then
183
+ RAM_BUDGET_MB=$ST_RAM_BUDGET_MB
184
+ BUDGET_SRC="ST_RAM_BUDGET_MB(手動)"
185
+ else
186
+ # 自動:可用 RAM 扣掉系統保留就是模型預算。
187
+ SYSTEM_RESERVE_MB=${ST_SYSTEM_RESERVE_MB:-2048}
188
+ RAM_BUDGET_MB=$(( USABLE_MB - SYSTEM_RESERVE_MB ))
189
+ [ "$RAM_BUDGET_MB" -gt 0 ] || die "可用 RAM ${USABLE_MB} MiB 不足系統保留 ${SYSTEM_RESERVE_MB} MiB"
190
+ BUDGET_SRC="自動偵測(可用 ${USABLE_MB} − 系統保留 ${SYSTEM_RESERVE_MB})"
191
+ fi
192
+
193
+ # arena = 預算 − 非 expert 權重(常駐) − KV − compute
194
+ ARENA_MB=$(( RAM_BUDGET_MB - NON_EXPERT_MIB - RESERVE_MB ))
195
+ [ "$ARENA_MB" -lt 0 ] && ARENA_MB=0
196
+ EXPERT_SLOTS=$(( ARENA_MB * MIB / EXPERT_BYTES ))
197
+
198
+ say "RAM 可用 ${USABLE_MB} MiB(cgroup 上限 ${CGROUP_MB} MiB)"
199
+ say "CPU ${THREADS} threads(配額 ${QUOTA:-無}/可用核心 $(affinity_cpus))、GPU ${GPU_MB} MiB"
200
+ say "模型結構:${N_LAYER} 層 × ${N_EXPERT} experts(每 token 用 ${N_EXPERT_USED}),單一 expert ≈ $(( EXPERT_BYTES / 1024 )) KiB"
201
+ say "RAM 預算 ${RAM_BUDGET_MB} MiB(來源:${BUDGET_SRC})"
202
+ say "推導:非 expert 權重=${NON_EXPERT_MIB} MiB(常駐) KV=${KV_RESERVE_MB} MiB compute=${COMPUTE_RESERVE_MB} MiB"
203
+ say "可配置 arena=${ARENA_MB} MiB(約 ${EXPERT_SLOTS} 個 expert 槽;expert 總量 ${EXPERT_TOTAL_MIB} MiB)"
204
+
205
+ refresh_memory_budget() {
206
+ # Build/download 可能改變 page-cache/cgroup 用量,啟動前一定要重新讀一次。
207
+ local avail_kb avail_bytes limit free
208
+ avail_kb=$(meminfo_kb MemAvailable)
209
+ [ "${avail_kb:-0}" -gt 0 ] || avail_kb=$(meminfo_kb MemFree)
210
+ avail_bytes=$(( ${avail_kb:-0} * 1024 ))
211
+ if limit=$(cgroup_mem_limit); then
212
+ free=$(( limit - $(cgroup_mem_used) ))
213
+ [ "$free" -lt 0 ] 2>/dev/null && free=0
214
+ avail_bytes=$(( avail_bytes < free ? avail_bytes : free ))
215
+ CGROUP_MB=$(( limit / MIB ))
216
+ fi
217
+ [ "$avail_bytes" -gt 0 ] || die "啟動前讀不到可用 RAM"
218
+ USABLE_MB=$(( avail_bytes / MIB ))
219
+ if [ -z "${ST_RAM_BUDGET_MB:-}" ]; then
220
+ SYSTEM_RESERVE_MB=${ST_SYSTEM_RESERVE_MB:-2048}
221
+ RAM_BUDGET_MB=$(( USABLE_MB - SYSTEM_RESERVE_MB ))
222
+ [ "$RAM_BUDGET_MB" -gt 0 ] || die "啟動前可用 RAM ${USABLE_MB} MiB 不足系統保留 ${SYSTEM_RESERVE_MB} MiB"
223
+ BUDGET_SRC="自動偵測(可用 ${USABLE_MB} − 系統保留 ${SYSTEM_RESERVE_MB})"
224
+ fi
225
+ ARENA_MB=$(( RAM_BUDGET_MB - NON_EXPERT_MIB - RESERVE_MB ))
226
+ [ "$ARENA_MB" -lt 0 ] && ARENA_MB=0
227
+ EXPERT_SLOTS=$(( ARENA_MB * MIB / EXPERT_BYTES ))
228
+ [ "$EXPERT_SLOTS" -lt 0 ] && EXPERT_SLOTS=0
229
+ say "啟動前重新偵測:可用 RAM=${USABLE_MB} MiB,預算=${RAM_BUDGET_MB} MiB,arena=${ARENA_MB} MiB"
230
+ }
231
+
232
+ # ============================================ 2. 取得 patch(自我安裝)
233
+ hf_get() { # hf_get <repo> <path> <dest>
234
+ local url="https://huggingface.co/$1/resolve/main/$2"
235
+ mkdir -p "$(dirname "$3")"
236
+ curl -fsSL -H "Authorization: Bearer ${HF_TOKEN:-}" -o "$3" "$url" \
237
+ || die "抓不到 $2(請確認 $HF_REPO 有這個檔案,或把 patches/ 放在腳本旁邊)"
238
+ }
239
+
240
+ ensure_patches() {
241
+ mkdir -p "$PATCH_DIR"
242
+ if [ -n "$(ls -A "$PATCH_DIR"/*.patch 2>/dev/null)" ]; then
243
+ say "使用本地的 patch:$(cd "$PATCH_DIR" && ls *.patch | tr '\n' ' ')"
244
+ return
245
+ fi
246
+ step "從 $HF_REPO 取得 patch"
247
+ local p
248
+ for p in 0001-st-expert-pager.patch; do
249
+ hf_get "$HF_REPO" "patches/$p" "$PATCH_DIR/$p" && say " patches/$p"
250
+ done
251
+ [ -n "$(ls -A "$PATCH_DIR"/*.patch 2>/dev/null)" ] || say " (這個 repo 目前沒有 patch,用上游行為)"
252
+ }
253
+
254
+ # ================================================== 3. 取得 llama.cpp 原始碼
255
+ ensure_source() {
256
+ if [ -d "$SRC_DIR/.git" ]; then
257
+ local have
258
+ have=$(git -C "$SRC_DIR" rev-parse HEAD 2>/dev/null || echo none)
259
+ if [ "$have" = "$LLAMA_COMMIT" ] && [ -f "$SRC_DIR/.st-patched" -o -f "$SRC_DIR/.st-unpatched" ]; then
260
+ say "llama.cpp 已經在 $LLAMA_COMMIT 且已處理 patch"
261
+ return
262
+ fi
263
+ fi
264
+ step "取得 llama.cpp $LLAMA_COMMIT"
265
+ mkdir -p "$WORK_DIR"
266
+ if [ ! -d "$SRC_DIR/.git" ]; then
267
+ rm -rf "$SRC_DIR"
268
+ git clone -q --filter=blob:none "$LLAMA_REPO" "$SRC_DIR" \
269
+ || die "git clone 失敗(檢查網路)"
270
+ fi
271
+ git -C "$SRC_DIR" fetch -q --depth 1 origin "$LLAMA_COMMIT" 2>/dev/null || true
272
+ git -C "$SRC_DIR" checkout -q --force --detach "$LLAMA_COMMIT" \
273
+ || die "拿不到 commit $LLAMA_COMMIT"
274
+ git -C "$SRC_DIR" reset -q --hard
275
+ rm -f "$SRC_DIR/.st-patched" "$SRC_DIR/.st-unpatched"
276
+ }
277
+
278
+ apply_patches() {
279
+ ensure_patches
280
+ [ -f "$SRC_DIR/.st-patched" ] && { say "patch 已套用"; return; }
281
+ local any=0
282
+ for p in "$PATCH_DIR"/*.patch; do
283
+ [ -e "$p" ] || continue
284
+ any=1
285
+ step "套用 $(basename "$p")"
286
+ # 已經套過就直接跳過(避免重跑時 patch 報錯)
287
+ if git -C "$SRC_DIR" apply --reverse --check "$p" >/dev/null 2>&1; then
288
+ say " 已套用,略過"
289
+ continue
290
+ fi
291
+ git -C "$SRC_DIR" apply --check "$p" 2>/dev/null \
292
+ || die "$(basename "$p") 套不上 llama.cpp $LLAMA_COMMIT(patch 過期了,要重新產生)"
293
+ git -C "$SRC_DIR" apply "$p" || die "$(basename "$p") 套用失敗"
294
+ say " $(basename "$p")"
295
+ done
296
+ if [ "$any" = 1 ]; then touch "$SRC_DIR/.st-patched"; else touch "$SRC_DIR/.st-unpatched"; fi
297
+ }
298
+
299
+ # ============================================================= 4. 編譯
300
+ build_if_needed() {
301
+ local server="$SRC_DIR/build/bin/llama-server"
302
+ if [ -x "$server" ] && { [ -f "$SRC_DIR/.st-patched" ] || [ -f "$SRC_DIR/.st-unpatched" ]; }; then
303
+ if [ -z "$(find "$SRC_DIR/src" -newer "$server" -name '*.cpp' -o -newer "$server" -name '*.h' 2>/dev/null | head -1)" ]; then
304
+ say "已經編譯好:$server"
305
+ return
306
+ fi
307
+ fi
308
+ step "編譯 llama-server(第一次會比較久)"
309
+ local jobs="${ST_BUILD_JOBS:-$(nproc 2>/dev/null || echo 2)}"
310
+ local gen=()
311
+ command -v ninja >/dev/null && gen=(-G Ninja)
312
+ mkdir -p "$LOG_DIR"
313
+ # 不開 -march=native:編譯機與執行機不一定同一台,native 會在別的機器 SIGILL。
314
+ cmake "${gen[@]}" -S "$SRC_DIR" -B "$SRC_DIR/build" \
315
+ -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF \
316
+ -DGGML_CURL=OFF -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF \
317
+ -DLLAMA_BUILD_SERVER=ON -DLLAMA_BUILD_TOOLS=ON \
318
+ >"$LOG_DIR/cmake.log" 2>&1 || die "cmake 失敗,見 $LOG_DIR/cmake.log"
319
+ cmake --build "$SRC_DIR/build" --target llama-server -j "$jobs" \
320
+ >"$LOG_DIR/build.log" 2>&1 || die "編譯失敗,見 $LOG_DIR/build.log"
321
+ [ -x "$server" ] || die "編譯完但找不到 llama-server"
322
+ mkdir -p "$BIN_DIR"
323
+ cp -f "$server" "$BIN_DIR/llama-server"
324
+ say "編譯完成:$BIN_DIR/llama-server"
325
+ }
326
+
327
+ # ====================================================== 5. 下載模型權重
328
+ ensure_model() {
329
+ mkdir -p "$MODEL_DIR"
330
+ local path="$MODEL_DIR/$MODEL_FILE"
331
+ if [ -f "$path" ]; then
332
+ local have
333
+ have=$(stat -c %s "$path")
334
+ if [ "$have" = "$MODEL_SIZE" ]; then
335
+ say "模型已經在硬碟上($(( MODEL_SIZE / MIB )) MiB)"
336
+ printf '%s' "$path"; return
337
+ fi
338
+ say "模型檔大小不對($have,應該是 $MODEL_SIZE),重新下載"
339
+ rm -f "$path"
340
+ fi
341
+ step "下載模型 $MODEL_FILE($(( MODEL_SIZE / MIB )) MiB,可續傳)"
342
+ curl -fL -C - --retry 10 --retry-delay 5 --retry-all-errors \
343
+ -H "Authorization: Bearer ${HF_TOKEN:-}" \
344
+ -o "$path.part" \
345
+ "https://huggingface.co/$MODEL_REPO/resolve/main/$MODEL_FILE" \
346
+ || die "下載失敗,再跑一次會從斷點繼續"
347
+ local got
348
+ got=$(stat -c %s "$path.part")
349
+ [ "$got" = "$MODEL_SIZE" ] || die "大小不符:$got != $MODEL_SIZE(再跑一次續傳)"
350
+ mv -f "$path.part" "$path"
351
+ printf '%s' "$path"
352
+ }
353
+
354
+ # =========================================================== 6. 啟動參數
355
+ build_args() {
356
+ SERVER_ARGS=(
357
+ --model "$MODEL_PATH"
358
+ --host 0.0.0.0
359
+ --port "$PORT"
360
+
361
+ -t "$THREADS" # decode
362
+ -tb "$THREADS" # prefill
363
+ -b "$UBATCH" # n_ubatch
364
+
365
+ -c "$CTX"
366
+ --jinja # 用 GGUF 內建的 chat template
367
+ --cache-reuse 256 # 多輪對話重用前綴
368
+
369
+ # 權重永遠走 file-backed mmap(不用 --mlock),所以 RAM 只放 arena。
370
+ # 分頁器的參數由 ST_* 環境變數帶進去(patch 0001)。
371
+ )
372
+ }
373
+
374
+ plan_report() {
375
+ cat >&2 <<EOF
376
+
377
+ 設定摘要
378
+ ─────────────────────────────────────────────
379
+ 模型 $MODEL_FILE($(( MODEL_SIZE / MIB )) MiB,只在 SSD)
380
+ 量化 Q4_K(不隨 RAM 改變)
381
+ 架構 ${MODEL_ARCH} ${N_LAYER} 層 hidden ${N_EMBD} ${N_EXPERT} experts(用 ${N_EXPERT_USED})
382
+ RAM 可用 ${USABLE_MB} MiB / cgroup 上限 ${CGROUP_MB} MiB
383
+ RAM 預算 ${RAM_BUDGET_MB} MiB(${BUDGET_SRC})
384
+ 非expert權重 ${NON_EXPERT_MIB} MiB(常駐 RAM)
385
+ KV 預留 ${KV_RESERVE_MB} MiB(ctx $CTX,f16;理論值 $KV_ESTIMATE_MB MiB,${KV_ELEMS} 元素/token)
386
+ compute ${COMPUTE_RESERVE_MB} MiB
387
+ arena ${ARENA_MB} MiB → 約 ${EXPERT_SLOTS} 個 expert 槽(expert 總量 ${EXPERT_TOTAL_MIB} MiB)
388
+ threads $THREADS(配額 ${QUOTA:-無}/可用核心 $(affinity_cpus))
389
+ ubatch $UBATCH ctx $CTX(模型上限 $MODEL_MAX_CTX)
390
+ GPU ${GPU_MB} MiB
391
+ 監聽 http://0.0.0.0:$PORT
392
+ 環境 ST_RAM_BUDGET_MB=$RAM_BUDGET_MB ST_RESERVE_MB=$RESERVE_MB \
393
+ ST_KV_RESERVE_MB=$KV_RESERVE_MB ST_THREADS=$THREADS ST_UBATCH=$UBATCH \
394
+ ST_CTX=$CTX ST_IO_THREADS=$(( THREADS * 3 )) ST_PREFETCH=0
395
+ 指令 ${SERVER_ARGS[*]}
396
+ ─────────────────────────────────────────────
397
+ EOF
398
+ }
399
+
400
+ mkdir -p "$LOG_DIR"
401
+
402
+ MODEL_PATH=""
403
+ if [ "$PLAN_ONLY" = 0 ]; then
404
+ ensure_source
405
+ apply_patches
406
+ build_if_needed
407
+ MODEL_PATH=$(ensure_model)
408
+ else
409
+ MODEL_PATH="$MODEL_DIR/$MODEL_FILE"
410
+ [ -x "$SRC_DIR/build/bin/llama-server" ] || say "尚未編譯(--plan 不會替你編)"
411
+ fi
412
+
413
+ [ -n "$MODEL_PATH" ] || MODEL_PATH="$MODEL_DIR/$MODEL_FILE"
414
+ refresh_memory_budget
415
+ build_args
416
+ plan_report
417
+
418
+ if [ "$PLAN_ONLY" = 1 ]; then
419
+ exit 0
420
+ fi
421
+
422
+ [ -f "$MODEL_PATH" ] || die "找不到模型 $MODEL_PATH"
423
+
424
+ SERVER="$SRC_DIR/build/bin/llama-server"
425
+ [ -x "$SERVER" ] || die "找不到 $SERVER"
426
+
427
+ # 啟動前丟掉模型檔的 page cache,否則第一次量到的 SSD 讀取量會偏低。
428
+ if [ -x "$SCRIPT_DIR/tools/drop_model_cache.py" ]; then
429
+ python3 "$SCRIPT_DIR/tools/drop_model_cache.py" "$MODEL_PATH" >/dev/null 2>&1 || true
430
+ fi
431
+
432
+ export ST_RAM_BUDGET_MB="$RAM_BUDGET_MB"
433
+ export ST_RESERVE_MB="$RESERVE_MB"
434
+ export ST_KV_RESERVE_MB="$KV_RESERVE_MB"
435
+ export ST_THREADS="$THREADS"
436
+ export ST_UBATCH="$UBATCH"
437
+ export ST_CTX="$CTX"
438
+ export ST_IO_THREADS=$(( THREADS * 3 ))
439
+ [ "$ST_IO_THREADS" -gt 48 ] && ST_IO_THREADS=48
440
+
441
+ step "啟動 llama-server(port $PORT)"
442
+ if [ "$VERIFY" = 1 ]; then
443
+ "$SERVER" "${SERVER_ARGS[@]}" &
444
+ SRV_PID=$!
445
+ trap 'kill "$SRV_PID" 2>/dev/null' EXIT
446
+ for _ in $(seq 1 180); do
447
+ curl -fsS "http://127.0.0.1:$PORT/health" >/dev/null 2>&1 && break
448
+ sleep 2
449
+ done
450
+ python3 "$SCRIPT_DIR/tools/verify_run.py" --port "$PORT" \
451
+ --pid "$SRV_PID" --model "$MODEL_PATH" --out "$SCRIPT_DIR/validate/last-run.json" \
452
+ --ram-budget-mb "$RAM_BUDGET_MB" --arena-mb "$ARENA_MB" --expert-slots "$EXPERT_SLOTS"
453
+ exit $?
454
+ fi
455
+
456
+ exec "$SERVER" "${SERVER_ARGS[@]}"
sync.sh ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env bash
2
+ # ==============================================================================
3
+ # sync.sh — 把工作成果推回 Hugging Face(agent 崩潰後的唯一保險)
4
+ #
5
+ # ./sync.sh "訊息" # 提交並推到 HelloSun/SmallThinker4b
6
+ # ./sync.sh --status # 只看狀態
7
+ # ./sync.sh --pull # 從遠端拉回最新(崩潰後續作第一步)
8
+ #
9
+ # 原則:**每改完程式並編譯過就上傳,不要累積。**
10
+ # Token: 環境變數 HF_TOKEN,或檔案 .hf_token
11
+ # ==============================================================================
12
+ set -uo pipefail
13
+
14
+ REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
15
+ STORAGE_REPO="${ST_STORAGE_REPO:-HelloSun/SmallThinker4b}"
16
+
17
+ MODE=push
18
+ MSG=""
19
+ for a in "$@"; do
20
+ case "$a" in
21
+ --status) MODE=status ;;
22
+ --pull) MODE=pull ;;
23
+ *) MSG="$a" ;;
24
+ esac
25
+ done
26
+ [[ -z "$MSG" ]] && MSG="sync: $(date -Iseconds)"
27
+
28
+ cd "$REPO_DIR" || { echo "[sync] 沒有 repo 目錄"; exit 1; }
29
+
30
+ # ── token ──
31
+ if [[ -z "${HF_TOKEN:-}" && -f "$REPO_DIR/.hf_token" ]]; then
32
+ HF_TOKEN="$(tr -d ' \n\r' < "$REPO_DIR/.hf_token")"
33
+ fi
34
+ if [[ -z "${HF_TOKEN:-}" ]]; then
35
+ echo "[sync] 找不到 HF_TOKEN"; exit 1
36
+ fi
37
+ export HF_TOKEN
38
+
39
+ git config user.email "${HF_GIT_EMAIL:-agent@huggingface.co}"
40
+ git config user.name "${HF_GIT_NAME:-Auto Upload Agent}"
41
+
42
+ ensure_remote() {
43
+ local name="$1" repo="$2"
44
+ local url="https://oauth2:${HF_TOKEN}@huggingface.co/${repo}"
45
+ if git remote get-url "$name" >/dev/null 2>&1; then
46
+ git remote set-url "$name" "$url"
47
+ else
48
+ git remote add "$name" "$url"
49
+ fi
50
+ }
51
+ ensure_remote origin "$STORAGE_REPO"
52
+
53
+ case "$MODE" in
54
+ status)
55
+ echo "── repo ──"; git remote -v
56
+ echo "── branch ──"; git status -sb | head -30
57
+ echo "── log ──"; git log --oneline -10
58
+ exit 0
59
+ ;;
60
+ pull)
61
+ git fetch origin
62
+ if git rev-parse HEAD >/dev/null 2>&1 && ! git diff --quiet 2>/dev/null; then
63
+ echo "[sync] 有未提交的改動,先 stash"; git stash push -u -m "sync-pull $(date -Iseconds)"
64
+ fi
65
+ git checkout -B main origin/main 2>/dev/null || git reset --hard origin/main
66
+ echo "[sync] 已拉到最新:$(git log --oneline -1)"
67
+ exit 0
68
+ ;;
69
+ esac
70
+
71
+ # ── push ──
72
+ git add -A
73
+ if git diff --cached --quiet; then
74
+ echo "[sync] 沒有改動,不用上傳"
75
+ else
76
+ git commit -q -m "$MSG" || { echo "[sync] commit 失敗"; exit 1; }
77
+ # 第一次推送時遠端還沒有 main,要帶 -u 並允許無關歷史
78
+ git push -u origin main 2>&1 | tail -3 \
79
+ || git push -u --force origin main 2>&1 | tail -3
80
+ fi
81
+
82
+ echo "[sync] 完成:$(git log --oneline -1)"
83
+ echo "[sync] https://huggingface.co/${STORAGE_REPO}"
tools/drop_model_cache.py ADDED
@@ -0,0 +1,51 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """丟掉模型檔在系統 page cache 裡的頁(POSIX_FADV_DONTNEED)。
3
+
4
+ 為什麼需要:量 SSD 讀取量之前一定要做,否則「上一輪留在核心 page cache」
5
+ 會讓數字虛高(看起來像是 SSD 變快了,其實是記憶體)。
6
+
7
+ 用法:
8
+ python3 tools/drop_model_cache.py <model.gguf> [more files...]
9
+ """
10
+ import os
11
+ import sys
12
+
13
+ POSIX_FADV_DONTNEED = 4
14
+ SYNC = 2
15
+
16
+
17
+ def drop(path: str) -> tuple[bool, str]:
18
+ try:
19
+ fd = os.open(path, os.O_RDONLY)
20
+ except OSError as e:
21
+ return False, str(e)
22
+ try:
23
+ size = os.fstat(fd).st_size
24
+ os.posix_fadvise(fd, 0, size, POSIX_FADV_DONTNEED)
25
+ os.fsync(fd)
26
+ return True, f"{size} bytes"
27
+ except (AttributeError, OSError) as e:
28
+ return False, str(e)
29
+ finally:
30
+ os.close(fd)
31
+
32
+
33
+ def main() -> int:
34
+ if len(sys.argv) < 2:
35
+ print(__doc__)
36
+ return 2
37
+ rc = 0
38
+ for p in sys.argv[1:]:
39
+ if not os.path.exists(p):
40
+ print(f"[drop] 找不到 {p}")
41
+ rc = 1
42
+ continue
43
+ ok, info = drop(p)
44
+ print(f"[drop] {'ok ' if ok else 'fail'} {p} {info}")
45
+ if not ok:
46
+ rc = 1
47
+ return rc
48
+
49
+
50
+ if __name__ == "__main__":
51
+ sys.exit(main())
tools/io_probe.py ADDED
@@ -0,0 +1,122 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """實測這台機器的 SSD 讀取能力。
3
+
4
+ **換機器一定要重測這個數字**,tok/s 幾乎完全由它決定(dense/每 token 重讀整個權重)。
5
+ 輸出 JSON 給 validate/io-probe.json。
6
+
7
+ python3 tools/io_probe.py --out validate/io-probe.json --size-mib 1024
8
+ """
9
+ import argparse
10
+ import json
11
+ import os
12
+ import sys
13
+ import time
14
+
15
+ MIB = 1024 * 1024
16
+
17
+
18
+ def drop_cache(fd: int, size: int) -> None:
19
+ os.posix_fadvise(fd, 0, size, 4) # POSIX_FADV_DONTNEED
20
+ os.fsync(fd)
21
+
22
+
23
+ def read_seq(path: str, size: int, threads: int) -> dict:
24
+ """threads 條執行緒各自讀不同區段,量「有效頻寬」。"""
25
+ import threading
26
+ per = size // threads
27
+ results = [0] * threads
28
+ fds = [os.open(path, os.O_RDONLY) for _ in range(threads)]
29
+ for fd in fds:
30
+ drop_cache(fd, size)
31
+
32
+ def work(i: int):
33
+ fd = fds[i]
34
+ off = i * per
35
+ left = per
36
+ chunk = 4 * MIB
37
+ got = 0
38
+ while left > 0:
39
+ n = os.pread(fd, min(chunk, left), off + got)
40
+ if not n:
41
+ break
42
+ got += n
43
+ left -= n
44
+ results[i] = got
45
+
46
+ ts = [threading.Thread(target=work, args=(i,)) for i in range(threads)]
47
+ t0 = time.time()
48
+ for t in ts:
49
+ t.start()
50
+ for t in ts:
51
+ t.join()
52
+ dt = time.time() - t0
53
+ for fd in fds:
54
+ os.close(fd)
55
+ total = sum(results)
56
+ return {"threads": threads, "bytes": total, "seconds": round(dt, 3),
57
+ "mb_per_s": round(total / MIB / dt, 1) if dt > 0 else None}
58
+
59
+
60
+ def read_rand4k(path: str, count: int) -> dict:
61
+ fd = os.open(path, os.O_RDONLY)
62
+ size = os.fstat(fd).st_size
63
+ drop_cache(fd, size)
64
+ step = max(size // (count + 1), 4096)
65
+ buf = 4096
66
+ t0 = time.time()
67
+ for i in range(1, count + 1):
68
+ os.pread(fd, buf, i * step)
69
+ dt = time.time() - t0
70
+ os.close(fd)
71
+ return {"count": count, "seconds": round(dt, 3), "iops": round(count / dt, 1) if dt else None}
72
+
73
+
74
+ def main() -> int:
75
+ ap = argparse.ArgumentParser()
76
+ ap.add_argument("--path", default=None, help="測試檔;沒給就用模型檔")
77
+ ap.add_argument("--model", default=None)
78
+ ap.add_argument("--out", required=True)
79
+ ap.add_argument("--size-mib", type=int, default=1024)
80
+ ap.add_argument("--ramp", type=int, default=512, help="隨機讀的次數")
81
+ a = ap.parse_args()
82
+
83
+ path = a.path
84
+ tmp = None
85
+ if not path:
86
+ m = a.model or os.environ.get("ST_MODEL_PATH")
87
+ if not m:
88
+ print("需要 --path 或 --model(或 ST_MODEL_PATH)", file=sys.stderr)
89
+ return 2
90
+ path = m
91
+ if not os.path.exists(path):
92
+ tmp = f"/tmp/io-probe-{os.getpid()}.bin"
93
+ size = a.size_mib * MIB
94
+ print(f"[io_probe] 造 {a.size_mib} MiB 測試檔 {tmp}", file=sys.stderr)
95
+ with open(tmp, "wb") as f:
96
+ f.write(os.urandom(size))
97
+ path = tmp
98
+
99
+ size = min(a.size_mib * MIB, os.path.getsize(path))
100
+ seq = [read_seq(path, size, t) for t in (1, 2, 4, 8) if size // t >= 8 * MIB]
101
+ res = {
102
+ "when": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
103
+ "file": path,
104
+ "size_mib": size // MIB,
105
+ "fs": os.statvfs(path).f_bsize,
106
+ "sequential": seq,
107
+ "random_4k": read_rand4k(path, a.ramp),
108
+ "cpu_count": os.cpu_count(),
109
+ }
110
+ if tmp:
111
+ res["tmp_removed"] = tmp
112
+ os.unlink(tmp)
113
+ os.makedirs(os.path.dirname(os.path.abspath(a.out)) or ".", exist_ok=True)
114
+ with open(a.out, "w") as f:
115
+ json.dump(res, f, indent=2)
116
+ f.write("\n")
117
+ print(json.dumps(res, indent=2))
118
+ return 0
119
+
120
+
121
+ if __name__ == "__main__":
122
+ sys.exit(main())
tools/verify_run.py ADDED
@@ -0,0 +1,200 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """對已經在跑的 llama-server 打一次 chat request,同時量記憶體。
3
+
4
+ 量到的东西(每 0.2s 取樣一次,取峰值):
5
+ - total RSS /proc/<pid>/status 的 VmRSS(**包含 file-backed mmap 的權重頁**)
6
+ - anon RSS smaps_rollup 的 Anonymous,扣掉 page cache 後的「真的在用 RAM」
7
+ - file RSS smaps_rollup 的 Private_Dirty + Private_Clean(權重對映)
8
+ - swap /proc/<pid>/status 的 VmSwap(必須是 0)
9
+ - io /proc/<pid>/io 的 read_bytes(真正落到 block device 的量)
10
+
11
+ 為什麼只看 total RSS:GGUF 是 mmap(MAP_SHARED) 對映的,權重頁會算進 RSS
12
+ 而且真的佔用系統 RAM。只看匿名記憶體會嚴重低估。這個坑本專案第一版就踩過。
13
+
14
+ 用法:
15
+ python3 tools/verify_run.py --port 8080 --pid 12345 --model path.gguf --out out.json
16
+ """
17
+ import argparse
18
+ import json
19
+ import os
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+ import time
24
+ import urllib.error
25
+ import urllib.request
26
+
27
+ MIB = 1024 * 1024
28
+
29
+
30
+ def read_status(pid: int) -> dict:
31
+ out = {}
32
+ try:
33
+ with open(f"/proc/{pid}/status") as f:
34
+ for line in f:
35
+ k, _, v = line.partition(":")
36
+ if k in ("VmRSS", "VmSwap", "VmHWM"):
37
+ out[k] = int(v.split()[0]) * 1024
38
+ except (OSError, ValueError, IndexError):
39
+ pass
40
+ return out
41
+
42
+
43
+ def read_smaps(pid: int) -> dict:
44
+ out = {"Anonymous": 0, "Private_Dirty": 0, "Private_Clean": 0, "Shared_Clean": 0}
45
+ try:
46
+ with open(f"/proc/{pid}/smaps_rollup") as f:
47
+ for line in f:
48
+ k, _, v = line.partition(":")
49
+ if k in out:
50
+ out[k] = int(v.split()[0]) * 1024
51
+ except (OSError, ValueError, IndexError):
52
+ pass
53
+ return out
54
+
55
+
56
+ def read_io(pid: int) -> dict:
57
+ out = {}
58
+ try:
59
+ with open(f"/proc/{pid}/io") as f:
60
+ for line in f:
61
+ k, _, v = line.partition(":")
62
+ out[k] = int(v.strip())
63
+ except (OSError, ValueError):
64
+ pass
65
+ return out
66
+
67
+
68
+ class Sampler(threading.Thread):
69
+ def __init__(self, pid: int, interval: float = 0.2):
70
+ super().__init__(daemon=True)
71
+ self.pid = pid
72
+ self.interval = interval
73
+ self.stop_flag = threading.Event()
74
+ self.peak = {"total_rss": 0, "anon_rss": 0, "file_rss": 0, "swap": 0, "hwm_rss": 0}
75
+ self.io0 = None
76
+ self.io1 = None
77
+
78
+ def run(self):
79
+ self.io0 = read_io(self.pid)
80
+ while not self.stop_flag.is_set():
81
+ st = read_status(self.pid)
82
+ sm = read_smaps(self.pid)
83
+ anon = sm["Anonymous"]
84
+ filed = sm["Private_Dirty"] + sm["Private_Clean"]
85
+ self.peak["total_rss"] = max(self.peak["total_rss"], st.get("VmRSS", 0))
86
+ self.peak["anon_rss"] = max(self.peak["anon_rss"], anon)
87
+ self.peak["file_rss"] = max(self.peak["file_rss"], filed)
88
+ self.peak["swap"] = max(self.peak["swap"], st.get("VmSwap", 0))
89
+ self.peak["hwm_rss"] = max(self.peak["hwm_rss"], st.get("VmHWM", 0))
90
+ self.stop_flag.wait(self.interval)
91
+ self.io1 = read_io(self.pid)
92
+
93
+
94
+ def wait_health(port: int, timeout: float) -> bool:
95
+ end = time.time() + timeout
96
+ while time.time() < end:
97
+ try:
98
+ with urllib.request.urlopen(f"http://127.0.0.1:{port}/health", timeout=5) as r:
99
+ if r.status == 200:
100
+ return True
101
+ except (urllib.error.URLError, OSError):
102
+ time.sleep(2)
103
+ return False
104
+
105
+
106
+ def chat(port: int, prompt: str, max_tokens: int, timeout: float) -> dict:
107
+ body = json.dumps({
108
+ "messages": [{"role": "user", "content": prompt}],
109
+ "max_tokens": max_tokens,
110
+ "temperature": 0.0,
111
+ }).encode()
112
+ req = urllib.request.Request(
113
+ f"http://127.0.0.1:{port}/v1/chat/completions",
114
+ data=body, headers={"Content-Type": "application/json"}, method="POST")
115
+ t0 = time.time()
116
+ with urllib.request.urlopen(req, timeout=timeout) as r:
117
+ data = json.loads(r.read())
118
+ dt = time.time() - t0
119
+ usage = data.get("usage", {})
120
+ n = usage.get("completion_tokens", 0)
121
+ return {
122
+ "prompt_tokens": usage.get("prompt_tokens"),
123
+ "completion_tokens": n,
124
+ "seconds": round(dt, 2),
125
+ "tok_per_s": round(n / dt, 4) if dt > 0 and n else None,
126
+ "content": data["choices"][0]["message"]["content"],
127
+ "timings": data.get("timings"),
128
+ }
129
+
130
+
131
+ def main() -> int:
132
+ ap = argparse.ArgumentParser()
133
+ ap.add_argument("--port", type=int, required=True)
134
+ ap.add_argument("--pid", type=int, required=True)
135
+ ap.add_argument("--model", required=True)
136
+ ap.add_argument("--out", required=True)
137
+ ap.add_argument("--prompt", default="Explain in one sentence what a MoE layer does.")
138
+ ap.add_argument("--max-tokens", type=int, default=16)
139
+ ap.add_argument("--timeout", type=float, default=1800)
140
+ ap.add_argument("--ram-budget-mb", type=int, default=0)
141
+ ap.add_argument("--arena-mb", type=int, default=0)
142
+ ap.add_argument("--expert-slots", type=int, default=0)
143
+ a = ap.parse_args()
144
+
145
+ result = {
146
+ "when": time.strftime("%Y-%m-%dT%H:%M:%S%z"),
147
+ "model": a.model,
148
+ "port": a.port,
149
+ "pid": a.pid,
150
+ "ram_budget_mb": a.ram_budget_mb,
151
+ "arena_mb": a.arena_mb,
152
+ "expert_slots": a.expert_slots,
153
+ }
154
+
155
+ if not wait_health(a.port, 300):
156
+ result["error"] = "server 沒有在 300 秒內 ready"
157
+ _write(a.out, result)
158
+ return 1
159
+
160
+ s = Sampler(a.pid)
161
+ s.start()
162
+ try:
163
+ result["chat"] = chat(a.port, a.prompt, a.max_tokens, a.timeout)
164
+ except (urllib.error.URLError, OSError, ValueError, KeyError) as e:
165
+ result["error"] = f"chat failed: {e}"
166
+ time.sleep(2) # 讓峰值取樣涵蓋到收尾
167
+ s.stop_flag.set()
168
+ s.join(timeout=10)
169
+
170
+ io_delta = {}
171
+ if s.io0 and s.io1:
172
+ for k in ("read_bytes", "rchar", "write_bytes"):
173
+ if k in s.io0 and k in s.io1:
174
+ io_delta[k] = s.io1[k] - s.io0[k]
175
+
176
+ result["peak"] = {
177
+ "total_rss_gb": round(s.peak["total_rss"] / 1024 ** 3, 4),
178
+ "anon_rss_gb": round(s.peak["anon_rss"] / 1024 ** 3, 4),
179
+ "file_rss_gb": round(s.peak["file_rss"] / 1024 ** 3, 4),
180
+ "peak_swap_gb": round(s.peak["swap"] / 1024 ** 3, 4),
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
187
+ _write(a.out, result)
188
+ print(json.dumps(result, indent=2, ensure_ascii=False))
189
+ return 0 if ok else 1
190
+
191
+
192
+ def _write(path: str, obj: dict) -> None:
193
+ os.makedirs(os.path.dirname(os.path.abspath(path)) or ".", exist_ok=True)
194
+ with open(path, "w") as f:
195
+ json.dump(obj, f, indent=2, ensure_ascii=False)
196
+ f.write("\n")
197
+
198
+
199
+ if __name__ == "__main__":
200
+ sys.exit(main())