Day 21 的 ncu 像把儀表接到機器上:它能告訴我們 DRAM throughput、SM throughput 與 warp stall,卻不會逐條列出 kernel 到底執行了哪些 GPU 指令。今天把機器外殼再拆一層,直接看 SASS(GPU 組合語言)。
先講最重要的界線:**CUTracer 數的是動態執行的指令,不是每類指令花掉的時間。**它很適合回答「有沒有 tensor core 指令」「位址計算與格式轉換多不多」「同步指令是否異常密集」,但不能只看一張指令直方圖,就斷言 kernel 的時間耗在運算、記憶體或同步。
今天回答四件事:
資料範圍提醒:主檔 A 只執行離線的 plan,還沒有回原訓練環境重跑;H100 trace 來自 Day 21 的最小示範程式。本文另外在 8×B200 主機的 device 1,以 CUDA 12.9、CUTracer v0.2.1 與 ncu 2025.2.1 重跑 vector add 與 BF16 GEMM,確認 trace 內容與工具邊界。B200 device 0–7 都存在,選 device 1 只是因為當時它是空的。
把這個系列用過的工具排成一座塔,每往下一層,視野越窄、重跑成本越高:
| 層 | 工具 | 回答的問題 | 成本 |
|---|---|---|---|
| 體檢表 | nvidia-smi | GPU 忙不忙 | 幾乎為零 |
| 錄影 | nsys | 誰慢、慢在哪、跟誰有關 | 錄一次,離線反覆讀 |
| 臨檢 | ncu | 這顆 kernel 的吞吐、stall 與資源限制 | 視 metrics 需要一次或多次 replay;Day 21 的 full set 是 39 passes |
| 顯微 | CUTracer | 這顆 kernel 動態執行了哪些 SASS 指令 | NVBit 插樁重跑,逐 warp 計數 |
CUTracer 建立在 NVIDIA 的 NVBit 上。它把 instrumentation tool 注入目標程式,在 GPU 二進位層替指令加上計數邏輯,最後得到 per-warp instruction histogram。這和 ncu 的硬體 counter 不是同一種證據:前者描述指令組成,後者描述硬體單元的吞吐與等待狀態。
實務上我會把 nsys、ncu 與 CUTracer 分開跑。這不是說任何版本都絕對不能共存,而是 NVBit 插樁本來就會改變程式的執行行為,工具疊加的相容性也跟 driver、CUDA 與容器權限有關。CUTracer 那次執行的時間不適合當 baseline;時間回 nsys 看,吞吐與 stall 回 ncu 看,CUTracer 專心回答指令組成。

nsys-ai cutracer 把工作切成三步,但輸出格式有一個容易漏掉的差別:
1. plan (不需 GPU) :從 nsys profile 排出候選 kernel,生成執行腳本
2. trace (需要 GPU) :NVBit 注入重跑,先產生 raw NDJSON 與 cubin
3. analyze (不需 GPU*):解析指令、對回 profile,再產生分類與提示
* 已解析的 CSV 可在 Mac 離線讀;raw trace 要有 nvdisasm,通常留在 CUDA Linux/Modal 解析。
先對主檔 A 跑第一步:
nsys-ai cutracer plan megatron.sqlite --top-n 5
CUTracer Plan — 5 kernel(s) selected from: megatron.sqlite
Selected for instrumentation:
ms % calls value category Kernel
─────────────────────────────────────────────────────────────
13919.94 10.0% 1785 HIGH custom_gemm nvjet_tst_128x320_64x3_2x1_v_bz_coopB_NNN
6988.03 5.0% 1260 HIGH custom_gemm nvjet_tst_128x320_64x3_2x1_v_bz_coopB_TNN
6132.46 4.4% 105 HIGH custom_gemm nvjet_tst_192x192_64x3_2x1_v_bz_coopB_NTN
5568.71 4.0% 525 HIGH custom_gemm nvjet_tst_192x192_64x4_2x1_v_bz_coopB_TNN
5510.66 4.0% 420 HIGH custom_gemm nvjet_tst_320x128_64x3_1x2_h_bz_coopB_NTT
Skipped (LOW instrumentation value,以下僅節錄):
ms % category Reason
─────────────────────────────────────────────────────
39809.77 28.7% flash_attn top-5 slots already filled by higher-priority kernels
24494.24 17.7% nccl_comms NCCL collective — always bandwidth-bound
1745.32 1.3% elementwise elementwise op — trivially memory-bound
這張表適合拿來排檢查順序,不適合直接當診斷書。NCCL collective — always bandwidth-bound 與 elementwise — trivially memory-bound 是 planner 內建的分類規則,不是它對這份 profile 新量到的結論。Day 21 的 H100 elementwise 確實由 ncu 證實接近 DRAM 屋頂,但那只能支持那顆 kernel,不能外推成所有 elementwise 都必然受記憶體頻寬限制。
表格的 % 也有口徑:這一版是相對於進入 planner 候選池的 GPU duration,不是整份 profile 的 GPU 總時間。它適合在候選之間比較,不能直接拿來回答「這顆占整次訓練幾成」。
加上 --script 會生成 kernel filter、輸出目錄與 CUTracer 路徑都填好的執行腳本。把 LAUNCH_CMD 換成原本的訓練命令後,再帶回 A 的原機器執行。
nsys-ai cutracer run --backend modal 產生的是一份可編輯的 Modal 應用程式。它在 CUDA Linux 映像裡編譯 CUTracer、掛載輸出 Volume、執行目標程式,並把 raw trace 解析成方便帶回 Mac 的 histogram CSV。也就是說,「trace 直接得到 CSV」是 Modal workflow 幫忙做完後處理的結果;直接在 GPU 主機跑 upstream CUTracer,先拿到的是 NDJSON 與 cubin。
這次 H100 示範改了三處:
torch 與 Day 21 的 mini.py,因為生成的映像不會自動包含你的訓練程式;nvjet,vectorized_elementwise 家族前綴,避免把某台機器的完整變體名稱寫死。filter 還有一個實測陷阱:CUDA kernel 名稱比對會區分大小寫,但本文使用的這版 nsys-ai runner 會把 filter 正規化成小寫。我們在 B200 用 vecAdd 測試時,命令以 exit code 0 結束,卻因實際比對成 vecadd 而沒有產生 trace。所以成功的判準不是程式有結束,而是輸出目錄真的有非空的 NDJSON、cubin 或 CSV。
版本也要一起記錄。這次 nsys-ai 的 Python dependency 允許 cutracer>=0.2.0,環境可能抓到較新的 Python package;安裝器實際編譯的上游原始碼則是 v0.2.1。重現結果時,至少要留下 nsys-ai commit、CUTracer Python package、CUTracer source、NVBit 與 CUDA 五個版本,不能只寫「有裝 CUTracer」。
同一台 H100 上,nsys 量到 nvjet GEMM 平均 175.1806 µs、elementwise 122.3166 µs;Day 21 的 ncu 分別是 174.75 與 121.73 µs,差約 0.25% 與 0.48%,也就是都在 0.5% 內。這次受限的 Modal 容器在開啟 CPU sampling 時會 segfault,加上 --sample=none --cpuctxsw=none -b none 才能錄 CUDA trace;這是該環境的實測限制,不是「Modal 一定不能做 CPU sampling」。

Modal H100 的 trace 抓到兩顆 kernel,但兩份輸出的解析程度不同:
| kernel | 動態指令計數總和 | 名稱解析狀態 |
|---|---|---|
| elementwise add | 72,500,000 | 13 個已解析 mnemonic |
| nvjet GEMM | 80,999,864 | 826 個未解析 opcode ID/靜態指令列 |
所以不能寫成「GEMM 有 826 種指令、elementwise 只有 13 種」。前者仍是 OPCODE_138 這類識別碼,後者已經合併成 IMAD、LDG 等 mnemonic,兩邊不是同一個分類層級。mini 程式實際執行五次;CSV 裡的 count 也彙總了多個 launch 與 warp。
同理,CSV 裡只看到 warp_id=0,也不代表 CUTracer 只取樣一個 warp。Modal 後處理器把 histogram 聚合成一列時,會用 [0, 0, mnemonic, count, ...] 寫出合計值。若要回答到底涵蓋幾個 warp,要回 raw metadata 或未聚合的 trace 查,不能從這個合成欄位猜。
回到 Mac 執行最後一步:
nsys-ai cutracer analyze mini.sqlite ./cutracer_out
Kernel: _ZN2at…vectorized_elementwise_kernel…
Instruction Mix:
███████████████░░░░░ compute 74.1%
██░░░░░░░░░░░░░░░░░░ memory 12.1%
░░░░░░░░░░░░░░░░░░░░ control 1.7%
Bottleneck: COMPUTE
Day 21 的 ncu 明明顯示這顆 elementwise add 接近 DRAM 屋頂,為什麼這裡判成 COMPUTE?先把 13 個 mnemonic 的動態計數攤開:
| 指令 | 佔比 | 在做什麼 |
|---|---|---|
| IMAD | 31.0% | 位址計算 |
| PRMT/SHF | 22.4% | byte 重排與位移 |
| FFMA | 13.8% | BF16 轉成 FP32 後執行加法 |
| LDC/ULDC | 12.1% | 讀取常數與參數 |
| F2FP | 6.9% | BF16/FP32 格式轉換 |
| LDG+STG | 5.2% | global-memory load/store 指令 |
| 其他 | 8.6% | S2R、ISETP、EXIT 等 |
這張表能告訴我們「位址計算與資料轉換的指令不少」,卻不能把 LDG+STG 的 5.2% 翻譯成「只有 5.2% 的成本在搬資料」。這裡的向量化 LDG 每個 participating lane 一次可載入 16 bytes;一筆 warp-level 動態指令最多同時涵蓋 32 個 lane。而且 LDG/STG 代表 global-memory 指令,請求可能由 cache 服務,不能一律寫成真的進出 DRAM。
ncu 的 DRAM Throughput 90.16% 也不是「90.16% 的時間在等 DRAM」,而是 DRAM 實際吞吐相對該硬體 peak rate 的比例。這顆 kernel 的完整證據是:DRAM 實測 3.02 TB/s、理論 DRAM roofline 達成率約 94.2%,而且 warp 每發一條指令,平均有 107.5 cycles 卡在 L1TEX scoreboard dependency,約占發射間隔的 92.5%。吞吐、roofline 與 stall 三項放在一起,才支持「主要在等記憶體資料就緒」。

為了確認上述限制不是 H100 或 Modal 的偶發狀況,我在實體 B200 再做兩組測試。這台主機第一次用一般帳號執行 ncu 時回傳 ERR_NVGPUCTRPERM,改以有權限的帳號收硬體 counter 才成功;遇到這個錯誤時要先查 profiling permission,不是把「沒有 counter」誤判成 kernel 沒有活動。
第一組是 upstream CUTracer 的 vector add;每次 launch 的 raw metadata 有 608 筆動態 warp-instruction、32 個不同 warp,兩次 launch 合計 1,216 筆。常見指令如下:
| mnemonic | 每次 launch 的動態計數 |
|---|---|
| LDC | 160 |
| IMAD | 128 |
| LDCU/LDG | 各 64 |
| S2R/S2UR/ISETP/DADD/STG/EXIT | 各 32 |
nsys-ai 按 count 分類為 memory 42.1%、compute 36.8%,最後標成 memory bottleneck;但 ncu 實測這個小 kernel 的 DRAM throughput 是 54.77%、SM throughput 是 27.90%,兩者都不到 60%,並提醒要繼續查 latency 與 scheduler 狀態。這正好示範:count-based 分類可以幫忙提問題,不能代替硬體量測下結論。
第二組是 1024×1024 的 cuBLAS BF16 GEMM。CUTracer 的 log 與 SASS 明確抓到 Blackwell 指令:
UTMALDG / UTMASTG
UTCHMMA.2CTA
raw trace 共有 641,834 筆動態 warp-instruction、1,024 個 warp,UTCHMMA 執行 4,096 次。它只占動態指令數約 0.64%,但一次 tensor core 指令完成的工作量遠大於一般 scalar 指令;占比低不表示 tensor 運算不重要。
接著把同一份 trace 交給目前的 nsys-ai analyze,卻得到:
warning: no SASS function matching ...
instruction_mix_pct: {"other": 100.0}
tensor_core_active: false
bottleneck: balanced
這次不能怪 CUDA 太舊:同一台機器的 CUDA 12.9 nvdisasm,透過 cutracer sass 可以正常反組譯 cubin,也看得到 UTCHMMA.2CTA。問題在後處理器:現有 function mapping 沒吃到這份 Blackwell cubin 的標籤格式,Tensor Core opcode 分類也還沒涵蓋 UTCHMMA。所以 tensor_core_active: false 是假陰性,balanced 則是解析失敗後的誤導輸出,都不是這顆 GEMM 的量測結論。
這顆小 GEMM 的 ncu 結果同樣不能草率貼標籤:Compute throughput 16.83%、DRAM throughput 6.79%,而且 grid 只有一個完整 wave。這比較像工作量太小、無法把 B200 餵飽,不是「運算與記憶體剛好平衡」。
第一,確認真的有 trace 到資料。exit code 0 不夠,還要看 NDJSON、cubin 或 CSV 是否存在且非空,kernel filter 的大小寫是否吻合。
第二,確認 mnemonic 已解析。如果輸出大多是 OPCODE_138 或直接落到 other 100%,後面的 instruction mix、tensor core 與 bottleneck 都先不要信。
第三,確認分類器支援這代架構。看到新的 Blackwell UTCHMMA,分類規則仍以 HMMA/IMMA/BMMA/DMMA 等舊命名為主時,tensor_core_active: false 反而是在提醒我們檢查 parser。
第四,回 ncu 與 nsys 對口徑。CUTracer 的插樁時間不作數,planner 的 skip reason 只是 heuristic,count-based bottleneck 也是提示;真正要談時間、吞吐與等待,仍要回到 timeline、counter、stall 與 roofline。
顯微鏡塔到今天蓋完四層:nvidia-smi 看忙不忙、nsys 看誰慢在哪、ncu 看吞吐與 stall、CUTracer 看指令長什麼樣。CUTracer 的價值不是取代前面三層,而是補上它們沒有的 SASS 組成:位址計算有多少、格式轉換有多少、tensor 與同步指令到底有沒有出現。
但越靠近底層,越不能把工具印出的標籤直接當答案。H100 的 5.2% LDG+STG 不等於只有 5.2% 成本在搬資料;B200 明明執行了 UTCHMMA,舊 parser 仍可能回報 tensor_core_active: false。先確認資料有抓到、名稱有解出來、架構規則跟得上,再談 bottleneck。
Part D 到此收官。明天開始的 Part E 要問一個新問題:這套「先驗證證據、再下結論」的流程,能不能交給 agent 代跑?第一課先從最現實的限制開始:一份 trace 塞不進 context window。
nvdisasm 與 Blackwell SASS 指令集:CUDA Binary Utilities