iT邦幫忙

2026 iThome 鐵人賽

DAY 15
1
AI Engineering

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

Day 15|摘第三瓣,幾萬顆小 kernel 是怎麼拖垮一張卡的

  • 分享至 

  • xImage
  •  

昨天收尾時還留了一個問題:idle gap 分布中,最左邊那一桶的數量很多,名冊裡卻沒有特別大的 gap。還有一批更小的 gap 未達 1 ms 門檻,也不會出現在名冊中。今天使用第三個 skill kernel_launch_overhead,進一步調查這些碎縫。先說結論:這類問題通常不是 GPU 算得太慢,而是 CPU 發射(launch)kernel 的速度跟不上 GPU,或者發射成本已經高於 kernel 本身的執行時間。

先說明 launch overhead 和 GPU idle 的關係。分診表上將 launch 單獨分類,但它最常見的現象就是 GPU 不時出現短暫空檔,昨天最左邊那桶碎縫很可能就是這樣來的。之所以單獨討論,是因為改善方法不同:其他 idle 問題通常處理同步、資料搬運或跨卡等待;launch overhead 則要調整程式的執行結構,減少發射次數,或降低每次發射的成本。

每一次發射都有固定成本

先看底層機制。CPU 要讓 GPU 執行一顆 kernel,需要呼叫一次 cudaLaunchKernel,其中包含 driver 檢查、參數打包與寫入命令佇列(queue)等流程。CPU 側通常會花幾個 µs,實測常見約 5 µs;GPU 從佇列取得指令到真正開始執行,也會有幾個 µs 的固定延遲。這些成本不會隨 kernel 的工作量等比例縮小。對執行 500 µs 的 kernel 來說,5 µs 大約只佔 1%;但對執行 1.5 µs 的小 kernel 來說,發射成本已經高於執行本身。

這個問題在新一代 GPU 上可能更明顯。GPU 的計算速度持續提升,同一顆 kernel 換到 H100 上可能快上兩、三倍,但 cudaLaunchKernel 的主機端流程不一定同步變快。GPU 越快、kernel 執行時間越短,launch overhead 所佔的比例就越高。

這些碎 kernel 多半來自 eager mode 的執行方式。PyTorch 預設會為每個運算發射對應的 kernel,例如 a * b + c 可能需要兩次發射。一層 transformer 裡的 layer normalization、殘差連接、activation 與 dropout 都可能產生小 kernel,再乘上層數,以及 optimizer 對各組參數的操作,一個 iteration 就可能出現數千甚至數萬次發射。這不一定是程式寫錯,而是預設執行方式的結果。以經驗來看,融合做得較完整的訓練 iteration,kernel 發射數大致是模型層數的數十倍;如果單一 iteration 出現幾萬次發射,通常代表還有相當多運算沒有融合。

可以用一個簡單估算建立量級感。如果每次發射花 5 µs,單一 CPU 核心理論上每秒最多能處理約 20 萬次。但未充分融合的訓練 iteration 常有數千到數萬次發射;若 iteration 長度為 800 ms,發射工作就可能佔去數十到上百 ms。一個實用的初步判斷是:將每個 iteration 的 launch 次數乘上 5 µs,如果結果接近 iteration 時間的 10%,CPU 發射能力就可能是瓶頸之一。

同一顆小 kernel 的成本比較:CPU 發射平均需要 5.1 µs,GPU 執行平均需要 1.5 µs,發射成本約為執行時間的 3.4 倍;每個 iteration 累積 7,400 次後約為 59 ms

佇列太淺或過深,都要注意

Day 11 曾經從 timeline 上觀察 kernel 發射到實際執行之間的距離,今天這個距離有了明確數值,就是輸出中的 Queue avg。它表示一顆 kernel 從 CPU 發射,到 GPU 真正開始執行之間,在佇列中等待的平均時間。數值太低或太高,分別代表不同問題。

Queue avg 趨近零,表示佇列常常見底。GPU 執行完當前 kernel 後,下一顆還沒有發送過來,因此出現短暫 idle。這就可能是昨天最左邊那批碎縫的來源,Device-level idle 也會跟著增加。問題通常在 CPU 端:可能是發射速度已經到達上限,也可能是 CPU 還要處理 DataLoader 或 Python 工作,導致 kernel 無法穩定地送進佇列。

Queue avg 很高時,代表 GPU 有足夠的待辦工作,通常不會因此 idle。但這不代表沒有成本:CPU 需要不斷發射 kernel 才能維持佇列,可能壓縮處理 DataLoader 或其他任務的時間;GPU 側的每顆小 kernel 之間,也仍然有幾個 µs 的固定延遲,累積幾萬次後仍然可觀。

佇列還有一個上限。CUDA 對每條 stream 的未完成發射數有限制,大約為 1024;達到上限後,cudaLaunchKernel 可能需要等待佇列釋出空間,導致 CPU 受到反壓(backpressure)。PyTorch 的 HTA 工具也將 queue length 達到 1024 作為 CPU 受限的判斷依據之一。這種狀況還有一個可觀察的跡象:原本約 5 µs 的 cudaLaunchKernel 呼叫,會突然增加到幾百 µs 甚至 ms 級。這不一定是 driver 變慢,也可能是發射端正在等待佇列空間。如果平均值被大量正常呼叫稀釋,可以改看 JSON 輸出的 max_api_us;出現 ms 級尖峰時,就值得進一步檢查。

簡單來說,佇列見底代表 GPU 在等 CPU;佇列打滿則代表 CPU 受到反壓。許多又短又密集的 kernel,容易讓佇列落入這兩種極端。Day 4 已經在 timeline 上看過類似特徵:CUDA API 列的 cudaLaunchKernel 非常密集,CUDA HW 列卻斷斷續續。今天的 Queue avg,就是將這個視覺特徵轉成可以記錄與比較的數值。

佇列深度的三種狀態:太淺時 GPU 等 CPU,適中時 CPU 與 GPU 保持重疊,達到 1024 次未完成發射的上限時 CPU 受到反壓

把這瓣摘下來

指令照舊:

nsys-ai skill run kernel_launch_overhead \
  baseline.t128k.host-fs-mbz-gpu-899.nsys-rep --trim 39 42

這個 skill 內建了篩選門檻:只列出平均執行時間低於 10 µs,而且至少發射 100 次的 kernel。可以用 -p min_launches=-p limit= 調整條件,用 -p device= 切換 GPU;八張卡可以沿用昨天的迴圈逐張檢查。由於 launch 模式主要受程式結構影響,同一份程式在八張卡上的結果應該相近。如果只有其中一張卡出現明顯較多的碎 kernel,可以優先檢查對應 CPU 是否被其他工作搶佔,或 CPU affinity 是否設定不當,並將它記錄為 Day 20 要追蹤的 straggler 線索。輸出如下(欄位為實際輸出,數字為示意):

-- Small and Frequent Kernel Launches --
Kernel                              Launches  API avg(us)  Queue avg(us)  Kernel avg(us)
elementwise_kernel<AddcmulFunctor>     12288          5.2           1.8           4.6
elementwise_kernel<SqrtFunctor>         9216          4.9           2.1           6.3
triton_poi_fused_mul_add_0              6144          5.1          88.4           1.5

這四個欄位分別是:Launches 代表這顆 kernel 在選定範圍內的發射次數;API avg 是每次 cudaLaunchKernel 在 CPU 側的平均耗時;Queue avg 是平均排隊時間;Kernel avg 則是 kernel 本身的平均執行時間。如果 Kernel avg 小於 API avg,代表 CPU 發射成本已經高於 kernel 本身,是優先檢查融合機會的明確訊號。上面三列都符合這個條件。不過,tracing 本身也會為每次 CUDA 呼叫帶來固定開銷,可能讓 API avg 略微偏高。因此這個欄位適合用來比較相對比例與辨識模式,不建議直接當成未錄製時的絕對耗時。

接著用 Queue avg 分類。前兩列的 Queue avg 不到 2 µs,屬於佇列容易見底的類型,可能對應昨天最左邊那批碎縫,GPU 確實有短暫等待。如果再對照 NVTX 範圍,發現這兩顆 kernel 都落在 optimizer 階段,就可以確認 Day 8 看到的密集小 kernel 來自未融合的 optimizer,各組參數都需要獨立發射。

第三列則不同:Queue avg 為 88 µs,表示這顆 kernel 發射後曾在佇列中等待,GPU 並沒有因此閒置。它的名稱以 triton_poi_fused 開頭,代表這已經是 torch.compile 融合後的產物,但執行時間仍然只有 1.5 µs。這可能表示融合還有改善空間,也可能是這段程式的結構本來就會產生短 kernel,需要再往下追蹤。

下結論前,還可以做兩個交叉驗證。第一,對照昨天的 idle gap 分布。如果佇列容易見底,小 kernel 發射造成的累積開銷,應該和最左邊碎縫的總時間處於相近量級。例如今天估算每個 iteration 約有 60 ms 的發射開銷,昨天碎縫的總時間也應大致對得上;如果差距太大,就要再查是否還有其他來源。

第二,對照 Day 10 的 cuda_api_sum。那張表已經列出 cudaLaunchKernel 的呼叫次數與總時間,可以和這份輸出互相驗證。JSON 版每列還會直接提供 total_api_ms,不需要自行換算。Day 10 的初步判斷是 cudaLaunchKernel 數量很多,同時出現大量 µs 級小 kernel;今天這個 skill 就是更具體的確認工具。

如果還想繼續追蹤 CPU 側,可以使用 thread_utilization,檢查各條 CPU 執行緒(thread)的忙碌程度。如果負責發射 kernel 的執行緒已經接近滿載,就能進一步確認 CPU 是瓶頸。反過來,如果執行緒使用率不高,佇列卻仍然常常見底,可以再檢查 Python 的 GIL、DataLoader 是否與主執行緒爭用 CPU,或系統排程是否受到其他工作影響。

使用這個 skill 有一個前提:它依賴 CPU sampling 資料,因此錄製 profile 時必須開啟 sampling。如果像 Day 9 那樣使用 --sample=none,工具會明確回報不適用,而不會在資料不足時產生結果。若要將 kernel 發射對回具體的 Python 程式行,可以在下次錄製時加上 --python-sampling。Nsight Systems 會定期取樣 Python call stack,讓 timeline 上的碎 kernel 能夠對回程式位置,用法和 Day 6 介紹的相關旗標類似。

再估算這個問題的可改善空間。示意輸出的三列合計約 2.7 萬次發射;三秒的選定範圍約包含 3.5 個 iteration,換算後每個 iteration 約有 7,400 次。若發射與 GPU 端開始執行的累計開銷以 8 µs 估算,總計接近 60 ms,約佔 800 ms iteration 的 7%。以 Amdahl's law 估算,如果能夠完全移除這部分,整體理論加速上限約為 1.08 倍;套用 Day 1 的報價,每月約可節省 3,000 美元。絕對金額比昨天的 idle 問題小,但下面幾種改善方法的實作成本通常也不高。

病歷的 launch 頁可以使用這個格式:第一行記錄每個 iteration 的發射次數、發射開銷與佔比;第二行記錄佇列屬於容易見底、正常,還是容易打滿;然後列出前三名小 kernel 的名稱、次數、所在階段與預定改善方式。Day 27 完成優化後,再用相同格式記錄一次,就能直接比較前後差異。

三種改善方法

對付碎 kernel,可以從改動較小的方法開始,再逐步處理程式結構。

第一種是融合(fusion)。連續的逐元素運算,往往可以融合成單一 kernel;torch.compile 的 TorchInductor 會自動處理這類融合。示意輸出中的 triton_poi_fused 名稱,就表示它是融合後的產物。Optimizer 也有融合版本:PyTorch 的 fused optimizer 可以將各組參數分散的小 kernel,整併成少數較大的 kernel。例如 Adam 系列可以設定 fused=True;對示意輸出前兩列的類型,這項調整就可能大幅減少發射次數。

第二種是 CUDA Graphs。融合是減少 kernel 數量,CUDA Graphs 則是減少重複發射的 CPU 成本。它會先擷取(capture)一個 iteration 中 kernel 的發射順序,後續的 iteration 只需發射一次,就能重播整張 graph。例如一個 iteration 含有 1,000 顆 kernel,原本需要 1,000 次 CPU 發射;使用 CUDA Graphs 後,重播階段只需要一次發射。torch.compilereduce-overhead 模式會嘗試使用 CUDA Graphs,不一定需要手動擷取。

不過,CUDA Graphs 對執行結構的要求較高:iteration 的流程與 tensor shape 需要穩定。如果 shape 持續變化,可能需要重新擷取,額外成本反而會抵銷收益。這和 Day 7 提過的 autotune 暖機問題類似。官方文件也特別提到:DataLoader 開啟 pin_memory 時,背景執行緒可能在擷取期間發出 CUDA 呼叫,導致 capture 失敗。如果遇到錄製錯誤,可以先檢查這項設定。

第三種是減少 iteration 內的同步。CPU 需要適度領先 GPU,佇列才能維持一定深度。每一次 .item() 或 iteration 內的同步 logging,都可能強迫 CPU 等待 GPU,讓佇列迅速見底。如果能將這些操作移到 iteration 之外,或降低執行頻率,通常只需小幅修改程式,就可能看到效果。

這三種方法都不需要自行撰寫 CUDA kernel,可先從設定 fused optimizer、開啟 torch.compile 或移動同步操作開始。因此,launch overhead 的改善成本通常不高,較難的部分是先確認碎縫是否真的來自發射開銷。另外,將 kernel 分散到多條 stream 並不會消除 CPU 發射成本。多 stream 主要解決 GPU 側的並行問題,不能直接改善 CPU 側的發射瓶頸。

推論(inference)場景更容易遇到這個問題。Batch 較小時,kernel 執行時間通常更短,launch overhead 所佔的比例也更高。LLM 的解碼階段每產生一個 token,都要執行一系列 kernel,因此許多推論引擎都會使用 CUDA Graphs。Day 28 分析推論 profile 時,會再對照這個結構。

三種改善方法與對應目標:融合減少 kernel 數量,CUDA Graphs 減少 CPU 發射次數,移除 iteration 內的同步則能減少 GPU 等待

三種常見的誤判

第一種,將執行時間不算短的 kernel 也當成 launch overhead 問題。昨天榜上那顆平均執行 60 µs 的 elementwise kernel,不會出現在今天的輸出中,因為它不符合小於 10 µs 的條件。對 60 µs 的 kernel 來說,發射開銷通常不到執行時間的 10%;它比較可能是 memory-bound 或尚未融合,應回到 Day 13 的分類方法判讀。10 µs 門檻的用意,就是先篩出發射成本與執行時間接近的 kernel。

第二種,將通訊 kernel 也算進來。NCCL 在某些拓撲下也會發射大量小 kernel。名單中看到以 nccl 開頭的項目時,應先單獨分類;這類 kernel 是通訊協定的一部分,會在 Day 18 再分析。如果直接混在一起,容易錯估 kernel 融合的改善空間。

第三種,太早導入 CUDA Graphs。如果模型的 shape 每個 batch 都在變化,可能需要頻繁重新擷取,效果反而比原本更差。較穩妥的順序是先處理 kernel 融合,並讓 shape 穩定,再評估 CUDA Graphs 是否適合。

小結與明天

第三瓣完成後,可以記住三件事。第一,kernel 發射有數個 µs 的固定成本;當 Kernel avg 小於 API avg,就值得優先檢查。第二,Queue avg 是 Day 11 所觀察排隊距離的數值版:佇列見底表示 GPU 在等 CPU,佇列達到約 1024 的上限時,CPU 則可能受到反壓。第三,常見改善方式包括 kernel 融合、CUDA Graphs 與減少 iteration 內的同步,都不需要自行撰寫 CUDA kernel。

到今天,GPU idle 的幾個主要來源已經確認了兩個:昨天找到同步造成的 gap,今天則將碎縫對回 kernel launch overhead。名冊上還有一類 gap 尚未處理:歸因欄出現大量 cudaMemcpyAsynccudaHostAlloc 的資料搬運問題。

三個 skill 完成後,病歷上的每個紅字也逐漸有了明確來源。明天 Day 16,使用 memory_transfers 繼續檢查 DataLoader 與 H2D 資料搬運的成本。

參考資料


上一篇
Day 14|摘第二瓣,GPU 發呆的時候跳表照跳,是什麼14?
下一篇
Day 16|資料搬運的隱形成本,pinned H2D 快嗎
系列文
GPU很忙?他真的有在做事嗎?19
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言