SmallThinker4b / STATUS.md
Auto Upload Agent
v11: 方向修正(hot expert 實體放 RAM,非 page 轉換)+ b9acf138 vanilla 基線 82.1 tok/s / peak RSS 2.674 GiB
a035243
|
Raw History Blame Contribute Delete
14.6 kB

STATUS — SmallThinker-4B-A0.6B:熱參數在 RAM、冷參數到 SSD

進度真相來源。每次改完程式、編譯過、驗證過就更新本檔並 ./sync.sh。

一句話

Tiiny/SmallThinker-4BA0.6B-Instruct-GGUF 的 SmallThinker-4B-A0.6B-Instruct.Q4_K.gguf (2,630,212,704 bytes = 2508 MiB)跑在 llama.cpp 上,權重永遠是 SSD 上的 file-backed mmap;RAM 只留「熱的」expert 權重 + KV + compute buffer, 用不到的 expert 會被明確丟回 SSD(madvise + posix_fadvise)。

模型事實(全部從 GGUF metadata 實測,不是抄設定檔)

項目 值
arch smallthinker
層數 32
hidden 1536
experts / 每 token 用 32 / 4
expert FFN 768
attention heads / KV heads 12 / 2
key_len / val_len 128 / 128
context_length(模型上限) 32768
量化 Q4_K_M

expert 權重總量 2092.5 MiB / 2508 MiB = 83.4%。 也就是說「熱在 RAM、冷在 SSD」 這件事基本上就是「expert 權重怎麼放」。

每個 expert 是三段連續位元組(blk.N.ffn_{gate,up,down}_exps.weight, expert 維度是最外層):

段 shape 每個 expert
gate [1536, 768, 32] 663,552 B(Q4_K)
up [1536, 768, 32] 663,552 B(Q4_K)
down [768, 1536, 32] 967,680 B(Q6_K)
合計 2,294,784 B = 2.19 MiB

共 32 × 32 = 1024 個 expert,整體平均 2.04 MiB/個(因為 16 層的 down 是 Q4_K)。

逐層實測(tools 之外,用 gguf-py 直接算):Q6_K down 的層 70.03 MiB/層、 Q4_K down 的層 60.75 MiB/層;Q6_K 的層號是 0,1,2,3,6,9,12,15,18,21,24,27,28,29,30,31。

⚠️ 三段的量化型別逐層可能不同(實測有些層的 down_exps 是 Q4_K 而非 Q6_K, nb[2 就不同)。所以「每個 expert 多大」必須逐 slot 算, 拿 layer 0 當全模型常數會算錯。

硬約束

  • 行程總 RSS(含 file-backed mmap 的權重頁)要受 RAM 預算約束。 只看匿名記憶體會嚴重低估 —— 這是本專案第一版的錯。
  • VmSwap = 0。
  • 輸出必須與上游逐字相同。這是分頁機制唯一不能妥協的正確性條件。

目前狀態(2026-10-07)

兩個目標都达成了:validate/ab-test.json → all_passed: true。

設定 peak 總 RSS swap SSD 重讀 命中率 淘汰次數 輸出逐字相同
ST_PAGER=0(上游行為) 2.565 GiB 0 — — — 基準
預算 1024 MiB 1.301 GiB 0 2317 MiB 57.4% 2894 ✅
預算 512 MiB 0.833 GiB 0 2316 MiB 55.1% 4572 ✅
預算 256 MiB 0.596 GiB 0 2320 MiB 53.7% 5625 ✅
預算 128 MiB 0.661 GiB 0 2317 MiB 54.1% 5298 ✅

同一份 llama-cli、同一個 prompt、同一個 seed,四種設定的輸出完全相同:

Hello! 😊 I'm DeepSeek-R1, your friendly AI assistant. I'm here to help with all kinds of questions, learning, and creative tasks

--budget 128 的 RSS 反而比 256 高一點:預算已經逼近「hot window 內必須 保留的量」(ST_HOT_TOKENS=8 × 32 層 × 4 experts/層 ≈ 66 MiB 的下限), 再往下壓沒有意義,反而讓命中率掉下來。

端到端(llama_server.sh --verify,ST_RAM_BUDGET_MB=512)

證據:validate/last-run.json

項目 值
peak 總 RSS 1.090 GiB(非 expert 權重 410 MiB + expert 分頁 510 MiB + KV/compute/sampler)
VmSwap 0
expert 常駐 / 預算 510.3 / 512.0 MiB ✅
命中率 41.2%
read_bytes(整個行程) 2440 MiB
chat 輸出 A **MoE (Model Parallelism) layer** enables a neural network to

設計取捨(為什麼不照抄 sddqwen35a3b_v01 的 pread arena)

sddqwen35a3b_v01 本專案
權重怎麼進 RAM pread 進自己配置的 arena 留在 llama.cpp 自己的 mmap
MoE 計算 自訂 ggml op,自己重寫 Q4_K 點積 不動,用上游 mul_mat_id
輸出正確性 要另外驗證 kernel 寫對沒有 逐字等於上游,結構上不可能錯
SSD 讀取 預取可與計算重疊 demand paging(第一次碰到缺頁才讀)
程式碼量 約 2800 行 約 550 行

取捨很清楚:放棄 I/O 預取,換取「計算路徑完全沒動過」的保證。 模型只有 2.45 GiB、SSD 讀取 300+ MB/s,demand paging 的penalty 可接受。

本機 SSD 讀取能力(實測,validate/io-probe.json)

threads 1 2 4 8
循序讀取 337 MB/s 667 MB/s 1328 MB/s 2558 MB/s

4K 隨機讀 ≈ 1767 IOPS。檔案系統是 overlayfs。 單執行緒 337 MB/s 是「每個 decode step 重讀 4 experts × 32 層」這個負載的基準, SSD 讀取頻寬是本模型 decode 速度的主要上限。

踩過的坑(全部有實測證據,不要重蹈)

坑 真相 怎麼知道的
ml.init_mappings() 的 prefetch 參數寫反 開著分頁時本來要關掉 prefetch(否則 MAP_POPULATE 會在載入那一瞬間把整個 2.45 GiB fault 進 page cache,峰值立刻爆掉),我卻寫成 init_mappings(!paging_active()) → 分頁開著時反而 prefetch=ON。症狀:peak RSS 2.6 GiB,而且後面的 sweep 明明有在跑、帳面 resident 也對 —— 只看分頁統計完全看不出問題 在 load_tensors 三個時點印 VmRSS:載入前 83 MB → load_all_data 前 2652 MB → 載入後 2646 MB。整個 2.5 GiB 是在載入時進去的
will_page() 不能看「權重載入了沒」 init_mappings() 在 load_all_data() 之前,那時 register_model() 還沒跑過,所以 enabled() 一定是 false → 判斷會永遠回「不分頁」→ 回到 MAP_POPULATE 同上症狀(改了之後峰值立刻掉到 0.83 GiB)
開 GGML_CPU_REPACK 時權重不在 mmap 內 ggml 把 Q4_K 轉成 repack 格式放進匿名緩衝區。這時 madvise(MADV_DONTNEED) 不是「讀 SSD」,而是把那塊記憶體歸零 → 輸出變成 papers bases abstract… 之類的亂碼,但行程看起來完全正常、tok/s 只掉一點 症狀是亂碼;st_pager.cpp 現在有 in_map 檢查會擋下並印出 buffer 型別(CPU_REPACK)
fscanf("%63[^:]: %llu") 讀 /proc/self/io 永遠讀不到 read_bytes 沒有開頭空格時 %[^:] 會把上一行的 \n 讀進 key,strcmp 永遠比不到 量到 read_bytes = 0,但 C 測試明明有讀
子行程退出後才讀 /proc/<pid>/io 行程一退出 /proc/<pid> 就消失,外部量測得到 {},看起來像「完全沒 I/O」 io_delta: {}。SSD 讀取量只能由行程自己在 atexit 寫出來
fscanf(" %63[^:]: %lld") 的空格不能省 對比上面兩條:同一個檔案,同一個 key,一個有空格一個沒有,結果差 100% 純 C 測試 /tmp/ev6.c 兩種寫法並排跑
用 peak RSS 判定分頁有沒生效 峰值取決於 prefetch 有沒有關,跟 prefill 長度無關(實測 prompt 2 token 與 211 token 的峰值幾乎一樣) rss_timeline_mib
nb[2] != ne[0]*nb[0] 檢查會誤判量化張量 量化張量 nb[0] 是一個 block 的位元組數,不是 1。只要檢查 nb[2] == ne[1]*nb[1] 「layer 0 不連續」誤報
第一輪 sweep 永遠什麼都不做 條件寫成 last_used < now - hot_tokens,第一輪時門檻是 0 而 last_used 也是 0,0 < 0 為假 sweep 計數有跳、但 resident 不降
拿 layer 0 的 nb[2] 當全模型常數 down_exps 逐層量化型別不同(實測 layer 4 是 Q4_K、layer 0 是 Q6_K) 「layer 4 part 2 形狀不一致」
os.pread() 回傳資料本身,不是讀到的位元組數 寫成 got += n 會得到 TypeError: int + bytes,而執行緒裡的例外不會讓主程式失敗 → io_probe 印出 bytes: 0、0.015 秒、0.0 MB/s,看起來像「SSD 完全沒速度」 bytes: 0 搭配「只花 15 ms」這個不合理組合才察覺
外部取樣 RSS 抓不到 decode 階段 一次 generation 的樣本數太少。改由行程自己在 sweep 裡讀 /proc/self/status 兩邊數字對不起來時才發現

(本專案被「量測方式/語意/時序」教訓共 6 次:① 只看外部取樣的 RSS ② 讀 /proc/self/io 的格式錯 ③ 在行程退出後才讀它 ④ 把 prefetch 語意寫反 ⑤ 分層時把「有打算分頁」和「已註冊分頁」混為一談 ⑥ 在執行緒裡把 pread 的 回傳值當成位元組數,例外被吞掉後看起來像「量到 0」。 共同特徵都是「看起來有在做事,但量錯了地方」。每次都是靠實測抓到的。)

,不要重蹈)

坑 真相 怎麼知道的
開 GGML_CPU_REPACK 時權重不在 mmap 內 ggml 把 Q4_K 轉成 repack 格式放進匿名緩衝區。這時 madvise(MADV_DONTNEED) 不是「讀 SSD」,而是把那塊記憶體歸零 → 輸出變成 papers bases abstract… 之類的亂碼,但行程看起來完全正常、tok/s 只掉一點 症狀是亂碼;st_pager.cpp 現在有 in_map 檢查會直接擋下並印出 buffer 型別
fscanf("%63[^:]: %llu") 讀 /proc/self/io 永遠讀不到 read_bytes 沒有開頭空格時 %[^:] 會把上一行的 \n 讀進 key,strcmp 永遠比不到 量到 read_bytes = 0,但 C 測試明明有讀
子行程退出後才讀 /proc/<pid>/io 行程一退出 /proc/<pid> 就消失,外部量測得到 {},看起來像「完全沒 I/O」 io_delta: {}。SSD 讀取量只能由行程自己在 atexit 寫出來
用 peak RSS 判定分頁有沒生效 peak 由 prefill 決定,而 prefill 的工作集 ≈ 整個模型。要看 decode 階段 rss_timeline_mib
nb[2] != ne[0]*nb[0] 檢查會誤判量化張量 量化張量 nb[0] 是一個 block 的位元組數,不是 1。只要檢查 nb[2] == ne[1]*nb[1] 「layer 0 不連續」誤報
第一輪 sweep 永遠什麼都不做 條件寫成 last_used < now - hot_tokens,第一輪時門檻是 0 而 last_used 也是 0,0 < 0 為假 sweep 計數有跳、但 resident 不降
拿 layer 0 的 nb[2] 當全模型常數 down_exps 逐層量化型別不同 「layer 4 part 2 形狀不一致」

常用指令

# 完整流程(自動抓 llama.cpp / 套 patch / 編譯 / 下載權重 / 啟動 / 驗證)
ST_HOME=~/.cache/smallthinker4b ./llama_server.sh --verify

# 只看推導出來的參數,不動任何東西
./llama_server.sh --plan

# A/B 驗證(baseline vs 各種預算)
python3 tools/ab_test.py --bin <llama-cli> --model <gguf> \
    --budget 1024 --budget 512 --budget 256 --out validate/ab-test.json

# 對照組:關掉分頁
ST_PAGER=0 ./llama_server.sh

# 手動壓低 RAM 預算
ST_RAM_BUDGET_MB=512 ./llama_server.sh

# 上傳(最重要的一步)
./sync.sh "改了什麼"

關鍵環境變數

變數 預設 說明
ST_PAGER 1 0 = 關閉分頁(上游行為,做 A/B 用)
ST_RAM_BUDGET_MB 自動偵測 RAM 總額
ST_RESERVE_MB 0 KV + compute 預留,從預算扣掉
ST_ARENA_MB 0 直接指定 expert 可常駐量(有值優先)
ST_HOT_TOKENS 8 最近 N 個 tick 用過的 expert 不淘汰
ST_VERBOSE 0 印每次淘汰
ST_STATS_FILE — 行程退出時把統計寫成 JSON(SIGUSR2 也會寫)
ST_EVICT_MODE both 診斷用:both/madvise/fadvise/none

檔案地圖

檔案 用途
llama_server.sh 一鍵啟動 + 自動推導參數
patches/0001-st-expert-pager.patch llama.cpp 改動(expert 分頁)
tools/ab_test.py A/B 驗證(RSS / swap / SSD 讀取 / 輸出比對)
tools/verify_run.py 對 llama-server 打一次 chat 並量記憶體
tools/io_probe.py 實測本機 SSD 讀取能力(換機器必跑)
tools/drop_model_cache.py 量測前丟掉模型檔的 page cache
validate/ 驗證證據
sync.sh 推回 HF(崩潰後的唯一保險)

2026-10-07 重開:方向修正(使用者明確要求)

使用者指示:「我要的是 hot expert 放記憶體,不是 page 轉換」。

因此 patches/0001-st-expert-pager.patch(v1–v10 的做法)方向被否決: 它讓權重留在 llama.cpp 自己的 file-backed mmap,用 madvise(MADV_DONTNEED) + posix_fadvise(DONTNEED) 把冷 expert 逐出 page cache。 那確實是「page 轉換」:hot expert 只是恰好還在 page cache 裡,不是被程式放進 RAM。

修正後的設計(本次目標)

參數 住哪
啟動參數(attn / norm / router / token_embd / output,410.19 MiB) 匿名 RAM(use_mmap=false 走 pread→匿名緩衝區,不依賴 page cache)
hot experts RAM arena(程式自己配置、pread 進來的實體 RAM)
cold experts SSD(留在 GGUF 檔案裡,只在需要時 pread 進 arena 槽位)

即:RAM arena + 槽位淘汰 + 自訂 MoE op(執行期才知道哪些 expert 被選到, 所以算權重時必須由 pager 提供 arena 裡的 bytes)。 這是 HelloSun/sddqwen35a3b_v01 的做法,也是本專案現在要採用的做法。

為什麼不能沿用參考專案的 commit

參考專案 sddqwen35a3b_v01 的 base commit 是 836d57176dc699a726c55418e4f96b8ca628e1bf。 實測在該 commit 上 原生路徑(SDQ_PAGER=0)對 smallthinker 就已經是亂碼 (輸出 and and the and all achieving and),pager 路徑同樣是亂碼。 → 該 commit 的 smallthinker 支援有問題,不能用它當 base。

**本專案 base commit 改用 b9acf138a1e28ce1fc23b5a4fc4b12444b50f7ea**(v1–v10 用過、 輸出正確的那個)。

v11 基線(vanilla,尚未套 arena patch)

validate/baseline-vanilla.json

項目 值
輸出 Hello! I'm DeepSeek-R1, your friendly AI assistant. 😊 ... ✅
decode 82.1 tok/s(16 threads,權重全在 page cache)
peak 總 RSS 2.674 GiB(= 整份 2.45 GiB 權重都在 RAM 裡)
swap 0

這是 arena 版本的正確性基準:同一 prompt / temp 0 / seed,輸出必須逐字相同。