sddqwen35a3b / docs /05_optimization.md
HelloSun's picture
S5:I/O 段平行 +48%(3.73→5.52 tok/s);docs/05、tools/io_probe.py、tools/route_overlap.py、io suite;stage.txt 記錄完整實驗與四個負結果
764e333 verified
|
Raw History Blame Contribute Delete
11 kB

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. 單次 1.9 MB 讀取要 ~7 ms,而且開幾條執行緒都不會變快。 這不是延遲問題(延遲只有 0.57 ms),是單流頻寬只有 ~275 MB/s。
  2. 聚合頻寬可以線性爬到 ~2.8 GB/s。→ 「多執行緒」有用,但只有在你有 足夠多個彼此獨立的讀取工作時才有用。
  3. 而 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 內無解的事實:

  1. 命中率幾乎到頂了。 tools/route_overlap.py 的離線模擬顯示 LRU 的命中率 == Belady(完美快取)的命中率,兩者都是 78.9 %, 而且從每層 256 槽到 4096 槽都不變 —— 代表這個工作流的時間局部性很強, 瓶頸不是「容量不夠」而是「這個工作流本身的首次觸發率」。現在實測 ~70 %, 最好的情況(無限 RAM)也只有 78.9 %。這推翻了我一開始的假設 (S4 docs/04 §0 以為「命中率天花板 +15 %」,實際上更接近 +12 % 且要無限 RAM)。
  2. 加大 arena 有效但很貴:1936 → 2039 槽(+5 %)讓命中率 69.25 % → 70.58 %, tok/s 5.37 → 5.85。而 arena 每多 1 槽就是 1.9 MB 的 8 GB 預算。
  3. 要再快只能讓「一個 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