Download docs/05_optimization.md from HelloSun/sddqwen35a3b: direct link, hf CLI and curl.
- Browser
- Download file 11 kB
-
https://huggingface.co/HelloSun/sddqwen35a3b/resolve/main/docs/05_optimization.md
- Command line
-
hf download hf://HelloSun/sddqwen35a3b/docs/05_optimization.md
-
curl -L -o 05_optimization.md https://huggingface.co/HelloSun/sddqwen35a3b/resolve/main/docs/05_optimization.md
05|把 decode 從 3.7 拉到 5.5 tok/s:診斷「關鍵路徑」而不是「哪裡慢」
硬體條件、量測方法與已知警告請先看
docs/03_results.md。 本文件記錄 S5 的全部實驗(含失敗的)。重現用的工具:tools/io_probe.py(量裝置)、tools/route_overlap.py(量路由重疊)、python -m sdq.experiments --suite io(端到端 A/B)。
0. 這一輪的結論(先看這裡)
| decode tok/s | 峰值 RSS | swap | |
|---|---|---|---|
| S4 收尾(mode C,I/O 序列讀) | 3.73 | 7446 MB | 0 MB |
| S5(I/O 段平行) | 5.52 | 7454 MB | 0 MB |
| 差異 | +48.0 % | 持平 | 持平 |
5 次取中位數、兩組除了 SDQ_IO_PARALLEL 之外全部條件相同
(bench/results/exp_s5_io_final.json)。命中率、SSD 位元組數、槽位數都沒有變,
唯一改變的是「一個 expert 的三段權重怎麼讀」。
另外修掉兩個會讓 8 GB 機器直接出事的缺陷(見 §5),比 +48% 更重要。
1. 先量裝置:原來「多開執行緒」這條路是死的
在改任何程式之前,先把儲存裝置的個性量清楚(tools/io_probe.py,
每格 1.9 MB 隨機讀、每組 64 次、每次讀完就 POSIX_FADV_DONTNEED):
| 併發執行緒 | 中位延遲 | 平均 | p90 | 聚合頻寬 |
|---|---|---|---|---|
| 1 | 6.94 ms | 6.69 ms | 7.99 ms | 276 MB/s |
| 2 | 7.27 ms | 6.80 ms | 9.12 ms | 535 MB/s |
| 4 | 7.84 ms | 7.11 ms | 9.06 ms | 988 MB/s |
| 6 | 7.69 ms | 7.42 ms | 9.34 ms | 1462 MB/s |
| 8 | 8.18 ms | 7.80 ms | 9.36 ms | 1812 MB/s |
| 12 | 7.94 ms | 7.68 ms | 9.29 ms | 2465 MB/s |
| 16 | 7.77 ms | 7.71 ms | 9.26 ms | 2841 MB/s |
4K 隨機讀的延遲是 0.57 ms(單執行緒 1457 IOPS,16 執行緒 23591 IOPS)。
三個關鍵推論:
- 單次 1.9 MB 讀取要 ~7 ms,而且開幾條執行緒都不會變快。 這不是延遲問題(延遲只有 0.57 ms),是單流頻寬只有 ~275 MB/s。
- 聚合頻寬可以線性爬到 ~2.8 GB/s。→ 「多執行緒」有用,但只有在你有 足夠多個彼此獨立的讀取工作時才有用。
- 而 decode 的結構是:每層每 token 只有 8 個 expert,8 條執行緒各讀一個。 → 要讀的東西只有 8 份,執行緒再多也沒有更多工作可以攤。
結論:「再加執行緒」這條路已經走到頭。必須改「每個 expert 怎麼讀」。
2. 真正的關鍵路徑:一個 expert 是「3 次循序 pread」
模型檔裡一個 expert 的三段權重在檔案上互不相鄰
(ffn_down_exps / ffn_gate_exps / ffn_up_exps 是三個分開的 tensor):
L0E0 gate = 1180930528 + 589824 B
up = 1335144928 + 589824 B
down = 995267040 + 720896 B
cpp/sdq_pager.cpp 的 read_expert() 原本是 for p in 0..2: pread(...)。
所以讀一個 expert 的時間 = 3 × 7 ms ≈ 21 ms 的延遲鏈,
而且這條鏈的長度與執行緒數無關 —— 這就是 mode C 再怎麼排程都救不了的原因。
把它變成三段同時讀之後(read_expert_par() + I/O 執行緒池):
| 一層 8 個 expert 全部 miss | 中位時間 |
|---|---|
| 8 執行緒 × 三段序列讀 | 12.60 ms |
| 16 執行緒 × 三段平行讀 | 7.57 ms |
| 24 執行緒 × 三段平行讀 | 7.58 ms |
3. 端到端效果(python -m sdq.experiments --suite io,repeat=3)
| 設定 | decode tok/s | 命中率 | SSD MB/token | 峰值 RSS | swap |
|---|---|---|---|---|---|
SDQ_IO_PARALLEL=0(序列讀) |
3.79 | 70.61 % | 202.1 | 7646 MB | 0 MB |
| I/O 執行緒 = 4 | 5.20 | 70.83 % | 201.8 | 7647 MB | 0 MB |
| I/O 執行緒 = 8 | 5.62 | 70.81 % | 201.8 | 7650 MB | 0 MB |
| I/O 執行緒 = 12 | 5.45 | 70.80 % | 201.8 | 7653 MB | 0 MB |
| I/O 執行緒 = 16 | 5.30 | 70.64 % | 201.6 | 7655 MB | 0 MB |
| I/O 執行緒 = 24(預設 3×n_threads) | 6.03 | 70.79 % | 201.9 | 7654 MB | 0 MB |
| I/O 執行緒 = 32 | 5.76 | 70.75 % | 202.2 | 7654 MB | 0 MB |
| I/O 執行緒 = 48 | 5.48 | 70.17 % | 203.8 | 7654 MB | 0 MB |
單次執行的抖動可達 ±20 %,所以上表的絕對值只能看趨勢; 信賴的數字是 §0 那組 5 次取中位數的最終 A/B。 可以確定的三件事:
- 段平行 vs 序列讀:**+40 % ~ +76 %**(兩輪獨立實驗都同方向)。
- I/O 執行緒數在 16~32 之間是平的,24 是安全點(預設 3 × 計算執行緒)。
- 命中率與 SSD 位元組數完全沒變 → 這個最佳化確實只改變「怎麼讀」。
4. 失敗的實驗(負結果)
4.1 把每一段再切成好幾塊並行讀(SDQ_IO_SPLIT)—— 沒用,預設維持 1
想法很自然:單次 590 KB ≈ 0.57 ms 延遲 + 2.1 ms 傳輸,切兩塊應該能省掉 1 ms。 實測(repeat=3,各組中位數):
| split | I/O=24 | I/O=48 |
|---|---|---|
| 1 | 5.57 | 5.75 |
| 2 | 4.89 | 4.98 |
| 3 | 5.42 | 5.38 |
| 4 | 5.66 | 4.91 |
| 6 | 5.01 | 5.86 |
沒有趨勢,全部落在噪音裡。 原因回頭看 §1 就清楚:8 個 expert × 3 段 = 24 個並行請求 已經把裝置灌飽,這時瓶頸是裝置的總頻寬,不是單一請求的延遲 —— 把 590 KB 切成兩塊只是讓同一條執行緒的兩次請求排隊,整體頻寬不變。 (開關保留:換成低佇列深度的裝置可能有用。)
4.2 n-gram 投機解碼 —— 在這個工作流上接受率是 0
多 token 批次確實能省 I/O(§5 的 U(k) 分析:k=4 省 38.5 % 的讀取), 但前提是「同時知道那 k 個 token」。用最省成本的草稿來源 —— n-gram / prompt-lookup (把生成文字裡上一次出現的 n-gram 後綴當草稿)—— 離線模擬結果是:
| n-gram 長度 n | 2 | 3 | 4 | 6 | 8 |
|---|---|---|---|---|---|
| 平均接受長度(k=8) | 0.00 | 0.00 | 0.00 | 0.00 | 0.00 |
模型在解釋性文字上完全不重複 n-gram。而且這個 GGUF 沒有 MTP / nextn 權重 (逐一讀過 733 個 tensor 名稱),所以「用模型自己的多 token 預測層當草稿」也用不了。 → Strata 宣稱的 1.6–1.8× 在這個工作流上無法用零成本草稿重現; 要投機解碼就得另外放一個小草稿模型(例如 0.6B)進來,那是另一個大工程。
4.3 cache-bypass / 只收錄用過 2 次以上的 expert —— 沒有素材
tools/route_overlap.py 量到的重複使用分布:48 個 token 的視窗內,
只有 6.8 % 的請求是「這個 (layer, expert) 只出現一次」。
准入策略(SDQ_ADMIT_MIN_HITS)能保護的東西太少,實測命中率反而從 70 % 掉到 65 %。
4.4 Strata 式熱門 expert 靜態釘選 —— 沿用 S4 的負結果
arena 只有 1936 槽,排行榜前 2048 名只覆蓋 54.5 % 的請求,比動態 LRU 的 ~70 % 還差。
程式碼保留(SDQ_HOT_PIN)。
5. 順手修掉兩個「8 GB 機器會直接出事」的缺陷
這兩個比 +48 % 更有價值,因為它們讓長提示詞根本跑不起來。
5.1 ubatch 預設 512 → 提示詞一長就 std::bad_alloc 崩潰
llama.cpp 的 CPU compute buffer 隨 n_ubatch 線性成長,而它是在 pager 算完
arena 大小之後才配置的,所以不會計進 RAM 扣減。實測
(RLIMIT_DATA 8 GB、ctx 4096、681 token 的中文提示詞):
-b |
compute buffer | 結果 |
|---|---|---|
| 512(原本的預設) | 502 MiB | 崩潰:std::bad_alloc |
| 256 | 251 MiB | 崩潰:std::bad_alloc |
| 128 | 125.5 MiB | 正常,prefill 681 tok / 22.6 s = 30.1 tok/s |
改成預設 128 之後不但修好崩潰,prefill 還比 S4 的 7 tok/s 快了 4 倍 (小 ubatch 让每批的 expert 聯集比較小、命中率比較高 —— 與 docs/04 §4.3 的 「ubatch=128 +7%」一致)。
5.2 RAM 預留 700 MB → 會超出 8 GB 預算
arena 是用「RAM 預算 − 當下 RSS − 預留」算出來的,但 KV cache 與 compute buffer
在 sizing 的當下還沒長出來,所以「預留」是唯一的保險。
實測(-b 128、ctx 4096、681 token 提示詞、RLIMIT_DATA 8 GB):
SDQ_RESERVE_MB |
槽數 | 峰值 RSS | decode |
|---|---|---|---|
| 700 | 2039 | 8231 MB(超出預算 39 MB) | 5.85 tok/s |
| 900(新的預設) | 1936 | 8035 MB(留 157 MB 餘量) | 5.37 tok/s |
| 1150 | 1808 | 7767 MB | 4.96 tok/s |
選 900:寧可少 8 % 速度,也不要在目標機器的 8 GB 上 OOM。 這是本專案的硬約束,優先於 KPI。
6. 現在的瓶頸長什麼樣(下一步的依據)
用 SDQ_PROFILE_TIME=1 看 op 內部的時間分布,decode 有 58 % 的時間花在
barrier 上等別的執行緒。結合 §1 的量測,可以把剩餘的關鍵路徑寫成一句話:
每層的時間 ≈ 「最慢那條執行緒讀完一個 miss 的 expert」的時間 ≈ 三段並行讀的時間 ≈ 2.5~4 ms;× 40 層 ≈ 100~160 ms/token。
三個已經確認、且在 8 GB 內無解的事實:
- 命中率幾乎到頂了。
tools/route_overlap.py的離線模擬顯示 LRU 的命中率 == Belady(完美快取)的命中率,兩者都是 78.9 %, 而且從每層 256 槽到 4096 槽都不變 —— 代表這個工作流的時間局部性很強, 瓶頸不是「容量不夠」而是「這個工作流本身的首次觸發率」。現在實測 ~70 %, 最好的情況(無限 RAM)也只有 78.9 %。這推翻了我一開始的假設 (S4 docs/04 §0 以為「命中率天花板 +15 %」,實際上更接近 +12 % 且要無限 RAM)。 - 加大 arena 有效但很貴:1936 → 2039 槽(+5 %)讓命中率 69.25 % → 70.58 %, tok/s 5.37 → 5.85。而 arena 每多 1 槽就是 1.9 MB 的 8 GB 預算。
- 要再快只能讓「一個 token 不只走 40 層」 —— 也就是多 token 一起算。
§4.2 擋住了零成本草稿;要繼續只有兩條路:
- 放一個小草稿模型做投機解碼(要額外 RAM/磁碟與大量工程)
- 或者接受「單 token decode 的極限大約就在 6~7 tok/s(這台機器)」
7. 重現方式
# 裝置 I/O 特性(決策的依據,每次換機器都該重跑)
python tools/io_probe.py --model /sdq/models/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf
# 記錄 decode 的路由,算 U(k) / 命中率天花板 / 重複使用分布
SDQ_ROUTE_LOG=/tmp/route.log /sdq/llama.cpp/build/bin/sdq-chat \
-m /sdq/models/Qwen3.6-35B-A3B-UD-Q4_K_M.gguf \
-p "請用三句話說明什麼是 SSD、RAM、CPU 之間的資料分層。" -n 48 \
-c 4096 -t 8 --temp 0 --no-chat --no-interactive --ram-budget-mb 8192
python tools/route_overlap.py /tmp/route.log
# 端到端 A/B(I/O 段平行 v.s. 序列讀)
python -m sdq.experiments --model <gguf> --bin <sdq-chat> --suite io --repeat 3
# op 內部時間分布
SDQ_PROFILE_TIME=1 <sdq-chat> -m <gguf> -p "..." -n 48 -t 8 --no-interactive