Muse Glimmer 30B kquant-dynamic(19.7 GB)· llama.cpp 8e7f22b(自建,arch 70,gcc 12.4)· 單卡 V100-SXM2-32GB · -ngl 99 -fa on · KV f16 · -c 8192 · /v1/chat/completions · temp 0 · max_tokens 300 · 三趟取中位數。對照的 Qwen3.6-27B 數字出自 /opt/llama.cpp(未動過的參照樹)
| 臂 | tok/s | 草稿 | 接受 | 接受率 | 每步 token | c |
|---|---|---|---|---|---|---|
| base(不開推測) | 32.63 | — | — | — | 1.000 | — |
| DFlash 預設 | 36.55 | 428 | 155 | 36.2% | 2.069 | 0.847 |
| DFlash n-max 8 | 31.55 | 901 | 184 | 20.4% | — | — |
| DFlash n-max 16 | 31.05 | 1,565 | 191 | 12.2% | — | — |
| 把成本除以「每步多驗幾格」 | 遞迴 block | 多驗幾格 | c | 每格的 c |
|---|---|---|---|---|
| Qwen3.6-27B · llama.cpp 單卡 · MTP | 48 / 65 | 1 | 0.298 | 0.298 |
| Muse Glimmer 30B · llama.cpp 單卡 · DFlash | 0 / 52 | 2.95 | 0.847 | 0.287 |
| Qwen3.6-27B · vLLM TP4 · MTP(最佳旗標) | 48 / 65 | 1 | 1.595 | 1.595 |
同一個引擎、同一張卡,一顆 65 層裡 48 個帶 GDN 遞迴狀態、一顆 52 層全注意力零遞迴,每多驗一格的邊際成本差 4%。遞迴狀態不是驗證變貴的原因。
我今晚一直把「48/65 帶遞迴」當成 c=2.01 的根因,寫進報告、也寫進給 fusion 委員會的知識庫 —— 委員會回來的三個候選全部建立在這個錯誤前提上,包括「qlen=2 對遞迴層是時間軸掃描」那個看起來最合理的。對照組花了兩小時,殺掉的是一整晚的推論方向。
差別在引擎:vLLM 每多驗一格要 1.595 個基準步,llama.cpp 兩顆模型都是 0.29。這讓 spec_sequence_masks is None 那個守衛從候選之一變成主嫌 —— 它是一條純引擎側、與模型架構無關的路徑選擇。
限制要講清楚:llama.cpp 兩顆都是單卡,vLLM 是 TP4,那一列同時跨了引擎和拓樸,不能當成純引擎差異的定量證據。但 llama.cpp 內部那兩列是同引擎、同卡數、兩種極端不同的架構,那組就足以殺掉遞迴假設。
27B Fable-Fus-711…MTP-IQ4_XS · vLLM 1.2.1 SM70 · TP4(V100-SXM2-32GB ×4,NVLink NV2 全網)· max-model-len 65536 · util 0.50 · block 32 · batched 16384 · seqs 8 · prefix caching 開 · MTP draft=1 · capture [2,4,8,16] · IQ4XS batched-MMVQ 與 tile-MMQ 兩顆都開 · 六臂同一 session、只換 --kv-cache-dtype
| KV | KV 池 | c1 解碼 | c1 接受率 | c4 解碼 | c4 接受率 |
|---|---|---|---|---|---|
| 2-bit · 不開 MTP | 3,191,988 | 59.25 | — | 48.52 | — |
| turboquant 2-bit | 2,513,305 | 33.80 | 51.5% | 24.85 | 50.3% |
| turboquant 3-bit | 2,010,644 | 33.41 | 50.0% | 26.49 | 61.5% |
| turboquant 4-bit | 1,615,145 | 33.47 | 52.3% | 27.10 | 62.4% |
| K 8-bit · V 4-bit | 1,230,798 | 34.73 | 51.5% | 27.95 | 61.9% |
| f16(完全不壓) | 546,613 | 33.71 | 51.5% | 27.77 | 61.8% |
單人接受率那一欄從 2-bit 到 f16 是 51.5 / 50.0 / 52.3 / 51.5 / 51.5%。壓到 2 位元和完全不壓,同一個數字。所以 llama.cpp 那邊的 72.4% 不是 KV 換來的,我先前把它寫成因果是錯的。
四併發那一欄確實會動(50.3% → 62.4%,3-bit 之後打平),但吞吐只從 24.85 走到 27.95 —— α 動了、S 沒動,正是 c 綁住時公式預測的樣子。這一輪反而是 c 是主因的正面證據。
f16 的代價很實在:KV 池只剩 546,613 token(2-bit 的 17%),四併發 TTFT 從 1.25 s 掉到 5.11 s。工具閘門六臂全 ALL_PASS。
這一輪的基準臂不能用。我把 capture 設成 [2,4,8,16] 套給所有臂,不開 MTP 時 batch=1 沒被捕捉、掉出 CUDA graph,所以 59.25 比同組態該有的 69.10 低。各 MTP 臂之間仍可比,但那個 59.25 不是基準。
27B IQ4_XS · vLLM 1.2.1 SM70 · TP4 · KV turboquant_2bit_nc · max-model-len 65536 · util 0.50 · block 32 · seqs 8 · capture [2,4,8,16](無推測臂用 [1,2,4,8])· 兩顆 IQ4_XS kernel 都開 · NOSYNC=1 · MTP draft=1 · 八臂同一 session,c 用本 session 自己的無推測臂算,不從記憶拿
| 臂 | c1 解碼 | 接受率 | 推測步 ms | c | TTFT |
|---|---|---|---|---|---|
| 不開推測 | 68.88 | — | 14.52 | — | 0.43s |
| MTP 原樣 | 34.29 | 51.5% | 44.18 | 2.043 | 1.46s |
FULL_FORWARD | 34.03 | 50.0% | 44.08 | 2.036 | 2.86s |
SPEC_CORE_OP | 39.62 | 49.3% | 37.68 | 1.595 | 4.42s |
003_SPEC_CORE_OP | 38.49 | 44.9% | 37.65 | 1.593 | 2.88s |
PACKED_RECURRENT_DECODE | 34.72 | 51.5% | 43.63 | 2.005 | 1.36s |
GDN_DECODE_FLASHQLA | 34.37 | 51.5% | 44.08 | 2.036 | 0.77s |
FUSED_SIGMOID_MIXED_QKV | 34.50 | 49.3% | 43.28 | 1.981 | 3.54s |
| 疊加 | c1 解碼 | 接受率 | c | TTFT |
|---|---|---|---|---|
FLASH_GRAPH 單獨 | 34.55 | 51.5% | 2.020 | 1.55s |
FLASH_GRAPH + SPEC_CORE_OP | 39.96 | 52.3% | 1.625 | 0.68s |
SPEC_CORE_OP + FUSED_SIGMOID | 38.70 | 45.6% | 1.61 | 3.61s |
SPEC_CORE_OP + PACKED_DECODE | 39.81 | 52.3% | 1.635 | 1.29s |
SPEC_CORE_OP 與 003_SPEC_CORE_OP 的 c 是 1.595 和 1.593 —— 幾乎同值,所以兩條路徑應該接到同一個底層改動。前者吞吐較高純粹是接受率沒被拖低(49.3% vs 44.9%)。其餘五個旗標全落在 c≈2.0,是雜訊。
最好的 MTP 現在是 39.62,對基準 68.88 仍是 0.575 倍。損益平衡要 c < α = 0.493,從 1.595 還差 3.2 倍。這一刀把 29.2 ms 的額外成本壓到 23.2 ms,離 llama.cpp 的 7.6 ms 還有 15.6 ms。
注意 SPEC_CORE_OP 單獨用時 TTFT 是 4.42s,基準的十倍 —— 解碼買到的 15.5% 是拿 prefill 換的。疊上 FLASH_GRAPH 就沒事了(見上表:0.68s,接受率還回到 52.3%),所以這個代價不必付。
同一顆 GGUF Qwen3.6-27B-Fable-Fus-711…MTP-IQ4_XS · 同一台 V100-SXM2-32GB。llama.cpp = 上游 b0.15.3、單卡、KV f16、-fa on · -c 8192 · --spec-type draft-mtp · p-min 0(完全不篩)· temp 0 seed 1 · 200 tok。vLLM = 1.2.1 SM70、TP4、KV turboquant_2bit_nc、max-model-len 65536 · util 0.50 · seqs 8 · 兩顆 IQ4_XS kernel 都開 · NOSYNC=1。兩邊都是 γ=1
| 引擎 | 基準 tok/s | 步 ms | α | c | S 預測 | S 實測 | MTP tok/s |
|---|---|---|---|---|---|---|---|
| llama.cpp · 單卡 | 39.16 | 25.53 | 0.722 | 0.298 | 1.327 | 1.327 | 51.97 |
| vLLM · TP4 | 68.86 | 14.52 | 0.515 | 2.010 | 0.503 | 0.503 | 34.64 |
S = (1+α)/(1+c) 對兩台引擎、四個獨立量到的數字各自命中到三位數。損益平衡條件是 α > c:llama.cpp 是 0.722 > 0.298 所以賺,vLLM 是 0.515 < 2.010 所以虧一半。而基準反過來 —— vLLM 的 68.86 是 llama.cpp 39.16 的 1.76 倍,因為它的 all-reduce 在 CUDA graph 裡,而 llama.cpp 的多卡是逐 seam 同步。
| 把 llama.cpp 的優點搬過來 | α | c | S | tok/s(基準 68.86) |
|---|---|---|---|---|
| 現況 | 0.515 | 2.010 | 0.503 | 34.64 |
| 只修 α | 0.722 | 2.010 | 0.572 | 39.4 · 仍然虧 |
| 只修 c | 0.515 | 0.298 | 1.167 | 80.4 |
| 兩個都修 | 0.722 | 0.298 | 1.327 | 91.4 |
c 是壓倒性的槓桿。只修接受率、把 α 拉到 llama.cpp 的水準,在 c=2.01 之下只有 39.4 —— 連基準的一半都不到,還是虧的。反過來只修 c、接受率原封不動,就有 80.4。我原本準備先追「草稿頭被餵錯」那條線,這個算式把順序倒過來了。
c=2.01 表示每個推測步多花 29.2 ms,而草稿頭是 65 層裡的 1 層;llama.cpp 做同一件事只花 7.6 ms。那 21.6 ms 的差距不可能是算力。
27B IQ4_XS · vLLM 1.2.1 SM70 · TP4 · KV turboquant_2bit_nc · max-model-len 65536 · util 0.50 · block 32 · seqs 8 · 兩顆 kernel 都開 · 三臂同一 session,MTP 兩臂只差一個環境變數
| 臂 | c1 解碼 | c4 解碼 | 接受率 | 工具閘門 |
|---|---|---|---|---|
| 不開推測(capture 含 1) | 68.86 | 48.59 | — | ALL_PASS |
MTP draft=1 · NOSYNC=0 | 32.42 | 24.45 | 51.5% | ALL_PASS |
MTP draft=1 · NOSYNC=1 | 34.64 | 25.05 | 51.5% | ALL_PASS |
build_gdn_spec_decode_state_contract 用布林遮罩挑出推測那幾列。每個 x[mask] 編出來都是 nonzero() 加 index(),而 nonzero() 必須把命中數讀回 host 才能決定輸出張量多大 —— 一次完整的裝置同步,一次 forward 好幾個。批次同質時(單人必然,多人只要沒有 prefill 插隊也是)遮罩全 True,x[mask] 就只是複製一份,x[~mask] 是空的,兩者都不需要 nonzero。而判斷同質與否的計數呼叫端已經在 CPU 上算好了,問它零成本。
基準步 14.52 ms,MTP 步從 46.73 掉到 43.74 ms → c 從 2.22 降到 2.01。損益平衡要 c < α = 0.52,還差四倍。接受率完全沒動(51.5%),證明這是純步成本的改動,沒有動到數值。
但這一輪最重要的不是那 6.8%。草稿頭是 65 層裡的 1 層,它多做的那次 forward 照理該花基準步的 1/65。實測花掉 2 個基準步 —— 差 130 倍。這個量級的落差不可能是算力,是管線。同步只佔其中 0.21。
同上一節,只換 num_speculative_tokens;capture 集合跟著改成 (1+γ) 的倍數(γ=1 [2,4,8,16] / γ=2 [3,6,12,24] / γ=3 [4,8,16,32]),否則會被靜默丟掉
| γ | 草稿 | 接受 | pos0 | pos1 條件 | pos2 條件 | 總接受率 | c1 解碼 |
|---|---|---|---|---|---|---|---|
| 1 | 730 | 368 | 50.4% | — | — | 50.4% | 33.21 |
| 2 | 1,374 | 410 | 44.8% | 33.1% | — | 29.8% | 30.81 |
| 3 | 2,034 | 419 | 43.5% | 30.5% | 37.8% | 20.6% | 27.86 |
| 對照組(外部,同架構) | 97% | 95% | 91% | — | — | ||
position 0 的接受率 不隨 γ 改變(50.4 / 44.8 / 43.5%),所以「草稿開太短」不成立。加長只會把後面幾個爛位置算進分母,總接受率一路掉到 20.6%,吞吐跟著掉 16%。γ=1 已經是這台機器上最好的設定。
對照組的 97 / 95 / 91% 和我們的 50 / 33 / 38% 差一個量級,這不是調參數能補的距離。而 llama.cpp 在同一顆權重上報 72.4%,它跑的是 --spec-draft-p-min 0.5 —— 信心不足就不提草稿,那些位置不進分母。把 p-min 降到 0 就能分辨那 72.4% 是更好的草稿頭還是篩過的分母。
27B · TP4 · KV turboquant_3bit_nc · max-model-len 32768 · util 0.75 · block 32 · batched 16384 · seqs 8 · --spec-method mtp · c1 · 每個臂都在同一 session 內量,基準也在同一 session 重測
| 臂 | 解碼 | 接受長度 | 步時間 | 對基準 |
|---|---|---|---|---|
| 不開推測 | 58.13 | — | 17.2 ms | — |
| spec3 · Q4_K_S | 21.90 | 3.44 | 156.9 ms | 9.1x |
spec3 · Q4_K_S · SPEC_CORE_OP=1 | 23.86 | 3.49 | 146.2 ms | 8.5x |
spec3 · Q4_K_S · 003_SPEC_CORE_OP=1 | 23.55 | 3.45 | 146.4 ms | 8.5x |
| spec3 · IQ4_XS | 18.31 | 3.61 | 197.4 ms | 11.5x |
spec3 · IQ4_XS · SPEC_DECODE_PIECEWISE=1 | 5.00 | 3.49 | 698.2 ms | 40.6x |
| spec3 · Q4_K_S · TP1 | 12.05 | 3.49 | 289.5 ms | 16.8x |
接受長度 3.44–3.61,和對照組的 3.4–3.8 同一區間。一個推測步該換到 3.5 個 token,結果步成本是 8.5–11.5 倍,所以淨值是虧的。接受率和步成本是兩件事,現在乾淨分開了。
設定層面全部試過,沒有一個能修:PIECEWISE 讓它慢 3.5 倍、TP1 慢一倍(所以屏障不是問題,平行反而在幫忙)、兩個 spec-core 旗標各買 7%、換掉 i-quant 買 21%。
| kernel | 不開推測 | 開推測 | 比 |
|---|---|---|---|
elementwise_kernel<128,4,…> | 96 | 41,292 | 430x |
_scatter_gather_elementwise_kernel | 0 | 8,160 | 全新 |
fused_sigmoid_gating_delta_rule_update(GDN 本體) | 28,704 | 8,064 | 0.28x |
不開推測跑 300 個 token(約 300 步),開推測只跑 84 個(約 23 步)。換算成每步:0.32 次對 1,795 次,多五千倍。而 GDN 本體的 kernel 反而更少(0.28x)—— 所以問題從來不在遞迴層的計算,在把 token 拆來拆去的搬運。
程式碼裡有一條免拆分的快路,但條件是:
ddtree_tree_gdn_pure_spec = ddtree_requires_branch and num_prefills == 0 and num_decodes == 0
它綁在樹狀推測上,而 MTP 是線性鏈,所以永遠進不去。單一請求開推測時 non-spec 那半根本是空的,卻還是照拆、照跑一遍空張量。
這條線上我給過的機制:遞迴狀態頻寬(算出來差三個數量級)、GDN 對序列迴圈(profiler 顯示啟動次數 1.02x)、ticket18 中等批次 kernel(開關對照,關掉還略好)、PRE_CONV_DIAG 逐層同步(被 _diag_preconv_done 擋住,每層只印一次)。四個全錯,四次都是實測推翻的。
還有一次是量測本身無效:index_select 歸因跑了兩個臂、拿到 454 對 448 看似「沒差」,但 request done 沒印出來 —— 伺服器還在載入就被我的迴圈誤判成死掉,那些計數是模型載入與 graph 捕捉的,不是解碼的。誤判來自拿 setsid 之後立刻退出的 PID 當存活判據。已改成看 pgrep,重跑中。
權重 17408 × 5120(此模型的 ffn 形狀)· V100-SXM2-32GB · cuda event 計時 · 30 次取平均 · fp16 那欄包含它必須付的解量化成本
| 列數 M | 量化 kernel | 解量化 + fp16 | 贏家 |
|---|---|---|---|
| 1 | 0.074 ms | 0.651 ms | 量化 8.8x |
| 8 | 0.177 ms | 0.656 ms | 量化 3.7x |
| 16 | 0.328 ms | 0.664 ms | 量化 2.0x |
| 32 | 0.647 ms | 0.686 ms | 量化 1.1x |
| 64 | 1.286 ms | 0.790 ms | fp16 1.6x |
| 128 | 2.584 ms | 1.020 ms | fp16 2.5x |
| 256 | 5.153 ms | 1.068 ms | fp16 4.8x |
| 1024 | 20.612 ms | 2.304 ms | fp16 8.9x |
| 2048 | 41.325 ms | 4.368 ms | fp16 9.5x |
質疑是「fp16 比 Q4 快不合理」。在解碼那一端完全正確 —— M=1 時量化快 8.8 倍。「fp16 更快」只在 M 超過約 48 之後才成立,而那是 prefill 的區間。
從算術強度推的交叉點是 M ≈ 35(V100 的機器平衡點約 139 FLOP/byte,Q4 在 M 列的強度是 4M),實測落在同一格。贏的不是位元組,是算力等級:量化 kernel 走 dp4a 用不到 tensor core,fp16 GEMM 走 HMMA。
可用的發現:DEQUANT_CUBLAS_MIN_ROWS 目前是 256,但 64–255 這一段 fp16 已經快 1.6–4.8 倍,門檻設得保守。
冷 = prefix cache 沒命中,暖 = 同一份提示再問一次。長 context 的代價幾乎全在冷啟動,所以 128K 能不能用,取決於同一份文件會不會被重複問。
| 情境 | context | 冷 TTFT | 暖 TTFT | prefix 命中 | 時期 | 註記 |
|---|---|---|---|---|---|---|
| 9B · TP4 · c1 | ~200 tok | 0.23s | 0.16s | 0% | GDN 開 | |
| 9B · TP4 · c1 | ~8K tok | 1.13s | 0.76s | 57% | GDN 開 | |
| 9B · TP4 · c1 | ~28K tok | 8.93s | 0.57s | 98% | GDN 開 | |
| 9B · TP4 · c8 | ~28K tok | 8.93s | 3.28s | 98% | GDN 開 | |
| 27B · TP4 · c1 | ~200 tok | 1.30s | 0.35s | 0% | GDN 開 | |
| 27B · TP4 · c1 | 105,856 tok | 390.90s | 31.10s | 96% | 現行 | 128K 配置 |
| 27B · TP4 · c8 | 105,856 tok | 390.90s | 247.50s | 96% | 現行 | 每人 |
| 27B · TP4 · c1 | 115,645 tok | 492.80s | 16.70s | 命中 | 現行 | needle 探針同一份提示 |
max-num-batched-tokens 16384 → 65536:冷 TTFT 412.42s → 304.04s(−26.3%)。block-size 16 → 64:暖 TTFT −15%。
27B IQ4_XS 開 MTP draft=1,每人 decode 從 c16 的 12.22 tok/s 掉到 c24 的 4.28,步時間 81.8 → 233.5 ms,而 batch 只加倍。而且 c24 與 c32 是 233.5 / 234.1 —— 幾乎同值。兩個不同 batch 花一樣的時間,通常代表它們走的是同一條路,而那條路是 eager。
一個推測步驟送出的是 席次 ×(1+draft) 個 token,vLLM 只保留能被那個倍數整除的 capture 尺寸。同一組 [1,2,4,8,16,32] 送進去,兩臂捕捉到的張數不同 —— 這是唯一的破綻,而它就印在 server log 裡:
| 組態 | 要求的 capture | 實際捕捉 | 可用席次上限 | c24 每人 |
|---|---|---|---|---|
| 不開 MTP | [1,2,4,8,16,32] | 6 張 | 32 | — |
| MTP draft=1 | [1,2,4,8,16,32] | 5 張 | 16 | 4.28 |
| MTP draft=2 | [1,2,4,8,16,32] | 0 張 | — | 連啟動都沒過 |
| MTP draft=1 | [1,2,4,8,16,32,48,64] | 7 張 | 32 | 8.17 |
draft=1 丟掉的是尺寸 1(不能被 2 整除),剩下 2,4,8,16,32 個 token,對應 1,2,4,8,16 個席次。draft=2 要 3 的倍數,那組裡一個都沒有。
第一次量到改善時,同一輪 c8 也從 17.97 掉到 9.82、工具呼叫閘門從 ALL_PASS 變成 FAILED。但那兩組數字跨了 session,而本機的規則是只有同 session 的比值算數。所以三臂重跑,只差 capture 集合:
| 併發 | old [1,2,4,8,16,32] | pow2 […,32,64] | full […,32,48,64] |
|---|---|---|---|
| c1 | 30.24 | 30.38 | 30.47 |
| c8 | 9.71 | 9.89 | 9.89 |
| c16 | 9.58 | 9.58 | 9.56 |
| c24 | 3.76 | 7.87 | 8.16 |
| c32 | 3.66 | 7.59 | 7.59 |
| toolcall ×2 | 兩次都 FAILED | 兩次都 FAILED | 兩次都 FAILED |
c8 三臂一樣,所以那個「退步」不存在 —— 17.97 在同 session 裡重現不出來。工具呼叫閘門連舊集合都失敗,所以也不是這次改動造成的。capture 對齊因此是乾淨的淨勝:c24 2.17 倍、c32 2.07 倍,小併發完全不動。含 48 比只含 2 的冪次在 c24 多 4%,沒有代價。
同一組態、同樣的舊 capture,在兩個 session 之間 c8 是 17.97 對 9.71 —— 差兩倍,原因未知。這不是這次改動的問題,但它是所有併發數字的背景雜訊:跨 session 的比較在這台機器上不成立,要下結論的對照一律同場跑完。
Leviathan、Kalman、Matias(Google Research,ICML 2023)的牆鐘加速:
S = (1 − αγ+1) / ((1 − α)(γc + 1))
α = 接受率,γ = 草稿長度,c = 草稿成本 ÷ 目標模型成本。Corollary 3.9 直接給出損益平衡條件:α > c。
| 論文 Table 2 | 我們 | |
|---|---|---|
| 目標模型 | T5-XXL 11B,單張 TPU-v4,batch 1 | 27B IQ4_XS,TP4 V100 |
| α 範圍 | 0.53 – 0.82 | 0.39 – 0.53 |
| c 範圍 | 0.007 – 0.073 | 2.04 |
| 加速 | 1.4x – 3.4x | 0.49x |
論文自己的數據就在證明 α 不是槓桿:Table 2 裡接受率最高的一列 —— T5-large,α = 0.82 —— 加速卻是最差的 1.7x,因為它的 c 最大(800M/11B)。而 α 只有 0.75 的 T5-small 拿到 3.4x。
DeepSeek-V3 報告說 MTP 模組是一層 transformer,重用主幹最後一層的 hidden state、共用 embedding 與 output head,所以它的固有 c ≈ 1/65 ≈ 0.015 —— 正好落在論文的範圍內。
我們的 2.04 來自論文 Section 3.3 的前提被違反。原文是:「we'll assume that we can run γ+1 concurrent evaluations of Mp in parallel without increasing the walltime」。在我們機器上 M=2 的前向要 1.85 倍,那個假設是假的,而差額就是我們的 c。
再往下一層:M=1 時矩陣乘只佔 8.4 ms,非矩陣乘佔 9.0 ms(52%),一次前向發 2,297 顆 kernel。一個 27B 的 decode 該有九成是讀權重,我們只有四成八 —— 所以第二個 token 位置幾乎要付全價。這同時解釋了單人只有 69 tok/s(頻寬的 27%)和 MTP 必賠,是同一個病灶。
推測解碼的整個前提是「多驗一個位置幾乎免費」。對 attention 成立 —— 多寫一格 KV,被拒絕就丟掉,零成本。對 linear attention / GDN 不成立:遞迴層沒有「丟掉一格」這種操作,它只保留最後一個 token 的狀態。
gpu_model_runner._update_states_after_model_execute:
"This is used for MTP/EAGLE for hybrid models, as in linear attention, only the last token's state is kept. In MTP/EAGLE, for draft tokens the state are kept until we decide how many tokens are accepted ... and a shifting is done during the next iteration based on the number of accepted tokens."
而那組緩衝區只在 speculative_config is not None and model_config.is_hybrid 時才配置 —— 純 transformer 開 MTP 完全不會走到這條路。
| 這顆 27B 的 block | 數量 |
|---|---|
帶 SSM 遞迴狀態(ssm_a/alpha/beta/conv1d/dt/norm/out) | 48 |
純 attention(attn_q/k/v/output) | 17 |
| 合計 | 65(64 層 + 1 個 MTP 層) |
第一次我用 startswith("attn_q") 判斷注意力層,結果把 GDN block 的 attn_qkv(那是 GDN 的輸入投影)也算進去,得到「48 對 65、42%」這種加起來超過總 block 數的結果。正確是 48 對 17。而且本機七顆 27B 變形 —— 含 Qwen3.6-27B-OFFICIAL-MTP-IQ4_XS —— metadata 完全一致:qwen35、65 blocks、48 個 SSM、ssm.state_size 128。Qwen3.6-27B 本來就是混合架構。
trace 的指紋完全對上。以下 kernel 在不開 MTP 時前 25 名根本看不到,開了之後全部冒出來 —— 而且它們是搬資料的,不是算數的:
| kernel | nospec | mtp | 做什麼 |
|---|---|---|---|
memcpy32_post | — | 1879 次 / 3.58 ms | 狀態搬移 |
_scatter_gather_elementwise | — | 268 | 依接受數散射 |
indexSelectSmallIndex | — | 268 | 選出要保留的 slot |
CatArrayBatchedCopy | — | 268 | 狀態串接 |
我一度寫成「混合架構所以 MTP 必輸」。一份獨立實測在同一個架構的 Qwen3.6-27B 上,MTP γ=3 拿到 2.40 倍。而那篇量到的回捲稅是把 c 從 0.20 推到 0.30 —— 足以把 1.17x 變成 0.96x,但離我們的 2.04 差了近七倍。
所以回捲稅解釋得了「為什麼混合架構比純 dense 吃虧」,解釋不了「為什麼我們比別人的混合架構還差七倍」。那一段還沒找到,而它才是我們真正該修的東西。
他們算 c 的方法也比我好:用每步讀多少 GB —— 27B Q8 的 verifier 讀約 27 GB、MTP 頭讀約 0.43 GB → c ≈ 0.016,而不是像我從 TPS 反解。
草稿頭精度:eh_proj 是 Q8_0 的那顆模型接受率更低(39.4% 對 4-bit 的 47.8%),S 兩者都是 0.45–0.49。本機 skill 裡「MTP 配方必須 gate eh_proj → q8_0」在這顆模型上不成立。
drafter 的 CUDA graph:把它完全關掉只差 0.5%(30.48 → 30.32)。往差的方向走不花錢,往好的方向自然也賺不到。
launch-bound:GPU 其實忙到 72–76%,eager launch 只有 835 次 / 9.19 ms。我先前算出「62–70% 閒置」是拿 5 個 step 的視窗去除以 16 個 token,分母錯了。
而且就算三者全修好也沒用:c = 2.04 之下,完美草稿(α→1、γ=1)也只有 2 / 3.04 = 0.66x。
我在上一節寫:nextn_predict_layers = 1,只有一顆 MTP 頭,所以 position 1 只有 15.2% 是架構決定的。這個解釋是錯的。
一份公開的單張 RTX 3090 紀錄跑同一顆 Qwen3.6-27B、同樣一顆 nextn 頭、同樣在 vLLM 上、同樣用 TurboQuant KV,拿到的是逐位置 97 / 95 / 91%、平均接受長度 3.4–3.8。單頭如果真的會讓 position 1 崩到 15%,那份紀錄的 position 3 不可能是 91%。架構不是原因,我們這台的配置才是。
| 該紀錄(3090,單卡) | 我們(V100 ×4) | |
|---|---|---|
| 逐位置接受率 | 97 / 95 / 91% | 50.5 / 15.2% |
| 平均接受長度 | 3.4–3.8 | 1.50 |
| 不開推測 | 38 tok/s | 66.5(四卡) |
| 開 MTP | 63.8 → 85(加 TurboQuant) | 30.2(倒扣) |
| 模型格式 | AutoRound INT4,mtp.fc 解量化成 BF16 | GGUF,blk.64 跟著主體量化 |
(a) draft 頭被量化壞了。對照組刻意把 MTP 那層留在 BF16。我們指定使用的 IQ4_XS 版本,blk.64 整層(含 nextn.eh_proj)都是 IQ4_XS。draft 頭失真不會產生錯誤輸出 —— target 每個 token 都會驗 —— 它只會提出一堆被打回票的 token,症狀就是低接受率。
(b) 七路合併位移了 hidden state。這個檔是 DavidAU 七個微調的合併,而 nextn 頭是對原始 base 訓的。若是這個,requant 救不回來,只能換權重檔。
切開的方法:Q4_0-requant 對 Q4_0-ehq8-requant —— 同一次 requant,只差 eh_proj 一個張量(Q4_0 對 Q8_0)。分岔 → 是 (a),修法是把 blk.64 gate 到 Q8_0 重做一次 requant;不分岔 → 是 (b)。
| 模型 | eh_proj | 接受率 | tg |
|---|---|---|---|
| Q4_0-requant | Q4_0 | 41.7% | 42.85 |
| Q4_0-ehq8-requant | Q8_0 | 40.5% | 42.35 |
| IQ4_XS | IQ4_XS | 47.2% | 47.19 |
| Q4_K_M | Q8_0 | 49.4% | 35.19 |
分離對照沒有分岔 —— 41.7% 對 40.5% 在雜訊內,而且量化最重的 IQ4_XS 接受率反而最高。四種量化全部落在 40–49%,和 eh_proj 的精度沒有相關性。所以把 blk.64 gate 到 Q8_0 重做 requant 不會有用,這條路不用走了。
另外兩個獨立佐證都指向同一邊:換引擎不改變答案(vLLM 量到 50.5%,llama.cpp 量到 40–49%),而模型作者自己的 card 就寫著「接受率低於 50% 就不該用 MTP 版」—— 我們正好卡在那條線上。
我一度懷疑是 8/6 為了 +24% 關掉的 VLLM_SM70_QWEN_GDN_FULL_FORWARD 污染了接受率 —— fork 的註解把 full forward 稱為「active-MTP 品質防護」。
不是。原始碼裡那個旗標只是 force,另有 auto 會在偵測到 speculative config 時自行武裝;而 8/8 與 8/9 兩次量測的伺服器 log 都印了 full-forward guard armed (force=False, auto=True, spec_core=False)。防護當時是開著的,用當時的 log 就能判定,不必重跑。
官方 safetensors 直轉的 Qwen3.6-27B-IQ4_XS-mtp,同量化型別、同引擎、同提示、同 session,各兩趟:
| 模型 | 接受率 | tg |
|---|---|---|
| 官方 Qwen3.6-27B | 60.7% / 60.7% | 52.8 / 53.9 |
| 合併 Fable-Fus-711 | 47.2% / 46.2% | 47.1 / 47.0 |
60.7% 離對照組的 97/95/91% 還差 34 個百分點,而那段幾乎全在量測條件。同一顆合併模型,只換內容類型:
| 內容 | 接受率 | tg | 實際產出開頭 |
|---|---|---|---|
| 高重複 | 99.0% | 77.10 | The quick brown fox jumps over… |
| 結構化 JSON | 85.4% | 69.56 | [ { "id": 1, "name": "Alice Johnson"… |
| 程式碼 | 70.9% | 60.68 | from __future__ import annotations… |
| 散文(原本下結論用的那題) | 47.2% | 47.35 | — |
同一顆模型從 47.2% 到 99.0%。我拿來下結論的那題是短提示、新散文、temp 0、冷啟動,而且 200 個 token 全被思考前言吃掉。對照組報的是 warmed 的 code/narrative,而且它自己就把兩者分開報(79.7 對 63.8 TPS)—— 兩邊根本不在同一格。
這對生產有直接意義:我們的工作負載是工具呼叫,那是結構化輸出,落在 85–99% 那一端,不是 47%。
第一次的內容對照整組作廢:官方模型對兩個完全不同的提示回傳一模一樣的 drafted 171 / accepted 141。我的驗證腳本比對輸出 hash、判定「巧合,數字有效」—— 判定是錯的。把生成內容印出來才看見兩份都是 <think> Here's a thinking process: 開頭的同一套樣板:這是推理模型,n_predict 200 根本走不到答案,每個臂量的都是同一段前言。
現在每個臂都印出實際生成的前 80 字。題目沒生效當場就看得出來,不必等某個數字剛好可疑。
還沒解決:官方那份 GGUF 不吃 /no_think(四個臂全都還是思考前言),所以官方欄仍在量前言,不能直接跟合併版的 70.9 / 85.4 / 99.0 比;另有兩格回傳空字串,標為無效,原因未查。
我先前寫「推測解碼在這裡結構上不可能贏,加速比 = τ/uq < 1」。那是錯的。這個技術的前提就是「驗證 k 個 draft 只花約一次 forward」——k+1 個 query 位置共用同一次 KV 掃描,所以加速比是 τ。產生 τ/uq 的是這個 fork 的後端,不是技術本身。
模型作者(DavidAU)的 model card 給了獨立佐證:同一顆模型在 llama.cpp 上,一般 GGUF 約 75 t/s、MTP 版可超過 90 t/s —— 那邊 MTP 贏 20%,我們這邊虧 65%。變數是引擎,不是模型也不是量化。
| spec-tokens | 接受率(greedy) | τ | position 0 | position 1 |
|---|---|---|---|---|
| 2 | 29.9% | 1.60 | 44.7% | 15.2% |
| 1 | 50.5% | 1.50 | 50.5% | — |
作者的 card 說接受率低於 50% 就不該用 MTP 版。我們在 spec-tokens 2 是 29.9%,降成 1 之後是 50.5% —— 剛好跨過門檻。原因在 metadata:qwen35.nextn_predict_layers = 1,這顆模型只有一顆 MTP 頭。要它吐第二個 token,只能拿第一個 draft 的隱藏狀態再推一次,誤差滾雪球 —— position 1 只有 15.2% 就是這麼來的。
溫度不是主因:作者建議的 temp 1.0 之下是 34.1%(spec 2)與 45.4%(spec 1),和 greedy 同一個量級。
接手檔原本寫「kernel 修好之後 τ ≈ 1.5–2.5x」。實測 τ 是 1.45–1.60,所以實際期望是 1.5–1.6x。已更正 —— 給錯的驗收目標比不給更糟,會讓一個成功的修正看起來像失敗。
27B 開 MTP 之後輸出崩壞,而且只在 spec-tokens ≥ 2 時發生。根因在 turboquant_attn.py:reorder_batch_threshold 是 2,所以 qlen 為 2(spec-tokens 1)走 _spec_decode_attention 正常,qlen ≥ 3 掉進 prefill 路徑,每一題都錯。那個 uniform-decode 迴圈寫成 for j in range(uq),本來就通用,只有這個門檻把 3 以上擋在外面。
| 臂 | threshold | 算術 | 複誦 | 序列 | 工具呼叫 | deg | decode/s |
|---|---|---|---|---|---|---|---|
| CTL_spec3(修正前) | 2 | 錯 | 錯 | 錯 | 0/3 | 0.21 | 21.7 |
| FIX_spec2 | 8 | 錯 | 對 | 對 | 3/3 | 1.00 | 24.9 |
| BASE_nospec(不開推測) | — | 錯 | 對 | 對 | 3/3 | 1.00 | 54.2 |
| FIX_spec3 | 8 | 錯 | 對 | 對 | 3/3 | 1.00 | 20.8 |
threshold 2 之下 12345×6789 答成 8333333333…、複誦只吐出一個 X、質數序列變成 2, 3, 5, 5, 5, 5, …1111111…,三個工具呼叫一個都沒發出來。threshold 改成 8 之後複誦、序列、三個工具呼叫全部通過,退化指標從 0.21 回到 1.00。
我原本從「兩個 threshold 8 的臂算術都錯,而且錯成不同的數」推論還有殘留 bug。無推測基準線把這一半推翻了:BASE_nospec 完全不開推測,算術一樣錯(8380905)。這題是 12345×6789,27B 心算五位乘四位本來就會錯 —— 那道題不是有效的正確性判別,複誦、序列與三個工具呼叫才是,而它們在 threshold 8 之下全過。
剩下的疑點沒有消失但弱得多:三個臂給出三個不同的錯答案(8380905 / 83810106 / 83809155),greedy 之下本該逐 token 相同。但推測解碼會把 qlen 從 1 變成 3,GEMM 形狀跟著變、浮點歸約順序跟著變,在接近平手的 argmax 上翻面是合理的 —— 這無法區分殘留 bug 與浮點非決定性。要判定得換測法:同一個提示,把開與不開推測的完整 token 序列做 diff,而不是問它答案對不對。這個探針要改。
同條件的無推測基準線量到 54.2 tok/s,對上 spec-tokens 2 的 24.9 與 spec-tokens 3 的 20.8 —— 退化 −54%。(先前我引的 54.3 來自另一個 session,當時不該那樣比;基準線重現到 54.2,所以那個比較事後看是站得住的,但理由是運氣不是方法。)
慢的機制是清楚的:_spec_decode_attention 把 uq 個驗證位置拆成 uq 次獨立的全 KV 掃描(out[j::uq] = self._decode_attention(...)),每一次都重掃整個 cache,所以成本是 1/uq。而接受長度 τ 實測 1.5–2.5,永遠不可能超過 uq —— 結構上就贏不了。
真正的解法是把 triton_turboquant_decode.py 改成多 query 的 kernel(目前簽章 Q_rot_ptr # [B, Hq, D] 只吃一個 query,累加器是 tl.zeros([BLOCK_D]))。天花板是 τ 倍,即 27B 約 80–135 tok/s。
fusion 委員會建議「把反量化融合進 attention kernel,可再擠 15–20%」。讀 triton_turboquant_decode.py(820 行)之後確認:反量化本來就在 kernel 內 —— 2/3-bit 索引解包、scale/zero 重建、centroid 查表全都在裡面,沒有先物化成 FP16 這一步。這條路沒有東西可以省。
MTP 在自由文本上是淨損,但工具呼叫的輸出高度重複(JSON 骨架、欄位名、<tool_call> 標籤幾乎每次相同)—— 那是 n-gram 命中率最高的地形。因此每個臂同時測工具呼叫與中文散文兩種負載:前者驗證假設,後者當對照組,確認它不會拖累一般對話。三種方法都不需要額外的 draft 模型。
| 方法 | 工具 c1 | 工具 c4 合計 | 散文 c1 | uniq | 備註 |
|---|---|---|---|---|---|
| baseline(無推測) | — | — | — | — | 排隊中 |
| ngram · 3 tokens | — | — | — | — | 排隊中 |
| ngram · 5 tokens | — | — | — | — | 排隊中 |
| ngram_gpu · 5 tokens | — | — | — | — | 排隊中 |
| suffix · 5 tokens | — | — | — | — | 排隊中 |
散文臂帶退化斷言。ngram_gpu 正是它攔下的:散文 25.6 tok/s 是所有推測臂最高,但輸出已是垃圾 —— 沒有閘門會被記成「最快」。但我後來去驗這道閘門,發現它對中文是瞎的。舊寫法用空白切詞,而中文不用空白,純中文輸出常常只切出一個 token,1/1 = 1.00,看起來滿分健康。在生產上實測校準,四種退化有三種拿到滿分:
| 樣本 | 字元 3-gram | 舊的 uniq-word |
|---|---|---|
| 中文正常輸出 ×3 | 0.92–1.00 | 1.000 |
| 英文正常輸出 | 0.578 | 0.713 |
| 單字重複 300 次 | 0.003 | 1.000 |
| 短句迴圈 | 0.022 | 1.000 |
| 兩句交替 | 0.034 | 1.000 |
| 英文詞迴圈 | 0.008 | 0.008 |
改用字元 3-gram(先去掉所有空白,中英文一視同仁):健康最低 0.578、退化最高 0.034,分離度 17 倍,門檻訂 0.40。這也代表先前那批推測臂裡若有純中文的退化,我根本看不見 —— `ngram_gpu` 是因為英文混雜才被抓到。
vLLM 的 ngram proposer 比對的是精確 token ID 序列,不是「這看起來像 JSON」—— 結構相似不等於 token 序列相同,所以工具呼叫的重複性並不會轉成命中率。而且失敗是正確性問題而非速度問題:三個推測臂都產不出合法的 tool_calls。同樣的量化 KV + ngram 失敗在 RTX 3090 上也有回報(vLLM issue #40831),因此不能歸因於 SM70 世代。