~/blog/dgx-spark-bandwidth-ceiling-85-percent

DGX Spark · part 42

[Benchmark] DGX Spark 跑 Qwen3.8-27B 到 65 tok/s:無投機只有 12.3,差別全在草稿預算

2026-08-2319 分鐘閱讀#dgx-spark#gb10#sm121#nvfp4English
cat --toc

TL;DR

GB10 的頻寬 273 GB/s,Qwen3.8-27B NVFP4 的語言主幹 18.77 GB,每吐一個 token 就要整份讀一次,上限 14.54 tok/s。實測無投機 12.32,吃掉約 85%。我試了兩條被推薦的優化路徑想撈剩下的,一條量不出效果,一條 SM121 不支援。真正有用的只有投機解碼:DFlash2 配草稿預算 12,寫程式的工作十發中位 65.02(範圍 56.6 到 69.3),是基準的 5.28 倍。而從 53.89 到 65 之間差的不是方法,是我一開始把草稿預算開太小。

前言

你可以找十個壯漢來搬家,但門就那麼寬,一次只過得去一個人。多找人不會讓搬家變快,能動的只有兩件事:把家具拆小,或者讓每個人一趟多扛幾件。

這是 DGX Spark 系列第 42 篇。#19 量到 NVFP4 比 FP8 慢,把死因判給硬體;#25 修正了一半,指出 NVFP4 贏的地方在頻寬不在算力。這篇補上最後一塊:頻寬還剩多少,以及剩下的該怎麼拿

答案是頻寬幾乎沒剩了,而該拿的東西在另一個地方 —— 而且我一開始拿錯了,還是被人問了才發現。

先算一次:273 ÷ 18.77 = 14.54,你的上限不用跑 benchmark 就知道

大型語言模型吐字的時候,每產生一個 token,都要把整份權重從記憶體讀進晶片算一次。不是讀一部分,是整份。所以單流生成速度有一個很硬的物理上限:

頻寬 ÷ 每個 token 要讀的位元組數 = 每秒能吐幾個 token

GB10 的統一記憶體頻寬是 273 GB/s(LPDDR5X,NVIDIA 官方規格)。分母要小心:我用的 RadixArk/Qwen3.8-27B-NVFP4 整包 20.42 GB,但裡面有東西是純文字對話不會讀的。拆開 safetensors 的 header 逐張量加總:

部位大小每個 token 都讀?
語言主幹18.77 GB
vision tower0.86 GB只有給圖片的時候
MTP 頭0.79 GB只有開投機解碼的時候

看第一列就好:純文字對話的分母是 18.77 GB,不是 20.42。

273 ÷ 18.77 = 14.54 tok/s。這台機器跑這顆模型,不開投機解碼就是這個天花板。

頻寬天花板的機制圖:上方一道標著 273 GB/s 的窄門,每個 token 都要把 18.77 GB 的語言主幹整份搬過去,得出 14.54 tok/s 上限;實測 12.32 貼在上限下方,兩者之間只有一成多的縫隙。

實測 12.32,吃掉理論上限的 85%

SGLang 跑,context 262144、KV cache 用 bf16、mem-fraction-static 設 0.90。十次重複,每次的提示詞都加不重複前綴避開快取:

十發中位12.3162
最低 / 最高12.3022 / 12.3844
極差0.67%

換到長文和短對話,分別是 12.42 與 12.33 —— 三種工作型態差不到 1%。

這種穩定度本身就是證據。如果瓶頸在算力,不同工作型態的計算量不一樣,速度就會跟著跳;現在它紋風不動,代表決定速度的是「搬 18.77 GB 要多久」,跟你叫它做什麼無關。

12.32 ÷ 14.54 = 約 85%

我試了兩條路想撈剩下那 15%,兩條都空手

那道縫隙要跟排程、取樣、框架本身的 overhead 一起分。但這是推論,所以我去試了兩個有外部依據的候選,都只跑不開投機的基準:

做法依據結果
把容器 pin 到十顆 Cortex-X925 大核心MiaAI-Lab 實測 +2~7%量不出來,效應 +0.029%,而自身跨輪漂移 0.751%
flashinfer_cutedsl 後端SGLang 的選項清單裡有不支援,啟動就被擋

第二個的錯誤訊息很乾脆:

mm_fp4 does not support backend 'cute-dsl' with capability 121

第一個更值得說。效應量 +0.029% 遠小於漂移不說,方向還在兩輪之間翻面(第一輪 +0.056%,第二輪 −0.135%)—— 那是雜訊的長相,不是效果的長相。

所以「換 kernel 撈不回來」不再只是我從算式推的,是去試過兩條路都空手。

唯一的槓桿:讓一趟讀取吐出多個 token

投機解碼(speculative decoding)的作法是:先用一個很小很快的模型猜出接下來好幾個 token,再讓大模型一次驗證這一整批。猜對的直接採用,猜錯的丟掉重來。

關鍵在於「驗證一批」跟「產生一個」對大模型來說,讀取權重的成本是一樣的 —— 都是把 18.77 GB 搬一次。所以只要猜得夠準,一趟就能換到好幾個 token。

這正是 DGX Spark 的主場。它用統一記憶體,驗證那一批幾乎不用額外成本;換到一台算力吃緊的機器上,驗證本身就會變成新瓶頸,紅利就吃不到了。

投機解碼為什麼能突破天花板:左邊是一般 decode,搬一次 18.77 GB 換一個 token;右邊是投機解碼,同樣搬一次,但一次驗證多個候選 token,接受長度 7.48 的情況下一趟換到將近七個半。

65 tok/s 從哪裡來?不是換方法,是把草稿預算從 8 調到 12

三種投機解碼都試過,DFlash2 最快。但第一輪我拿到的數字是 53.89,而不是 65 —— 差的那一截只在一個旗標上。

DFlash2 草稿預算寫程式的 decode接受長度
8(recipe 預設)53.896.12
1265.02(十發 56.6–69.3)7.48
1669.218.13

那個 8 是哪來的?是我照抄別人 recipe 的預設值。 而照抄預設等於繼承別人的調參,不等於繼承別人的最佳解。

判斷「還有沒有空間」要看的指標是飽和度,也就是接受長度除以草稿預算:

方法接受長度草稿預算飽和度判讀
DFlash26.12876.5%快用滿了,加預算有機會
MTP4.16583.2%用得最滿,被預算卡住
DSpark4.68858.6%猜不準,加預算沒用

不是看接受率。接受率高可能只是因為你的預算開太小,它當然容易猜滿。

一個數字紀律:這裡的中位數不能只跑五發

草稿預算 12 這一格我跑了兩次。第一次五發,中位 68.57;第二次全新起容器跑十發,中位 65.02,範圍 56.6 到 69.3。

五發那次的中位落在分佈的高側。 投機解碼的接受率跟生成內容直接相關,所以它的離散度天生就寬 —— 這一格是 19.5%,而不開投機只有 0.67%。離散度這麼寬的量測,五發不足以定中位。

我原本要把 68 寫進這篇的標題,是被要求重現才擋下來的。這裡列 65,以及對同一次量測的無投機基準的比值 5.28 倍 —— 比值比絕對值可靠,機器冷熱不影響它。

同一個旗標,在寫程式上大幅變快,在短對話上變慢 12%

草稿預算不是愈大愈好,要看你拿它做什麼:

工作型態草稿預算 8草稿預算 16變化
寫程式53.8969.21大幅變快
長文28.1428.07−0.25%
短對話26.0422.97−11.8%

原因還是接受率:程式碼的下一個 token 好猜(73.1%),閒聊很難猜(28.1%)。猜不準的時候,更大的草稿只是讓你付更多驗證成本、丟掉更多。

所以推薦表要分流,不能給一組全域預設:

工作型態用什麼實測
寫程式DFlash2,--speculative-num-draft-tokens 1265.02(十發 56.6–69.3)
長文DFlash2,預算 828.14
短對話DFlash2,預算 826.04
只能用權重內建的 MTPEAGLE,steps 5 / topk 4 / draft 1641.57

看到別人貼「DGX Spark 跑到 XX tok/s」的時候,第一個要問的是他在跑什麼工作,不是他用什麼設定。

開了投機解碼,產出的內容會變 —— 但這不能怪投機解碼

這件事我原本以為不用寫,查完發現一定要寫。

同一個提示詞、temperature=0、固定 seed,不開投機跟開 DFlash2 產出的程式碼不一樣。兩份都是合法的實作,差異從一個 docstring 裡的大小寫開始(Cachecache),之後就各走各的。

我一開始猜是浮點的隨機抖動,測了發現不是:同一組設定連跑三次,兩邊都是逐位元完全一樣。所以是兩條各自穩定、但彼此不同的路徑。

真正的答案來自一個完全不含投機解碼的對照 —— 無投機的伺服器,同一個請求,一次單獨送、一次同時送兩個:

batch=1        39e462cb7494b66f...
batch=2 之一   b660d8f884b9d35c...
batch=2 之二   b660d8f884b9d35c...

光是把並行請求從 1 個變成 2 個,輸出就變了,而同一批裡的兩個請求彼此完全一致。批次改變矩陣乘法的加總順序,浮點不滿足結合律,在機率幾乎平手的位置就足以讓選擇翻面。

所以投機解碼的差異是同一個現象。任何會改變 batch 形狀的東西都可能這樣,不是投機解碼特有的缺陷。但你該知道它會發生 —— 尤其如果你有那種「同樣輸入必須得到同樣輸出」的測試。

照著做:這台機器上跑 Qwen3.8-27B 的配置

環境是單台 DGX Spark(GB10 / SM121 / aarch64 / 128 GB 統一記憶體),驅動 580.159.03。用 MiaAI-Lab 的 SGLang recipe,那是我找得到把 DFlash2 在這台機器上跑得起來的公開作法。

--mem-fraction-static 0.90            # 不要用 0.95
--context-length 262144               # YaRN 關閉
--kv-cache-dtype bf16                 # 不要用 fp8
--fp4-gemm-backend flashinfer_cutlass # 不要用 auto
--speculative-algorithm DFLASH
--speculative-num-draft-tokens 12     # 寫程式用 12,短對話用 8

第一條是因為 0.95 配上比較高的同時請求數,實測會把整台機器弄到重開。第三條有兩個獨立理由:DFlash 投機解碼與所有 KV cache 量化根本不相容,因為它的 draft 需要 non-causal attention,而各家 backend 都拒絕這個組合,engine 直接起不來(vLLM #41559);另外我自己在 2026-03-30 量到過 fp8 KV 會讓輸出陷入無限重複。第四條是因為這版 SGLang 不會把 auto 改寫成它最後選中的後端,用 auto 之後就查不出它到底跑了哪個 backend。

還有一個跟設定無關但會讓你懷疑人生的坑:DFlash2 的串流回應,一個事件平均帶 3.75 個 token。用事件數算速度會得到大約 9 tok/s,看起來像壞掉了。一律用回應裡的 completion_tokens 除以時間。


進階:量測過程、六個判斷錯的地方,以及完整數據

不讀這節不影響使用。上面的結論已經完整,這節是拿到那些數字的過程、我判斷錯的地方,以及所有原始值。

我以為 dense 模型會吃到頻寬紅利,結果沒有

當時的狀況:之前在 GB10 上量 NVFP4 全部是 MoE 模型,而 MoE 每個 token 只點亮少數 expert,本來就稀疏,權重位寬減半省下的頻寬相對有限。Qwen3.8-27B 是 dense,每個 token 讀全部權重。

預想的解:所以我以為 dense 的 NVFP4 會明顯超過同尺寸的 FP8,甚至可能翻倍。

做了什麼:照上面的設定跑三個工作型態,拿不開投機的數字。

結果:12.4 左右。而 NVIDIA 開發者論壇上同模型 FP8 的回報大約 15 到 20 —— 那是別人的機器與設定,不是對照組。

反思:我的推論漏了一層。位寬減半確實省下頻寬,但省下來的東西只有在「頻寬是唯一瓶頸」時才會全部變成速度 —— 而它本來就已經是唯一瓶頸,所以省下的頻寬已經反映在 12.4 這個數字裡了。而且我拿來對照的 FP8 數字來自別人的機器、別人的引擎、別人的提示詞,那不是對照組,是三個變數一起變的兩個數字。

85% 這個數字,我第一次算成 93%

當時的狀況:第一次算天花板,我直接拿 du -sb 量模型目錄,得到 20.42 GB。

預想的解:模型有多大,每個 token 就要讀多少。

做了什麼:273 ÷ 20.42 = 13.37,實測 12.44,算出 93%。數字漂亮,差點就這樣寫出去。

結果:拆開 safetensors header 逐張量加總之後,發現裡面有 vision tower 0.86 GB 和 MTP 頭 0.79 GB,純文字對話一個位元組都不會讀。真正的分母是 18.77 GB,天花板 14.54。

反思:du 是衍生數字,我卻拿它當基準。1.65 GB 的誤差聽起來不多,但它讓「頭上還剩多少」從 7% 變成一成多,整整一倍。判斷「還值不值得優化」的時候,那是兩個不同的答案。

怎麼證明它沒有偷偷反量化?用頻寬,不要用 log

當時的狀況:NVFP4 有一個經典失敗模式:引擎讀進 4-bit 權重檔,但算之前先在晶片上反量化回 BF16,於是省下的頻寬一分都沒省到。這種情況下檔案是 4-bit,速度卻是 BF16 的速度。

預想的解:去啟動 log 裡 grep,看它選了哪條 kernel 路徑。

做了什麼:grep 了,拿到 fp4_gemm_runner_backend='flashinfer_cutlass',沒有 marlin(最典型的反量化 fallback)。

結果:這個證據不夠硬。log 印出格式的名字,不代表那條路徑真的在跑 —— 我在另一張卡上被同樣的字串騙過一次。所以改用頻寬反推:

假設每 token 足跡天花板實測 12.32 佔
真的讀 4-bit18.77 GB14.5485%
先反量化成 FP8 再讀37.5 GB7.28169%
先反量化成 BF16 再讀75.1 GB3.64338%

看後兩列:如果它偷偷反量化了,實測速度會超過物理上限,那不可能發生。所以權重確實以打包後的 4-bit 寬度被讀取。

反思:這個判斷方法比讀 log 硬,因為它不依賴引擎有沒有誠實印出自己在做什麼。任何「我的量化到底有沒有生效」的問題,都可以先用這招問一次。

兩根便宜槓桿,兩根都死

當時的狀況:12.3 離 14.54 還有一成多,想知道那裡面有沒有東西可以撈。

預想的解:兩個候選都有外部依據 —— 大核 pinning 別人量過 +2~7%,flashinfer_cutedsl 在 SGLang 的選項清單裡有但 auto 沒選它。

做了什麼:A/B/A/B 區塊交替設計,兩個配置各跑兩輪、每輪 5 次,配置之間冷卻。用區塊交替而不是一個跑完再跑下一個,是因為連續長跑有熱漂移,交替可以讓漂移對兩邊影響相同,而且兩輪之間的差就是漂移本身的量

結果:大核 pinning 效應 +0.029%,漂移 0.751%,而且方向跨輪翻面。flashinfer_cutedsl 直接被 capability gate 擋掉。

反思:這裡有一個我原本會犯的錯 —— 我差點用「效應量只有 2~7%、小於我的雜訊門檻,所以測不出來」當理由不做這個實驗。那是錯的:我的門檻是拿去跟外部公開數字比的,同一台機器上同一個 session 的變異只有 0.4%。效應量小於雜訊是量測設計問題,正解是把雜訊壓下去,不是放棄。

最貴的一次:我把草稿預算開太小,而且是被問了才發現

當時的狀況:第一輪三種投機解碼跑完,DFlash2 53.89 全桶最快,結論看起來很乾淨。

預想的解:三種方法各自用它們的推薦設定,誰快誰就是快。

做了什麼:MTP 給 5 個草稿 token,DSpark 和 DFlash2 給 8 個 —— 後兩者照抄 recipe 預設,前者是我自己寫死的。我甚至明文禁止掃描這個參數,理由是別人在另一個推論引擎上量到預算開大反而變慢。

結果:被問「會不會是哪邊搞錯了」之後回頭看接受長度,MTP 的飽和度是 83.2% —— 它把配給它的 5 個用掉 4.16 個,是三者最高。它被預算卡住,不是猜不準。 重跑之後 DFlash2 在預算 12 明顯更快。

反思:兩個錯疊在一起。第一,對照本身不公平,我拿 5 個預算的方法去跟 8 個的比。第二,更嚴重的是我拿另一個引擎的參數掃描結論,去禁止自己引擎的實驗 —— 那個結論在它的脈絡裡是對的,但它不是我這台機器的證據。省下的是半小時,代價是一整天。

第二貴的一次:我用五發就想把數字寫進標題

當時的狀況:預算 12 那格五發中位 68.57,我把它寫進了推薦表和文章標題草稿。

預想的解:五發中位夠了,其他配置的離散度都只有 2~8%。

做了什麼:被問「可以跑一下確認一下真的這麼快嗎」之後,全新起容器重跑十發。

結果:中位 65.02,範圍 56.63 到 69.30,離散度 19.5%。五發那次的中位落在分佈高側。

反思:我用一個平均離散度去推斷一個特別不穩的配置。投機解碼的接受率跟生成內容綁在一起,它的抖動天生就比不開投機大一個量級。離散度超過 15% 的量測,五發不足以定中位。 而這個數字本來要進對外文章的標題 —— 寧可現在垮,不要發出去才垮。

一個我提出來、然後被自己的數據推翻的模型

第一輪的三個點都套得上一個很漂亮的關係:

decode ≈ k × baseline × 接受長度      k 落在 0.69–0.74

我把它寫進第二輪的工單當受測假設,要求每個新配置都算 k 值。結果:

配置k帶內?
DFlash2 draft 120.700
DFlash2 draft 160.685邊緣
MTP steps7/topk1/draft80.576
MTP steps5/topk4/draft160.610

模型被推翻了。 接受長度不足以單獨預測速度 —— 直線鏈草稿跟樹狀草稿的提案成本結構不一樣,不能共用一個係數。

寫在這裡是因為它示範了一件事:估算的正當用途是設計實驗(該量哪一格、預期落在哪),不是取消實驗。 它被證偽,而證偽本身就是這次量測的產出之一。

一個會讓你追錯方向的坑

想直接指定 FP4 後端做對照,加了 --fp4-gemm-runner-backend 這個旗標。容器立刻 exit(2),而啟動腳本的就緒檢查完全不知情,對著一個從沒開過的埠一直 curl 到自己逾時。整整八分鐘我以為是「服務起不來」。真正的錯誤訊息在容器 log 裡:

sglang serve: error: unrecognized arguments: --fp4-gemm-runner-backend flashinfer_cutedsl

那個欄位在 server 參數裡叫 fp4_gemm_runner_backend,但 CLI 不接受直譯的破折號寫法。

「旗標打錯」跟「服務起不來」在就緒檢查那一層長得一模一樣,兩邊都是連不上,而分辨它們的資訊只存在於另一個檔案。以後寫就緒檢查,要先確認被檢查的東西真的還活著,再開始等它變好。

完整數據

所有回應的 cached_tokens 都是 0,前綴快取確認沒生效。速度一律用 completion_tokens 除以實際耗時,不是串流事件數。每一發的輸出長度都固定在 520 個 token(finish_reason 全部是 length),所以差異純粹是耗時。

⚠️ 標示 5 發的那些格子,精度不如標示 10 發的那兩格。 見上面關於離散度的那節 —— 這裡照實標註,不要把它們當同等強度的證據。

第一輪:三種方法,各方法用自己的預設預算(各 5 發)

方法寫程式長文短對話
無投機12.4412.4212.33
MTP n=535.71 / 79.2%24.42 / 45.8%21.11 / 37.5%
DSpark 預算 842.88 / 52.8%19.36 / 15.5%17.64 / 13.4%
DFlash2 預算 853.89 / 73.1%28.14 / 30.1%26.04 / 28.1%

第二輪:公平對照與參數掃描(只跑寫程式,括號設計 REF→M1→M2→M3→D1→D2→REF,漂移 1.307%,各 5 發)

參數decode接受率接受長度飽和度
REF_AMTP steps4/topk1/draft535.7079.40%4.1683.2%
M1MTP steps7/topk1/draft838.8063.10%5.4267.7%
M2MTP steps4/topk4/draft838.5851.45%4.6057.5%
M3MTP steps5/topk4/draft1641.5729.75%5.4734.2%
D1DFlash2 draft1268.5762.95%7.8865.7%
D2DFlash2 draft1669.2147.40%8.1350.8%
REF_BMTP steps4/topk1/draft536.1779.64%4.1983.9%

第三輪:獨立重現(全新容器,各 10 發)

配置十發原值中位範圍極差
無投機12.32 / 12.38 / 12.33 / 12.34 / 12.31 / 12.30 / 12.34 / 12.31 / 12.31 / 12.3112.316212.302–12.3840.67%
DFlash2 draft1256.63 / 63.76 / 68.46 / 68.17 / 66.68 / 62.98 / 64.56 / 65.48 / 63.47 / 69.3065.018156.63–69.3019.5%

比值:65.0181 ÷ 12.3162 = 5.28 倍

補一則對 #19 的更正

系列 #19 我寫過這麼一句:

這是晶片級限制。沒有任何 driver 更新、firmware 刷新、軟體 patch 能補上一條不存在的電路。

那句話是錯的。 #25 已經修了一半,指出 mma.kind::mxf4nvf4 在 W4A4 那條路真的會跑;這篇的頻寬反推把另一半也修掉 —— 權重確實以 4-bit 寬度被讀取,晶片沒有缺那個能力。#19 當時看到的慢,是軟體層挑錯 kernel 路徑,不是硬體缺電路。

我把 #19 留著沒重寫,只在頂部加了更新提示。判斷錯的過程留著,比事後改成正確的樣子有用。

收穫

最花時間的地方是那八分鐘的假死。旗標打錯跟服務起不來,在就緒檢查那一層完全分不出來,而分辨它們的資訊在另一個檔案裡。

可以搬走的診斷方法有三個。頻寬反推:你的量化到底有沒有生效,不用讀引擎的 log,把實測速度跟各種假設下的物理上限比一次,超過上限的假設直接淘汰。飽和度:投機解碼還有沒有空間,看接受長度除以草稿預算,不要看接受率。以及最後那個 batch=1 對 batch=2 的對照 —— 要判斷某個差異是不是某個功能造成的,最乾淨的作法是找一個完全不含那個功能、卻能重現同樣差異的設定

而這整件事最讓我在意的不是那幾倍,是它藏起來的方式。我拿別人量過的結論,禁止了自己該做的量測;又拿五發的中位,當成可以寫進標題的數字。兩次都是用一個看起來夠好的理由,省掉一次該做的觀察 —— 而兩次都是被問了一句話才翻出來的。

常見問題

DGX Spark 跑 Qwen3.8-27B 最快能到幾 tok/s?
寫程式的工作上,DFlash2 投機解碼配草稿預算 12,十發實測中位 65.02 tok/s,範圍 56.6 到 69.3,是同一台機器無投機基準的 5.28 倍。長文與短對話低很多,分別是 28.14 與 26.04,因為投機解碼的接受率跟工作型態直接相關。
為什麼我換了 kernel、換了後端,DGX Spark 的速度都沒什麼變?
因為單流 decode 卡在把權重從記憶體搬進晶片,不是搬進去之後的計算。實測已經吃掉頻寬上限的約 85%,剩下的還要跟排程、取樣、框架 overhead 分。我實際試過兩條被推薦的優化路徑,一條量不出效果,一條在 SM121 上根本不支援。
投機解碼的草稿預算(num-draft-tokens)要設多少?
看工作型態,不是看機器。同一顆模型同一台機器上,把 DFlash2 的草稿預算從 8 調到 12,寫程式的工作明顯變快;但調到 16 之後,短對話反而慢了 11.8%。判斷還有沒有空間要看飽和度,也就是接受長度除以草稿預算,不是看接受率。
開了投機解碼,產生的內容會跟沒開的時候一樣嗎?
不一樣,但那不是投機解碼的問題。我做過對照:完全不開投機解碼,只是把並行請求從 1 個變成 2 個,輸出就變了。批次會改變矩陣乘法的加總順序,浮點又不滿足結合律,在機率幾乎平手的位置就足以讓選擇翻面。任何會改變 batch 形狀的東西都可能這樣。

接著讀

不想錯過新文章?

訂閱我確保不漏接!

隨時一鍵退訂。