vatt'ghern jaskier's ballads
本文 4 個互動圖表在手機上以重點摘要呈現,互動版請以桌面瀏覽器開啟。

五月初一個下午,vLLM 上的 DeepSeek-V4-Flash 在 MI300X 上開機失敗:FP8 cache 寫進去是一個 byte,讀出來剛好錯了一個 factor of two。整套 stack 沒有 segfault,沒有 NaN,只是模型輸出徹底亂掉——這是 fnuz 與 OCP 兩個 FP8 標準互看不順眼的味道。

DeepSeek-V4-Flash 在 AMD MI300X 上 bring-up——一份 ROCm 案場的時間軸

月初 Fergus Finn 把這個月在 doubleword 內部走過的 bring-up 寫成一篇案場日誌;本文順著那條時間軸把每一個壞掉的地方、找出根因的那一刻、以及最後把 2485 tok/s 推到 2699 tok/s 的優化都串起來看。

這不是一篇 vendor PR。

doubleword 是一家把 self-hosted inference 賣給 enterprise 的公司,MI300X 對他們是「能不能用、CP 值能不能打 H100」的商業判斷。

Fergus 寫的是這個判斷背後三十天的工程實作:哪一條 kernel path 是缺的、哪一條是壞的、根因在哪一層、最小修補是什麼、修完之後量到多少。

比起 marketing 端講的「MI300X 比 H100 便宜多少」,這份案場日誌更有用:它告訴你要把一個 frontier model serve 起來必須付出的具體成本。

本文的結構順著事情發生的順序走,五個事件、五個根因。

最後拉一條 takeaway——這條 bring-up 的軌跡對 inference backend 工程的一般教訓是什麼。

每一節的格式都一樣:先呈現症狀(reader 看到什麼),再說明嫌疑(為什麼懷疑這層),然後揭示根因(具體是哪一行 code),最後給出修補(哪一個 commit、改動的核心是什麼)。

這個格式刻意模仿 oncall postmortem 的節奏——bring-up 在本質上是一連串的 mini-postmortem,只是 trigger 不是 production incident,而是「按下 serve、什麼沒反應 / 反應錯了」。

開機之前——MI300X 已經在那裡兩年了

MI300X 在 2023 年 12 月發表。

它是 AMD 第一顆把 CDNA3 計算 die、HBM3、以及 chiplet I/O 全部塞到同一個 socket 的 datacenter GPU。

單卡 192 GB HBM3,是同時期 H100 SXM 的 2.4 倍。

FP8 算力標稱 2.6 PFLOPS,比同時期 H100 SXM 高出將近 30%。

紙面數字非常漂亮。

問題是這顆卡上線快兩年之後,主流 inference stack 對它的支援還在「能跑大部分模型,但有些剛發表的新東西不行」的灰色狀態。

在 H100 那邊,新 model release 跟新 kernel 通常同一週就上 vLLM。

在 MI300X 這邊,能不能 serve 變成一個要單獨評估的工程命題。

這個差距的成因不是 AMD 沒做事,而是 NVIDIA 的 inference stack 跟 model authors 之間的對接路徑長期固化——FlashAttention 作者、vLLM committer、PyTorch core team 與 NVIDIA 的 kernel 工程師之間是同一張通訊錄。

AMD 那邊的對應路徑這兩年才開始成形:ROCm team、AITER maintainers、社群裡的少數對 MI300X 有實機的開發者,連接還很鬆。新模型一發表,這條鬆連接需要時間消化。

從 vLLM 的角度看,這個灰色狀態的成因是兩條互相牽扯的依賴鏈。

第一條是 AITER——AMD 維護的 ROCm-native attention/MoE kernel collection,補的是 ROCm 上 PyTorch 沒有的 fused kernels。它的位置類似 H100 上的 FlashAttention 加 FlashInfer 加 cuBLASLt 的混合體,但覆蓋度落後 NVIDIA 那邊半年到一年。

第二條是 Triton,用來補 AITER 沒覆蓋到的 long-tail kernel。Triton 在 ROCm 上跑得起來,但 codegen 出來的程式碼跟 AMD MFMA 指令集的契合度不如 CDNA 自家寫的 kernel;用 Triton 做 fast path 是無奈之選,用它做 fallback 反而剛好。

在 H100 那邊 vLLM 把 FlashAttention 與 NVIDIA-tuned kernels 視為 first-class,跑得很順;在 MI300X 這邊每一條新的模型架構——尤其是用了 sparse MLA、MoE expert routing、新型 FP8 quant scheme 的——都要靠 AITER 加 Triton 兩條鏈接上才能跑。任意一條鏈缺了該模型需要的那個 op,模型就 serve 不起來。

DeepSeek-V4-Flash 是 2026 年初發表的開源 MoE 模型,三個架構元素同時撞上 ROCm 的灰色地帶。

第一是 sparse MLA attention。

Multi-head Latent Attention 把 K/V 壓到 latent space 算 attention。

sparse 變體再加一層 dynamic mask 只算被命中的 K/V tile。

這個架構在 H100 上跑 FlashInfer 或 FlashAttention-3 的 sparse path 是有 tested kernel 的,MI300X 這邊 AITER 的覆蓋有缺口。

第二是 fused FP8 KV-cache compressor。

模型把 K/V 寫進 cache 之前先做 compress + quant + cache write 三件事 fused 成一個 kernel——這個 fused kernel 必須用對 FP8 dialect 才能跟讀回來時的 dequant 一致。

第三是 MoE 上 256-expert top-8 routing。

每個 token 都要從 256 個 expert 裡選 8 個算 forward,這個 routing 走的是一條 bitmatrix-based dispatch,dispatch 後面接 expert-mask 加 fused matmul。

任何一個環節壞掉都會讓 token 被 route 到錯誤 expert,產出輕微但偵測不到的 quality 退化。

任意一個架構元素在 ROCm 上的 kernel 路徑壞掉,模型輸出就會徹底亂掉——這就是 Fergus 在五月初接手時看到的狀態:「拿出 v4-flash 的 weights、vLLM 加上 ROCm fork、按下 serve、輸出是 garbage」。

故事不是「重寫一整套 backend」,故事是把這四五個地方一個一個找出來、定位根因、最小改動修掉、再回頭量一遍 token/s。下面這條軸線是案場時間的折疊版本,從 2023 年 12 月的硬體發表,一路走到 2026 年 6 月 1 日 commit 全部 land 完。

2023 年 12 月到 2026 年 6 月的 bring-up 時間軸 · 6 個關鍵事件

2023 · 12 MI300X 出貨 CDNA3 · 192 GB HBM3 fnuz FP8 dtype 2026 · 04 v4-flash 發表 sparse MLA + MoE + FP8 cache 2026 · 05 初 vLLM 輸出 garbage FP8 byte 讀出來差 2× 2026 · 05 中 AITER 護欄 cb8a18556 · gfx942 拒絕 capture-safe 22cc02230 · 靜態 metadata 2026 · 05 末 2026 · 06 · 01 2699 tok/s +8.6% 案場結束 硬體發表 bring-up 完工
從硬體發表到 vLLM 可正常 serve DeepSeek-V4-Flash,相隔 30 個月。最後一條從 garbage 走到 2699 tok/s 的修補密集發生在 5 月那 5 週裡。

從硬體發表到 vLLM 可正常 serve DeepSeek-V4-Flash,相隔 30 個月

時間軸從 2023-12 到 2026-06 共 6 個事件,最後 5 週密集修補讓 token/s 從 garbage 升到 2699。

軸線右邊密集那一段裡的每一個事件都對應一個 git commit;本文後續每一節挖一個事件出來,從症狀、根因、修補一路講到對未來 bring-up 的啟示。

第一個壞掉的是 FP8 dtype

Fergus 接手的第一個現象不是 crash,而是「輸出是 garbage」。

這是最難 debug 的失敗模式之一。

所有東西都跑完了。

沒有 exception。

weight load 一切正常。

但 decode 出來的字串就是雜訊。

GPU stack 沒有任何信號告訴你哪裡不對。

traditional debug 路徑會先檢查 weight 是否正確 load、tokenizer 是否對齊、sampler 是否被誤設成 random。

這些都過了之後才會開始懷疑數值層。

問題是數值層的 bug 不會在 framework log 裡留下任何足跡——loss 不會 NaN、attention score 不會跳到無窮大,只是整體的 logits 平移/放大成一個沒有意義的 distribution。

下游 sampler 從這個 distribution 抽出來的 token 跟 prompt 完全脫鉤。

FP8 是嫌疑犯。

DeepSeek-V4-Flash 的 KV cache 用 FP8 存。

attention 算分數時把 cache 讀回來反 quantize 成 BF16。

如果存進去跟讀出來用的 FP8 dialect 不一致,數值會變成原本的兩倍或一半,整條 attention pipeline 拿到完全亂掉的 logits。

模型還是會輸出 token,只是這些 token 跟 prompt 沒有任何關係。

關鍵的事實是:MI300X 的 FP8 是 fnuz——finite, nans, unsigned zero。這個 dialect 是 AMD 跟 Graphcore 在 2023 年提出的;NVIDIA、Arm、Intel 透過 Open Compute Project 提出另一個 OCP-standard FP8。兩者的 exponent bias 差一。

用 Fergus 的話:「the same byte read as the wrong dialect comes back off by exactly a factor of two」。一個 byte 讀錯方言,回來剛好差 2×——這就是 garbage output 的物理機制。

下面這張圖把 fnuz 與 OCP 的差別具體拆出來。E4M3 FP8 的 bit 排列是 1 個 sign bit + 4 個 exponent bit + 3 個 mantissa bit;兩個 dialect 的 byte layout 完全一樣,差別只在 value = (1 + mantissa/2³) × 2^(exp − bias) 這個公式裡的 bias 常數。fnuz 用 bias=8、OCP 用 bias=7。同一個 byte 0 1000 000,fnuz 算出 1.0,OCP 算出 2.0。

點 button 在兩種 FP8 dialect 之間切換 · 觀察同一個 bit pattern 的 value 差 2×

fnuz · bias=8 · value=1.0
8 bits 0 1000 000 sign exp = 8 mantissa value = (1 + mantissa/2³) × 2^(exp − bias) value = (1 + 0) × 2^(8 − 8) = 1.0 reconstructed value 1.0 同一個 byte,bias 改 1,value 差 2× 目前:fnuz dialect(MI300X 原生)
FP8 E4M3 的 fnuz 對應 bias=8;OCP 對應 bias=7。同一個 byte 0 1000 000,在 fnuz 解出 1.0,在 OCP 解出 2.0——KV cache 寫一個 dialect、讀另一個 dialect 就會整條 attention 偏 2×。

FP8 E4M3 的 fnuz 對應 bias=8;OCP 對應 bias=7

切換 fnuz 與 OCP 兩種 FP8 dialect:bias 差 1 讓同一 byte 解出的值差 2 倍,即 garbage 的根因。

把 cache write 那一端用 fnuz 編碼、cache read 那一端用 OCP 解碼,整個 attention 的數值剛好被放大 2×;反過來則被縮小 2×。Attention softmax 對 input scale 不是完全 invariant 的——溫度等於 sqrt(d_head) 對輸入 scale 敏感——所以這 2× 偏差會讓 logits 的 distribution 整體偏掉到完全沒辦法產生有意義 token 的程度。

修復方式不浪漫但有效。

commit 236de4e64「makes the DeepSeek v4 compressor and fused compress / quant / cache writes use the platform FP8 dtype」——讓 cache write 那條路徑統一用 current_platform 報出來的 dtype。

第二條相關的 commit bd06e5d87 進一步處理 sliding-window K-cache:「routes the sliding-window K-cache through a fnuz-aware fused quantise-and-insert helper」。

sliding-window K-cache 是給長 context 用的 ring buffer。

每個 step 把新算的 K 量化後 insert 進去。

這條 path 原本繞過 platform dtype 的注入,得單獨 patch。

這兩個 patch 加起來把整條 FP8 pipeline 校準到 MI300X 的 fnuz 方言上。

背後有一個 subtle 的點值得記住。

current_platform 是 vLLM 抽象出來的硬體 dispatch 層,但 DeepSeek 模型本身的 compressor 是 model code 那邊寫死了 OCP dtype。

「正確 dispatch」這件事必須跨 vLLM 與 model code 兩個邊界——一個 platform-aware 的 dtype helper 在 model code 裡是「該注入」還是「該硬寫」,是設計選擇。

Fergus 走的是注入這條路。

這個選擇對 model author 不是免費的——他們必須在 model code 裡承認 platform 是一個 first-class 的變數,而不是「總之我寫 FP8 就好、framework 會處理」。

把 FP8 校正之後,garbage 變成「至少看起來像中文跟 code」。

但隨後 sparse MLA 那條路又踩到第二個地雷。

throughput 還沒人看,因為連 baseline 都還沒成立。

對下次接到類似 ticket 的人來說,這一節最該記的是一個 quick check。

在 ROCm 上 serve 模型如果輸出 garbage,第一步不是看 weight 也不是看 sampler,是去 grep 整個 cache write path 上對 FP8 dtype 的指定方式。

如果有任何一處 hardcode 了 OCP 或者 hardcode 了 fnuz 而沒走 platform dispatch,那條 path 就是嫌疑犯。

這個檢查只要 5 分鐘,可以省掉幾天的 attention numeric debug。

第二個壞掉的是 AITER 的 sparse MLA 路徑

FP8 校正過後,輸出至少看起來像「合法的 token sequence」。

但是 quality 還是低於預期。

throughput 也只有 vLLM benchmark 在 H100 上的零頭。

下一個嫌疑犯是 attention path 本身。

DeepSeek-V4-Flash 用的是 sparse MLA——multi-head latent attention with sparsity。

MLA 的精神是把 K/V projection 壓縮成 latent space,再用 dynamic mask 決定每個 query 跟哪些 key 算 attention。

sparse 那邊則進一步只算 mask 命中的 K/V tiles。

這個架構在 H100 上跑 FlashInfer 或 FlashAttention-3 的 sparse path 是有 tested kernel 的。

但 MI300X 這邊 AITER 的覆蓋有缺口。

Fergus 點名三條 path 在 gfx942 上是缺的或壞的:

  • paged MQA logits(gfx942 缺)
  • sparse MLA prefill(gfx942 缺)
  • sparse MLA decode(gfx942 缺)

另外有兩條 path 雖然存在於 AITER 裡,但「break specifically on gfx942」——也就是同樣的 API、同樣的呼叫姿勢,在較新的 AMD 晶片上跑得起來,在 MI300X (gfx942) 上跑出錯誤結果:

  • AITER prefill MQA logits(在 gfx942 上輸出錯)
  • AITER sparse prefill logits(在 gfx942 上輸出錯)

這種「條件式正確」是 multi-arch kernel library 最難 debug 的失敗模式。

unit test 在 CI 跑的是某一張卡,過了,PR merged。

另一張卡上就壞掉,但 issue tracker 上找不到對應的 bug 報告,因為大部分用 ROCm 的人不在 gfx942 上跑這個 op。

典型的 hardware-conditional silent breakage——issue 的存在依賴某一個特定 hardware × workload 交集,社群裡同時觸到這個交集的人非常少。

Fergus 走的修法分兩段。

第一段是寫 ROCm-specific helper。

對於 AITER 缺的 path,直接寫一個 Triton 版本當 fallback。

對於 AITER 有但 gfx942 壞的 path,加一個 current_platform guard,在 gfx942 上拒絕 dispatch、強制走 Triton。

第二段是 commit cb8a18556 把這個邏輯 land 進 vLLM 的 ROCm backend,把上述五條 path 全部接到「在 gfx942 上 refuse AITER、走 Triton 後援」的 dispatch 行為。

看哪些 attention path 走 AITER、哪些走 Triton fallback · 5 條 path

attention path gfx942 dispatch 後援 kernel paged MQA logits refuse Triton sparse MLA prefill refuse Triton sparse MLA decode refuse Triton + capture-safe metadata AITER prefill MQA logits refuse Triton AITER sparse prefill logits refuse Triton 五條 attention path 在 gfx942 上全部 refuse AITER、走 Triton fallback;sparse MLA decode 那條另外要求 capture-safe metadata。
commit cb8a18556「implements ROCm-specific helpers with fallback to Triton when AITER coverage gaps exist」並加 gfx942 guard 拒絕分派到壞掉的 AITER path。重點不是禁用 AITER,是讓 dispatch 層在 gfx942 上跑得到 Triton 後援。

commit cb8a18556「implements ROCm-specific helpers with fall…

5 條 attention path 在 gfx942 上 refuse AITER 改走 Triton,sparse MLA 從 garbage 變正確輸出。

這裡有一個常見的 backend 設計陷阱值得拆解。

當你有 fast path(AITER)和 slow path(Triton),「fast path 在某張卡壞了」時,最 lazy 的修法是全域禁用 fast path。

這條路很誘人。

patch 簡短、不需要動 dispatch 層、不需要寫 hardware-aware code,CI 馬上會綠。

但這條路有兩個成本。

第一是其他硬體上 AITER 還是 fast 的,全域禁用會直接降低 H100 跟新 AMD chip 的 throughput——這是 doubleword 不能接受的,因為他們同時 serve 多個 hardware target。

第二是 staging 的成本。

如果未來 AITER 把這條 path 修好了,你又要回頭把全域 disable 拿掉,且要小心不要拿錯。

每張新發表的卡上,又要重新走一輪「先全部禁用、再逐條打開」。

Fergus 沒走這條路。

保留 AITER 在覆蓋且 correctness 過關的 path 上跑,僅針對 gfx942 + sparse MLA 這個交集拒絕分派。

這需要 dispatch 層把硬體 ID 與 kernel ID 兩個維度都納入決策,犧牲一點程式碼複雜度,換到 H100 跟其他 AMD chip 不受影響。

refuse-and-fallback 的姿勢還有另一個好處。

未來 AITER 把缺的 kernel 補齊或修好 gfx942 path 時,guard 可以一條一條移除,不需要重寫 vLLM 的 ROCm backend。

這個 staging strategy 在多廠商 kernel library 競爭的 AI infra 裡值得學起來。

還有一個工程細節值得提。

Triton fallback 並不是免費的。

Triton codegen 對 AMD MFMA 指令集的 lowering 沒有 AITER 那麼緊,同樣一個 op 跑 Triton 通常比跑 AITER 慢 20-40%。

但「慢且正確」遠比「快且錯」要重要——尤其在 production inference 路徑上,correctness regression 是直接打到用戶的 quality。

fallback 在 throughput 上的代價在後面 graph capture 跟 launch shape 固化兩個優化裡會被部分追回來。

修完之後 sparse MLA 跑得起來、輸出 quality 對齊 H100 reference。

但 throughput 還是不對:量出來只有 800 tok/s 左右,遠低於同尺寸模型在 MI300X 上應有的數字。

第三個壞點開始浮現。

第三個壞掉的是 HIP graph capture

第二輪 patch 之後 sparse MLA 走 Triton 跑得起來。

throughput 卻只有 800 tok/s——比同尺寸的 dense model 在 MI300X 上低了 3 倍以上。

這個落差不可能來自 fallback overhead,從規模上是另一個 root cause。

原因是 HIP graph capture 沒開。

HIP graph 是 ROCm 上對應 CUDA graph 的機制。

把 inference 的 forward path 錄下來成一張 graph,每次 decode 一個 token 時 replay 整張 graph,省去每個 kernel launch 的 host overhead。

對於 small-batch decode——每次只算 1 token、kernel 數量爆多——這個 overhead 佔比可達 30-50%。

MoE 模型尤其重,因為每一層要 launch 的 expert matmul kernel 數量隨 top-k 線性放大。

graph capture 對 kernel 內部行為的要求很硬。

Fergus 把它精準寫為「the captured region has to be a pure function of its device inputs」。

錄下來的這段 code 必須完全由 device input 決定 output。

不能依賴 host 上的狀態。

不能在 capture 期間做 host-to-device scalar write。

不能做動態 allocation。

Sparse MLA decode 原本的 metadata builder 違反這條規矩。

每個 step 都根據當前 batch 的 sparsity pattern 重新算 metadata、把結果寫成 ragged tensor。

這個 ragged 是動態 allocation——allocation size 隨 sparsity pattern 變化,無法在 capture 之前固定。

capture 一進來就拒絕,於是 graph 沒抓到,每個 step 都退回 kernel-by-kernel launch。

commit 22cc02230「rebuilds the sparse MLA decode metadata as static, capture-safe tensors: no dynamic ragged allocations, no host-to-device scalar writes under capture」。

把 metadata 改寫成固定形狀的 tensor。

sparsity pattern 用 bitmask 表達在 tensor element 上。

allocation 在 capture 之前完成。

capture 一過去之後,每個 decode step 走的是 device-side 的 graph replay,host overhead 接近零。

這個改動的代價是 metadata tensor 的形狀必須對 maximum sparsity 預留空間。

若實際 sparsity 比上限稀疏,會有部分 tensor lane 被 mask 掉但仍佔記憶體。

在 192 GB HBM3 上這點 overhead 不是瓶頸。

對 H100 的 80 GB HBM3e 才會比較吃緊。

設計上這條改動是 MI300X-friendly 的,順手也對 H100 不傷。

下面這個 hero canvas 模擬把 capture 走通前後的 throughput 對比畫出來。動態 metadata 的 trace 是 launch overhead 佔大頭、token/s 在 800 上下;static metadata 走 graph replay 之後 token/s 升到 2400+,再加上後面 FP8 + AITER fallback 的合作,最後落在 2485 tok/s baseline。觀察 token/s trace 的形狀差異——graph capture 之前是鋸齒(每個 step 抖動),之後是平滑曲線。

按 play 觀察 bring-up 三個階段的 token/s · 五分鐘走完 garbage → 2485 → 2699

t=0 · stage=尚未開機
四個階段:(1) garbage 階段 (FP8 dialect 錯亂、tok/s 等於 0)、(2) FP8 校正後 sparse MLA 透過 Triton fallback 跑起來 (~800 tok/s)、(3) HIP graph capture 通過 (~2485 tok/s)、(4) Triton launch shape 固化 + MXFP4 tile tuning (~2699 tok/s)。每個階段的轉換對應實際 commit landed 的時間。

四個階段:(1) garbage 階段 (FP8 dialect 錯亂、tok/s 等於 0)、(2) FP8 校正後…

動畫顯示四階段 token/s 軌跡:FP8 修正後從 0 升到 800,HIP graph capture 後跳到 2485,優化後達 2699。

看完曲線之後最值得記的不是「2485」這個數字,而是兩個轉折。

FP8 校正讓 0 變成 800——從 garbage 變成可跑。HIP graph capture 讓 800 變成 2485——從可跑變成可用。前者是 correctness 修補,後者是 launch overhead 在小 batch 上的決定性影響。任何不打開 graph capture 的 vLLM build——無論 H100 還是 MI300X——decode token/s 都會掉到 30% 以下。MoE 加 sparse attention 因為 per-step kernel 數量多,這個 ratio 比 dense model 更慘。

graph capture 走通之後,throughput 從 800 跳到 2485 看起來很乾淨,但實務上還沒結束——量到 2485 之後 quality check 跑出來,有一小撮 token 路由錯了。下一節跳進這個微妙的 quality 退化。

但 graph capture 走通並不是 bring-up 的終點。throughput 上來之後,下一輪 quality check 跑出來,揭出兩個獨立的 routing/padding bug 同時存在——這是第四第五個壞點,也是整個案場裡最微妙的兩條。

到這個階段 throughput 已經接近 2485 tok/s,correctness 卻又出現新的徵兆:在高 concurrency 下,少數 token 偶爾會被 routed 到錯誤的 expert,產生輕微 quality 退化。這種「99% 正確」的失敗模式比 garbage 更難 debug,因為輸出看起來合理——只有用 ground-truth perplexity 跟 reference 比,才會發現分數差了一點點。

追進去之後 Fergus 發現兩個獨立的 root cause 同時在作用。

Cause A:MoE expert-mask 的 shape 算錯。

原本的判斷邏輯是:如果 ROCm AITER 在這個 build 裡是全域啟用的,就用 AITER 慣用的 mask shape;否則用 Triton 慣用的 mask shape。

問題是 AITER「全域啟用」並不代表「這個 matmul 也是 AITER 在做」。

前面 cb8a18556 那段 fallback 邏輯可能已經在這條 path 上把 AITER 換成 Triton。

判斷錯的結果是 mask shape 跟 kernel 期望的 layout 對不上,expert routing 把 token 送錯位址。

commit 8b5f7aa2c 把這個判斷改成「detect on the specific kernel about to be called」——按 kernel 為單位決策、不按 build flag 為單位決策。

這條改動在 patch 上很小,10 行內。

但它是 cb8a18556 那條 dispatch 重寫之後的必然 follow-up——你把 dispatch 變成 per-kernel decision,下游所有依賴「全域 flag」的判斷都要回頭跟上。

這類「dispatch decision 從 global flag 變成 per-call decision」之後的下游 cascade,是 backend refactor 常見的 followup 工作。

Cause B:Triton kernel 的 padding mask 算錯。

Triton 寫 kernel 的時候會把 thread block 內超出 logical 邊界的 lane 用 mask 屏蔽,避免 OOB read/write。

一個內部 kernel 寫錯了:mask 算的是「跟 global tensor bound 比較」而不是「跟 logical block size 比較」。

在 small block + low concurrency 下,global bound 跟 logical bound 通常會一致,bug 沒人發現。

在 high concurrency + multi-block 下,padded lane 對的是 global tensor 的尾巴。

Fergus 描述得很傳神:「scribbled across the MoE routing bitmatrix」——padded lane 把 routing 用的 bitmask 也覆蓋掉。

下游的 expert dispatch 就拿到污染過的 routing。

commit c32932bb9 把 mask 改回對 logical block size。

這是一個 5 行的 patch,卻是整個 bring-up 裡花最多時間定位的根因。

它只在特定 batch shape 下觸發、stack trace 完全沒有指向 routing layer、症狀是「expert utilisation 分布偏離預期」。

Fergus 提到這個 bug 是靠在 MoE routing layer 上「埋 invariant assert」才查到。

assert expert assignment 的 bitmask 跟前一層算的 logits top-k 一致。

assert fire 之後反推 bitmask 在哪一個 kernel 之後被污染。

這個 debugging 技巧在 inference backend 上特別有用:用 invariant 把每一層的 expected output 寫成 assertion,跑得通就移除,跑不通就 binary-search 上游。

這兩個 patch 之後,throughput 穩定在 2485 tok/s 並且輸出 quality 跟 H100 reference 對齊。correctness 修補的部分到這裡告一段落。

Fergus 沒有給絕對 watt 或 HBM 使用率數字——他坦白寫的是這篇的範圍只到 bring-up,不到 production tuning:「performance comparison and tuning is the next blog post」。但這個 baseline 已經足以證明 vLLM-on-MI300X-with-DeepSeek-V4-Flash 是可用的 production stack。

接下來的優化是 8.6% 的最後一哩——靜態 Triton launch shape、MXFP4 tile shape 調參、weight materialization caching。這三個都是 inference shape 可預測的前提下做的 specialisation,跟 correctness 無關但會把 token/s 推到 2699。

第一個是靜態 Triton launch shapes。Triton kernel 預設會在每次 launch 時根據實際 input shape 算 launch geometry——thread block 數量、warp 數量、shared memory 用量。如果 shape 在 inference 中固定,這個計算每次都得到一樣的結果;把 launch shape 提前固化、dispatch overhead 就被砍掉。production decode 的 shape 確實高度可預測——典型 decode 都是 batch=N、seq=1、context=K——這個固化的回報很大。

第二個是 MXFP4 tile shape 調參。MXFP4 是 MoE expert weight 用的 4-bit quant scheme:每個 32-element group 共享一個 scale。dequant + matmul 時,tile shape 直接影響 HBM bandwidth 利用率。在 MI300X 的 HBM3 上,最佳 tile shape 跟 H100 不同,需要單獨 tune。tune 結果是 MoE expert matmul 的 effective HBM utilization 拉到接近 peak。

第三個是 weight materialization caching。MoE expert weight 在 forward 時 lazy-materialize 成 BF16 tile;如果某些 expert 在連續 N 個 step 都被啟用,可以快取已經 materialize 的 tile,省掉 dequant 開銷。production traffic 上 expert utilization 不是 uniform——少數熱門 expert 會被頻繁觸發——這個 cache 有可觀的 hit rate。

三條優化的共同特徵都不是「換新 kernel」,是「把現有 kernel 在 inference shape 固定的前提下做特化」。production inference workload 的 shape 確實高度可預測;用這個前提把 dispatch overhead 砍掉,在 MoE + sparse attention 的模型上有特別大的回報,因為每個 step 要 launch 的 kernel 數量本來就多。

2699 tok/s 跟 H100 SXM 上的 reference 還差多少?

Fergus 沒給直接比較數字。

根據 vLLM 公開 benchmark,H100 SXM 上同 model size、同 batch shape 的 decode throughput 大約在 3400-3800 tok/s 範圍。

MI300X 拿到 2699 tok/s 約是 H100 的 71-79%。

考慮 MI300X 的 list price 約是 H100 SXM 的 60-65%,每美元 token/s 的 ROI 已經越過盈虧線。

從 garbage 走到 2699 tok/s 的這條軌跡,把 bring-up 的全部 commits 串起來看到的不是「一個聰明的 patch」。

是六個獨立 root cause 的順序排隊。

下面這節把這條軌跡掛在牆上,看對 AI infra 工程的一般教訓是什麼。

把這個案場掛在牆上看到什麼

從硬體發表到 production-ready inference stack 走了 30 個月——其中最後 5 週的密集修補貢獻了從 garbage 到 2699 tok/s 的轉換。把這條軌跡掛在牆上,會看到三個對 AI infra 工程師有用的觀察。

第一,多廠商 kernel library 競爭把「dispatch 層」的設計品質變成 backend 工程的決定性能力。

AITER 跟 Triton 都不是壞的工具,但「在這顆卡的這個 op 上要走哪條路」這個決策必須做得細到 (硬體 ID × kernel ID) 這個粒度——做粗了就掉 throughput 或掉 correctness,做太細了 dispatch 自己變成 hotspot。

Fergus 走的 refuse-and-fallback 是一個值得借鏡的設計姿勢:dispatch 層保持邏輯,個別 kernel 拒絕 dispatch,guard 條件未來可以一條一條退掉。這比「全域 disable AITER」要繁瑣很多,但它保留了其他平台跟其他 op 的 fast path,也保留了未來修補的 staging 空間。

對下次設計 multi-vendor inference backend 的人來說,第一個要釐清的不是 kernel library 怎麼挑,是 dispatch 層的決策粒度怎麼定。「flag-of-flags」這條路看似簡單,扛不住未來 hardware × kernel × shape 三個維度交叉變大的時候。

第二,FP8 不是一個 dtype,是兩個 dtype,而且 vLLM 跟 model code 兩邊都要正確 dispatch 才不會壞。

fnuz 跟 OCP 的 exponent bias 差一這件事在規格上是已知的;上層 framework 是否把 platform-aware dtype 正確注入到 model code 的每一個 cache write 路徑上,是工程細節。

下次接到 FP8 模型在 ROCm 上輸出 garbage 的 ticket,第一件事查的是 cache write 用的是不是 current_platform.fp8_dtype。第二件事查的是 model code 裡有沒有任何一處 hardcode 了某個 dtype——sliding-window K-cache 就是這樣繞過 platform dispatch 的,需要單獨 patch。

這個教訓在 BF16/FP16 上不存在,因為這兩個 dtype 在所有現代 GPU 上 layout 一致;FP8 之後 dtype 就跟 hardware 綁了,dispatch 不再是 optional。

第三,graph capture 的 capture-safe 約束不是顯而易見的,它經常在「動態 metadata builder」這條路上被打破。

一個用 ragged tensor 表達 sparsity pattern 的 metadata builder 在 eager mode 跑得很好,一進 graph capture 就被拒絕;新模型架構加進來時這條 path 要特別 review。

Fergus 用 static + bitmask 取代 ragged 是標準解法,但這個解法本身對 sparsity 在 batch 內的分布有上限假設,不是所有 sparsity pattern 都能這樣編碼。下一代模型如果引入 unbounded sparsity——譬如 sparsity 上限隨 context 長度動態變化的設計——capture-safe 的設計又要重新走一遍。

這條 bring-up 軸線給我們的最終訊號很簡單:MI300X 是 viable 的 inference 平台,前提是 backend 團隊願意做 dispatch 層的 surgical work。AMD 自己在 ROCm 上要補的,不只是 kernel——是把 dispatch 哲學在 vendor library 跟 community framework 之間談清楚,讓 cb8a18556 這種 guard 不必每張新卡、每個新 model 都重寫一次。

doubleword 把這份案場日誌公開,對其他 ROCm 上要 bring-up 新模型的團隊是直接的時間救援:六個 commit、五個 root cause、三十天的工程時間,能省下別人重踩一遍的兩到三個月。這比任何「MI300X 比 H100 便宜」的 marketing 數字都來得有用。

下一份報告大概會是 Fergus 在文章結尾預告的 performance tuning blog:把 2699 tok/s 拉到接近 H100 的 stretch goal。但那是 production tuning 的故事,bring-up 這條案場已經結束。

案場結語:把 garbage 變成 2699 tok/s 的,不是新 kernel,是一條一條把 dispatch、dtype、graph capture、padding mask 校準到 MI300X 的 fnuz 方言上——這是 inference backend 工程的真正樣貌。