#!/usr/bin/env bash # ============================================================================== # SmallThinker-4B-A0.6B-Instruct — 熱參數在 RAM、冷參數在 SSD 的啟動器 # # ./llama_server.sh # port 8080 # ./llama_server.sh --port 9000 # 換 port # ./llama_server.sh --plan # 只印推導出來的參數,不動任何東西 # ./llama_server.sh --verify # 啟動後打一次 /v1/chat/completions 並量記憶體 # # 這個檔案假設「環境還沒建立」:什麼都沒有時,它會自己抓 llama.cpp、套 patch、 # 編譯、把權重抓回來,最後啟動 llama-server。已經建立好的話就跳過對應步驟。 # # 三個必須先講清楚的前提(不然這個設計看起來很怪): # # 1. **模型與量化不變。** 權重永遠是 Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF 的 # SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf,2630212704 bytes(2.45 GiB), # 永遠只做 SSD 上的 file-backed mmap。RAM 少的機器**不會**換一個小一點的 GGUF。 # # 2. **跟著機器變的是「RAM 放得下多少權重」。** RAM 越大 → arena 越大 → 命中率越高 # → SSD 讀取越少。分頁器自己會算,這裡只需要把 RAM 預算餵對(ST_RAM_BUDGET_MB)。 # # 3. **不打開 swap。** 匿名記憶體超出預算就是 crash,而不是安靜地 swap 掉。 # ============================================================================== set -uo pipefail SCRIPT_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd) # ------------------------------------------------------------------ 設定 # 這些是「這個模型的事實」,不是可以調的參數;要改改這裡,不要在呼叫時傳參數。 HF_REPO="${ST_HF_REPO:-HelloSun/SmallThinker4b}" LLAMA_REPO="${ST_LLAMA_REPO:-https://github.com/ggml-org/llama.cpp.git}" # patch 是針對這個 commit 生成的。換 commit 一定要重新產生 patch。 LLAMA_COMMIT="${ST_LLAMA_COMMIT:-b9acf138a1e28ce1fc23b5a4fc4b12444b50f7ea}" MODEL_REPO="Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF" MODEL_FILE="SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf" MODEL_SIZE=2630212704 # bytes,驗證下載用 MODEL_ARCH="smallthinker" N_LAYER=32 # smallthinker.block_count N_EMBD=1536 # smallthinker.embedding_length N_EXPERT=32 # smallthinker.expert_count N_EXPERT_USED=4 # smallthinker.expert_used_count EXPERT_FFN=768 # smallthinker.expert_feed_forward_length N_HEAD=12 # smallthinker.attention.head_count N_HEAD_KV=2 # smallthinker.attention.head_count_kv KEY_LEN=128 # smallthinker.attention.key_length VAL_LEN=128 # smallthinker.attention.value_length MODEL_MAX_CTX=32768 # smallthinker.context_length # 單一 expert 權重 = gate + up + down 三段,元素數 3 * N_EMBD * EXPERT_FFN。 # Q4_K 每元素實際約 0.5625 B/elem(GGUF metadata 實測),這裡保守用 0.6。 EXPERT_ELEMS=$(( 3 * N_EMBD * EXPERT_FFN )) EXPERT_BYTES=$(( EXPERT_ELEMS * 6 / 10 )) EXPERT_TOTAL_MIB=$(( N_LAYER * N_EXPERT * EXPERT_BYTES / 1048576 )) # 工作目錄:原始碼、patch、模型、執行檔都放這裡。 WORK_DIR="${ST_HOME:-${XDG_CACHE_HOME:-$HOME/.cache}/smallthinker4b}" SRC_DIR="$WORK_DIR/llama.cpp" BIN_DIR="$WORK_DIR/bin" MODEL_DIR="${ST_MODEL_DIR:-$WORK_DIR/models}" PATCH_DIR="$SCRIPT_DIR/patches" LOG_DIR="$WORK_DIR/logs" # ------------------------------------------------------------------ 參數 PORT=8080 PLAN_ONLY=0 VERIFY=0 while [ $# -gt 0 ]; do case "$1" in --port) PORT="${2:?--port 需要一個數字}"; shift ;; --plan) PLAN_ONLY=1 ;; --verify) VERIFY=1 ;; -h|--help) sed -n '2,20p' "$0"; exit 0 ;; *) die() { printf '[st4b] 未知參數:%s(只有 --port / --plan / --verify)\n' "$1" >&2; exit 2; }; die "$1" ;; esac shift done say() { printf '[st4b] %s\n' "$*" >&2; } step() { printf '\033[1;34m==>\033[0m %s\n' "$*" >&2; } die() { printf '[st4b] ERROR: %s\n' "$*" >&2; exit 1; } command -v git >/dev/null || die "需要 git" command -v cmake >/dev/null || die "需要 cmake(apt install cmake build-essential)" command -v curl >/dev/null || die "需要 curl" command -v python3 >/dev/null || die "需要 python3" # ======================================================== 1. 偵測系統資源 # 放在最前面:後面所有步驟(尤其是 RAM 預算)都要用到。 MIB=1048576 meminfo_kb() { awk -v k="$1:" '$1==k {print $2; exit} END{}' /proc/meminfo 2>/dev/null || echo 0 } cgroup_mem_limit() { local v if [ -r /sys/fs/cgroup/memory.max ]; then v=$(tr -d '[:space:]' /dev/null; then printf '%s' "$v"; return 0 fi fi return 1 } cgroup_mem_used() { if [ -r /sys/fs/cgroup/memory.current ]; then tr -d '[:space:]' /dev/null; then echo $(( q / p )); return 0 fi return 1 fi return 1 } affinity_cpus() { nproc 2>/dev/null || getconf _NPROCESSORS_ONLN 2>/dev/null || echo 1; } gpu_mb() { command -v nvidia-smi >/dev/null 2>&1 || { echo 0; return; } nvidia-smi --query-gpu=memory.total --format=csv,noheader,nounits 2>/dev/null \ | awk '{s+=$1} END{print s+0}' } # --- RAM ------------------------------------------------------------------- # 容器裡 /proc/meminfo 的 MemAvailable 是**宿主**的餘量,直接用會高估, # 必須跟 cgroup 上限取較小值。這個坑在 sddqwen35a3b_v01 踩過一次。 AVAIL_KB=$(meminfo_kb MemAvailable) [ "${AVAIL_KB:-0}" -gt 0 ] || AVAIL_KB=$(meminfo_kb MemFree) AVAIL_BYTES=$(( ${AVAIL_KB:-0} * 1024 )) if LIMIT=$(cgroup_mem_limit); then FREE=$(( LIMIT - $(cgroup_mem_used) )) [ "$FREE" -lt 0 ] 2>/dev/null && FREE=0 AVAIL_BYTES=$(( AVAIL_BYTES < FREE ? AVAIL_BYTES : FREE )) fi [ "$AVAIL_BYTES" -gt 0 ] || die "讀不到可用的 RAM" USABLE_MB=$(( AVAIL_BYTES / MIB )) CGROUP_MB=$( { cgroup_mem_limit || echo 0; } | awk -v m=$MIB '{printf "%d", $1/m}') # --- CPU ------------------------------------------------------------------- CPUS=$(affinity_cpus) QUOTA=$(cgroup_cpu_quota || true) [ -n "${QUOTA:-}" ] && [ "$QUOTA" -lt "$CPUS" ] 2>/dev/null && CPUS="$QUOTA" # SmallThinker 每層每 token 只有 n_expert_used=4 個 expert 可以平行, # threads 超過 4 之後只是多開執行緒搶同一批 CPU。 THREADS=$(( CPUS > 8 ? 8 : CPUS )) [ "$THREADS" -lt 1 ] && THREADS=1 GPU_MB=$(gpu_mb) # --- 推導啟動參數 ----------------------------------------------------------- # ST_RAM_BUDGET_MB 可以手動壓低做實驗(冷權重真的留在 SSD);沒給就自動偵測。 CTX=${ST_CTX:-4096} UBATCH=${ST_UBATCH:-512} # KV:每個 token = N_LAYER * N_HEAD_KV * (KEY_LEN + VAL_LEN) 個元素,f16 = 2 bytes。 KV_ELEMS=$(( N_LAYER * N_HEAD_KV * (KEY_LEN + VAL_LEN) )) KV_ESTIMATE_MB=$(( CTX * KV_ELEMS * 2 / MIB )) KV_RESERVE_MB=${ST_KV_RESERVE_MB:-$KV_ESTIMATE_MB} [ "$KV_RESERVE_MB" -lt "$KV_ESTIMATE_MB" ] && KV_RESERVE_MB="$KV_ESTIMATE_MB" COMPUTE_RESERVE_MB=${ST_COMPUTE_RESERVE_MB:-$(( UBATCH * 2 + 800 ))} RESERVE_MB=$(( KV_RESERVE_MB + COMPUTE_RESERVE_MB )) # 非 expert 的權重(embedding / attention / norms / router)必須常駐 RAM, # 否則每 token 都要重讀。粗估 = 模型總大小 − expert 總量。 NON_EXPERT_MIB=$(( $(stat -c %s "$MODEL_DIR/$MODEL_FILE" 2>/dev/null || echo $MODEL_SIZE) / MIB - EXPERT_TOTAL_MIB )) [ "$NON_EXPERT_MIB" -lt 0 ] && NON_EXPERT_MIB=0 if [ -n "${ST_RAM_BUDGET_MB:-}" ]; then RAM_BUDGET_MB=$ST_RAM_BUDGET_MB BUDGET_SRC="ST_RAM_BUDGET_MB(手動)" else # 自動:可用 RAM 扣掉系統保留就是模型預算。 SYSTEM_RESERVE_MB=${ST_SYSTEM_RESERVE_MB:-2048} RAM_BUDGET_MB=$(( USABLE_MB - SYSTEM_RESERVE_MB )) [ "$RAM_BUDGET_MB" -gt 0 ] || die "可用 RAM ${USABLE_MB} MiB 不足系統保留 ${SYSTEM_RESERVE_MB} MiB" BUDGET_SRC="自動偵測(可用 ${USABLE_MB} − 系統保留 ${SYSTEM_RESERVE_MB})" fi # arena = 預算 − 非 expert 權重(常駐) − KV − compute ARENA_MB=$(( RAM_BUDGET_MB - NON_EXPERT_MIB - RESERVE_MB )) PAGER_BUDGET_NOTE="" if [ "$ARENA_MB" -gt 0 ]; then PAGER_BUDGET_MB="$ARENA_MB" else # 預算已經不足以同時負擔「非 expert 權重 + KV + compute」→ 讓分頁器 # 用整個預算當 expert 額度。這不是繞過,而是因為此時 KV/compute # 根本不可能用到那麼多,算式本身失去意義。 PAGER_BUDGET_MB="$RAM_BUDGET_MB" PAGER_BUDGET_NOTE="(預算不足以扣掉常駐權重與 KV/compute,故用整個預算)" fi EXPERT_SLOTS=$(( PAGER_BUDGET_MB * MIB / EXPERT_BYTES )) say "RAM 可用 ${USABLE_MB} MiB(cgroup 上限 ${CGROUP_MB} MiB)" say "CPU ${THREADS} threads(配額 ${QUOTA:-無}/可用核心 $(affinity_cpus))、GPU ${GPU_MB} MiB" say "模型結構:${N_LAYER} 層 × ${N_EXPERT} experts(每 token 用 ${N_EXPERT_USED}),單一 expert ≈ $(( EXPERT_BYTES / 1024 )) KiB" say "RAM 預算 ${RAM_BUDGET_MB} MiB(來源:${BUDGET_SRC})" say "推導:非 expert 權重=${NON_EXPERT_MIB} MiB(常駐) KV=${KV_RESERVE_MB} MiB compute=${COMPUTE_RESERVE_MB} MiB" say "可配置 arena=${ARENA_MB} MiB → 實際分頁預算 ${PAGER_BUDGET_MB} MiB(約 ${EXPERT_SLOTS} 個 expert 槽;expert 總量 ${EXPERT_TOTAL_MIB} MiB)" refresh_memory_budget() { # Build/download 可能改變 page-cache/cgroup 用量,啟動前一定要重新讀一次。 local avail_kb avail_bytes limit free avail_kb=$(meminfo_kb MemAvailable) [ "${avail_kb:-0}" -gt 0 ] || avail_kb=$(meminfo_kb MemFree) avail_bytes=$(( ${avail_kb:-0} * 1024 )) if limit=$(cgroup_mem_limit); then free=$(( limit - $(cgroup_mem_used) )) [ "$free" -lt 0 ] 2>/dev/null && free=0 avail_bytes=$(( avail_bytes < free ? avail_bytes : free )) CGROUP_MB=$(( limit / MIB )) fi [ "$avail_bytes" -gt 0 ] || die "啟動前讀不到可用 RAM" USABLE_MB=$(( avail_bytes / MIB )) if [ -z "${ST_RAM_BUDGET_MB:-}" ]; then SYSTEM_RESERVE_MB=${ST_SYSTEM_RESERVE_MB:-2048} RAM_BUDGET_MB=$(( USABLE_MB - SYSTEM_RESERVE_MB )) [ "$RAM_BUDGET_MB" -gt 0 ] || die "啟動前可用 RAM ${USABLE_MB} MiB 不足系統保留 ${SYSTEM_RESERVE_MB} MiB" BUDGET_SRC="自動偵測(可用 ${USABLE_MB} − 系統保留 ${SYSTEM_RESERVE_MB})" fi ARENA_MB=$(( RAM_BUDGET_MB - NON_EXPERT_MIB - RESERVE_MB )) if [ "$ARENA_MB" -gt 0 ]; then PAGER_BUDGET_MB="$ARENA_MB" else PAGER_BUDGET_MB="$RAM_BUDGET_MB" fi EXPERT_SLOTS=$(( PAGER_BUDGET_MB * MIB / EXPERT_BYTES )) [ "$EXPERT_SLOTS" -lt 0 ] && EXPERT_SLOTS=0 say "啟動前重新偵測:可用 RAM=${USABLE_MB} MiB,預算=${RAM_BUDGET_MB} MiB,arena=${ARENA_MB} MiB" } # ============================================ 2. 取得 patch(自我安裝) hf_get() { # hf_get local url="https://huggingface.co/$1/resolve/main/$2" mkdir -p "$(dirname "$3")" curl -fsSL -H "Authorization: Bearer ${HF_TOKEN:-}" -o "$3" "$url" \ || die "抓不到 $2(請確認 $HF_REPO 有這個檔案,或把 patches/ 放在腳本旁邊)" } ensure_patches() { mkdir -p "$PATCH_DIR" if [ -n "$(ls -A "$PATCH_DIR"/*.patch 2>/dev/null)" ]; then say "使用本地的 patch:$(cd "$PATCH_DIR" && ls *.patch | tr '\n' ' ')" return fi # 遠端還沒有 patch(第一版就是這樣)是正常狀況,不是錯誤: # 沒有 patch 時走上游行為,權重一樣是 SSD 上的 file-backed mmap。 step "從 $HF_REPO 取得 patch" local p got=0 for p in 0001-st-expert-pager.patch; do if curl -fsSL -H "Authorization: Bearer ${HF_TOKEN:-}" -o "$PATCH_DIR/$p.part" \ "https://huggingface.co/$HF_REPO/resolve/main/patches/$p" 2>/dev/null; then mv -f "$PATCH_DIR/$p.part" "$PATCH_DIR/$p"; say " patches/$p"; got=1 else rm -f "$PATCH_DIR/$p.part"; say " (遠端沒有 $p)" fi done [ "$got" = 1 ] || say " 用上游行為:權重走 SSD file-backed mmap,沒有 expert 級分頁" } # ================================================== 3. 取得 llama.cpp 原始碼 ensure_source() { if [ -d "$SRC_DIR/.git" ]; then local have have=$(git -C "$SRC_DIR" rev-parse HEAD 2>/dev/null || echo none) if [ "$have" = "$LLAMA_COMMIT" ] && [ -f "$SRC_DIR/.st-patched" -o -f "$SRC_DIR/.st-unpatched" ]; then say "llama.cpp 已經在 $LLAMA_COMMIT 且已處理 patch" return fi fi step "取得 llama.cpp $LLAMA_COMMIT" mkdir -p "$WORK_DIR" if [ ! -d "$SRC_DIR/.git" ]; then rm -rf "$SRC_DIR" git clone -q --filter=blob:none "$LLAMA_REPO" "$SRC_DIR" \ || die "git clone 失敗(檢查網路)" fi git -C "$SRC_DIR" fetch -q --depth 1 origin "$LLAMA_COMMIT" 2>/dev/null || true git -C "$SRC_DIR" checkout -q --force --detach "$LLAMA_COMMIT" \ || die "拿不到 commit $LLAMA_COMMIT" git -C "$SRC_DIR" reset -q --hard rm -f "$SRC_DIR/.st-patched" "$SRC_DIR/.st-unpatched" } apply_patches() { ensure_patches [ -f "$SRC_DIR/.st-patched" ] && { say "patch 已套用"; return; } local any=0 for p in "$PATCH_DIR"/*.patch; do [ -e "$p" ] || continue any=1 step "套用 $(basename "$p")" # 已經套過就直接跳過(避免重跑時 patch 報錯) if git -C "$SRC_DIR" apply --reverse --check "$p" >/dev/null 2>&1; then say " 已套用,略過" continue fi git -C "$SRC_DIR" apply --check "$p" 2>/dev/null \ || die "$(basename "$p") 套不上 llama.cpp $LLAMA_COMMIT(patch 過期了,要重新產生)" git -C "$SRC_DIR" apply "$p" || die "$(basename "$p") 套用失敗" say " $(basename "$p")" done if [ "$any" = 1 ]; then touch "$SRC_DIR/.st-patched"; else touch "$SRC_DIR/.st-unpatched"; fi } # ============================================================= 4. 編譯 build_if_needed() { local server="$SRC_DIR/build/bin/llama-server" if [ -x "$server" ] && { [ -f "$SRC_DIR/.st-patched" ] || [ -f "$SRC_DIR/.st-unpatched" ]; }; then if [ -z "$(find "$SRC_DIR/src" -newer "$server" -name '*.cpp' -o -newer "$server" -name '*.h' 2>/dev/null | head -1)" ]; then say "已經編譯好:$server" return fi fi step "編譯 llama-server(第一次會比較久)" local jobs="${ST_BUILD_JOBS:-$(nproc 2>/dev/null || echo 2)}" local gen=() command -v ninja >/dev/null && gen=(-G Ninja) mkdir -p "$LOG_DIR" # 不開 -march=native:編譯機與執行機不一定同一台,native 會在別的機器 SIGILL。 # -DGGML_CPU_REPACK=OFF 是硬性要求,不能拿掉: # 開著時 ggml 會把 Q4_K 權重轉成 repack 格式放進**匿名**緩衝區, # 權重就整個離開 mmap。這時對它 madvise(MADV_DONTNEED) 不是「讀 SSD」, # 而是**把那塊記憶體歸零** → 輸出變亂碼,但行程看起來完全正常。 # (這個坑真的踩過,症狀是「輸出變成 'papers bases abstract' 之類的亂碼」, # 而且 tok/s 只掉一點點 —— 非常不像出錯。st_pager.cpp 也有 in_map 檢查擋著。) cmake "${gen[@]}" -S "$SRC_DIR" -B "$SRC_DIR/build" \ -DCMAKE_BUILD_TYPE=Release -DGGML_NATIVE=OFF -DGGML_CPU_REPACK=OFF \ -DGGML_CURL=OFF -DLLAMA_BUILD_TESTS=OFF -DLLAMA_BUILD_EXAMPLES=OFF \ -DLLAMA_BUILD_SERVER=ON -DLLAMA_BUILD_TOOLS=ON \ >"$LOG_DIR/cmake.log" 2>&1 || die "cmake 失敗,見 $LOG_DIR/cmake.log" cmake --build "$SRC_DIR/build" --target llama-server -j "$jobs" \ >"$LOG_DIR/build.log" 2>&1 || die "編譯失敗,見 $LOG_DIR/build.log" [ -x "$server" ] || die "編譯完但找不到 llama-server" mkdir -p "$BIN_DIR" cp -f "$server" "$BIN_DIR/llama-server" say "編譯完成:$BIN_DIR/llama-server" } # ====================================================== 5. 下載模型權重 ensure_model() { mkdir -p "$MODEL_DIR" local path="$MODEL_DIR/$MODEL_FILE" if [ -f "$path" ]; then local have have=$(stat -c %s "$path") if [ "$have" = "$MODEL_SIZE" ]; then say "模型已經在硬碟上($(( MODEL_SIZE / MIB )) MiB)" printf '%s' "$path"; return fi say "模型檔大小不對($have,應該是 $MODEL_SIZE),重新下載" rm -f "$path" fi step "下載模型 $MODEL_FILE($(( MODEL_SIZE / MIB )) MiB,可續傳)" curl -fL -C - --retry 10 --retry-delay 5 --retry-all-errors \ -H "Authorization: Bearer ${HF_TOKEN:-}" \ -o "$path.part" \ "https://huggingface.co/$MODEL_REPO/resolve/main/$MODEL_FILE" \ || die "下載失敗,再跑一次會從斷點繼續" local got got=$(stat -c %s "$path.part") [ "$got" = "$MODEL_SIZE" ] || die "大小不符:$got != $MODEL_SIZE(再跑一次續傳)" mv -f "$path.part" "$path" printf '%s' "$path" } # =========================================================== 6. 啟動參數 build_args() { SERVER_ARGS=( --model "$MODEL_PATH" --host 0.0.0.0 --port "$PORT" -t "$THREADS" # decode -tb "$THREADS" # prefill -b "$UBATCH" # n_ubatch -c "$CTX" --jinja # 用 GGUF 內建的 chat template --cache-reuse 256 # 多輪對話重用前綴 # 權重永遠走 file-backed mmap(不用 --mlock),所以 RAM 只放 arena。 # 分頁器的參數由 ST_* 環境變數帶進去(patch 0001)。 ) } plan_report() { cat >&2 <