Text Generation
llama-cpp-python
GGUF
llama.cpp
Mixture of Experts
ssd-offload
smallthinker
expert-paging
low-ram
Instructions to use HelloSun/SmallThinker4b with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- llama-cpp-python
How to use HelloSun/SmallThinker4b with llama-cpp-python:
# !pip install llama-cpp-python from llama_cpp import Llama llm = Llama.from_pretrained( repo_id="HelloSun/SmallThinker4b", filename="{{GGUF_FILE}}", )output = llm( "Once upon a time,", max_tokens=512, echo=True ) print(output)
- Notebooks
- Google Colab
- Kaggle
c commited on
Commit ·
faea13d
0
Parent(s):
init
Browse files- .gitignore +4 -0
- README.md +27 -0
- llama_server.sh +456 -0
- sync.sh +83 -0
- tools/drop_model_cache.py +51 -0
- tools/io_probe.py +122 -0
- 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())
|