Day 14 的hole list裡,還有一批歸因欄寫滿 cudaMemcpyAsync 與 cudaHostAlloc 的 gap 沒有處理,分診表上的資料線也還空著,今天用 memory_transfers 一起檢查。另外,從這篇開始有一個調整:前幾天的病歷數字是為了教讀法而設計的示意值,這篇之後的數字都來自那兩份公開 profile 的實際量測。
先說今天的重點。GPU 的資料搬運大致可以分成兩類:GPU 內部搬運,以及主機與 GPU 之間的傳輸。前者的頻寬高出一個數量級以上;後者需要跨越 PCIe,成本高低很大一部分取決於主機端記憶體是 pinned 還是 pageable。
先建立量級感。GPU 內部的 D2D(device to device)搬運走 HBM:主檔 A 整份檔累計搬了 2.7 TB,依 memcpy bytes 除以 duration 算出的有效頻寬是 2,134 GB/s;B 檔也量到 1,536 GB/s。卡與卡之間另有 NVLink,以 H100 SXM 為例,每顆 GPU 的第四代 NVLink 總頻寬是 900 GB/s,那是 Day 18 的主題。
相較之下,主機與 GPU 之間走 PCIe。Gen5 x16 扣掉編碼開銷後,每個方向約 63 GB/s,實務上常簡寫成 64 GB/s;廠商規格若寫 128 GB/s,通常指兩個方向合計。Gen4 x16 則是每個方向約 32 GB/s。NVLink 的 900 GB/s 也是雙向總頻寬,兩者的規格口徑要一致後才能比較。
即使先不比較 NVLink,光看這兩份 profile 的 D2D 實測與 PCIe 單向上限,差距就超過二十倍。原本可以留在 GPU 上的資料,如果被安排走 PCIe 來回,就會多出一筆成本。這是資料線的第一條判讀原則:D2D 大,通常只是框架在 GPU 內部調度資料;H2D、D2H 明顯偏大時,才需要繼續追查。
另外先澄清一個常見誤會:這份輸出看不到跨卡通訊。NCCL 傳輸使用的是 kernel,不會出現在 memcpy 相關的統計裡,因此 D2D 那一行是卡內搬運,不是卡對卡。通訊的量測是另一套工具,Day 18 再處理。
還有一個 Day 11 提過的重點值得複習:PCIe 傳輸由獨立的 copy engine 執行,DMA 不佔用 SM。只要硬體支援,而且 stream 安排得當,傳輸就有機會與計算重疊,藏掉一部分成本。完整條件下一節再說明。

PCIe Gen5 x16 的單向理論上限約為 63 GB/s,實際能達到多少,主要取決於主機端記憶體的型態。
主機記憶體預設是可分頁的(pageable),作業系統隨時可能搬動它的實體位置。DMA 需要固定的實體位址,因此 pageable 資料送進 GPU 前,CUDA 需要先建立一份 page-locked staging copy,再交給 copy engine;中間多了一段主機端複製,延遲與頻寬都會受到影響。鎖頁記憶體(pinned memory)則已經向作業系統登記不可搬動,copy engine 可以直接讀取,省掉 staging 這一段。
不過,pinned 並不等於自動重疊。non_blocking=True 可以避免 PyTorch 在每次 copy 後讓主執行緒等待;如果要讓 H2D 與 GPU 上的 kernel 真正在 device side 重疊,還需要把傳輸放進獨立的 non-default CUDA stream,並且確認 GPU 有可用的 copy engine。pinned source、side stream 與硬體支援,三個條件都要成立。
這幾個名詞很常被寫在同一句裡,其實分別處理不同層次的問題:
| 設定或資源 | 它處理的是什麼 | 它沒有保證什麼 |
|---|---|---|
pin_memory=True |
DataLoader 將 batch 準備在 pinned host memory,讓 DMA 可以直接讀取 | 不保證 H2D 與 kernel 重疊 |
non_blocking=True |
.to() 後不插入 cudaStreamSynchronize,主執行緒可以繼續排工作 |
不代表 device side 一定並行 |
| non-default CUDA stream | 讓 H2D 與主要計算排在不同 stream | 資料使用前仍要建立相依關係 |
| copy engine | 實際執行 DMA 的硬體資源 | copy engine 忙碌或硬體不支援時,仍無法重疊 |
所以「非同步」至少有兩種意思。host side 非同步,指 Python 主執行緒不必等 copy 完成;device side 重疊,才是 timeline 上真的同時出現 memcpy 與 kernel。CUDA Runtime 文件也特別提醒:函式名稱有 Async,不代表所有參數組合都一定對 host 非同步;pageable memory 需要 staging 時,driver 仍可能先同步 stream。PyTorch 的官方實驗則顯示,四種組合中,只有「pinned source+獨立 stream」能與 main stream 的 kernel 重疊。
差距有多大,B 檔裡剛好同時存在兩種傳輸,同一台 H100 機器、同一條 PCIe:
方向 路徑 筆數 總量 有效頻寬
H2D pageable 2,026 26.6 GB 7.3 GB/s
H2D pinned 1,164 136.3 GB 55.4 GB/s
D2H pageable 527 23.6 GB 2.9 GB/s
D2H pinned 18 2.6 GB 55.1 GB/s
pinned 路徑達到 Gen5 單向理論值的約 88%,pageable 只剩一成出頭,同方向相差約 7.6 倍;D2H 方向差距更大,約 19 倍。這些數字不是引用自 benchmark 文章,而是從這份公開 profile 直接計算出來的。Day 10 提過個位數 GB/s 的有效頻寬可能是 pageable,這條初步判斷今天有了實測版本。

指令照舊,這個 skill 沒有額外參數:
nsys-ai skill run memory_transfers megatron.sqlite --trim 39 42
輸出是一張按方向彙總的摘要(以下為主檔 A 的實際輸出):
── Memory Transfers Summary ──
Direction Count Total(MB) Total(ms)
────────────────────────────────────────────
D2D 70 299267.78 140.37
H2D 105 58.73 5.33
D2H 238 0.00 0.58
判讀分三步。第一步計算佔比:Total(ms) 除以選定範圍的長度,H2D 的 5.33 ms 佔三秒範圍的 0.18%,可以視為正常。要注意這個 skill 沒有 device 參數,輸出是所有 GPU 的合計;以單卡的範圍長度當分母,得到的是偏高的估計,多卡情況下實際佔比只會更低,Day 13 的分母問題在這裡同樣適用。第二步計算有效頻寬:Total(MB) 除以 Total(ms),兩欄直接相除即可,Day 10 需要跨兩張表手算的資訊,這裡一張輸出就能取得。第三步看方向的組成:D2D 大多屬於內部調度;H2D 偏大時檢查 DataLoader;D2H 偏大時檢查是誰在把資料搬回主機。
這裡也提供一個事前估算的方式:每個 iteration 的資料量(MB)除以 55,就是 pinned 路徑下理想的傳輸毫秒數;除以 7 則是 pageable 的情況。以常見的情境來說,每個 iteration 有 2 GB 的影像 batch,pinned 約需 36 ms、pageable 約需 286 ms;若 iteration 長度為一秒,前者佔 3.6%,安排得當時有機會藏在計算後面,後者則佔 29%,光靠重疊很難彌補。
第二步有一個需要先拆解的陷阱。A 檔的 H2D 換算出來只有約 11 GB/s,遠低於 pinned 應有的水準,是 pageable 嗎?先看每筆的平均大小:58.73 MB 除以 105 筆,平均一筆只有 0.56 MB。這麼小的傳輸,固定的啟動延遲會佔去大部分時間,跑不出高頻寬是正常現象,與記憶體型態無關。因此判讀原則要修正為:低有效頻寬加上大筆傳輸,才指向 pageable;小筆傳輸的低頻寬,應優先考慮合併批次,而不是更換記憶體型態。
若要進一步確認記憶體型態,可以直接查資料庫。這個 skill 的摘要沒有列出型態,但 sqlite 的 MEMCPY 表有 srcKind、dstKind 兩個欄位,一句 GROUP BY 就能分組統計。欄位編號可以用 Day 12 提過的驗證方式反推:D2D 的兩端必然都是 device,對應的編號就是 device;剩下兩個編號,比較各自的有效頻寬就能分辨哪個是 pinned、哪個是 pageable。上一節那張對照表就是用這個方法查出來的。單筆傳輸還有更快的檢查方式:Day 11 提過,在 timeline 上把 memcpy 的 bar hover 起來,bytes 與換算後的頻寬會直接顯示。
主檔 A 在穩定範圍內的資料線可以排除。H2D 每個 iteration 約二十多 MB、佔比 0.18%,示意卡當初填的 8% 在這份檔案上並不存在。Day 15 提過,排除也是會診的正式結果:確認這台機器的 DataLoader 沒有明顯問題之後,後續效能變化就不需要優先懷疑這條線。
不過 A 檔有一個值得記錄的現象:D2H 有 238 筆、總量趨近於零的微小傳輸,換算每個 iteration 接近一百筆。這是每一步從 GPU 讀取數值的典型模式,例如 loss、gradient norm 或進度條更新,與 Day 14 的同步問題是同一件事的另一面。目前總量只有 0.58 ms,還不構成問題,但這個模式值得認識:若筆數隨 iteration 明顯增加,通常代表 .item() 這類操作被放進了主要迴圈。若要追查來源,Day 5 提過每筆傳輸都帶 correlationId,連回 CUDA API 表就能找到發出呼叫的位置。
B 檔的載入階段則是問題明顯的例子。50 到 70 秒的範圍內,約 18 GB 的資料以 pageable 路徑從 GPU 搬回主機(2.9 GB/s,花了 6.3 秒),另有約 22 GB 以 pageable 路徑搬上 GPU(3.3 秒),來回接近十秒都花在 PCIe 上;對照組是相同量級的 18 GB 在卡內 D2D 只需要 12 ms,相差約五百倍。至於載入階段為何有大量 D2H,可能與權重在主機端的格式處理有關,這裡不進一步推測。這發生在載入階段,屬於一次性成本;但如果類似的搬運模式出現在每個 iteration 內,影響就會非常明顯。這也解釋了 Day 14 在 B 檔上看到的那批 memcpy 歸因的大 gap 的來源。
順帶把 Day 10 的示意卡與實測結果對照一次:attention 示意三成,實測 28.5%;idle 示意兩成,實測 1.5%;memcpy 示意 8%,實測 0.18%;通訊示意一成五,實測 28%。attention 接近不是巧合,那一格反映的是計算結構,模型決定後變動不大;差距大的是 idle 與通訊這類取決於系統狀態的項目,只有實測才能提供答案。示意卡教的是讀法,實測給的是這台機器的結論;方法都成立,但每份 profile 的數字要自己量。
資料搬運的改善,可以分成三層:先把主機端的 batch 準備好,再安排 H2D 與計算重疊,最後設法減少傳輸本身。
第一種是先準備 pinned batch。DataLoader 設定 pin_memory=True,可以由背景執行緒把完成的 batch 放進鎖頁記憶體;傳輸時再搭配 .to(device, non_blocking=True),避免主執行緒在每次 copy 後同步。不要在訓練迴圈裡臨時呼叫 .pin_memory():鎖頁配置需要作業系統配合,是相對重的操作,可能到毫秒等級,適合提早配置並重複使用。Day 14 名冊上那筆 cudaHostAlloc 佔用上百 ms 的歸因,就值得先檢查是不是在迴圈內反覆配置。
第二種是把 H2D 放進 side stream。DataLoader 的 num_workers 與 prefetch_factor 解決的是 CPU 端準備資料的速度,並不會自動讓 H2D 與 kernel 重疊。要在計算當前 batch 時傳輸下一個 batch,需要另外建立 CUDA stream,將非同步 copy 排進該 stream,使用前再透過 wait_stream 建立正確的相依關係。概念上會長成這樣:
copy_stream = torch.cuda.Stream()
# 計算 current_batch 的同時,先把下一批排進 copy stream
with torch.cuda.stream(copy_stream):
next_batch = cpu_batch.to("cuda", non_blocking=True)
# copy stream 傳下一批時,main stream 繼續算目前這批
loss = train_step(current_batch)
# 下一輪真正使用 next_batch 前,main stream 才等待 copy 完成
torch.cuda.current_stream().wait_stream(copy_stream)
next_batch.record_stream(torch.cuda.current_stream())
這段只呈現 stream 之間的關係;完整的 prefetcher 還需要處理第一批、最後一批與巢狀資料結構。side stream 配置的 tensor 也要處理生命週期,這裡的 record_stream 是告訴 caching allocator:main stream 還在使用這塊 storage,不要太早回收。PyTorch 的官方 DataPrefetcher 範例採用的就是這個結構。至於 worker 數量,沒有固定答案;從合理的小值開始,再用 throughput、CPU 使用率與 Day 14 的 gap 歸因調整即可。
第三種是減少跨 PCIe 的傳輸本身。可以合併零碎的小傳輸、將重複使用的資料留在 GPU,也可以把適合的前處理移到 GPU;.item() 這類把數值搬回主機的操作,則盡量移出主要迴圈。更進一步的選項還有 GPUDirect Storage(資料從 SSD 直接進入 GPU,不經過主機記憶體)與 NVIDIA DALI(解碼與增強在 GPU 上執行),方向都是減少資料來回,這裡先點名不展開。
改善後的驗收方式要看原本是哪一種問題。若是大筆 pageable 傳輸,改成 pinned 後,有效頻寬應該接近同一台機器的 pinned baseline;以 B 檔來說就是 50 至 55 GB/s。若問題是大量小傳輸,重點則是筆數下降、平均大小上升,以及 Day 14 名冊中 memcpy 歸因的 gap 縮短,不需要強求每一筆都跑到 50 GB/s。量測、提出假設、修改後再量測,Day 30 要總結的流程,在這裡就是日常操作。
兩個副作用需要注意。鎖頁記憶體佔用的是不可分頁的實體記憶體,配置過多會影響主機整體運作,不建議無差別全開;另外 Day 15 提過,pin_memory 的背景執行緒可能破壞 CUDA Graphs 的擷取,兩者同時使用時要參考官方的處理方式。

第一種,把載入階段當成常態。模型權重上卡、第一個 iteration 的資料暖身,都是一次性的大量傳輸;如果選定範圍包含這一段,佔比會被大幅高估。B 檔那接近 40 GB 的來回就屬於這類,拉統計前先確認範圍落在穩定階段,Day 7 的原則在這裡同樣適用。
第二種,只看 H2D 忽略 D2H。輸入資料的傳輸大家都會注意,反方向的微小傳輸卻常被忽略;但 238 筆微小 D2H 背後常常就是一行 .item(),而且它同時造成同步問題,一個現象對應兩種成本。方向欄的三行都需要判讀。
第三種,看到低頻寬就認定是 pageable。前面拆解過:小筆傳輸的低頻寬由啟動延遲主導,改善方式是合併批次;大筆傳輸的低頻寬才指向記憶體型態。下結論前先確認平均每筆的大小,必要時用 SQL 查 srcKind 確認。
資料線檢查完成,可以記住三件事。第一,D2D 與 PCIe 的頻寬差了一個數量級以上,能留在卡上的資料就不要安排來回。第二,同一條 PCIe 上,pinned 與 pageable 的實測差距約 7.6 倍、D2H 方向約 19 倍;佔比用 Total(ms) 除以範圍長度、頻寬用兩欄相除、低頻寬先檢查筆均大小再判斷型態。第三,主檔 A 的資料線可以排除,B 檔的載入階段則示範了問題的樣子。
病歷首頁到這裡,idle、launch、資料三條線在實測中都已排除或屬於輕微;剩下一個明顯偏高的項目:通訊,28%。不過在進入通訊分析之前,還有一格需要先填:這台機器的計算,到底發揮了硬體的幾成。
明天 Day 17,使用 region_mfu,把 Day 1 的 MFU 直覺,變成 Roofline 座標上的一個點。
memory_transfers 輸出(方向×Count/MB/ms):nsys-ai docs — user/skills(以 0.3.0 實測為準)。Async API 在不同記憶體型態下的同步行為:CUDA Runtime API — API synchronization behavior。non_blocking 與 side stream 的重疊條件:PyTorch — A Guide on Good Usage of non_blocking and pin_memory()。pin_memory 背景執行緒與 CUDA Graphs 擷取相容性:NVIDIA — CUDA Graph Capture Failures。GindaChen/nsys-hero)與 B 檔(HF rich7421/fastvideo-wan-h100-sp1-nsys),本文頻寬與佔比皆為 nsys-ai 0.3.0 與 SQL 實際計算結果。