# 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. 重現方式 ```bash # 裝置 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 --bin --suite io --repeat 3 # op 內部時間分布 SDQ_PROFILE_TIME=1 -m -p "..." -n 48 -t 8 --no-interactive ```