vatt'ghern jaskier's ballads

一份完全正常的儀表板,可以陪著一台已經死掉三小時的機器一起讀 100%。16 張 B300 卡在 save barrier 上,沒有 OOM、沒有 segfault、沒有任何錯誤訊息,只有安靜。

看功耗,不要看利用率

六張 NVIDIA B300、兩個節點、FSDP/ZeRO-3——Samsung SDS 一個四人團隊(Seon Ho Kim、Ui Jeong Jeon、Su Hyeon Kim、Min Tae Hwang)拿這套硬體全參數微調 Qwen3-32B,一個 32.76B 參數的稠密模型。B300 屬於 2025 到 2026 年才出貨的 Blackwell Ultra 世代,公開的實戰經驗幾乎是空白,論文自己講得很白:「We do not introduce a new training algorithm, a new parallelism strategy, or a new optimizer.」沒有新演算法,全部是操作層面的紀錄。真正值錢的東西也在這裡:他們把大家預設會盯著看的 utilization 欄位,換成了 board 功耗,理由是那次深夜當機,utilization 完全沒有告訴他們發生了什麼事。論文把定位講在最前面:「We claim no new algorithm. The individual mechanisms we use are established practice; our contribution is the integrated field experience and a set of calibrated measurements on new hardware.」四個工程師沒有要證明自己發明了什麼新方法,只是把跑起來、卡住、修好的整個過程原封不動記下來。他們把這份紀錄定位成「among the first published field accounts on this accelerator」——檢索得到的 B300 實戰經驗幾乎是空白,這也是這張功耗 triage 表格外值錢的原因之一:沒有前人的坑可以抄,只能自己一段一段量出來。全參數微調一個 32.76B 的模型,光是參數、梯度、optimizer state 三份加起來,用混合精度粗估都要逼近半 TB,塞不進單張 GPU 的顯示記憶體。FSDP/ZeRO-3 把這三份狀態都切成分片、攤到 16 張 GPU 上,換來的代價是訓練過程裡多了大量「先把分片臨時湊回來、用完再拆開」的 collective op——這也是後面「通訊有沒有藏好」會變成一個候選懷疑的原因。

「利用率 100%」,騙過了整個當機的三小時

那次 training run 一開始很正常。從 pretrained checkpoint 接著跑,前五個小時 loss 從 1.60 掉到 0.74、再掉到大約 0.68,吞吐量穩定在 53k tok/s 上下,16 張 GPU 的 utilization 全部釘在 100%。跑到第 3,850 步(總共 3,857 步),系統要進入 epoch 結尾的 checkpoint 存檔,16 張 GPU 同時卡住——不是慢下來,是完全不動,一路卡了三個小時,直到 NCCL watchdog 自己跳出來才被人發現。作者形容得很乾脆:「No dmesg OOM, no segfault, no error: pure silence, with utilization still reading 100%.」沒有任何一個常見的錯誤訊號亮起來,utilization 這欄甚至還在告訴你「一切正常」。

問題出在 nvidia-smi 這個欄位量的東西,本來就不是「有沒有在做有用的工作」。論文寫得直接:「nvidia-smi's utilization percentage reports only that a kernel is scheduled, not that it does useful compute... During a full NCCL deadlock utilization stays pinned at 100% even though throughput is exactly zero.」它量的是「有沒有 kernel 被排進 GPU 的執行佇列」,deadlock 時所有 rank 都卡在同一個 collective op 上等彼此,GPU 確實還「佔著」那個 kernel 沒放,utilization 因此照樣讀 100%,跟 GPU 正在拼命做 matmul 的畫面長得一模一樣。光靠這一個指標,沒辦法分辨這兩種 100% 誰是誰。

「進入 checkpoint」在 FSDP 這種 sharded 訓練裡不是把一個檔案存到硬碟那麼單純:每個 rank 手上只有整個模型的一小片,得先靠一次 collective op 把分散在 16 張 GPU 上的參數、optimizer state 重新聚合成完整的 checkpoint,才能寫到磁碟。這個聚合本身也是一個所有 rank 都要一起參與的 barrier——只要有任何一個 rank 沒有準時走到這一步,其他 15 個就只能乾等。

切換要不要疊加功耗頻段 · 2 種視角

同一台機器、同一個當機事件——utilization 從頭到尾沒變過 compute 頻段 940–1040 W 健康,matmul-bound deadlock 頻段 190–195 W 靜默,長達 3 小時 step 3,850/3,857,進 checkpoint NCCL watchdog 介入 utilization 100%,全程沒有變化 0h 4h 5h 8h
時間軸依論文回報的 5 小時正常運行+3 小時卡死換算成真實比例;功耗數字取自論文回報的功耗頻段區間,非逐秒遙測記錄。切成「只看 utilization」視角時,畫面上只剩一條平的 100% 線——這正是團隊當晚三小時看到的東西。

把功耗切成五段,才看得出正在發生什麼事

團隊後來改看的是 board 功耗,不是 utilization。他們把 B300 在這套訓練負載下的功耗,按實際觀察到的區間切成五段,每一段搭配當下的 utilization 模式,寫成一張 triage 表:

B300 功耗頻段 triage 表——同一個「utilization 100%」,落在不同功耗區間代表完全不同的狀態。
狀態 每 GPU 功耗 utilization 模式 判讀
compute940–1040 W100%,均勻matmul-bound,健康
communication~640 W100%,均勻通訊為主;若持續太久代表 overlap 沒藏好
data starvation~575 W55–100%,不均部分 rank 在等資料(straggler)
deadlock190–195 W1 個 rank 0%,其餘 100%卡死;若只有 rank 0 是 0% 則是在存 checkpoint
idle~135 W0%,全部 rank沒有工作在跑

健康的 compute phase 功耗落在 940 到 1040 瓦之間,utilization 均勻釘在 100%——這才是「真的在算」的樣子。通訊為主的階段功耗會掉到大約 640 瓦,如果這個狀態持續太久,代表通訊沒有被計算蓋住,overlap 失敗了。data starvation(部分 rank 在等資料)功耗落在 575 瓦左右,utilization 不再均勻,時高時低。deadlock 的訊號很特別:190 到 195 瓦,而且是「一個 rank 是 0%、其他全部釘在 100%」——這個不對稱的形狀,跟單純的 idle(全部 rank 都是 0%,功耗只剩 135 瓦)完全不一樣,也跟前面幾種狀態都不同。

這張表也不是萬用的。論文在最後的 Threats to Validity 一節先把話說在前面:deadlock 這個機制本身跟規模無關,但效率數字不該被外推到兩個 node、InfiniBand 互連範圍以外;這裡列出的瓦數是 B300 專屬的數值,分類邏輯跟訊號形狀應該可以搬到別台硬體上,但絕對閾值必須重新校準過。換一顆 GPU、換一種 cluster 拓樸,940 到 1040 瓦這種具體數字不會直接照抄,但「用功耗形狀分辨狀態」這個做法本身可以照搬。論文也老實承認,這些結論目前只在一個模型、一份資料集上驗證過:「The generality argument is structural, not empirical across models.」——判讀邏輯有沒有道理是講得通的,但換一個模型大小、換一種資料型態,實際數字準不準,還沒有人重複驗證過。這張表也不只是拿來做人工排查——後面會看到,團隊後來把這套「功耗與 utilization 形狀」的判讀邏輯,直接寫進了一個跑在訓練之外的 watcher 裡,自動分辨到底是完成、崩潰還是卡住。

懷疑一:是不是共享儲存又拖慢了訓練?

團隊第一個懷疑的對象是儲存。原因很具體:更早之前,這套 pipeline 發生過一次「52k → 21k tok/s」的吞吐量崩潰,當時被歸咎給共享儲存 NFS。要驗證這個懷疑,他們做了一次控制良好的 A/B 測試:同一份資料,一條路徑是每一步都直接向 NFS 讀取再 tokenize(分成冷啟動——先 drop_caches——跟熱啟動兩種情況),另一條路徑是先把資料 tokenize、打包好放在 node 本地 cache 裡直接讀。要做出乾淨的冷啟動測試,得先把整台機器的檔案系統快取清空——像 `drop_caches` 這類指令會強制丟掉 page cache 裡留著的資料,逼下一次讀取真的去問 NFS,而不是不小心從記憶體裡撈到上一輪留下的副本。這一步很容易被跳過,跳過的話冷啟動測試其實還是在量熱啟動的數字,兩條路徑當然看起來一樣快,可是那個「一樣快」沒有意義。

A/B 測試三條路徑差在 0.5% 以內;歷史上那次崩潰是唯一的例外,原因不是儲存介質。
路徑 吞吐量
NFS 逐步讀取+tokenize(冷啟動)52.8k tok/s
NFS 逐步讀取+tokenize(熱啟動)53.0k tok/s
Pretokenized,node 本地 cache53.0k tok/s
歷史事件:單執行緒 tokenize+壅塞 NFS21k tok/s

三個數字幾乎重疊:NFS 冷讀 52.8k tok/s、NFS 熱讀 53.0k tok/s、本地 pretokenized cache 53.0k tok/s。原因不難理解:整個語料只有大約 4GB,攤在每個 node 有 4TB 記憶體的機器上,第一次讀過之後就整份留在 page cache 裡;就算真的踩到冷啟動,代價也很小——32B 模型的 FSDP 一個 step 要花大約 5 秒,讀 4GB 冷資料只要 3 秒,時間上蓋得過去。「儲存介質」這個懷疑,在這個資料集大小下站不住腳,但這個結論有前提:只在資料集塞得進 page cache 時成立,換成遠大於 RAM 的資料集,結論會反過來。這也是為什麼團隊敢把「資料集大小 vs per-node RAM」這個比例,直接當成要不要花力氣做本地 pretokenized cache 的判斷準則——語料量遠小於 RAM 時,這筆投資的報酬幾乎是零;只有語料量開始逼近甚至超過 RAM,本地 cache 才會真的值得做。

那先前那次「52k → 21k」的崩潰又是怎麼回事?重新診斷之後發現,肇因是單執行緒 tokenize(`TOKENIZERS_PARALLELISM=false`,CPU load average 大約 15)疊加上一段壅塞、正在冷卻的共享 NFS。換成平行 tokenize、搭配沒有被搶佔的網路之後,「With parallel tokenization and an uncontended fabric, even a fully cold NFS read matches the local cache.」——瓶頸從頭到尾都是 tokenize 的 CPU 吞吐量跟 NFS 的併發爭用,從來不是儲存介質本身太慢。這個教訓對任何跑在共享 NFS 上的訓練 pipeline 都成立:tokenize 這一步如果被設成單執行緒,CPU 很容易先於網路或磁碟撞到瓶頸;CPU load 偏高、NFS 又同時被別的 job 搶頻寬時,兩個因素疊在一起很容易被誤讀成「儲存太慢」。團隊實際上做的事,是先把兩個變因拆開量——單獨測 tokenize 的 CPU 吞吐量,再單獨測 NFS 本身的頻寬——才確定問題出在哪一邊。

懷疑二:這次也是通訊沒藏好嗎?

排除了儲存,下一個自然的懷疑是通訊:FSDP/ZeRO-3 本來就要在 16 張 GPU 之間頻繁做 all-gather 跟 reduce-scatter,如果通訊沒有被計算完全蓋住,功耗照理會卡在 640 瓦那個「通訊為主」的頻段,而不是 940 到 1040 瓦的 compute 頻段。FSDP/ZeRO-3 把模型參數、梯度、optimizer state 都切成分片,攤在每張 GPU 上:每一次 forward 要先用 all-gather 把當下這層的完整參數暫時湊回來,backward 算完梯度後又要用 reduce-scatter 把梯度分片送回各自負責的 GPU。這些 collective op 如果排得夠好,可以跟下一層的矩陣乘法同時進行,讓通訊的時間被計算的時間蓋住,GPU 看起來就像沒有花時間在等網路——這正是為什麼功耗會留在 940 到 1040 瓦,而不是掉到 640 瓦附近。實際量到的數字給了明確答案:那五個小時的正常 run 裡,GPU 全程停在 940 到 1040 瓦,「confirming communication was hidden behind compute」——通訊確實被藏在計算底下,這條路也排除了。

順帶一提,這張 triage 表也讓「calibrated strong-scaling」這件事變得可信,不只是嘴上說「近乎線性」。團隊在 4 張、8 張、16 張 GPU 上各自量了一輪。

4 張 GPU(半個 node)量到 13.4k tok/s3,350 tok/s/GPU,跑完一個 epoch 要 20.9 小時,累積 83.7 GPU·h,scaling efficiency 100%(以 4 GPU 為基準)。; 8 張(1 個 node)量到 26.7k tok/s3,338 tok/s/GPU,20.9 小時的一半、10.5 小時跑完一個 epoch,累積 84.1 GPU·h,效率 100%。; 16 張(2 個 node,走 InfiniBand)量到 53.0k tok/s3,313 tok/s/GPU,5.3 小時跑完一個 epoch,累積 84.7 GPU·h,效率 99%——唯一沒有守住 100% 的一格,論文歸因於跨兩個 node 的 IB 邊界約 1% 的稅。

滑鼠移到(或點選)任一個吞吐量數字看細節。三格 per-GPU 吞吐量都落在 3,313 到 3,350 之間,每 epoch 的 GPU·h 也守恆在 84 上下。

per-GPU 的吞吐量在三種規模下幾乎不動(3,313 到 3,350 tok/s,差距在 1% 以內),每個 epoch 要花的 GPU·h 也守恆在 84 上下——這正是計算主導、通訊確實藏好的系統該有的樣子。作者自己講得保守:「near-linear strong scaling for a compute-bound model at this size is the expected regime」,他們把這組數字當成參考資料,沒有宣稱這是什麼突破,也提醒這些效率數字不該外推到兩個 node、IB 互連範圍以外。3,313 到 3,350 之間那一小段差距,方向也不是隨機的——16 張 GPU 那一格的 per-GPU 吞吐量剛好是三組數字裡最低的一個,跟 scaling efficiency 從 100% 微降到 99% 的方向一致;論文自己把這一格拆得很清楚:唯一的損耗是 16 卡時跨兩個 node 的 IB 邊界收走的約 1% 稅,4 卡與 8 卡都只走 NVLink。即使如此,作者仍把整組數字定位成參考資料而不是發現。「每個 epoch 的 GPU·h 守恆在 84 上下」這句話換個角度看,其實是一個好用的健檢指標:同樣的設定如果哪天量出來的 GPU·h 明顯偏離這個基準,不必等到 utilization 或 loss 出現異常,這個數字本身就已經在報警了。

真正的原因:一個 rank 比別人少算了 53 個 block

儲存排除了,通訊也排除了,deadlock 的功耗訊號指向另一個方向:190 到 195 瓦、一個 rank 是 0%、其他 15 個全部釘在 100%。真正的原因出在資料切分。loader 是按「列」平均切給每個 rank 的,但每一筆 persona 紀錄的長度不一樣,切完再打包成 2048 token 一個 block 之後,各個 rank 分到的 block 數量就不再相等——最少的一個 rank 是 30,835 個 block,最多是 30,888 個,差了 53 個。

53 個 block 換算成優化器步數,大約是 7 步(每 8 個 block 對應 1 個 optimizer step)。「The 53-block spread is 0.17% of the per-rank workload」,「invisible to every metric except the one that mattered」。分到最少 block 的 rank 3 提早跑完自己的迴圈,直接走進 epoch 結尾的 save barrier;同一時間,其他 15 個 rank 還在等彼此把 gradient 做 all-reduce。一邊在等 save barrier、一邊在等 all-reduce,兩種不同的 collective op 互相卡死。論文自己把這件事歸到熟悉的失敗家族裡:這是分散式訓練裡的 Join/drop_last 問題,只是這次出現在 packing 這一層,而不是常見的樣本數量不對齊。PyTorch 的 DDP 文件早就描述過這種失敗模式:如果每個 process 分到的 batch 數量不一樣,數量少的 process 會先跑完自己的迭代、離開 training loop,其他 process 卻還在等它一起做下一輪的 all-reduce,於是先跑完的 process 等在 loop 外面,其他 process 卡在 loop 裡面的 collective op 上,兩邊互相等對方,誰都不會先動。PyTorch 官方給的解法是 `Join` 這個 context manager,或者乾脆設定 `drop_last=True` 把多出來的樣本丟掉,強制每個 process 分到一樣的 batch 數量。這次的 deadlock 走的是同一種邏輯,只是不平衡發生在更早的 packing 階段。0.17% 這個數字,比大多數人預設的「告警閾值」還要小得多——論文只說這個差距「invisible to every metric except the one that mattered」;合理的推測是,一般吞吐量或效能監控設的容許誤差本來就比 0.17% 寬得多,所以這種程度的不平衡不會觸發任何告警。

修法(作者叫它 evenfix)思路很直接:讓每個 rank 在進迴圈前,先用一次 all_reduce 把自己的 block 數量降到全 domain 的最小值,迴圈跑到這個最小值就統一停下:

local_blocks = file_bytes // 4 // seq_len
min_blocks = all_reduce(local_blocks, op=MIN)
// 每個 rank 都用同一個全域最小值
if blk_idx >= min_blocks:
    break  // 所有 rank 在同一步統一停下

16 個 rank 從此跑一樣的迭代次數,走進 save barrier 的那一刻天生對稱,deadlock 在結構上不可能再發生——不是靠監控抓到,而是讓這個狀態本身不存在。

把三小時的靜默,變成 2.71 秒就打回票

evenfix 解決了根因,但團隊還加了兩層防護,理由很現實:這次的失敗模式擋住了,下一次別的失敗模式還是可能發生。第一層是啟動前的 invariant gate,跑六項檢查——cache 完整性跟 per-rank block 是否平衡、token 總量是否落在大約 1.01B 的 10% 誤差內、兩個 node 上 GPU occupancy 是否都不是 0、`master_port` 有沒有被占用、checkpoint 硬碟空間是否留了至少 150GB,加一個可選的 InfiniBand 頻寬探測。六項全部跑完,兩個 node 合計量到的時間是 2.71 秒。2.71 秒跟後面會提到的 130 GPU·h 放在一起看,反差才是重點:多花不到 3 秒去核對六個 invariant,換來的是不用再賭一次三小時看不見盡頭的沉默。

2.71 秒跑完的六項 pre-run 檢查

  • 01 cache 完整性 + per-rank block 平衡

    正是這次事故該擋下 rank 3 那 53 個 block 差距的一關。

  • 02 token 總量檢查

    確認總 token 數落在約 1.01B 的 10% 誤差內。

  • 03 GPU occupancy

    兩個 node 上都要確認 occupancy 不是 0。

  • 04 master_port 是否可用

    確認這個埠沒有被前一個 process 占用。

  • 05 checkpoint 硬碟空間

    至少要留 150GB 的空間。

  • 06 IB 頻寬探測(可選)

    量測跨 node InfiniBand 頻寬是否正常。

2.71 秒跑完的六項 pre-run 檢查 01cache/block 平衡 02token 總量 03GPU occupancy 04master_port 05硬碟空間 ≥150GB 06IB 頻寬(可選) 六項合計,兩個 node 一起跑完:2.71 秒。 01 這一項,正是事後回頭看最該擋下這次 deadlock 的一關。

第一項檢查——per-rank block 是否平衡——正是這次 deadlock 事後回頭看最該擋下它的那一關:如果啟動前就先確認每個 rank 的 block 數量一致,rank 3 那 53 個 block 的差距在訓練跑起來以前就會被打回票,不用等三個小時才發現。

第二層是一個獨立於這次訓練 session、跑在另一台機器上的 watcher,負責從外部判斷三種狀態:完成(epoch checkpoint 出現了)、崩潰(process 不見了但 checkpoint 沒出現)、卡住(step 計數器停滯不動,或者「the power/utilization deadlock signature persists across two samples」——功耗與利用率的 deadlock 訊號連續兩次採樣都還在)。這種「在訓練之外、獨立於同一個 session」的設計本身也是重點:如果 watcher 跟訓練程式擠在同一個 process 群組裡,訓練整組卡死的時候,watcher 很可能一起卡死,等於失去了唯一還能對外求救的管道;把它放到另一台機器上跑,才能保證訓練真的死透的時候,還有人看得到。把這套判讀邏輯搬到別的訓練環境,第一步通常不是重新量一次 940 到 1040 瓦這些絕對數字,而是先建立自己機器在「已知 healthy」跟「已知 hang」兩種狀態下各自的功耗基準線,再拿基準線去做故障分類——這正是團隊在 B300 上做的事,換一顆 GPU 也可以照做一次。這次事故被團隊算成一筆帳:卡住本身耗掉大約 48 GPU·h,加上 fix 之前 checkpoint 只在 epoch 結尾存一次,等於白跑掉的那五個小時進度又賠上大約 85 GPU·h,一次事故總共大約 130 GPU·h——換算下來,相當於一台 16 張 GPU 的機器平白多跑了超過 8 個小時。

一份 190 瓦的靜默:這起事故最後留下來的,不是一套更聰明的監控,而是一個換掉的量表與一道 2.71 秒的閘門。作者把結論寫成三句話——「watch power, not utilization」、「verify invariants before launch」、「a passing smoke test is not evidence of a safe full run」。53 個 block 的落差、0.17% 的不平衡、三小時的安靜、130 GPU·h 的帳單,全部串在同一條線上:那條線的起點,是一個從頭到尾都顯示 100% 的欄位。