Agent Telemetry

V100 推論叢集 · DGX 訓練研究
快照2026-10-01_2354UTC+8 · 每 3 小時更新
現況 · 2026-08-17
線現在的數字狀態
生產 · Qwen3.8-27B IQ4_XS · TP4
100.78.29.29:8601 · fable-27b · 設計上限八個併發使用者 · context 131,072(改了 kernel 才拿到,模型宣告 262,144)· prefill 預算 131072 @ util 0.85
單人 68.1 · 八人合計 260(每人 32.6,同時產出時)
混合負載、錯開到達:合計 155
文件問答:八人各 21.6K → 55 秒 · 八人各 64K → 5 分 47 秒
八人同時互動的文件上限抓 20–25K
上線中 · 工具閘門 8/8
一小時 soak 過:0 失敗 · 記憶體零增長
換掉 Codex 部署的 Q4_K_Mc4 合計 86.7 → 107.8(+24%) · c8 133 → 154(+16%)零行 code · 換檔而已
IQ4_XS 自寫 kernel(batched-MMVQ + tile-MMQ)兩顆都在 4/4 worker arm上線中 · 一小時 soak 過
推測解碼(MTP)最佳 39.96,仍是基準的 0.58 倍;四併發 0.32 倍已收 · 預設關
Qwen3.6-27B(前一顆生產)單人 68.88 · c4 合計 108.8留著,未刪

Qwen3.8 和 Qwen3.6 是同一個架構(qwen35、65 blocks、48 個帶 GDN、866 個張量),所以 3.6 上調出來的設定全部直接適用。唯一真正的槓桿是量化檔:Q4_K_M 讓自寫的批次 kernel 掛不上去,換成同一個 repo 的 IQ4_XS 就拿到併發 +24%。而 max-num-seqs、capture 集合、util 三個旋鈕四種組合完全沒有作用 —— c4 合計全都落在 86.5–86.9。

推測解碼這條線的上限已經算得出來:c 修到 llama.cpp 的 0.29、α 維持實測的 0.52 → S = 1.18 → 81 tok/s,比基準好 18%,而且只在單人。目前不建議投入。

本頁分成七個大類,每類裡最新的在最上面,每段標了產出日期。打「約」的日期是回填的,可能差一天。總表裡標 GDN 開 的列是 8/6 那次變更之前量的 —— 當時是對的,但不代表現在的生產。被推翻的結論一律保留並標明,不刪 —— 看得到我錯在哪比看不到有用。

最新 · 2026-08-20

上線上下文從 66,560 提到 81,920,+23% 文件長度,吞吐零代價。三趟獨立量測:c1 68.18 / 67.74 / 67.94、c8 合計 154.7 / 154.6 / 154.3、全員串流 260.39 —— 和 66,560 的 68.1 / 155 / 260.2 完全一樣。兩顆 kernel 4/4、工具閘門 8/8、真實 81,624 token 文件 144 秒跑完、答案連貫。deploy38.sh 已改,備份 .bak_66560。
上線上下文 81,920 → 131,072,+60%,吞吐零代價。擋住的是 kernel:SM70 解碼分割只白名單 (256, 512, 1024),而分割是從 context 長度推導的,131,072 推出 2048 → Unsupported decode partition_size: 2048。在 LAUNCH_BY_PARTITION 加一個 case 2048 重建 flash_attn_v100_cuda...so 就過了。三組同 session 對照 c1 68.24 / 67.78 / 68.00、全員串流 260.14 / 260.02 / 259.35、c8 合計 154.67 / 153.73 / 153.64 —— 全在 ±1% 噪音內。我原本擔心的 occupancy 代價沒出現:那三個 shared array 是 D*4 + PARTITION_SIZE*12 bytes,2048 仍在 48KB 內。重啟後生產實測 c1 68.19、c8 合計 152.92、工具閘門 8/8、KV 2,747,830 token。deploy38.sh 備份 .bak_81920_pre131k、原版 kernel 留在 .so.bak_pre2048。
128K 卡的不是 kernel,是一個逾時128K 文件一直回 500,我一度以為是 kernel 斷言。實際是 IPC 逾時。traceback 在 shm_broadcast.py 的 TimeoutError → EngineDeadError。源頭 envs.py:514 的 VLLM_EXECUTE_MODEL_TIMEOUT_SECONDS 預設 300 秒,而 128K 的 prefill 要 500.2 秒。調到 2400 直接過。
上線 · prefill 預算 131072已進 deploy38.sh 並在跑著的伺服器上驗過。128K 文件 133.1s / 974 tok/s(原 500.2s / 259),64K 54.8s / 1,183 不變,答案內容正常。c1 68.37、c8 合計 153.62、兩顆 kernel 4/4、KV 2,719,744、GPU 28GB/卡、40–46°C。回滾 deploy38.sh.bak_65536_util060。同時補了兩個工程修正:就緒檢查改成等自己 log 的 GPU KV cache size(原本 poll /health,殘留伺服器會立刻回答),以及 teardown 按 PID 清場 —— 「假設機器是乾淨的」今天害我撞了三次。
我把 prefill 的超線性診斷錯了那不是注意力的 O(n²),是分塊懲罰。我早上報的是 64,824 tok 54.9s(1,182 tok/s)→ 96,024 tok 233.3s(412)→ 129,625 tok 500.2s(259),約 O(n^2.4)。真正的原因是 --max-num-batched-tokens 釘在 65536:64K 剛好一塊裝得下,96K 和 128K 要切兩塊。把預算提到 131072(util 0.60→0.85 才有記憶體)之後:64K 56.6s(1,146)、96K 64.6s(1,487)、128K 132.5s(978)—— 128K 快了 3.8 倍,而本來就沒被切的 64K 完全沒變。拿掉分塊之後 prefill 大致是線性的,三個長度都落在 1,000–1,500 tok/s。一般流量零代價:c1 68.46、c8 合計 154.26。KV 2,719,744,仍高過八人各 131K 所需的 1,048,576。
分塊調小是嚴格劣化,不是取捨我以為分塊粒度可以拿來換互動延遲,兩邊都輸。同一輪掃描:互動 TTFT 211.7 / 207.5 / 101.3 秒、四份 64K 文件 wall 236.1 / 231.8 / 125.6 秒 (8192 / 16384 / 65536)。8192 和 16384 幾乎相同 —— 代價是「有沒有被切」,不是「切多細」。
priority 排程也救不了,而且有害最後一個沒試過的旋鈕,試完可以關掉了。引擎有 --scheduling-policy priority,而且 /v1/responses 帶得了每請求的 priority(/v1/chat/completions 沒這個欄位)。同一台伺服器兩輪對照:四份 64K 進行中,互動請求 priority −1 花 296.7s、priority 0 花 258.5s —— 而兩輪的「文件完成時間減請求耗時」都是 20.0 秒,正好等於我探針的發送延遲。不管什麼優先權,請求都在文件做完的那一刻才完成,和 fcfs 一樣。短提示流量沒受影響(c8 153.35 對 fcfs 153.45),但同樣四份 64K 文件 316.7s 對 fcfs 的 124.1s(各量一次)。這個 fork 沒有任何排程手段能讓互動請求繞過進行中的 prefill —— 分塊粒度和優先權都試過,都死,分塊還是嚴格劣化。唯一有效的是讓 prefill 本身更快,而那條已經拿到 3.7 倍。
自己造成的兩次失敗兩次都不是被測物的問題,而且錯誤訊息都不提我在測什麼。① 清場的等待迴圈放在 kill -9 之前,驅動還沒把 VRAM 還回來就啟動 → Free memory on device cuda:3 (17.32/31.73 GiB) on startup is less than desired。差點被我讀成「priority 不支援」。② 我在腳本正執行 deploy38.sh 時去改它 —— bash 是邊讀邊執行的,byte offset 一移位還原就死在半路,留下四個孤兒 TP worker 各扣 14 GiB,生產停機 27 分鐘。執行中的 shell 腳本絕對不能改,要改就複製成新檔名。
互動請求會被餓死到最後一刻三個 arm 的「文件 wall 減 TTFT」都是 24.3 秒。探針是文件開跑 20 秒後才發的,所以那 24.3 = 20 + 4。意思是排程器完全沒有交錯:互動請求要等所有 prefill 做完,一個 token 都插不進去。對到小數點後一位,不是巧合。所以 TTFT ≈ 還沒做完的 prefill 總時間,降它唯一的路是讓 prefill 本身更快 —— 上面那條路正是。
batch 預算真正在交易的是 KV 容量這是我開始掃之前沒想到的。KV cache:5,495,661(8192)、5,107,126(16384)、2,747,830(65536)、起不來(131072 @ util 0.60,No available memory for the cache blocks)。activation buffer 隨預算等比長,util 0.85 才買得回空間。
更早的 120 條 —— 含已撤回的結論,保留備查
一個假旋鈕VLLM_FLASH_V100_DECODE_PARTITION_SIZE=1024 是無效的。deploy38.sh 一直釘著它,所以我先問「釘 1024 是不是就不用改 kernel」。釘 1024 的 131,072 跑出來跟 2048 逐項一樣(128K 文件 499.7s 對 500.2s、c1 67.97 對 68.46、c8 154.26 對 154.27),數字分不出是「pin 生效」還是「pin 被忽略」。唯一能分的是換 .so:原版 kernel 在同樣釘 1024 下照樣報 Unsupported decode partition_size: 2048。vLLM 從 context 推導的值壓過環境變數,那行設定完全沒作用。
順手修掉一個假綠燈deploy38.sh 的就緒檢查是 poll /health,而殘留的舊伺服器會立刻回答。結果它 1 秒就判定就緒,再去 grep 剛啟動的 log,報 IQ4XS_BMMVQ_READY on 0 workers <-- NOT ARMED —— 假陰性。改成等它自己 log 裡的 GPU KV cache size,第一次上場就正確擋下 DEPLOY FAILED。
單人 68 不是天花板 —— 六成的時間沒在讀記憶體我整天說「batch-1 已經吃滿頻寬」,錯了。掃 TP 度數就能拆開,不用 profiler 也不用 sudo —— 只改 --tensor-parallel-size,每卡要讀的 bytes 差 4 倍:TP4 68.16(14.671 ms/步,3.66 GB/卡)、TP2 49.66(20.137 ms,7.31 GB)、TP1 32.85(30.441 ms,14.62 GB)。擬合 t = F + bytes/BW,三組配對的斜率 1.410/1.439/1.498 ms/GB、截距 9.19/9.41/9.83 ms。只用 TP4+TP2 擬合去預測 TP1 是 32.2,實測 32.85 —— 差 2%,而 TP1 沒有參與擬合。
拆出來的兩個數字有效頻寬約 695 GB/s = V100 峰值 900 的 77%;固定開銷 F ≈ 9.4 ms/步,佔 TP4 單步的 64%。F 不隨 TP 改變,這直接排除跨卡通訊 —— TP1 完全沒有 all-reduce 卻付一樣的 F。
剖析:1,456 個 kernel/步,而 GEMV 全都是健康的nvprof(不用 sudo,只有 6% 拖累:30.75 對原生 32.85)。歸屬可信 —— GDN kernel 每步觸發 48.2 次,而模型正好有 48 個 GDN block。TP1 的 32.5 ms/步:mul_mat_vec_iq4xs_batched 438 次 20.6 ms(約 700 GB/s)、Q5_K 65 次 2.85 ms(628)、Q6_K 輸出投影 1 次 1.57 ms(669)。三族 GEMV 都是頻寬有效率的,沒東西可撿 —— Q5_K 不是慢路徑,只是張量大(attn_qkv ×48 是 [5120,10240],output.weight 是 12.7 億參數)。
我把最大那一項算錯了,對照組抓出來「FillFunctor<int> 每步 241 次、2.1 ms」是假的。我拿 200-token 那次的總呼叫數除以 208 步,但沒驗證「均勻分佈在每一步」這個假設 —— 我只對 GDN kernel 驗過(48.2 對 48 個 block)。改跑 50 token(58 步)對照:每步比值應該是 0.279。GEMV 0.289、quantize 0.292、triton 0.256、GDN 0.282、Q5_K 0.292、RMSNorm 0.263 —— 六個全中。而 FillFunctor 是 50,095 → 160,比值 0.003。它是一次性爆發被我平均進每一步,不是每步成本。
用兩次相減重算,固定成本自動抵消(208 步 − 58 步) ÷ 150 才是真正的每步成本。呼叫數整齊到不像實測:GEMV 432、quantize_q8_1_b 432(完全 1:1)、Q5_K 64(每層一次)、GDN 48.0(正好 48 個 block)。時間:IQ4_XS GEMV ~20.6 ms、Q5_K 2.67、Q6_K 1.57、triton_poi_fused_0 2.09、quantize_q8_1_b 0.96、GDN 0.71、triton_ 0.57、RMSNorm 0.63、conv1d 等 0.35。可攻擊的總量從 7.5 ms 修正為約 5.5 ms,而最大的一項是 triton_poi_fused_0(78 次/步,約每層 1.2 次),不是我原本說的整數填充。
補丁正確,但方向錯了,否決五種形狀全部 bit-identical,包含 M=3 那個非 2 冪尾端群組 —— 然後慢 7.5 倍。rows=4096, K=5120:M=1 27.5 → 59.4 µs、M=8 70.7 → 532.9 µs。原因是委員會裡有人在寫任何程式之前就預言的:融合之後每個 block 各自重新量化 X,重複工作隨 rows 線性增長。原本那個「獨立啟動」反而是最佳形態 —— X 只量化一次,所有 row 共讀。它每步 0.96 ms 是 432 次 × 2.2 µs,已經接近地板。剩下的路線 2(共用同一個 X 的 GEMV 只量化一次)上限很小:一層裡真正共用的只有 gate/up,約省 0.5 ms、單步 1.5%。
換硬體呢?DGX 會慢 4.3 倍用 V100 量出來的常數,幾分鐘就答完,不用搬那 15 GB 模型。關鍵是解碼是權重綁定的:模型 14.62 GB,每個 token 讀一遍,所以任何機器的上限就是 bytes ÷ 實測頻寬。GB10 用純 torch 串流讀取量到 217.5 GB/s(複製 234.2),換算 62.4 ms/token = 16.0 tok/s;4×V100 TP4 是每卡 695 GB/s 四張並行,實測 68.4 tok/s。比值 0.23x,而且 16.0 是地板不是預測 —— 它假設非權重 kernel 全部免費,那在 V100 上佔 19%。
是架構不是世代GB10 是統一 LPDDR5X、單一記憶體池;V100 是 HBM2,而且四張卡的頻寬會疊加。217 GB/s 對 GB10 約 273 的規格是 80%,所以量測本身健康,不是測壞。三個但書要一起看:torch 2.9.1 警告 GB10 是 capability 12.1 而它只支援到 12.0(跑得動但非原生,專建版本可能更快,但翻不了 4.3 倍);量測當下 DGX 只剩 18.5 GB 可用(總 130.6),15.3 GB 模型加 KV 現在擺不進去;而且機器有輕負載(load 0.75),會壓低數字但壓不出四倍。
DGX 的優勢在別的地方130 GB 統一記憶體裝得下 V100 塞不下的模型,不用切分、沒有跨卡流量。但對一個已經在四張 V100 上跑得好好的 27B,搬過去是降級。可重用的方法:任何權重綁定的解碼要評估新硬體,用 2 GB 串流讀取量實測頻寬,拿模型 bytes 去除 —— 並且說清楚那是地板。
重寫的問題有答案了:已經有人寫好,值 1.8%我先前只掃了輸出側。輸入側那兩個旗標加上輸出側,合起來就是這條路徑的融合重寫 —— 有人早就實作了,而且留著預設關。基準 c1 68.40 / c8 合計 153.49(輸出 665 B / 137 詞)。INPUT_PROJECTION_OP 680 B(不同)68.43 / 153.04;INPUT_CORE_OP 667 B(不同)68.54 / 154.14;輸入+輸出一起開 670 B(不同)68.35 / 156.24。三個全部改變數值,單人速率全在雜訊內,最好的情況是只在八人合計上 +1.8%。和輸出側單獨開的 +1.2% 是同一個故事,也就是它們預設關的原因。
收尾的算術:為什麼沒有更大的東西單步 32.5 ms 裡 24.84 ms(76%)是三族 GEMV 在讀權重,695 GB/s = V100 峰值的 77%。那是物理不是低效 —— 模型 14.62 GB,每產一個 token 就得整個讀一遍。剩下 6.12 ms(19%)分散在七個 kernel 家族,三條可攻擊的線全關:清零 2.09 ms(歸屬失敗兩次:名字不唯一、57 GB 快取混著多設定)、quantize 0.96 ms(融合實測慢 7.5 倍)、GDN update 0.71 ms(調度參數全在雜訊內)。把非 GEMV 全部消掉是 +23%(68 → 84),那是 Amdahl 上限不是目標,實際融合大概拿回三分之一。數週工程換個位數,而且最大的那一項連位置都指不出來。
這條線的結論解碼已經量到底。剩下的選項是換硬體,或接受 單人 68 / 八人合計 153 就是這個模型在四張 V100 上的數字。已經拿到並上線的是另一半:context 131,072、96K 文件 233 → 64.5 秒、128K 500 → 133 秒。
GDN:掃三個旗標,否決三個65 個 block 裡 48 個是 GDN,而那些預設關閉的旗標不是沒人試過,是試過才關的。基準 665 bytes / 137 詞,c1 68.33、c8 合計 153.46。QWEN_GDN_OUTPUT_PROJECTION_OP=1(把每層的 reshape/gated RMSNorm/v-reorder/out_proj 融成一個自訂 op):輸出變成 687 bytes / 140 詞,不同,合計只 +1.2%。拿數值變動換 1.2% 不是吞吐選項。GDN_Z_CONTIGUOUS + MIXED_QKV_CONTIGUOUS:輸出崩潰成 2 bytes,而速率一點沒動 —— 連取捨都不算。GDN_EMPTY_CORE_OUT 就是我前一晚手工改 torch.zeros→torch.empty 的那個開關,早就存在而且早就預設關。我重新發明了一個已被否決的東西 —— 改模型檔之前先盤點旗標。
Triton 調參:差點交出一個假的 19%第一輪每個配置都比預設快 15–19%,包括互相矛盾的 warps2 和 warps8、bv16 和 bv64。六個擠在 152–160 µs,而 default 一個人在 188。那個整齊本身就是破綻 —— 真的調參會有的快有的慢。加上重複 arm 就拆穿了:同一個 default 兩輪 150.50 vs 187.98 µs,差 25%;同一輪內三次 default 150.50 / 157.86 / 156.83;warps2 兩次 153.47 / 157.41。所有配置間的差距都躺在這個散佈裡,而且方向還在兩輪之間翻轉。checksum 全程相同,所以計算沒變,變的是量測條件。沒有那三個重複 arm,我會報「warps=2 快 19%」並附一個很像樣的 Volta 解釋。
同一天第二次:對照組一起失敗時,錯的是架子微基準第一版用 (B=8, T=1),結果每個配置都在 kernel 第 177 行掛掉 —— 包括 default。我當下讀成「BV 是假旋鈕」,實際意思是「我的輸入形狀錯了」。真實解碼路徑會把變長攤平:帶 cu_seqlens 時 q.shape[0] 必須是 1,還要 ssm_state_indices 和 use_qk_l2norm_in_kernel=True。對照組也失敗,就不是被測物失敗。
清零那條線也關掉了,而且是雙重否定把 qwen3_5.py:471 的 torch.zeros 換成 torch.empty 之後:生成直接斷掉,而且速度一點沒變。同提示、temperature 0:665 bytes 的完整答案變成 **First 12 —— 10 bytes、兩個詞就停。填充列沒有被遞迴核心寫滿,垃圾會傳出去,那個清零是承重的。而吞吐 c1 68.47 對 67.61、c8 合計 153.75 對 153.60 —— 噪音。
這兩件事合起來才是重點輸出壞掉證明編譯圖真的重編了、empty 真的生效了 —— 既然生效卻沒有加速,那 core_attn_out 就不是那 2.09 ms。那個數字是把 triton_poi_fused_0 的呼叫數加總來的,而 inductor 的 kernel 是每個圖各自從 0 編號 —— 名字不唯一。78 次/步是好幾個不同清零 kernel 的總和,我拿掉的只是其中便宜的一個。2.09 ms 是真的,但來源未歸屬,而我唯一的線索(kernel 名字)已證實不具鑑別力。
事前查對的那個風險不是失敗原因我下手前先讀了 quantize_q8_1_b:它 blockDim.y = 1、每個 block 一列,amax 是沿列做 width-32 shuffle —— 列與列獨立,填充列的 inf 污染不到別列的尺度。那個檢查是對的,但失敗來自下游而不是量化。查對前提不保證查到的是對的前提。
力氣改放清零那一項2.09 ms、單步 6.4%,是 quantize 的四倍。來源雙重確認:triton_poi_fused_0 的 Triton IR 是純 tt.store 寫 0.0 到 f16 緩衝區、沒有任何 load;而 inductor 自己的呼叫端寫著 rand_strided((8, 12, 128), dtype=torch.float16) = 12,288 元素 =(捕捉尺寸 8, 48 v_head ÷ TP4, head_dim 128),正是 qwen3_5.py:471 的 core_attn_out。注意 inductor 的 kernel 是每個圖各自從 0 編號,所以那個名字不唯一,78 次/步是多個清零 kernel 的總和。不是安全的一字改成 torch.empty:緩衝區按填充後的捕捉尺寸 8 配置,實際 token 可能只有 1,填充列不會被遞迴核心寫到。
我自己的驗證腳本先出了假綠燈它印出「parity holds」,最壞誤差 0.000e+00 —— 而實際差值全是 NaN。我用隨機 uint8 當 IQ4_XS 權重,但 136 bytes 的前兩個 byte 是區塊的 fp16 尺度,隨機值解出 inf/NaN,兩顆 kernel 都吐 NaN。而守門寫的是 if mx > 1e-3 —— nan > 1e-3 在 Python 是 False,一路放行,max(0.0, nan) 也保留 0.0 把最壞值報成 0。我整天在講「守門要拒絕出數字」,然後自己寫了一個對 NaN 無感的守門。
派工:只派界定得清楚的那一項quantize_q8_1_b 每步 440 次,和 438 次 GEMV 是 1:1 —— 每個矩陣向量積前都重新量化一次激活,而 llama.cpp 那邊早就融進 mmvq。單檔、自足、驗證條件明確,已附線上真正 runtime 編譯的 iq4_mmvq_batched.cu 與正確的參考 header 派給委員會。整數填充沒派,而那個決定後來被證明對得莫名其妙 —— 我當時的理由是「來源未定位」,實際上它根本不是每步成本。不派自己說不清的東西這條規矩救了我一次,但救的方式和我以為的不同。
兩個早就指向這裡的線索,我當時沒追IQ2_M 比 IQ4_XS 小卻更慢(頻寬綁定的話少讀 bytes 應該更快),以及 c1 到 c8 的 step 速率只掉一半,而每步工作量是 8 倍。兩個都和「頻寬綁定」矛盾,而我兩個都當成雜訊放過了。
併發曲線:走了就立刻回來上坡下坡完全重合,短提示沒有任何遲滯。1→2→4→8→8→4→2→1 連續跑、arm 之間不冷卻:每人 68.13 / 56.36 / 47.87 / 34.01 上去,34.03 / 47.91 / 56.40 / 68.64 回來 —— 差異全在 0.1% 內。合計 59.4 / 83.8 / 107.7 / 152.8:八人時每人只剩一半速度,但總產出是單人的 2.24 倍。使用者數量本身不留後遺症,人走了速度立刻滿血。
我之前把 40.51 解釋錯了那不是「prefill 之後沒穩」,是「prefill 進行中」。我原本寫的是 KV 塞滿、排程要 45 秒才回復。實測推翻:四份 64K 文件跑完後量單人,+0s 就是 68.21(基準 68.23),+15s 68.01、+30s 67.89、+60s 68.05 —— 沒有恢復期。真正的機制是同時性:趁四份 64K 還在 prefill 時量,解碼 45.71(−33%),而 TTFT 從 0.2 秒變成 71.2 秒;它們一結束就回到 68.24 / 0.2s。會讓其他人卡住的不是「幾個使用者」,是「當下有沒有人在丟長文件」,而且痛的是首字延遲不是產出速率。
差點誤判第一次在 81,920 量到 c1 40.51 就差點把一個免費的 +23% 判成不能用。隔 45 秒重量三趟 68.18 / 67.74 / 67.94。當時我把「隔了 45 秒」讀成「需要 45 秒恢復」—— 真正的差別是那 45 秒裡 prefill 做完了。同一組數字,兩種因果,只有後者禁得起對照實驗。
量到八個人各問一份 64K 文件:每人等 5 分 47 秒,而 prefill 幾乎不併行。1 人 54.4s → 4 人 184.2s(3.4×)→ 8 人 347.2s(6.4×)。八人的總 prefill 518,624 tok / 347s = 1,494 tok/s,單人是 64,828 / 54.4 = 1,192 —— 八倍的人只換到 1.25 倍的 prefill 吞吐。延遲分布很緊(340.2–347.1,差 2%),所以是公平的:八個人一起前進、一起完成,沒有人被餓死,但總成本基本上是串行的。
產品線八人同時互動的文件上限抓 20–25K。一個人問 64K:54 秒,可以。八個人各問 ~20K:約 1 分鐘,可以。八個人各問 64K:每人 5 分 47 秒 —— 那是「送出去等結果」不是互動。文件長度 21.6K→64K 是 3 倍,八人牆鐘 55s→347s 是 6.3 倍,再次驗證 prefill 隨長度超線性。這是產品決策不是調參數 —— 設定旋鈕在這台機器上已證實全部無效,而 prefill 不共用是架構層的。
否定把權重解碼提出 NCOLS 迴圈:程式碼是對的,速度是零。九形狀正確性全過、誤差與基準逐格相同且非零、兩臂都確認 BMMVQ 4/4 真的掛上。結果 c8 全員串流 260.15 → 261.90(+0.7%)、c4 合計 107.40 → 107.28、c1 持平 —— 全在雜訊裡(同組態不同輪已量到 8% 變異)。「權重在迴圈裡被重讀八次」描述的是源碼的形狀,不是時間花掉的地方 —— L1 本來就吸收掉了。
我自己的前提就錯了我派這個任務時說「效率只有 28%,有 72% 可拿」—— 那個數字用的正是被稀釋的 155。用正確的 260 重算:理想 8×68.1 = 545、實際 260、效率 48% 不是 28%,剩餘空間是 2.1 倍不是 3.5 倍。而 68 → 260 表示每次讀權重現在服務 3.8 個 token,攤提確實在發生。我拿一個被高估的缺口去派工,委員會照著找,找到一個源碼上成立、執行上不成立的解釋。四輪派工的根本問題不是附件也不是引號,是問題本身不準。
派工四輪的失敗都在我① 同時要診斷和 patch,預算被診斷吃光;② 叫它「不確定就自己寫」,它去反推位元佈局;③ 附的參考 header 來自另一棵 llama.cpp,vec_dot 三參數變四參數、__dp4a 變 ggml_cuda_dp4a,編不過;④ 提示裡的雙引號被 PowerShell 吃掉,argparse 拒收。委員會的診斷從第一輪就沒變過。
擋住了驗收鏈按設計運作:編譯錯誤擋一次、缺 pybind 擋一次、每次自動還原,生產全程沒被碰過。而且每臂都印 BMMVQ x/4 —— kernel 沒掛上時伺服器照樣起得來、照樣有數字,那個數字看起來會像「改動沒效果」而不是「改動沒生效」。
更正我把八人的容量少報了 40%。Wayne 問「一個人 35,八個人怎麼只有 155」,那個問題直接指出錯在哪。兩個數字的分母不同:每人的 decode 是「首末 token 之間」(不含 TTFT、不含沒在跑的時間),aggregate 是「全部 token ÷ 整段牆鐘」。而量測工具刻意把到達時間散在兩秒、輸出長度隨機取 100–400,所以牆鐘由最長的請求決定、token 總數卻不是 —— 後半段只剩兩三個人在跑。
量到八個人同時產出時,合計是 260 不是 155。改成同長度、同時放行、只取全員都在串流的視窗:c1 68.08、c4 每人 46.51 合計 186.2、c8 每人 32.61 合計 260.2 —— 和 8 × 32.61 = 260.87 幾乎完全相等,樸素相乘本來就是對的。兩個數字都要留但要標清楚:260 是機器容量,155 是混合負載下的實際吞吐,前者答「撐得住多少」,後者答「跑起來像什麼」。
第五次整天的 SSE 全滅終於查清楚:串流的欄位叫 reasoning,非串流叫 reasoning_content —— 同一個東西兩個名字。delta.content 一直存在但是空字串,所以只試那兩個非串流名稱的檢查會安靜地數到 0 個 token。把第一 KB 用 repr() 印出來花十秒,而我先前猜了兩次格式。
驗收一小時八人 soak 全過,而且記憶體一格沒動。12 個視窗,解碼 p50 32.36–32.60(變異 0.74%)、合計 152.9–167.2、TTFT 0.91–1.20s、0 次失敗、GPU 記憶體 17,162 MiB 從第一分鐘到第六十分鐘完全不變。四種失敗模式(速率漂移 / 記憶體增長 / 尾端請求開始壞 / kernel 脫落)全數通過。這個部署可以交出去了。
量到席次砍到 8 沒有任何好處,而且我的理由是錯的。我以為砍掉 56 個席次和三組 CUDA graph 能還記憶體給 KV pool —— KV pool 兩者都是 2,170,595 token,一個 byte 都沒差。席次不從 KV pool 扣:vLLM 照 util 先切好 KV,席次只管排程上限。c8 合計 158.4 對 154.7、長文件 60.2s 對 61.6s,都在雜訊內(同組態不同輪已量到 8% 變異)。排程類設定至此已在兩種量化上各掃一輪,結論一致:全部無效。
能力Qwen3.8 的視覺是可用的,六項全中。448×448 四色象限加中央黑方塊,它答出左上紅、右上綠、左下藍、右下黃,以及中央黑方塊「跨在四個象限交界上」。兩個前提:只能走 llama.cpp(vLLM 的 GGUF 路徑不吃 mmproj),以及必須加 --image-min-tokens 1024 —— 不加的話 96×96 的圖只換到 81 個 prompt token,加了之後是 1,103。
量到八個人各拿一份 21.6K 文件:55 秒全部拿到答案,而共用文件沒有任何好處。八份不同文件牆鐘 54.8s、八份逐位元組相同(且先暖過)55.0s —— 差 0.2 秒。3.6 上量到的「不共用 prefill」在 3.8 加了 bt 65536 之後仍然成立。合計吞吐 18.7 tok/s,對短提示八人的 155 —— 長 context 併發是八倍代價,時間幾乎全在 prefill。
變好了但排隊變成並行了。3.6 那次八人問同一份文件是「一人 31 秒、最後一個等 245 秒」;現在八個人一起在 55 秒完成,延遲差距只有 0.3 秒。對使用者體驗來說,「大家一起等 55 秒」比「有人 31 秒有人 245 秒」好得多。(兩次文件大小不同,不能比秒數;可比的是排隊 vs 並行。)
第三次了視覺第一輪回報空答案,而模型其實成功了 —— 日誌寫著 loaded multimodal model、生成 120 個 token。我讀 content 讀到空,內容在 reasoning_content。本 session 第三次讀錯欄位,而我還為這件事寫過記憶。
量到解碼隨 context 掉得很溫和:提示放大 250 倍,速率只掉 37%。258 tok 67.52 → 3,618 65.55 → 9,618 62.24 → 21,618 56.36 → 43,218 48.46 → 64,818 42.73。而 64.8K 的 42.73 和 Codex 在 65,536 量到的 42.8 幾乎完全吻合 —— 兩個人、兩套探針、兩種量法,獨立得到同一個數字。
量到冷 64K TTFT 90.71 秒,對 Codex 的 162.6 秒 —— −44%,是 max-num-batched-tokens 65536 換來的。
自己的坑踩第二次那張表的「冷 TTFT」欄只有最後一列可信。六個長度是巢狀前綴(同一段文字重複到不同長度),所以量到 43K 時前面 21K 早在快取裡 —— 破綻是 43,218 的冷 TTFT(4.63s)比 21,618 的(6.24s)還低。這個陷阱我 8/7 就寫進本頁過(「這一輪的冷數字不能用,我先講」),今天又踩。差別是這次在讀數字時抓到,沒拿去下結論。暖 TTFT 與解碼那兩欄不受影響。
量到併發到 64 仍然沒有轉折點。合計 c32 247 → c48 315 → c64 357,每人 15.3 / 11.4 / 10.0,TTFT 都在 6 秒內。KV pool 2,170,595 token,64 人各分 3.4 萬,容量還沒到極限。這台四卡 V100 同時服務 64 個人是可用的。(注意 c32 兩輪量到 266.7 與 246.7,capture 集合不同差 8% —— 單次差異別當成改善。)
上線Qwen3.8-27B 換成 IQ4_XS 之後併發吞吐 +24%,而且一行 code 都沒寫。Codex 部署的是 Q4_K_M,我先前為 IQ4_XS 寫的兩顆批次 kernel 掛不上去;同一個 HF repo 就有 IQ4_XS 版(15.3 GB)。換檔後兩顆 kernel 在 4/4 worker arm:c4 合計 86.74 → 107.75、c8 133.14 → 153.86,單人 69.42 → 68.15(−1.8%,量化較小的代價)。工具閘門 8/8、真實文本連貫。回滾:/home/mavis/codex_qwen38_launch.saved,Q4_K_M 檔原封不動。
量到到 32 併發還沒有天花板。合計 c1 68 → c8 155 → c16 186.7 → c24 217.7 → c32 266.7,每人仍有 14.63 tok/s、TTFT 1.64s。三十二個人同時用是可用的。正在往 48 / 64 推。
撤回「併發設定對齊能補回 25%」—— 錯的。max-num-seqs(16 vs 8)、capture 集合、util(0.60 vs 0.50)四種組合,c4 合計全部落在 86.5–86.9,c8 全部 133.0–133.6。那 25% 從頭到尾都是量化檔,不是排程設定。
澄清Codex 的 TPS 難看是量法不是部署。他用 65,536 token 的提示量端到端,所以 17.4 / 10.17 / 5.61 裡絕大部分是 prefill(TTFT 162.6 秒)。拿他正在跑的那個服務改用短提示是 69.16,和我調了一整晚的 Qwen3.6(68.88)一模一樣。他的解碼一直都是好的。
有效--max-num-batched-tokens 16384 → 65536:21.6K 冷 prefill 19.8s → 15.4s(−22%),decode 不動。和我先前在 106K 上量到的 −26.3% 同一個量級,已套進生產。
撤回「MTP 接受率低是因為 KV 位元寬」—— 錯的,我自己的六臂實驗把它推翻了。同一顆權重、同一組 kernel,只掃 KV 精度,單人接受率是 2-bit 51.5% / 3-bit 50.0% / 4-bit 52.3% / k8v4 51.5% / f16 51.5% —— 完全不動。連完全不壓的 f16 都一樣,所以 llama.cpp 的 72.4% 不是 KV 換來的。先前那個 47.0→87.1% 的觀察是單一提示的單次量測,沒有重複、沒有跨提示,被我當成因果寫了上來。四併發下 KV 精度確實有效(2-bit 50.3% → 4-bit 62.4% 後打平),但吞吐紋風不動(24.85 → 27.10),因為綁住的是 c 不是 α。
為什麼量不到七次插樁全部失敗,原因是結構性的,不是手滑。torch._dynamo.exc.Unsupported: Attempted to call function marked as skipped — module: time, qualname: perf_counter。整條 GDN 路徑在 torch.compile 的 fullgraph 捕捉區內,任何 Python 層計時器都會讓捕捉失敗、引擎起不來。加上 vLLM 自己的 /start_profile 在 TP4 下寫不出東西(worker 是獨立 process,只武裝 rank 0)—— 這台機器上要看進推測步裡面只剩 nsys --output=…%p 一條路,那不是備案,是唯一解。已還原並重跑驗過 39.66 對插樁前的 39.84,NOSYNC 八處仍在 —— 能 parse 不等於伺服器起得來,一定要重跑一臂。
結案Muse 這條線到此為止,不再測。它的任務是當對照組,而它做到了 ——遞迴假設已經被殺掉,再測下去是換模型的決策,不是優化決策。權重和自建的 llama.cpp 留在機器上(/mnt/data/backups/windows-v100/models_muse、/mnt/data/llama.cpp-new),要用隨時能起。
量到vLLM 單卡也是 1.238,拓樸只佔超額的 27%。TP1 base 33.52 → MTP 22.69(α 51.5%)→ 步 66.77 ms 對基準 29.83 ms。超額的 1.305 裡 0.948(73%)是引擎、0.357(27%)是 TP。架構、拓樸、草稿機制三個變數全部排除完,病灶在 vLLM 的推測路徑本身。
天花板就算把 c 修到 llama.cpp 的 0.29,配現在的 α=0.52,S = 1.52/1.29 = 1.18 → 81 tok/s,比基準 68.88 好 18%,而且只在單人。四併發合計 34.80 對基準 108.80(0.32 倍)。這條線的上限現在算得出來了。
推翻主假設遞迴狀態不是驗證變貴的原因 —— 對照組把我整晚的前提殺掉了。同一個 llama.cpp、同一張卡:Qwen3.6-27B(65 層裡 48 個帶 GDN)每多驗一格付 0.298 個基準步;Muse Glimmer 30B(52 層全注意力、零遞迴)付 0.287 —— 差 4%。而 vLLM 在同一顆 Qwen3.6 上每格要 1.595,5.4 倍,而且和架構無關。我一直把「48/65 帶遞迴」當成 c=2.01 的根因,給 fusion 委員會的知識庫也是那樣寫的,委員會的三個候選全部建立在這個錯誤前提上。這讓 spec_sequence_masks is None 那個守衛從「候選之一」變成主嫌 —— 它正好是一條純引擎側、與模型架構無關的路徑選擇。(限制:llama.cpp 兩顆都是單卡、vLLM 是 TP4,跨了引擎也跨了拓樸;但 llama.cpp 內部那組同引擎同卡數的比較是乾淨的,足以殺掉遞迴假設。)
量到Muse Glimmer + 官方 DFlash 草稿器:1.12 倍,公式第三次命中。base 32.63 → DFlash 預設 36.55 tok/s;每步 2.069 個 token ÷ (1+0.847) = 1.120,實測 36.55/32.63 = 1.120。加大區塊一樣是壞事(和 MTP 的 γ 同形狀):n-max 8 → 31.55(接受率 20.4%)、n-max 16 → 31.05(12.2%,草稿量從 428 暴增到 1565)。日誌:n_max=16 exceeds the trained block size 16 -- clamping to 15。預設最好。
又一次300 個 token 全在 reasoning_content 而不是 content(打到 finish_reason: length 時還在推理段),所以我的文本檢查印了 uniq_word_ratio 0.00 DEGENERATE —— 吞吐數字是有效的,壞的是我讀錯欄位。實際內容 1585 字元、連貫英文。
作廢Muse 的前兩輪數字全部作廢,兩次都是我的錯,而且第二次騙過了我自己寫的檢查。第一輪:--spec-type 預設是 none,我只給了 --model-draft —— 兩個草稿臂會在推測關閉下跑成基準。第二輪:對 instruct 模型打了 /completion,沒有東西套 chat template,生成到第 2 個 token 就停(predicted_n=2,內容是 " -"),而那三組 tok/s 是在兩個 token 上量出來的。我的退化檢查放行了 —— 一個字的輸出 uniq_word_ratio 是 1.00,滿分。那個檢查是為了抓重複寫的,而重複是相反的失敗。已改成:先斷言實際生成的 token 數,不到 100 就標 INVALID、不准印速率。
學到DFlash 不是一顆自迴歸小模型,是「遮罩 token 的區塊平行草稿器」。伺服器 log:block_size=16, mask_token_id=201818, n_extract=5 —— 它一次提一整段、靠 mask token 填空。我拿量 MTP 的條件(n-max 1)去套等於把它掐死在一格,所以 accepted 0 / generated 1。重跑改成給它自己的區塊大小。另外這顆官方 GGUF 有個真警告:special_eot_id is not in special_eog_ids,tokenizer 設定可能有問題。
驗收最佳推測組態過閘門了,但四併發是災難,這定死了它的位置。FLASH_GRAPH+SPEC_CORE_OP:c1 39.84(掃描時 39.96,可複現)、接受率 52.3%、工具閘門 8/8 PASS、真實文本 uniq_word_ratio 0.79 沒有退化。但 c4 合計吞吐只有 34.80,無推測基準是 108.80 —— 0.32 倍,TTFT 5.03s。批次已經把成本攤平的地方,推測解碼只會添亂。就算做到最好,這條線也只在單人有意義,而單人現在也才 0.58 倍。生產維持不開推測的 68.88。
注意c1 的 TTFT 這輪是 1.78s,掃描那輪是 0.68s —— 同一組態、不同輪次差 2.6 倍。prefill 的量測變異比我以為的大,那個 0.68s 不能當定值引用。
建好了新的 llama.cpp 建起來了(commit 8e7f22b、gcc 12.4.0、CUDA arch 70、ninja -j2),編譯日誌裡有 common_speculative_impl_draft_dflash —— DFlash 推測支援確實在裡面。/opt/llama.cpp 覆核過完好,對照組數字仍可比。
修好「解碼的 15.5% 是拿 prefill 換的」那個代價有解:兩個旗標一起開。FLASH_V100_0DOT3_COMPILE_GRAPH 單獨對解碼沒用(34.55,c 2.020),但疊在 SPEC_CORE_OP 上把它造成的傷害修掉 —— c1 39.96、接受率回到 52.3%(全場最高,與不開推測同水準)、TTFT 從 4.42s 回到 0.68s。委員會把這個旗標排第一是對的,但理由錯了:它救的是 graph 與 prefill,不是解碼主力。
新玩具Meta Muse Glimmer 30B 已下載並驗過(23 GB,三顆檔案的 byte 數與遠端 content-length 逐一相等,731 個張量目錄讀到底)。它是我這條線缺的對照組:52 個 block 全是注意力、沒有 SSM(對照 Qwen3.6 的 65 block 裡 48 個帶 GDN 遞迴),KV head 32:2、滑動窗 2048、context 131072,而且官方另出一顆 DFlash 草稿模型。一顆沒有遞迴狀態又自帶草稿模型的 30B,正好能分辨 c=2.01 是架構特有還是引擎通病。官方沒有純 Q4,只有 kquant-17gb(16.8 GB,為 24 GB 消費卡而生)和 kquant-dynamic(19.7 GB)—— 取後者,我們是 32 GB×4,沒有理由為遷就別人的卡吃品質損失。
擋路的/opt/llama.cpp 是 7/6 建的,muse-glimmer 支援是 8/10 才合併(#26841,b10353 起)—— 認不得這個 arch。要跑得建新的,而且不動 /opt/llama.cpp:今晚 llama.cpp 那組 α 0.722 / c 0.298 的數字全出自它,重建會毀掉可比性。新的建到 /mnt/data/llama.cpp-new。
量到八個推測路徑旗標,只有兩個動得了 c,而且落在同一個值。SPEC_CORE_OP c1 39.62(c 2.043→1.595,+15.5%)、003_SPEC_CORE_OP 38.49(c 1.593)。兩條路徑的 c 幾乎同值不是巧合,應該接到同一個改動;SPEC_CORE_OP 吞吐較高只是因為它的接受率沒被拖低(49.3% vs 44.9%)。其餘五個全在 c≈2.0,是雜訊。
推翻委員會委員會把 FULL_FORWARD 排在 SPEC_CORE_OP 前面(理由是名稱暗示同時修候選 ①②)—— 實測反過來。FULL_FORWARD 不但沒用(34.03,c 2.036),TTFT 還從 1.46s 惡化到 2.86s。排序要靠量,不靠命名。
找到推測一開,GDN 的快速解碼路徑就結構性地拿不到 —— 這寫在守衛裡,不是推論。qwen_gdn_linear_attn.py 的 4365 與 5706 兩處都有 and attn_metadata.spec_sequence_masks is None,所以用 packed_decode 那條路在推測步永遠不會被選中,一律落到通用的 _forward_core,而那裡把 token 拆成 spec / non-spec 兩組分別跑。conv 那半有守(num_prefills>0 / elif num_decodes>0 / else None),單人時沒有空跑;成本在後面的 gating 與遞迴段。同一個守衛還帶出委員會不知道的交互作用:FUSED_SIGMOID_MIXED_QKV=1 會讓 mixed_qkv_decode_requested 為真,也會關掉這條快速路徑。
委員會給對的TP4 下 torch profiler 為什麼永遠是空的:worker 是獨立 process,start_profile 只武裝 rank 0 的 context,跨不過 process 邊界。這解釋了我三次失敗,替代方案是 nsys --output=…%p 每個 PID 各自出檔。委員會的價值在候選假設與這條路,不在優先順序。(Codex 這次 thread start failed 無實質產出,融合時整份排除,實際是 GLM-5.2 與 MiniMax-M3.0 兩份。)
換路要看清那 29.2 ms 花在哪,我連三次拿不到 profile,停手換方法。第一次沒設 VLLM_TORCH_PROFILER_DIR;第二次設了但讀取端掃的是自己的預設目錄;第三次目錄對上了,TP4 下 /start_profile 之後那個目錄仍然是空的,fork 自己的 phase report 也沒進 log。三次失敗長同一個樣子 = 問題在假設不在實作(RULES 5),所以不再想辦法「看進步驟裡面」,改直接量哪一種實作最便宜 —— 這個 fork 帶了八個切換推測路徑的旗標,一臂一個,c 用同一 session 自己的無推測臂算,不從記憶拿。
委員會把這題交給 fusion 委員會(codex / glm / m3,附完整 KB)。問題陳述現在夠乾淨可以外包了:公式在兩台引擎上獨立驗證過、十二個假設各有具體死因、fork 旗標清單已列。該召開的時機我錯過了兩次 —— KV/γ/p-min 連三輪同一目標失敗那次,以及 c=2.04 掛在報告上很久卻沒人從架構角度問「1/65 的工作量為什麼花 2 個基準步」那次。
定案兩台引擎的帳算平了,而且 S = (1+α)/(1+c) 對兩邊都命中到三位數。llama.cpp 單卡:基準 39.16、α 0.722、c 0.298 → 預測 1.327、實測 1.327。vLLM TP4:基準 68.86、α 0.515、c 2.010 → 預測 0.503、實測 0.503。四個獨立量到的數字各自命中,這個模型不是事後湊的。
價碼把 llama.cpp 的 α 和 c 搬到 vLLM 的 68.86 基準上 = 91.4 tok/s。拆開看:只修 c(2.01→0.30)即使 α 停在 0.515 也有 80.4;只修 α(0.515→0.722)而 c 停在 2.01 只有 39.4 —— 連損益平衡都到不了。所以 c 是壓倒性的槓桿,我原本要先追的草稿頭餵法要往後排。
撤回「llama.cpp 的 72.4% 是 p-min 篩出來的」—— 也錯了,是我下一個被自己數據打掉的假設。把 p-min 降到 0、完全不篩,llama.cpp 在 γ=1 仍有 72.2%,草稿/步 = 115/(200−83) = 0.98,等於 γ,篩子根本沒動。vLLM 同樣 γ=1 是 50.4%。同一顆 GGUF、同一顆 MTP 頭、同一張卡 —— vLLM 真的把草稿頭餵得比較差,那是個可定位的 bug 不是架構宿命。(順帶更正我自己的量測腳本:它印的「drafted per generated token」分母錯了,該用步數 —— 接受的草稿會讓一步產兩個 token,把分母灌大,害兩個沒篩的臂被標成 FILTER IS ACTIVE。)
線索vLLM 餵給草稿頭的是過完最終 norm 的 trunk 狀態:target_hidden_states = hidden_states[...],而 qwen3_next.py:953 是 hidden_states, _ = self.norm(...) 之後才 return;MTP 頭接著又對它做一次 pre_fc_norm_hidden。llama.cpp 的對應圖在 src/models/qwen35.cpp(源碼在機器上),下一輪對照。這條排在 c 後面。
修好把 GDN 推測狀態合約裡的 nonzero() 拿掉,MTP 單人 +6.8%(32.42 → 34.64),四人 +2.5%,接受率不動(51.5%),工具閘門 ALL_PASS。那段用布林遮罩挑推測列,每個 x[mask] 都是 nonzero() —— 它得把命中數讀回 host 才知道輸出多大,是一次完整的裝置同步,而且一次 forward 好幾個。批次同質時遮罩全 True,那個挑選就只是複製一份。判斷用的計數呼叫端已經在 CPU 上算好,問它不花錢。c 從 2.22 降到 2.01。預設關,VLLM_SM70_GDN_NOSYNC=1 才開。
量到基準確認 68.86(capture 含 1),和先前的 69.10 對得上 —— KV 掃描那個 59.25 確實是 capture 集合的假象,不是退化。
剩下的病灶草稿頭是 65 層裡的 1 層,它多做的那次 forward 該花基準步的 1/65。實測花掉 2 個基準步 —— 差 130 倍。這不是算力,是管線。今天證明其中 0.21 是同步造成的(c 2.22→2.01),剩下 2.01 還在。先前的 forward 拆解指向 elementwise 佔 42%,那是 48 個遞迴 block 的狀態搬運與回捲,下一刀在那。
失誤llama.cpp 那三臂第一次跑全部 NO NUMBERS —— 我用了 -ctk turbo2,但 /opt/llama.cpp 是上游 b0.15.3 不是 atomic-llama fork,turbo2 是 fork 專有型別。旗標檢查有做、KV 型別沒檢查。改 f16 重跑中。
量到加長草稿只有壞處,γ 不是我們少開的那個開關。vLLM γ=1/2/3 的 position 0 接受率是 50.4 / 44.8 / 43.5% —— 第一個位置不隨 γ 改變,而總接受率被後面的位置一路拉低到 51.5 → 31.1 → 23.7%,吞吐跟著 33.21 → 30.81 → 27.86。逐位置條件接受率 pos1 33.1% / pos2 37.8%,離對照組的 95 / 91% 差一個量級。
下一刀所以 llama.cpp 的 72.4% 更可疑了:同一顆權重,vLLM 在 γ=3 只有 20.6%。只剩兩種解釋,而且可以一刀切開 —— ① p-min 0.5 讓它信心不足時根本不提草稿,那些注定被拒的位置沒進 draft_n,百分比是在篩過的子集上量的;② vLLM 真的把草稿頭餵錯了。把 llama.cpp 的 p-min 降到 0 重量,掉到 ~50% 就是①(那接受率從頭到尾都不是差別,差別是 c),留在 ~72% 就是②(那是個值得修的 bug)。跑了。
再更正我先前寫「MTP 在混合遞迴架構上必輸,建議收掉」—— 那個結論下太早,撤回。一份獨立實測在 同一個架構的 Qwen3.6-27B 上,MTP γ=3 拿到 2.40 倍。回捲稅是真的,但它把 c 從 0.20 推到 0.30 —— 足以把小贏變小輸,離我們的 c = 2.04 差得很遠。那個差距底下還有東西,還沒找到。另外那篇說 Qwen3.6-27B 是「純 dense、沒有 GDN」也是錯的 —— 本機七顆變形(含 OFFICIAL 那顆)metadata 全是 qwen35、65 blocks、48 個帶 SSM、ssm.state_size 128。看檔名不算,要看 metadata。
回捲稅有解混合模型的成本有名字也有 PR:草稿被拒絕時,舊版引擎會完整回捲到最近的檢查點(因為 GDN 遞迴被融進單一 kernel),業界叫它 gated DeltaNet rewind tax。vLLM PR 22400 改成 ring buffer 保留每 token 中間狀態,只回捲被拒絕的後綴,把懲罰從 O(γ) 降到 O(γ−k)。我們 fork 裡有 spec_state_slot_selectors 這類機制,但沒找到 ring buffer —— 是不是半套還沒確認。
病因開 MTP 額外的成本來自這顆模型有 48 層帶遞迴狀態。推測解碼的整個前提是「多驗一個位置幾乎免費」—— 對 attention 成立(多寫一格 KV,拒絕就丟掉),對 linear attention / GDN 不成立:它只保留最後一個 token 的狀態。vLLM 自己的 docstring 寫著,有草稿時每個位置的狀態都得留著,等驗證結果出來再依接受數平移 48 層的狀態,而那組緩衝區只在「推測解碼 + 混合模型」時才配置。實際比例:65 個 block 裡 48 個帶 SSM 狀態、17 個純 attention。trace 裡那些在不開 MTP 時前 25 名根本看不到的 kernel —— memcpy32_post 1879 次、scatter_gather 268、indexSelect 268、CatArrayBatchedCopy 268 —— 全是狀態簿記,不是算術。論文的 1.8x / 3.4x 全部量在純 transformer 上。
壓測過了27B 一小時、四併發、混合提示:12 個窗口速率 46.64–47.04,變動不到 1%,零失敗。記憶體 15688 → 16678 MiB 全發生在前 15 分鐘、之後九個窗口完全持平(配置器安頓,不是洩漏);一小時負載後工具閘門仍 ALL_PASS。
最大一筆批次化 MMVQ:c8 每人 20.58 → 32.31(+57%)、c4 36.74 → 48.47(+32%)、c2 48.52 → 57.27(+18%),c8 合計吞吐 104.7 → 143.3。fork 的 mul_mat_vec_q 把 token 位置放在 blockIdx.y —— 每個位置各開一組 block、各讀一次 45 MiB 的權重。profiler 的證據無可辯駁:同一顆 kernel 在有無推測下 launch 次數幾乎相同(2489 對 2468),時間卻差 60% —— 同樣次數的呼叫、每次做兩倍的搬運。改成一個 block 帶 NCOLS 個位置、權重靠 L1 重用後,每位置成本從 0.086→0.074(幾乎不降)變成 0.084→0.032。M=1 持平所以不傷單人;九種權重形狀 × 每個 M 的誤差與原 kernel 一致到印出的精度。
定案MTP 在這台機器上不可能贏,而且原因不是接受率。Leviathan/Kalman/Matias(ICML 2023)的加速式是 (1−α^(γ+1)) / ((1−α)(γc+1)),Corollary 3.9 的損益平衡是 α > c。我們實測 α = 0.478、c = 2.04 —— 差 4.3 倍。他們論文裡 c 的範圍是 0.007–0.073,我們是他們畫過最壞情況的 28 倍。而且 Table 2 自己就在證明 α 不是槓桿:接受率最高的 T5-large(0.82)加速最差(1.7x),因為它的 c 最大。
根因c = 2.04 不是草稿頭的成本。DeepSeek-V3 報告說 MTP 是一層 transformer、重用主幹的 hidden state,固有 c ≈ 0.015,正好在論文範圍內。我們的 c 被灌大,是因為論文 Section 3.3 的前提 —— 「同時算 γ+1 個位置不增加牆鐘時間」—— 在我們機器上是假的:M=2 前向要 1.85 倍。而那又是因為 M=1 時矩陣乘只佔 8.4 ms、非矩陣乘佔 9.0 ms(52%),一次前向 2,297 顆 kernel —— decode 根本不是權重頻寬受限。同一個病灶同時壓住單人 69 tok/s 和 MTP。
撤回我先前說「草稿頭被壓成 4-bit 是接受率低的原因」—— 錯的。實測 eh_proj 是 Q8_0 的那顆模型,接受率更低(39.4% 對 47.8%),而 S 兩者都落在 0.45–0.49。也一併撤回「drafter 的 CUDA graph 是瓶頸」(全關掉只差 0.5%)與「decode 是 launch-bound」(GPU 其實忙到 72–76%,我先前用錯了分母)。
結案 2「27B 過不了工具呼叫閘門」是假的,而且是閘門的錯。閘門只用 tool_choice=required 問。兩顆模型正好相反:生產 9B 在 required 能用、auto 回空;27B 反過來。兩顆都不壞,各自在一種模式下吐出完全正確的呼叫。失敗那側都是同一個樣子:tool_calls 空的,而 reasoning 欄位裡模型正在說「我應該呼叫 lookup_inventory」—— reasoning parser 把開頭那段吃掉,tool parser 就什麼都不剩。閘門已改成兩種都問、任一成立即通過並指名哪一種;生產與 27B 現在都 ALL_PASS。我為這個假訊號往 kernel 和 capture 各查了一輪,那些成因不存在。
還有一個綠燈說謊t5a_no_think_plain 在生產通過,但模型開頭就是 Thinking Process:\n\n1. **Analyze the Request:** —— 那個檢查只找字面的 <think> 標籤,這種寫法直接走過去。沒有動它:把生產監控轉紅是關於既有行為的決定,不是量測 bug。
根因27B 開 MTP 之後在 16→24 併發之間掉懸崖,是 capture 集合對不上。推測解碼每步送的是 席次 ×(1+draft) 個 token,而 vLLM 只保留能被那個倍數整除的尺寸。同一組 [1,2,4,8,16,32]:不開 MTP 捕捉到 6 張 decode 圖,draft=1 只剩 5 張(1 不能被 2 整除)。真正可用的席次上限因此是 16,c24 和 c32 全走 eager —— 兩者步時間 233.5 / 234.1 ms 幾乎同值,就是這個原因。draft=2 要 3 的倍數,那組裡一個都沒有,所以它捕捉到 0 張。
修了capture 改成 [1,2,4,8,16,32,48,64] 後捕捉數 7/7,c24 每人 4.28 → 8.17(合計 97 → 181)、c32 4.27 → 7.60(合計 128 → 225)。
結案三臂同場對照:c8 三臂完全一樣(9.71 / 9.89 / 9.89),所以先前那個「c8 從 17.97 掉到 9.82」不是 capture 造成的 —— 17.97 在同 session 裡重現不出來,那是跨 session 的漂移。t3_tool_call 也是三臂全失敗含舊集合,同樣與 capture 無關。所以 capture 對齊是乾淨的淨勝:c24 3.76 → 8.16(2.17x)、c32 3.66 → 7.59(2.07x),c1/c8/c16 在雜訊內不動。
留著但那代表同一組態在不同 session 之間,c8 可以差兩倍(17.97 對 9.71,都是舊 capture)。原因未知。凡是要拿來下結論的併發數字,都得同 session 取。
證實ggml_mul_mat_a8 對 IQ4_XS 完全沒有運算。profiler 顯示它只發射 quantize_q8_1,之後沒有任何 matmul,輸出緩衝區沒被寫入。它先前之所以通過數值比對,是 torch 的 caching allocator 把剛算完參考值的那塊記憶體回收再配給它 —— 讀到的是上一次的殘值。因此把 IQ4_XS 加進 MMQ_QUANT_TYPES 來修批次崩塌,會讓模型安靜地吐垃圾而且不報錯。
量到IQ4_XS 批次路徑的實際成本(blk.0.ffn_up,17408×5120):反量化整顆權重 0.663 ms + cuBLAS 0.441 ms = 1.106 ms,而且不隨 M 變;同尺寸的 K-quant 走真 MMQ 在 M=16 只要 0.355 ms。mmvq 則是線性的,M=1 0.086 ms、M=64 4.169 ms,與反量化路徑的交叉點在 M≈15 —— 而 gguf.py 的門檻寫死在 8,偏保守。
做出來了IQ4_XS 的批次 kernel 寫好、驗過、接上了。端到端(同 session、只切一個環境變數、MTP draft=1):c8 每人 9.72 → 14.71(+51%)、c16 9.58 → 12.54(+31%)、c24 8.15 → 9.85(+21%)、c32 7.60 → 9.00(+18%);合計 c32 225 → 266。兩臂產出的文字逐字相同。
關鍵第一版全錯(相對誤差 ~1.0),因為我沿用了標頭的 QR4_XS/QI4_XS —— 那是 8 和 8,描述的是 MMVQ 怎麼把超區塊分給執行緒,不是 MMQ 的 tile 幾何。tiling 要的是 QK=256 / QR=2 / QI=32,現在綁了 static_assert 讓它不能再漂走。
再一輪把 kvalues_iq4nl 查表從 vec-dot 搬到載入時做一次(x tile 會被重用 mmq_x 次,等於同一組查表重做 64 遍)又多 1.5 倍:對比 dispatcher 現行選擇,M=8 2.48x、M=16 4.95x、M=32 2.57x、M=64 1.95x。
沒解釋掉開 kernel 之後單人 c1 少 1.5%(29.97–30.01 對 30.45–30.47,三輪六次讀數一致),而 c1 的 M=2 根本沒進 kernel 的窗口。我猜是 dispatch 裡的 os.getenv 每次矩陣乘都跑,提到模組層級後只移動 0.3% —— 猜錯了。而且用算的就排除得掉:條件短路後開關只差兩次整數比較(約 25 µs),實際差距是每步 0.6 ms。成因在別處,還沒找到。操作結論:只有單人就別開,有併發就開。
改了驗法正確性門檻本來是拍腦袋的絕對值,在 M=1 給過一次假警報 —— 同一顆張量兩次隨機輸入是 9e-4 和 3.8e-2,差別在分母是單列最大值。改成拿 mmvq 當標尺問「比要取代的那條路徑差嗎」、固定亂數種子:九種權重形狀 × 五種 M,誤差跟 mmvq 一致到印出的精度。
找到fork 裡早就有手寫的批次 kernel(mul_mat_q40_dec_y、mul_mat_q40_x64,註記 vs fork 1.26–2.35x),runtime 編譯、env 控制、shape gate 全都在,但都卡在 qweight_type == 2,也就是 Q4_0 專用。缺的只有 load_tiles_iq4_xs 和 vec_dot_iq4_xs_q8_1_mul_mat 兩個函式 —— IQ4_XS 的 32 元素子區塊剛好等於一整個 Q4_0 區塊(都是 4 個 int、低 nibble 都是元素 0–15),所以 tile 幾何可以原封不動沿用。已寫好,待驗。
測了llama.cpp 的 KV dtype 受控組。llama-bench 的 -ctk/-ctv 是位置配對,先前那批其實只量到 V —— 重跑後:V 量化花 8% 生成速度,K 量化再花約 30%(K 在 QKT 內圈)。
測了llama.cpp 四卡三種 split-mode:layer 38.9(等於單卡)、row 22.0(慢 43%)、tensor NCCL 崩潰。llama.cpp 在這台沒有可用的 TP4,所以它只能當參照組。
排除flash attention 不是變數(f16 開關 FA:703.5 對 710.5)。先前 llama-bench 中止是因為 tbq3 必須要有 FA。
找到一份公開的 3090 紀錄跑同一顆 27B、同樣在 vLLM 上,MTP 逐位置接受率 97/95/91%、接受長度 3.4–3.8、125K 下 85 tok/s。我們是 50.5/15.2%、接受長度 1.50。差的是配置,不是架構。
更正我先前寫「只有一顆 MTP 頭,所以 position 1 崩到 15.2% 是架構決定的」。那個解釋錯了 —— 對照組同樣一顆 nextn 頭,position 3 是 91%。
否定draft 頭的量化不是低接受率的原因。四種量化(Q4_0 / Q8_0 / IQ4_XS)全部落在 40–49%,彼此在雜訊內,而且量化最重的 IQ4_XS 反而最高。把 blk.64 gate 到 Q8_0 重做 requant 這條路不用走了。
否定也不是我 8/6 關掉 GDN 旗標造成的。原始碼裡那個旗標只是 force,另有 auto 會自行武裝;8/8 與 8/9 的伺服器 log 都印了 guard armed (force=False, auto=True)。
修好llama.cpp 的 -sm tensor 崩潰根因是 NCCL 版本:系統的 2.18.3+cuda12.0 對上 CUDA 13 driver,查 entry point 失敗被誤譯成 「CUDA driver is a stub library」。LD_PRELOAD vLLM venv 的 2.27.5+cuda12.9 就跑起來了。
撤回所有 llama.cpp 四卡的速度數字,以及「四卡贏不了單卡」這個結論。-ts 是斜線分隔,我用了逗號 —— 在 llama-bench 那是「掃多組設定」,結果四個臂全部跑在 GPU 0。破綻是 pp512 四種配置差 0.15%。重跑中,這次會驗每張卡有沒有吃到權重。
已撤回MTP 接受率低的原因是 KV 位元寬。2-bit → 3-bit,JSON 從 47.0% 到 87.1%。 8/12 的六臂掃描推翻了它(見本頁最上方)。留著這條是為了記錄我當時憑一次量測就寫了因果。同一段裡「3-bit 在 64K 仍有 64.65 倍併發餘裕」是對的,而且六臂又量了一次:2-bit 池 3.19M tok、3-bit 2.01M、f16 只有 0.55M。
量到KV 確實分散到四卡,而且超線性:TP1 370,085 tok → TP4 5,751,747 tok,15.5 倍不是 4 倍。TurboQuant 2-bit 給的併發是 f16 的 5.3 倍。
解決8 併發 30 以上做到了:Q4_K_S,c8 34.4,同一顆新權重、單人不輸 IQ4_XS。i-quant 在這個 build 裡沒有批次 kernel,ggml_mul_mat_a8 對它是空轉返回(M=2 到 512 耗時全是 0.03 ms,換算 3000 TFLOPS,V100 峰值 125)。
修正整天「27B 過不了工具呼叫閘門」是我自己的假警報 —— 測試腳本少了 --enable-auto-tool-choice 和 --tool-call-parser,生產的啟動腳本一直都有。
驗證fp16 對 Q4 的交叉點在 M = 32–64,和從算術強度推的 M ≈ 35 吻合。解碼端(M=1)量化快 8.8 倍,「fp16 更快」只在 prefill 區間成立。
做了本頁改成分類 + 類內最新在上,補上 TPS / TTFT 總表、27B 完整矩陣、KV 容量表,並把外部數據移到獨立表格。

全部資料 · 依主題分頁

自動產生 · 自有硬體推論與訓練研究狀態歷史快照 580 份 →2026-10-01_2354