iT邦幫忙

2026 iThome 鐵人賽

DAY 18
0
AI Engineering

GPU很忙?他真的有在做事嗎?系列 第 18

Day 18|多卡訓練在傳什麼?讀懂 NCCL 的帳

  • 分享至 

  • xImage
  •  

Day 17 對齊完 MFU 的口徑後,主檔 A 還有一筆大帳沒有解釋:kernel 時間裡有將近三成掛在 ncclDevKernel 開頭的名字底下。Part D 從今天開始處理多卡,第一步先不急著調 NCCL,而是回答三個比較基本的問題:

  1. 多卡訓練到底在傳什麼,資料量有多大?
  2. NCCL kernel 的名字能告訴我們什麼,又有哪些資訊不能只看名字?
  3. 這些通訊有多少和其他 GPU 工作重疊,有多少沒有藏起來?

第三題很重要。通訊 kernel 跑了 300 ms,不代表 step 一定因此增加 300 ms;如果它和其他工作同時執行,其中一部分成本已經被藏起來。今天先把帳分清楚,明天再談如何增加 overlap。

為什麼多卡非傳不可?

先用最常見的資料平行(data parallelism)建立量感。每張 GPU 拿不同資料,對同一份模型做 forward 與 backward;backward 結束後,每張卡只有自己那批資料算出的梯度。若下一步要維持相同模型狀態,所有 data-parallel ranks 就必須同步梯度或更新後的參數。

經典 DDP 使用 AllReduce:先把所有 rank 的梯度加總,再讓每個 rank 都拿到相同結果。若以環狀(ring)演算法實作,ReduceScatter 與 AllGather 各走 N−1 個階段,因此每個 rank 的送出量約為:

每卡送出量 ≈ 2 × (N−1) / N × payload

接收量也相同。假設模型有 8B 參數,且要同步的是 BF16 梯度,payload 約 16 GB;8 張卡做 ring AllReduce 時,每張卡每步約送出:

2 × 7 / 8 × 16 GB ≈ 28 GB

這只是量級示範,不是主檔 A 的實測。真實 payload 取決於梯度通訊精度、bucket、參數分片與框架設定;如果梯度以 FP32 歸約,資料量還會再翻倍。不過這個例子足以說明:通訊不是程式不小心多做的雜事,而是分散式訓練為了維持一致狀態必須支付的成本。

NCCL 文件還給了一條很實用的語意等式:

AllReduce = ReduceScatter + AllGather

ReduceScatter 負責歸約後只留下每個 rank 的一份 shard,AllGather 再把所有 shards 集回完整結果。這表示兩個 collective 接在一起可以得到 AllReduce 的結果,但不代表一次 ncclAllReduce 在 timeline 上一定會拆成兩個獨立 kernel。

ZeRO 並不是每一階段都「只省記憶體、不增加通訊」

原本草稿把所有 ZeRO 都寫成通訊量與 AllReduce 相同,這樣太簡化。比較準確的說法是:

  • ZeRO-1/Megatron distributed optimizer 這類只切 optimizer state 的做法,常見資料流是梯度 ReduceScatter、各 rank 更新自己的參數 shard,最後再 AllGather 更新後的參數。以相同 dtype 與 payload 估算,總流量可以接近一次 ring AllReduce。
  • ZeRO-2 再把 gradient 分片,仍可用 ReduceScatter 完成梯度同步;實際流量會受實作與通訊精度影響。
  • ZeRO-3/FSDP 連參數都分片,forward 與 backward 需要按 layer 或 FSDP unit AllGather 參數,再 ReduceScatter 梯度。它省下更多記憶體,但 collective 次數與通訊時機都和 ZeRO-1/2 不同,不能用「通訊量完全一樣」一語帶過。

Tensor parallelism 傳的又是另一種資料。它通常在每一層交換 activation 或 activation gradient,資料量跟 microbatch、序列長度、hidden dimension 與層數有關;MoE expert parallelism 則常用 All-to-All,把 token 送到 expert 所在的 rank。先確認「在傳參數、梯度還是 activation」,通訊量公式才有意義。

平行化資料流 常見傳輸內容 timeline 常見 collective
DDP 梯度 AllReduce,或分桶後的多次 AllReduce
Distributed optimizer/ZeRO-1/2 梯度 shard、更新後參數 ReduceScatter+AllGather
FSDP/ZeRO-3 layer/unit 參數、梯度 shard 多次 AllGather+ReduceScatter
TP/sequence parallel activation、activation gradient AllGather、ReduceScatter、AllReduce
MoE expert parallel 送往不同 experts 的 tokens All-to-All 或等價交換

環狀 AllReduce 的每卡流量,以及 ReduceScatter 加 AllGather 在語意上等同 AllReduce;不同分片方法的通訊次數與時機仍需分開估算

先用 nsys-ai 對主檔 A 實際查一次

這次不只看先前整理的表。我使用本機同步到 upstream 的 nsys-aiv0.3.0-21-g1cc03b5),對同一份 megatron.sqlite 與 39.964–46.123 秒的五步穩定區間重新執行。

先確認裝置是否錄完整:預期八卡,實際少一張

這是 --nproc_per_node=8--num-gpus-per-node=8--tp-size=8 的作業,TARGET_INFO_GPU 也列出 device 0–7,因此預期確實是八張 GPU。不過直接查 kernel table,結果只有 device 1–7:

SELECT deviceId, COUNT(*) AS kernels
FROM CUPTI_ACTIVITY_KIND_KERNEL
GROUP BY deviceId
ORDER BY deviceId;
deviceId  kernels
--------  -------
1         6460
2         6460
3         6460
4         6460
5         6460
6         6460
7         6460

device 0 不只沒有 NCCL,整份檔案連一般 kernel、memcpy 與 memset 都沒有;但它的 CUDA context 和 NVTX events 仍然存在。用 nsys-ai 指定 device 0 也會分別得到 No NCCL collectives foundOverlap analysis: no kernels found

所以正確解讀不是「這個 job 只有七張卡」,而是「八卡 job 的這份 profile 只錄到 device 1–7 的 GPU activity」。以下跨卡加總與範圍都只涵蓋這七張,不能當成完整八卡 job 的總量;device 0 也不能用其他卡的平均值補上。這個 capture 缺口本身就是分析前應先做的完整性檢查。

確認限制後,先從有資料的 device 4 開始:

nsys-ai skill run nccl_breakdown megatron.sqlite \
  --trim 39.964 46.123 -p device=4

實際輸出:

NCCL Collective Breakdown
  [Stream 52]
    allgather          1.2%    22.4ms  ×4    avg=5.6ms  [5.3–6.0ms]
  [Stream 56]
    allgather         63.5%  1145.5ms  ×135  avg=8.5ms  [5.3–57.4ms]
    reducescatter     34.8%   628.5ms  ×90   avg=7.0ms  [5.3–55.8ms]
  [Stream 60]
    allreduce          0.4%     7.5ms  ×10   avg=0.7ms  [0.1–1.4ms]

這裡的百分比是「device 4 的 NCCL 時間組成」,不是占 step wall time 的比例。主通訊都在 stream 56:五步共有 135 次 AllGather 與 90 次 ReduceScatter,換成每步就是 27 次與 18 次;另外兩條 stream 每步再加約 4.5 ms 與 1.5 ms。三條合計每步約 361 ms,而不是把不同裝置的時間全部相加。

nccl_breakdown 會按 collective 類型與 stream 分組;若要保留完整 kernel symbol,再用底層 SQL 交叉檢查:

SELECT s.value AS kernel_name,
       COUNT(*) AS calls,
       SUM(k.end - k.start) / 1e6 AS total_ms
FROM CUPTI_ACTIVITY_KIND_KERNEL k
JOIN StringIds s ON k.demangledName = s.id
WHERE s.value LIKE 'ncclDevKernel%'
  AND k.start >= 39964000000
  AND k.end <= 46123000000
GROUP BY s.value
ORDER BY total_ms DESC;

把有 kernel 記錄的 device 1–7 加總後,結果和原本表格一致:

A 檔實測(五步 × 已錄到的 7 卡) 次數 總時間 平均
AllGather_RING_LL 973 7,727.83 ms 7.942 ms
ReduceScatter_Sum_bf16_RING_LL 630 4,654.17 ms 7.388 ms
AllReduce_Sum_f32_TREE_LL 70 46.95 ms 0.671 ms

有記錄的七張卡,collective 次數都是 239 次,表示呼叫數量相當整齊;但總 NCCL 時間仍從 1,710.19 到 1,842.57 ms 不等。次數一致不代表抵達時間與完成時間也一致,後面還要看每張卡的 overlap。

NCCL kernel 名稱可以讀到哪裡?

主檔 A 的 symbol 大致長這樣:

ncclDevKernel_<操作>[_<歸約>_<資料型別>]_<演算法提示>_<協定提示>

ncclDevKernel_ReduceScatter_Sum_bf16_RING_LL 為例:

  • ReduceScatter 是 collective 類型。
  • Sum_bf16 表示 reduction 與資料型別;AllGather 不做 reduction,因此名稱裡沒有這一段,也不能只靠 symbol 判斷它搬的是 BF16 還是其他 dtype。
  • RING_LL 看起來像演算法與 protocol,但要把它當成 kernel specialization 的提示,不能直接當作 runtime tuning 的真值。

最後一點很容易踩雷。NCCL 的 device code 會讓多種 collective 實作共用代表性的 specialized kernel symbol;目前 NVIDIA/NCCL 的產生程式甚至會把多個實際 protocol 映到名稱帶 _LL 的 kernel entry。換句話說,timeline 看到 _LL,不代表這次傳輸一定真的選了 LL。不同 NCCL 版本的 symbol 也可能改變,它不是穩定的公開診斷介面。

因此,原稿根據三個 _LL 名稱推論「所有 collective 都走 LL,所以有效頻寬最多一半」並不成立。若要確認實際演算法與 protocol,應在可重跑的環境開 NCCL tuning log:

NCCL_DEBUG=INFO \
NCCL_DEBUG_SUBSYS=INIT,GRAPH,TUNING \
python train.py ...

必要時再用 NCCL_ALGONCCL_PROTO 做 A/B test,但不要因為 kernel 名稱長得像 _LL 就直接強制切換。先用 nccl-tests 建立同拓撲、同訊息大小的 baseline,再比較 training run,會比猜 symbol 可靠。

另一個限制是訊息大小。NCCL 2.16.2 的 release notes 記載 NVTX instrumentation 開始加入 call arguments;但 payload 最後能不能出現在 .sqlite,仍取決於 NCCL、Nsight Systems 與匯出版本。主檔 A 的相關欄位沒有可用 payload,所以目前只能確認次數與時間,無法從這份檔案直接算每一筆 GB/s。看不到 bytes 不代表傳輸量是零,而是證據不完整。

NCCL kernel symbol 的欄位拆解;操作、歸約與資料型別可當線索,RING/LL suffix 不是 runtime tuning 的可靠證明

知識點:algbw 和 busbw 不是同一個頻寬

拿到 message size 之後,下一個常見錯誤是直接用 payload ÷ kernel time 跟 NVLink 規格相比。NCCL tests 會同時報兩個不同數字:

algorithm bandwidth(algbw)= payload size ÷ elapsed time

這個數字回答「應用程式完成這個 collective 的速度」。但 collective 會在多個 ranks 間搬動資料,實際鏈路流量不一定只等於一份 payload,因此 NCCL tests 再用 operation-specific factor 換算 bus bandwidth:

Ring AllReduce:     busbw = algbw × 2(N−1)/N
AllGather / RS:     busbw = algbw × (N−1)/N

前者適合看端到端完成速度,後者則把 collective 的演算法流量納入換算,讓不同 rank 數的結果比較接近同一個硬體頻寬尺度。若使用 NVLS、CollNet 或分層演算法,busbw 又不一定等於某一條實體 link 的流量。實務上最穩妥的比較方式,是用相同 GPU 拓撲與相近 payload 跑 all_reduce_perfall_gather_perfreduce_scatter_perf,再把訓練中的時間跟 baseline 對照。

主檔 A 缺 message size,因此本文不硬算頻寬。現階段能可靠回答的是 collective 類型、呼叫次數、duration 分布與 overlap;頻寬效率要等 bytes 或 NCCL log 補齊。

AllGather+ReduceScatter 能反推哪種平行化?

collective 的組合可以縮小範圍,但很少能單獨定案:

主要組合 常見來源 可以下的結論
大宗 AllReduce DDP 梯度同步 可能是 replicated data-parallel path
大宗 AllGather+ReduceScatter distributed optimizer、FSDP/ZeRO-3、TP sequence parallelism、部分 context parallelism 存在分片式資料流,但不知道切的是參數、梯度或 activation
大宗 All-to-All MoE expert parallelism、Ulysses 類 sequence parallelism 很可能在做重新分配,但仍要看 communicator 與 call site
Send/Recv pipeline parallelism、ring attention/context parallelism 有點對點交換,不能只靠名字判定是哪一種

套回 A 檔,大宗確實是 AllGather 與 BF16 ReduceScatter,所以可以合理判斷它有固定、頻繁的分片式資料流。Megatron distributed optimizer 會做 gradient ReduceScatter,再 AllGather 更新後的參數;sequence parallel 的 autograd mapping 也會讓 forward 的 AllGather 在 backward 對應 ReduceScatter,反之亦然。兩種路徑都能產生目前看到的組合。

A 在五步穩定區間、已錄七張卡的比例是 973:630 ≈ 1.54,接近 1.5:1,而且每張有記錄的卡都是 239 次。這表示呼叫形狀很規律,可能跟 layer 或固定 bucket 排程有關;但它不能證明一定是 sequence parallelism。Distributed optimizer 的參數 AllGather、gradient accumulation、recomputation 與 bucket 邊界都會改變比值。要定案,仍需 NVTX call site、communicator ranks 或 training config。

已錄到的每個 device 都有 10 次 FP32 AllReduce,因此:

10 ÷ 5 steps = 每卡每步 2 次

它們平均只跑 0.671 ms,顯然和兩個主要 collective 不是同一種時間尺度。gradient norm、overflow flag、token/loss 統計,或未分片參數的梯度聚合,都可能產生這類固定頻率的 FP32 AllReduce;但沒有 message size 與 call site 前,文章不替它指定唯一用途。這裡能確定的是「每步固定兩次、FP32、duration 短」,其餘保留成待驗證假說。

stream 是排程線索,不等於 communicator 數量

device 4 的通訊分布在 stream 52、56、60。原稿說「每個 communicator 各用自己的 stream,所以至少有三個通訊群組」,這個推論不可靠。NCCL API 會把操作 enqueue 到呼叫者傳入的 CUDA stream;同一 communicator 可以在框架控制下使用不同 stream,多個 communicator 也可能共用排程方式。stream ID 告訴我們工作被放在哪條 CUDA 執行佇列,不能直接換算成 TP/DP/PP group 數量。

不過時間分布仍然有用:

  • stream 56 是主力,每步約 27 次 AllGather、18 次 ReduceScatter,共約 355 ms。
  • stream 52 每步不到一次 AllGather,約 4.5 ms。
  • stream 60 每步兩次 FP32 AllReduce,約 1.5 ms。

三條合計每步約 47.8 次 collective,平均每 26 ms 左右就出現一次。這種固定而密集的節奏,比「每步只在尾端做一次大同步」更像 layer-wise 或 bucketed communication;但 DDP 也會在 backward 中按 bucket 提前啟動 AllReduce,所以仍只能當線索,不能只憑分布替框架下診斷。

把視野拉出穩定區間,還能看到訓練開始前的 Broadcast,以及結束時的 communicator teardown。一次性的 initialization/shutdown 要和每步重複的 steady state 分開,只有後者會持續影響 tokens/s。

另外要記得,NCCL kernel 是真的 GPU kernel。它會佔用 SM 與執行資源,也會被 Day 14 的 busy time、Day 17 的 kernel union 算進去;GPU 顯示很忙時,其中一部分可能是在通訊。另一方面,collective 必須等參與 ranks 的資料都到位才能完成,因此某張卡上的 NCCL kernel duration 也可能包含等待 peer 的時間。單筆變長不一定代表 link 變慢,也可能是上游計算或 launch 較晚。

用 overlap_breakdown 查未藏住的通訊

接著對同一個 device 4 與五步範圍執行:

nsys-ai skill run overlap_breakdown megatron.sqlite \
  --trim 39.964 46.123 -p device=4

實際結果:

── Compute/Communication Overlap ──
  Total span:    6155.8ms
  Compute only:  4260.4ms
  NCCL only:     1094.7ms
  Overlap:        709.2ms (39.3% of NCCL overlapped)
  Idle:            91.4ms
  Kernels:       1875 compute + 239 NCCL
  Devices:       4 (analyzed); profile also has [1, 2, 3, 5, 6, 7] — re-run with -p device=N for each

這裡的 compute 是工具的分類名稱:實作上,它把名稱不含 NCCL 的 GPU kernels 都歸在這一側,所以更精確的理解是「其他 GPU kernels」,不保證每一個都是 Tensor Core 計算。四個區間是用聯集與交集算的,不是把 kernel duration 直接相加。

device 4 的 NCCL 總時間為 1094.74 + 709.19 = 1803.93 ms,占 6.156 秒範圍的 29.3%;其中 39.3% 與其他 GPU kernels 重疊,未重疊部分是 1094.74 ms,平均每步約 219 ms。

但只看 device 4 還不夠。我把 profile 裡有 kernel 資料的 device 1–7 全部重跑一次:

device NCCL overlap NCCL only(五步) 平均每步 NCCL only
1 34.3% 1,129.27 ms 225.85 ms
2 35.3% 1,106.14 ms 221.23 ms
3 36.2% 1,137.00 ms 227.40 ms
4 39.3% 1,094.74 ms 218.95 ms
5 35.9% 1,180.34 ms 236.07 ms
6 33.8% 1,177.84 ms 235.57 ms
7 38.7% 1,099.37 ms 219.87 ms

device 4 剛好是已錄七張卡中 overlap 最高、NCCL only 最少的一張。如果只挑它代表所有已錄裝置,結論會稍微樂觀。device 1–7 的 overlap 落在 33.8–39.3%,未與其他 kernels 重疊的 NCCL 則是每步約 219–236 ms;這個範圍比單一數字更適合當 Day 19 的起點。由於 device 0 缺 trace,本文不宣稱這就是完整八卡 job 的全域範圍。

最後再收緊一次用詞:NCCL only 是「沒有和其他 GPU kernels 重疊的通訊時間」,可以當作改善 overlap 的機會上限,但不等於全部都能從 wall time 一比一拿回來。它可能仍和 CPU 工作、memory copy 或其他裝置活動重疊,也可能受無法移動的資料相依性限制。要估真正 critical-path saving,還需要比較修改前後的 step wall time。

device 4 五步窗的 NCCL 與其他 GPU kernel overlap;未重疊 NCCL 是優先檢查的區間,但不代表全部都能直接從 wall time 回收

四種常見誤判

第一,看到 _RING_LL 就當成實際 tuning 結果。kernel symbol 可能只是共用的 specialized entry;實際 algorithm/protocol 要看 NCCL tuning log 或受控的 A/B test。

第二,把 stream 數量當成 communicator 數量。stream 是 CUDA 排程佇列,不直接等於 TP、DP 或 PP group;要看 rank membership 與框架設定。

第三,把多卡 kernel duration 直接相加後除以 wall time。已錄七張卡的 12.43 秒是跨卡總和,不是 6.16 秒視窗內的 wall 占比。先固定 device,再做 interval union。

第四,把 NCCL only 全部當成可回收時間。它是未和其他 GPU kernels 重疊的區間,也是值得優先調查的上限;能否真的縮短 step,仍取決於相依性、straggler 與修改後的 critical path。

小結與明天

今天先把 NCCL 的帳讀到證據允許的程度。Ring AllReduce 的每卡流量約為 2(N−1)/N × payload,但 ZeRO/FSDP 不同階段的通訊時機與總量不能混成一句話。主檔 A 預期有八張 GPU,實際 SQLite 卻只有 device 1–7 的 GPU activity;已錄七張都有規律的 AllGather、BF16 ReduceScatter,以及每步兩次的 FP32 AllReduce,足以判斷存在分片式資料流,還不足以只靠 kernel 名稱定案是哪種平行化。

實際用 nccl_breakdownoverlap_breakdown 重跑後,device 1–7 每步都有 239 ÷ 5 ≈ 47.8 次 collective;NCCL overlap 落在 33.8–39.3%,NCCL only 約 219–236 ms/step。這段未藏住的通訊是明天要追的重點,但先把它當機會上限,不急著宣稱全部可回收;缺少的 device 0 則需要重新 capture 才能補齊。

明天進 Day 19:正式拆 overlap ratio、啟動時機與資料相依性,看看主檔 A 的通訊為什麼只有約三分之一藏進其他 GPU 工作裡。

參考資料


上一篇
Day 17|AI 訓練算力用了多少?MFU 與 Roofline
下一篇
Day 19|訓練 LLM 的通訊成本藏得好嗎?拆解 NCCL overlap
系列文
GPU很忙?他真的有在做事嗎?19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言