驗 ggml_mul_mat_a8 對 IQ4_XS 的正確性時,它跟獨立參考值的 max abs err 是 0.00000,三個 batch 大小都一樣。量化整數 kernel 跟 fp16 反量化再矩陣乘,光是累加順序不同就該有 1e-3 等級的差 —— bit-exact 相同只可能是兩者跑了同一段 code,或讀了同一塊記憶體。
時間說得更大聲:M=64 跑 20 次共 0.56 ms,換算 407 TFLOPS,而 V100 的 fp16 tensor core 峰值是 125。
profiler 給出答案:那一次呼叫只發射了 quantize_q8_1(量化 activation),之後沒有任何 matmul。輸出緩衝區從頭到尾沒被寫入 —— torch 的 caching allocator 把剛算完參考值、剛被釋放的那塊記憶體配給了它,所以比對讀到的是自己上一步的答案。
| 路徑 | 實際發射的 kernel | M=64 時間 |
|---|---|---|
| 反量化整顆 + cuBLAS | dequantize_block_iq4_xs 0.663 + cutlass 0.441 | 1.106 ms |
| mmvq | mul_mat_vec_q<block_iq4_xs> | 4.169 ms |
| mmq | 只有 quantize_q8_1 | 不存在 |
任何人若順手把 IQ4_XS 加進 MMQ_QUANT_TYPES 來修批次崩塌,模型會安靜地吐垃圾,而且不會報錯。目前生產沒中,只是因為 gguf.py 正好把 i-quant 排除在那條分支外。
做法上的教訓:正確性閘門要毒化輸出緩衝區,並且斷言誤差不為零 —— 對上獨立實作,零誤差是可疑不是優秀。再配一道 roofline(搬的位元組 ÷ 時間 對照卡的峰值),因為沒運算的 kernel 一定快得不合理,那是最便宜的破綻。
IQ4_XS 在 M>8 掉進「反量化整顆權重再 cuBLAS」,固定 1.106 ms 且不隨 M 變;同尺寸的 K-quant 走真 MMQ 在 M=16 只要 0.355 ms。mmvq 是線性的,與反量化路徑的交叉點在 M≈15,而 gguf.py 的門檻寫死在 8。
| M | mmvq | 反量化 + GEMM | 目前會選 |
|---|---|---|---|
| 1 | 0.086 | 1.112 | mmvq |
| 8 | 0.585 | 1.098 | mmvq |
| 12 | 0.871 | 1.101 | 反量化(慢 26%) |
| 16 | 1.157 | 1.076 | 反量化 |
| 64 | 4.408 | 1.214 | 反量化 |
| M | 新 kernel | dispatcher 現行會選的 | 倍數 |
|---|---|---|---|
| 4 | 0.213 | mmvq 0.283 | 1.33x |
| 8 | 0.214 | mmvq 0.529 | 2.48x |
| 16 | 0.205 | 反量化 1.017 | 4.95x |
| 32 | 0.421 | 反量化 1.083 | 2.57x |
| 64 | 0.616 | 反量化 1.202 | 1.95x |
| 96 | 1.005 | 反量化 1.410 | 1.40x |
端到端,同 session、只切 VLLM_GGUF_IQ4XS_MMQ、MTP draft=1:
| 併發 | 關 | 開 | 每人 | 合計 |
|---|---|---|---|---|
| c1 | 30.42 | 29.91 | −1.7%(對照組,M=2 兩邊都走 mmvq) | — |
| c8 | 9.72 | 14.71 | +51% | 72.7 → 108.8 |
| c16 | 9.58 | 12.54 | +31% | 140.2 → 186.4 |
| c24 | 8.15 | 9.85 | +21% | 182.4 → 217.7 |
| c32 | 7.60 | 9.00 | +18% | 225.3 → 265.9 |
兩臂產出的文字逐字相同。工具呼叫閘門兩邊一樣失敗 —— 那個問題在 kernel 關閉、也在每一組 capture 集合下都存在,不是這次引入的。
c1 開 kernel 是 29.97–30.01、關是 30.45–30.47,三輪六次讀數完全一致,不是雜訊。而 c1 的 M=2 根本沒進 kernel 的窗口。
我第一個假設是 dispatch 裡那行 os.getenv 每次矩陣乘都跑 —— 把它提到模組層級後只從 29.90 移到 29.99,假設是錯的。而且用算的就能排除:條件在 4 <= x.shape[0] 短路,開跟關差兩次整數比較,60 層算下來約 25 µs,實際差距卻是每步 0.6 ms,差 24 倍。
所以成因在別處 —— 最可能是 graph 捕捉時把 kernel 錄進去、連帶改變記憶體池佈局。我沒有證據指向更細的原因,就先照實記著。操作上的結論很明確:只有單人用就別開,有併發就開,1.5% 換 51%。
第一版全錯,相對誤差 ~1.0,因為我沿用了標頭的 QR4_XS/QI4_XS。那是 8 和 8,描述的是 MMVQ 怎麼把超區塊分給執行緒,不是 MMQ 的 tile 幾何 —— 用了它們,模板每次推進四個超區塊而 load_tiles 只給一個。現在用具名常數並綁了 static_assert。
正確性門檻本來是拍腦袋的絕對值,在 M=1 給過一次假警報:同一顆張量兩次隨機輸入是 9e-4 和 3.8e-2,差別只在分母是單列最大值。改成拿 mmvq 當標尺——「比要取代的那條路徑差嗎」——並固定亂數種子。九種權重形狀 × 五種 M,誤差跟 mmvq 一致到印出的精度。
真正的意外是 fork 裡早就有手寫的批次 kernel:mul_mat_q40_dec_y(M≤32,註記 vs fork 1.26–1.84x)與 mul_mat_q40_x64(32<M≤96,1.6–2.35x),runtime 編譯、env 開關、shape gate 一應俱全,但都卡在 qweight_type == 2 —— Q4_0 專用。
而 IQ4_XS 的 32 元素子區塊剛好等於一整個 Q4_0 區塊:都是 4 個 int,低 nibble 都是元素 0–15、高 nibble 都是 16–31,而泛型模板的 k += vdr=4 讓 k/4 兩邊都正好是子區塊索引。所以 tile 幾何原封不動,只有兩處要換:scale 存 d×(ls−32)(每子區塊一個,粒度與 Q4_0 相同),量化值經 kvalues_iq4nl 查表而不是 q−8。load_tiles_iq4_xs 與 vec_dot_iq4_xs_q8_1_mul_mat —— 見上一節,做完了。
llama.cpp 是參照組,不是候選。它證明了 MTP 在這顆模型上本來就該賺,所以 vLLM 這邊虧是我們的配置有問題 —— 這個證據值錢。但它自己不能當產線。
| 項目 | llama.cpp | vLLM(我們的 fork) |
|---|---|---|
| MTP | 38.8 → 55.9 t/s(+44%,單卡短 context) | 虧,qlen 2 的 target forward 是 qlen 1 的 1.88x |
| TurboQuant KV 解量化 | 沒融合。K 量化在 QKT 內圈,pp 掉 54% | 融合進 attention kernel |
| 四卡 | 四種模式沒有一個贏過單卡,見下 | TP4 可用 |
我先後報過 row 四卡 22.0(慢 43%)、再更正成 37.82、layer 38.9、tensor 34.47,並據此下結論說「四張卡沒有一個贏過單卡、多的三張卡什麼都沒做」。那些數字一個都不成立,結論也不成立。
破綻是 pp512:提示處理是算力綁的,四張卡該接近四倍,我卻拿到單卡 852.93、row2 853.34、row4 853.91、關 peer copy 854.24 —— 差 0.15%。真的分到四張卡不可能和單卡撞得這麼準。
原因在 llama-bench 自己印的表裡,我沒去看:ts 欄是 1.00,不是 1.00/1.00/1.00/1.00,而且 row2 印了兩對數據、row4 印了四對。-ts 的語法是 <ts0/ts1/..> —— 斜線分隔;逗號在 llama-bench 是「掃多組設定」。所以 -ts 1,1,1,1 被讀成「跑四次,每次 tensor-split = 1.00」,也就是全部塞在 GPU 0。四個臂量到的是同一件事。
這是同一天的第三次同類錯誤(還有 -ctk/-ctv 的位置配對、llama-cli 的 22.0):旗標吃進去、沒報錯、但做的不是我以為的事。重跑的版本不再下 -ts(預設就按 VRAM 平均切),並且每個臂在跑的時候取樣四張卡的記憶體,1–3 號卡沒吃到權重就把該臂標成 INVALID —— 不看旗標,看卡上有沒有東西。
| llama.cpp 拓樸 | 各卡峰值 (MiB) | pp512 | tg128 |
|---|---|---|---|
| 單卡 | 14645 / 10 / 10 / 10 | 855.54 | 39.24 |
layer 四卡 | 4041 / 3629 / 3835 / 4365 | 863.42 | 38.46 |
row 四卡 | 4891 / 4681 / 4685 / 5055 | 88.99 | 22.12 |
tensor 四卡 | 4519 / 4313 / 4313 / 4313 | 2333.09 | 33.61 |
提示處理是會縮放的:tensor 模式 2333 對單卡 855,2.7 倍。我先前說「多的三張卡什麼都沒做」是錯的。
row 模式是病態的,不是「不會變快」:pp512 掉到 88.99,比單卡慢十倍。先前那個假的 37.82 剛好把這件事整個蓋住。
撐過來的只有一項:解碼確實不隨卡數變快,三種模式都輸給單卡。但這次是真的量到的,而且理由清楚 —— batch 1 每層都要四路 all-reduce,65 層的同步次數吃掉頻寬省下的量。成本在屏障的數量,不在它的大小。
-sm tensor 一直 abort 在 ggml_backend_cuda_comm_allreduce_nccl,NCCL 印的是 Cuda failure 'CUDA driver is a stub library'。但 libcuda.so.1 指到的是真的 driver(580.173.02,96 MB),不是 stub —— 那句話是舊 NCCL 對「driver entry point 查不到」的誤譯。
版本對不上:系統 NCCL 是 2.18.3+cuda12.0(2023 年),driver 卻回報 cudaDriverVersion 13000。把 vLLM venv 自帶的 2.27.5+cuda12.9 用 LD_PRELOAD 蓋上去,tensor 模式立刻跑起來(exit 0)。
這一條不受上面那個 -ts 錯誤影響:崩潰與不崩潰是二元的,和權重切在哪張卡無關。但它當時測到的速度(34.47)跟其他四卡數字一起作廢。
硬體側也確認過,而且這部分是獨立量的:四卡全 NV2 網狀、nvidia-smi topo -p2p r 全 OK。所以四卡到底能不能加速,現在是未知而不是「不能」—— 重跑中。
先前的 llama.cpp 數字用 f16 KV,對上 vLLM 的 2-bit —— 那不是比較。補測之後,llama.cpp 開 TurboQuant KV 反而更慢,MTP 的增益也從 +26% 塌到 +2%。
受控組(llama-bench 的 -ctk/-ctv 是位置配對,所以這三列其實只量到 V):
| type_k | type_v | pp24576 | tg128 |
|---|---|---|---|
| f16 | f16 | 711.6 | 39.41 |
| f16 | tbq3_0 | 330.3(−54%) | 36.30(−8%) |
| f16 | tbq4_0 | 377.3(−47%) | 36.38(−8%) |
V 量化只花 8% 的生成速度。但 K 與 V 都量化時整體掉到 25.5 t/s,多出來的約 30% 全記在 K 頭上 —— K 在 QKT 的內圈,每個 query 對每個 key 都要重讀重解一次,V 只在加權求和時碰一次。-ctk f16 -ctv tbq3 是還沒測的中間操作點。
順帶排除:flash attention 不是變數(f16 開關 FA 是 703.5 對 710.5)。早先 llama-bench 中止是因為 tbq3 必須要有 FA,-fa 0 連 context 都建不起來。
V100 32GB 的 HBM2 是 900 GB/s。TP4 之下每卡每 token 要串的權重:9B 是 1.63 GB、27B 是 4.31 GB,對應的屋頂是 552 與 209 tok/s。實測 123.6 與 54.2 —— 22.4% 與 25.9%。而且 TP 每翻倍只 +30%(理想 +100%),擬合 T(n)=固定+w/n 得到約 45% 的解碼時間不隨卡數縮減。
我一開始把它講成互連頻寬問題。那是錯的 —— TP 之下權重切開放在各卡,跨卡的只有 activation,而且只在 row-parallel 層(o_proj、down_proj)之後把部分和加起來。payload 只有 hidden×2 bytes ≈ 8 KB,每 token 全部加起來約 1 MB,在 NVLink(NV2,每卡 6 條 ×25.78 GB/s)上是 20 微秒,可忽略。真正的成本是每 token 128 道同步屏障,不是頻寬。(拓樸已確認:四卡兩兩 NV2,不是 PCIe。)
output.weight 是 BF16,而其他所有權重都是 Q4_K/Q6_K。詞表 248,320 很大,所以這一個張量就是 9B 的 29.2%(1940 MB)、27B 的 13.8%(2425 MB),而且每產生一個 token 就要完整讀一次。
| 臂 | 大小 | c1 decode/s | c4 decode/s | think% | deg |
|---|---|---|---|---|---|
| Q4_0 · bf16head | 6,359 MB | 128.9 | 85.9 | 3 | 0.98 |
| Q4_0 · q6head | 5,198 MB(−18.3%) | 136.1 | 88.4 | 100 | 0.76 |
純頻寬模型預測 +22.3%,實測 +5.6%(c1)、+2.9%(c4)。位元組不是綁住解碼的東西 —— 這與「只跑到屋頂 25%」互相印證:真正的瓶頸在反量化的計算量、同步屏障與 kernel 效率,不在記憶體頻寬。所以「把 head 量化掉」這條路關掉:5% 的收益還要付 deg 0.98 → 0.76 的品質代價。
連帶也修正了先前那個 +20% TPS 的推估 —— 那是從位元組比例外推的,不是量出來的,實際只有四分之一。
這一輪有三個結果被丟掉(27B 的 28000/c4、c8,以及整個 q6head 臂),報成 only 0 content deltas,我一度當成模型壞掉。真相:工具只數 delta.content,而思考 token 走別的欄位。第一次修我改成也數 reasoning_content —— 還是 0,因為這個 fork 送的欄位叫 reasoning,不是 OpenAI 慣用的 reasoning_content。抓原始 SSE 才看到。工具現在兩個都吃,並多一個 think% 欄 —— 上表 q6head 的 c1 是 100% 思考,這種臂跟 3% 思考的臂放在一起比,本來就該標出來。
| 拓樸 | c1 decode/s | 該拓樸的記憶體屋頂 | 屋頂效率 | 每 token | 固定成本佔比 |
|---|---|---|---|---|---|
| TP1 | 69.7 | 138.7 | 50.3% | 14.35 ms | 42% |
| TP2 | 96.2 | 277.3 | 34.7% | 10.40 ms | 58% |
| TP4(生產) | 123.4 | 554.7 | 22.2% | 8.10 ms | 74% |
加卡只把可縮減的 8.32 ms 越切越薄,那 6.02 ms 動都不動 —— 所以屋頂效率一路從 50.3% 掉到 22.2%,到了 TP4 每個 token 有 四分之三 是這個固定成本。
我先前說固定成本是 row-parallel 那 128 道 all-reduce 屏障。錯了:TP1 一次 all-reduce 都沒有,固定成本照樣是 6.02 ms。今天被打掉的三個結構性推論依序是 —— 互連頻寬、LM head 位元組、同步屏障。共同模式是我從架構往下推,而不是先量。
順帶更正數字:先前的 TP1→TP2 +29.5%、TP2→TP4 +31.0% 是把兩組不同量測鏈接算的。同 session 直接量是 +38.0% 與 +28.3%,TP1→TP4 是 +77.0% 不是 +70%。
既然邊際報酬這麼差,同樣四張卡拆成四個獨立副本反而多得多:
| 配置 | 4 使用者總吞吐 | 16 使用者總吞吐 | 單人速度 |
|---|---|---|---|
| 1 × TP4(現況) | 123.4 | 153.4 | 123.4 |
| 4 × TP1 副本 | 278.8(2.3x) | 333.6(2.2x) | 69.7 |
單人速度掉 44%(123.4 → 69.7),前面要加一個 router。9B 只有 6.5 GB,一張卡一個副本很寬鬆(util 0.30 之後每卡才 9.5GB)。但啟動必須序列化 —— 四個模型同時載入正是先前疑似燒掉 PSU 的功耗型態,記憶體有餘裕不解除這條紀律。
那 6.02 ms 還沒定位。已知不是頻寬也不是通訊;剩下的嫌疑是 piecewise CUDA graph 在 21 個 splitting_ops(整條 qwen_gdn_* 都在裡面)上被切斷造成的逐層 kernel launch、GDN 本身、以及 248,320 詞表的 logits 與取樣。下一輪量這個。