~/blog/qwen38-dual-2080ti-tensor-parallel

改裝 2080 Ti 22G · part 14

[Benchmark] 兩張改裝 2080 Ti 玩 tensor parallel,Qwen3.8-27B 衝到 59.6 tok/s

cat --toc

TL;DR

兩張改裝 2080 Ti 22G 接上 llama.cpp 的 tensor parallel,同一顆 Qwen3.8-27B 從單卡 37.1016 tok/s 衝到雙卡 59.632 tok/s,262K context 開好開滿還能看圖。關鍵在 tensor parallel 要跟 MTP 投機解碼疊在一起才會動——各自單開都停在 37 上下,一起開才跳到 59.6。截至 2026-08-22,我找不到公開的 Qwen3.8-27B、雙 2080 Ti、llama.cpp tensor + MTP 這組配對量測;同顆模型的雙 5060 Ti,社群量到 65.9–76.0,可以當個對照。

前言

兩個人一起搬一張長桌,如果沒人喊口令,各走各的,反而比一個人拖著還慢。多一雙手從來不是重點,那句口令才是。兩張顯示卡合跑同一顆模型也一樣:插上去很容易,難的是讓它們每算一層都對得上帳。

Part 13 講的是單卡上的 Qwen3.8-27B怎麼靠改設定檔從 41.6 拉到 47.6 tok/s。這篇往橫向走:跟上一篇單純調參數不同,這次牽涉到 llama.cpp 上游好幾個月的變動——把第二張 2080 Ti 接進來,讓 tensor parallel 跟 MTP 投機解碼疊在一起,看兩張老卡合力能不能把速度再往上推一截。

同一天量到的最好成績是雙卡 59.632 tok/s,262K context 開好開滿,還能看圖。過程踩了不少坑。第一幕先給能整段照抄的組態,外加三個會擋住你的雷;摔過的跤跟完整證據收在後面的進階區,不讀不影響你照做。

為什麼要接第二張卡

這條路以前也不是沒人試過——llama.cpp 長年靠 -sm row 做多卡切分(-sm 就是 split mode,切法的意思:row 是把每個張量橫著切一刀分給兩張卡,後面要講的 tensor 則是把同一層的運算整個攤開讓兩張卡並肩算),只是那條路今年年中被上游自己判了死刑,接手的是這篇要講的 -sm tensor。單卡的路也已經走到底。part13 那篇把 MTP 草稿深度掃過一輪,n=4 定案在 47.56 tok/s——這裡先說清楚,那是 64K 深 context harness、每發只吐 100 token 量出來的數字,跟這篇用的 400-token 冷 prompt 是不同的量測窗口,兩個數字不能直接放到同一條線上比。重點是那次掃描已經封頂:draft 深度再往上加,n=6、n=7 因為 VRAM 撐不住,伺服器開機就死,單卡這條路的縱深已經用盡。剩下能走的只有橫向——llama.cpp 剛好在這幾個月給了一條新路,把第二張改裝 2080 Ti 接進來。

這是什麼、你會拿到什麼

這篇要做的事很單純:同一顆 Qwen3.8-27B,改用 llama.cpp 的 tensor parallel(把同一層的矩陣運算切開,兩張卡各算一半,再把結果加總回來)攤開在兩張改裝 2080 Ti 22G 上,同時疊上 MTP 投機解碼(MTP = multi-token prediction,模型內建的草稿產生器:讓它一次先猜好幾個字,正式模型再一次驗完。猜中的部分等於用一次計算換到好幾個字,所以是白賺;猜錯就丟掉重算,最多打平,part13 講過)。單卡時代這兩件事是分開調的旋鈕;雙卡之後,它們變成要一起轉才有用的同一顆旋鈕,這也是這篇最重要的發現。

具體一點說:兩張卡各扛半顆模型,每算一層就要對一次帳,把彼此算出來的部分加起來。這個對帳動作叫 allreduce,是多卡運算的標準用語:大家各算一份,加總之後再把同一份結果送回每張卡手上。兩張卡之間的溝通品質,直接決定 tensor parallel 划不划算——溝通太慢,省下來的算力都賠在等待上。

MTP 走的是另一條路:草稿頭一次吐出好幾個字,正式模型一次驗完,猜對的就省掉重算。

tensor parallel 省的是「算得動」,MTP 省的是「算得快」,兩件事解決的是不同瓶頸,疊在一起才會出現這篇開頭那個 59.632 的數字。

這就是為什麼這篇的主角不是「多一張卡」,而是「兩個機制疊在一起」。

如果你手上剛好也有兩張塞得下這顆模型的舊卡,不確定要不要接起來跑同一顆模型,這篇的組態你可以直接照抄。

照著做完,你會拿到:

  • 同一顆模型從單卡 37.1016 tok/s 衝到雙卡 59.632 tok/s
  • 262K context 開好開滿,三種 KV cache 型別都能載入
  • --mmproj 掛著,看圖功能沒斷

你需要準備什麼

  • 兩張改裝 2080 Ti 22G——VRAM 是關鍵,27B 權重加上 KV cache 疊起來,沒有加大這一步的容量塞不進去
  • 一個新到吃得下 -sm tensor 的 llama.cpp build,同時要新到已經把 -sm row 砍掉,這篇用的是 b10064
  • Qwen3.8-27B 的 GGUF 權重,內建 MTP 草稿頭
  • 兩張卡插在同一台機器上,能被 CUDA_VISIBLE_DEVICES=0,1 指到

現在正式在跑的組態

這套組態你可以整段照抄——參數順序、環境變數都是當天轉正上線的原始版本:

CUDA_VISIBLE_DEVICES=0,1 \
GGML_CUDA_ALLREDUCE=internal \
llama.cpp-b10064/build-cuda75-nccl/bin/llama-server \
  -m <你的 Qwen3.8-27B GGUF> \
  -sm tensor -fa on -c 262144 \
  --spec-type draft-mtp --spec-draft-n-max 3 \
  --mmproj <mmproj GGUF> \
  # 主 KV 全部留 f16,不要加 -ctk / -ctv,卡上塞得下就別省
  # ...其他參數照舊

幾個關鍵旗標:-sm tensor 是真正的 tensor parallel;-c 262144 要手動給,因為 -sm tensor 目前不吃 llama.cpp 的自動配置功能;--spec-draft-n-max 3 是雙卡組態下量出來的冠軍值,單卡時代的經驗在這裡全部作廢,細節在進階區;GGML_CUDA_ALLREDUCE=internal 這一行一定要自己加上去,拿掉之後會發生什麼事,後面有完整死法。--mmproj 掛上去之後也順手驗收過:拿一張三色色塊圖問它是什麼顏色,紅綠藍三色都答對,看圖功能沒被雙卡設定拖累。

雙卡 tensor + MTP:37.1 到 59.632 tok/s

組態code 型 decode繁中散文21K 深填後
單卡 Q4_K + MTP n=4(當天量的基準)37.101618.6074
雙卡 -sm row(b9870,已被上游刪掉的模式)38.4308
雙卡 -sm tensor,不開 MTP37.5449
雙卡 tensor + MTP n=3 + 全 KV f16(現在正式在跑)59.63234.448.989

這表看最後一列就好:tensor parallel 跟 MTP 各自單開都停在 37 上下,一起開才跳到 59.6,中間那一格差不是加法,是疊乘。

雙卡 tensor + MTP:各自單開都停在 37,一起開才 59.6

單卡基準的接受率:code 68.22% / 70.17%,繁中 21.92% / 22.86%。接受率就是草稿通過驗證的比例,猜得越準,正式模型要重算的部分越少,這也是後面 n-sweep 要打敗的起點。轉正後單獨拆開兩發量:shallow-1 61.339 tok/s(accepted 281/352)、shallow-2 57.925(274/373),平均就是表裡的 59.632;深填(先灌 21,201 token 進去再量,模擬長對話養肥之後的真實情境)之後,decode 48.989(accepted 258/420),prefill 776.805 tok/s。兩張卡最終吃掉 GPU0 18888 MiB、GPU1 17738 MiB。

開起來之後想確認兩件事真的同時生效,不用等到跑分才知道:看 nvidia-smi,兩張卡的 VRAM 佔用跟功耗應該同時往上跳,只有一張在動代表 tensor parallel 沒吃到;丟一個長一點的請求進去,看 log 裡 accepted/draft 那幾個計數有沒有在跳,這篇後面所有 acceptance 數字都是從這個管道量出來的。

同顆模型放到別人的機器上比一比:雙 5060 Ti 這張現在市面上還在賣的新卡,社群公開量到 65.9 tok/s(n=2)/76.0 tok/s(n=3);我們這兩張二手改裝的 Turing 老卡(當時入手價:淘寶標價 ¥2079、到手含海運雜費約 NT$11,000 一張,兩張抓 NT$22,000)量到 59.632。跟他們同樣開 n=3 的那筆(76.0)比,我們慢約兩成;跟 n=2 的 65.9 比則差一成。價錢差的倍數比速度差的幅度大得多。公開資料裡也有雙 2080 Ti 跑 llama.cpp 的速度,落在 38–50 tok/s 之間,但那組是 Qwen3.6,模型版本不同,不能直接跟這篇的 Qwen3.8 放在同一條線上比。

截至 2026-08-22,我找不到公開的 Qwen3.8-27B、雙 2080 Ti、llama.cpp tensor + MTP 這組配對量測。

三個會直接擋住你的坑

  1. -sm row 已經是死的:上游 PR #24216(2026-07-06 merged)把它的 CUDA 實作整段刪掉,拿現在的 build 開下去,model load 就會死,錯誤是 device CUDA0 does not support split buffers。這個錯誤訊息看起來很像「這代卡不支援」,問題其實出在 build 版本被上游砍過一段,跟這代硬體支不支援無關——別浪費時間修這條路,直接換 -sm tensor。想先知道自己手上的 build 有沒有踩到這雷,可以跑 strings <你的 llama-server binary> | grep -c split_buffer,抓到 0 就代表已經被砍過。
  2. -sm tensor 不支援 --fit:llama.cpp 有個自動抓 context 上限的功能,-sm tensor 目前不吃這套,-c 要自己手動給,忘記給就等於你以為開好的長 context 其實只吃到預設值,而且不會報錯提醒你,你會以為自己在跑 262K,其實只有預設的那一點點。
  3. GGML_CUDA_ALLREDUCE=internal 是必需品,不是保險:雙卡之間要互相同步計算結果(allreduce),Linux 上如果沒有自己設這個環境變數,預設會走 NCCL,而 NCCL 這條路在這台機器上第一次真正呼叫就會直接炸掉。細節收在後面的進階區。

收尾

三個坑躲過去,兩張卡疊起來的速度就在那裡:37 到 59.6,中間沒有魔法,只是 tensor parallel 跟 MTP 疊在一起才會動。剩下的是怎麼撞出這條路——row split 怎麼死、NCCL 怎麼炸、n 值怎麼重洗、KV 型別怎麼選、8-bit 稅要不要付,全部收在下面,不讀不影響你現在就能照抄的這套組態。


進階:從 row split 到 Q8_0 的五段除錯記錄

不讀這節不影響使用,前面的步驟照做就能拿到同樣的結果。這節是給想深追的人和 AI 的完整除錯紀錄。

兩張卡怎麼接:三條路,兩條死——row split 載入就死、NCCL 第一次呼叫就炸、走 PCIe 的 internal 跑出 59.632

-sm row 開下去直接死,是這代卡不行嗎?

現在的 build(b10064)直接開 -sm row 想把第二張卡接進來,model load 那關就死,錯誤是 device CUDA0 does not support split buffers——split buffer 就是 -sm row 用來把一顆張量橫切成兩半、分別擺在兩張卡上的那塊記憶體結構,這行的意思是「這張卡沒有這種東西可以用」。

當時的預想是這代 Turing 硬體本身不支援 split buffer——要嘛換新卡,要嘛放棄雙卡。

做的事:讓研究用的 agent 去挖 llama.cpp 上游的 commit 歷史,對比不同 build 版本裡 split_buffer 這個字串出現幾次。

結果很乾脆:舊 build b9870 裡出現 45 次,現在的 b10064 是 0 次。PR #24216(2026-07-06 merged,標題就叫「CUDA: remove -sm row, refactor cuBLAS」)把這條路整段刪掉了。換回 b9870 重跑,真的能載入,兩卡跑出 mean 38.4308——對單卡基準只快 3.6%,落在雜訊裡,等於能跑但沒用。

反思預想:我以為缺的是硬體支援,實際缺的是「還沒被砍掉的 build」。支援與否是 build 相依,不是 GPU 世代相依——這兩件事長得很像,錯認了就會去買卡而不是換版本。而且就算找到還沒刪的版本,也只是換來一條已經死透的老路,真正要走的是繼任者 -sm tensor(PR #19378,文件到現在仍標 experimental)。

② NCCL 裝好、連結也對,為什麼一呼叫就死?

-sm tensor 要兩張卡互相 allreduce——把各自算出來的半成品加總、再同步回兩邊。Linux 上這件事預設交給 NCCL 處理,所以我先把 NCCL 裝起來。

當時的預想很單純:NCCL 裝上、CMake 抓得到、ldd 連結成功,那就是萬事俱備了。

做的事:apt 裝 libnccl2、CMake 顯示 Found NCCLldd 確認連到 libnccl.so.2,啟動伺服器。

結果第一次真正呼叫 ggml_backend_cuda_comm_allreduce_nccl 裡的 ncclAllReduce 就是 unhandled cuda error,伺服器當場死。這條路我一開始就定好規矩:炸一次就收手,不重試,所以「NCCL 這條路修好之後能再快多少」變成永遠的未測,不是已否——這兩件事差很多。

後來拉了一輪三方辯論決定要不要修這條路,結論是 NO-GO,而且兩條聽起來很合理的主張都被打掉了:allreduce 每次傳的量只有 micro-batch 大小乘上模型的 hidden dimension,遠小於整條 262K 序列(所以「長 context 會被互連卡死」是算錯的);NCCL_P2P_LEVEL=NVL 官方查無這個效果,不是修法。

反思預想:我以為「連結成功」就是「能跑」的證明。實際上連結只證明兩邊的符號位址對得上,第一次真正呼叫才是唯一的證據。而改走另一條路之後問題就沒了:GGML_CUDA_ALLREDUCE=internal(改走 PCIe、pinned host memory)直接跑出 59.632 tok/s,整篇文章的數字都是它跑出來的。這裡要老實講一件事:我始終沒查出 NCCL 為什麼會炸。我事前就決定這條分支只給一次機會,炸了就換路,所以「NCCL 修好之後會不會更快」在這台機器上是個沒答案的問題,不是已經有答案的問題。

③ n=3 不是上一輪的輸家嗎,怎麼換組態就翻身?

tensor parallel 加上 MTP 之後,要重新決定 --spec-draft-n-max 開多少——也就是讓草稿頭一次猜幾個字。

當時的預想是直接沿用單卡時代的經驗:n=2 或 n=4 是甜蜜點,n=3 反而是開起來比較差的那個。

做的事:在雙卡 tensor 組態下,把 n=2 到 n=7 重掃一次。

結果整個洗牌:

ncode decode備註
252.50acceptance 0.679
359.4415冠軍,acceptance 78.87% / 74.12%
457.7197
551.4095
744.3623第二發甚至掉到 37.1107,等於白開

n=6 這格因為前一輪的條件式判斷沒觸發,從頭到尾沒測過——是「未測」不是「不行」,這兩個結論的份量差很多。

反思預想:我把單卡量出來的 draft 長度甜蜜點當成模型的固定屬性帶了過來。實際上那個值是跟著整套組態(怎麼切、allreduce 怎麼走)一起變的,換一個維度就要整組重洗,舊答案一個都不能繼承。

④ 主 KV 量化:塞得下就別省

想知道主 KV cache 是不是也能像 draft cache 一樣量化來省 VRAM。

當時的預想是:先前那篇「量化 draft cache 反而更慢」的教訓在,主 KV 大概也會踩同一顆雷,但我沒把它跟 MTP 的接受率掛勾,以為頂多掉一點速度。

做的事:同一個 prompt,對 q4_0、q8_0、f16 三種主 KV 型別各跑一次淺 decode 跟 21K 深填後的 decode。

主 KV淺 decode淺 acceptance深 decode深 acceptance
q4_057.160.72945.010.579
q8_052.670.65551.200.719
f1659.960.80753.730.726

f16 全勝,而且 prefill 三種型別根本沒差(767.60 / 760.20 / 766.21 同一個量級),等於量化在這裡沒換到任何東西。ctx 階梯三種型別都載得進 262144,f16 在 262K 一張卡吃 17,670 MiB。

反思預想:我以為量化 KV 只是拿速度換 VRAM,實際上它會直接污染 MTP 的接受率——量化過的 KV 讓草稿模型的猜測跟正式模型的判斷更容易對不上,深填之後差距被放大到接近腰斬。這張卡目前塞得下 f16,那答案就很乾脆:塞不下才量化,塞得下就別省。

⚠️ 每個型別只有兩發樣本,絕對差值不能就這樣外推到別的組態。

⑤ Q8_0 權重:8-bit 稅跟 f16 KV 的天花板

想知道把權重從 Q4_K 換成 Q8_0 划不划算,順便看主 KV 用 q8_0 能不能撐到原生 262K。

當時的預想是:Q8_0 精度更高,速度會比 Q4_K 慢一點但差距有限,換上去大概是個穩賺的升級。

做的事:換裝社群版 Q8_0 權重(官方 GGUF repo 是 gated,改走未 gated 的社群版本),先跑一輪 code/繁中/深填,再疊上主 KV q8_0 衝 131K 跟 262K,最後試主 KV f16 配 Q8_0 權重,看能撐到哪一階 context。

結果分兩半。速度這半:code decode 55.13,對照正式在跑的 Q4(59.632)是 -7.5%;繁中 34.90,比 Q4 的 34.4 微升 1.5%;深填 46.10;VRAM 峰值 19,824 / 18,674 MiB。疊 q8_0 主 KV 到 131K 是 code 54.34、繁中 31.81、深填 42.51;推到 262K 則是 needle 49.93、深填 48.77、code 51.11,VRAM 峰 20,426 MiB、只剩 2,102 MiB 餘裕,大海撈針測試三種設定全過。

容量這半就沒那麼客氣:換回 f16 主 KV 想衝滿 262144,直接 CUDA OOM;K 用 f16、V 用 q8_0 的混合寫法也在 tensor-split 那關直接噴 assert;退到 196608 才穩穩過關,52.32 tok/s,峰值 20,944 MiB、餘裕只剩 1,584 MiB。

反思預想:我以為 8-bit 換來的是「精度多一點、速度少一點」的線性交換。實際上代價集中在 code 這種對細節敏感的任務(-7.5%),繁中散文幾乎沒感覺;而 f16 KV 的天花板比我以為的低很多,而且失敗得很直接——不是變慢,是 OOM 到開不了機,得退一整階 ctx。至於 Q8 該不該取代 Q4 正式上線,那要比的是輸出品質,我還沒測完;這裡先把速度跟容量的數字記下來。

三個被打臉的假設

這天被推翻的不只一個技術細節。動工前我把 -sm row 載入失敗讀成「這代卡不支援」——查下去才知道是上游把那段程式碼刪了,跟硬體無關;動工前我把「llama.cpp 雙卡沒有路」當成 row split 死掉之後的定案,結果被當晚的考古一次打臉:row 死了,tensor 活著;動工前我還以為「把模型攤到兩張卡上」本身就是那個加速,結果 -sm tensor 單開只有 +1.02%,是那天最洩氣的一個數字;連帶把單卡時代的 n=2/n=4 甜蜜點也當成模型的固定屬性帶過來,換了組態同樣全部作廢。三次都是同一種錯——把「我目前查到的邊界」當成「這件事真正的邊界」。

收穫

最花時間的地方:替 NCCL 這條死路收屍——裝到看起來萬事俱備、CMake 抓得到、ldd 連得上,結果第一次真正呼叫才炸,而且票面規則禁止重試,只能就地判死、換路線。

可以帶著走的判斷方式:支援與否先看 build,不看 GPU 世代——換一個沒被砍過這段程式碼的舊 build,就能立刻分清楚是硬體真的不行還是上游剛好刪掉;連結能不能建立起來,只證明兩邊符號位址對得上,第一次真正呼叫才是能不能跑的唯一證據;draft 長度、KV 型別這類超參數,換一個維度(單卡→雙卡、無 MTP→有 MTP)就要整組重測,舊答案不能繼承。

常見問題

`-sm row` 是不是還能用?
看你用哪個 build。上游在 PR #24216(2026-07-06 merged)把它的 CUDA 實作整段刪掉,拿現在的新 build 開下去,model load 就會死,錯誤是 `device CUDA0 does not support split buffers`。換回刪除前的舊 build(例如 b9870)還能載入,但速度只比單卡快 3.6%,等於雜訊,不值得為此留一個舊 build。
`-sm tensor` 支援自動抓 context 上限(`--fit`)嗎?
不支援。忘記手動給 `-c`,長 context 就沒吃到,而伺服器不會報錯,只會默默用自己的預設上限開起來,離你想要的 262K 差了一大截。
裝好 NCCL、`ldd` 也連得上,為什麼 `-sm tensor` 一跑就死?
連結成功不等於能跑。apt 裝好、CMake 找得到 NCCL、`ldd` 也連到 `libnccl.so.2`,但第一次真正呼叫 `ncclAllReduce` 就是 unhandled cuda error,伺服器直接死。目前判定是 NO-GO;`GGML_CUDA_ALLREDUCE=internal`(走 PCIe)是必須自己加上去的環境變數,不設的話 Linux 上預設會走 NCCL 那條死路。

接著讀

不想錯過新文章?

訂閱我確保不漏接!

隨時一鍵退訂。