PyTorch のプロファイリング(第 1 部):torch.profiler を始めるための初心者ガイド
Hugging Face は PyTorch のパフォーマンスプロファイリングツール「torch.profiler」の初心者向けガイドを公開し、モデル最適化のための具体的な実装手順と可視化手法を解説している。
キーポイント
torch.profiler の基本機能と導入
PyTorch 標準ライブラリである torch.profiler を使用して、トレーニングや推論プロセスのボトルネックを特定する基本的なセットアップ方法を解説している。
詳細なパフォーマンスデータの取得方法
CPU と GPU のアクティビティ、カーネル実行時間、メモリ使用量など、モデルの動作に関する詳細なメトリクスを収集する具体的なコード例を示している。
可視化と分析ツールの活用
収集したデータを Chrome Trace Viewer や TensorBoard などのツールで可視化し、非効率な部分を直感的に理解して最適化するワークフローを紹介している。
プロファイラー出力の2つの主要アーティファクト
統計的サマリーを提供する「プロファイラテーブル」と、CPU/GPUの時間軸実行を示す「プロファイラトレース」の2種類を生成します。
ボトルネック特定と動作分析の使い分け
テーブルは処理時間の長いホットスポットやボトルネックを見つけるのに役立ち、トレースはカーネルの実行タイミングやCPU/GPUの重なりなどを調査するために使用されます。
自己時間と総時間の違い
"Self"カラムはイベント自体にかかった時間を示し、"total"カラムにはそのイベントとその子イベント(子供)の両方の時間が含まれるため、パフォーマンス分析時にこの区別が重要である。
オーバーヘッドバウンドの識別
CPU 時間の大半が GPU 計算よりもカーネル準備やデータ転送に費やされている場合、これは「オーバーヘッドバウンド」状態であり、GPU がアイドル時間を持つことを示す赤信号である。
影響分析・編集コメントを表示
影響分析
この記事は、大規模言語モデルや深層学習モデルの開発現場において、パフォーマンス最適化のハードルを下げる重要な役割を果たします。特に、プロファイリングツールの基本的な使い方を体系的に学ぶことで、開発者が効率的にリソースを管理し、モデルの速度向上を実現する基盤となる知識を提供しています。
編集コメント
Hugging Face の技術ブログとして、実践的なコード例を交えた解説は非常に質が高く、開発者が即座にパフォーマンス分析を開始できる内容です。
**
*プロファイリングできないものは、最適化もできません。
*
大規模言語モデル(LLM)から 1 秒あたりのトークン数をさらに引き出そうとする場合でも、推論時間を数ミリ秒短縮しようとする場合でも、あるいはトレーニングループが仕様書で約束された速度よりも遅い理由を理解したい場合でも、その道は最終的にプロファイリングを経由することになります。
問題は、プロファイリングの参入障壁が高いことです。トレースは色とりどりの長方形が密集した壁のように見えます。イベントには恐ろしい名前がついています。ほとんどのチュートリアルでは、すでにそれらを読みこなせることを前提としています。そのため、*プロファイリングすべきだと知っていても*、トレースを開くと、後回しにするか誰かに任せるのがよい作業のように感じられてしまいます。この投稿と、これによって始まるシリーズは、その参入障壁を下げようとする私たちの試みです。
これはPyTorch におけるプロファイリングというシリーズの最初の投稿で、ここではプロファイラトレースを読み解くスキルを徐々に身につけ、それを使って最適化を推進していきます。計画は以下の通りです:
- パート 1(今回の投稿): 最も単純な操作である行列乗算にバイアス加算を加えたものから始め、プロファイラが返す結果の読み方を学びます。
- パート 2:
nn.Linearと小さな MLP にスケールアップし、トレースを使って最適化を動機付けるとともに、その背後にあるカーネル(*kernels*)を覗き見てみます。
- パート 3: トランスフォーマーを用いた大規模言語モデルでこれらすべてを組み合わせて実践します。
私たちは初心者の視点からこの旅を記録します。基本的な PyTorch の知識以外に前提条件はありません。いくつかの「なるほど!」という瞬間がある、気楽な読み物としてお読みください。本稿の構成は意図的に質問主導で設計されています:トレースを開き、「待てよ、なぜ*それが*起こっているのか?」と問いかけ、何かが理解できるまで答えを追いかけていきます。終わる頃には、以下がわかるようになっているはずです。
- torch.profiler のセットアップ方法と、実際に返される内容
- プロファイラーテーブルとトレースの読み方(CPU ライン、GPU ライン、そしてその間の怪しいギャップ)
- Python からの呼び出しから CUDA カーネルに至るまでの一連のイベント連鎖
- torch.compile を適用した際に何が変わり(より興味深いのは)、何が変わらないか
始める前に、以下の2つの定義を覚えておくと、以下がより読みやすくなります。
- GPU カーネルとは、GPU の多数のスレッド上で並列実行されるプログラムのことです。
- CPU はこれらのカーネルのスケジューリングと起動を担当します。
通常、あなたが直接 GPU カーネルを書く必要はありません。PyTorch の操作を使用すると、自動的に1つ以上のカーネルに変換され、GPU 上でそのタスクを実行します。
この2つの概念を頭に入れておいて、質問をし始めましょう。
**
本稿で使用したスクリプト全体は以下の通りです:01_matmul_add.py。このスクリプトを別のタブで開き、コードを一つずつ追って歩くことをお勧めします。本稿の例では NVIDIA A100-SXM4-80GB GPU を使用してスクリプトを実行しています。
行列乗算と加算演算
Sara Hooker 博士が正しく皮肉っぽく述べたように、私たちが主に水で構成されているのと同様に、ディープニューラルネットワークも主に行列乗算(matrix multiplication)で構成されています。これほど基本的な演算である以上、プロファイリングの旅を他のものから始めるのは惜しいことです。
def fn(x, w, b):
return torch.add(torch.matmul(x, w), b)
この行列加算と行列乗算は、ニューロン内で重み(weights)とバイアス(biases)がどのように相互作用するかを模倣しています。この演算(あえて冗談を言います)は、後ほど本記事で取り上げるコンパイル torch.compile の実働 への道を開く仕組みを理解する助けとなります。
プロファイリングには torch.profiler モジュールを使用します。必要な手順は以下の通りです。
- プロファイル対象のコードを準備する(ここでは行列乗算と加算演算をラップした def fn)
- アルゴリズムに注釈をつける。これは完全に任意ですが、行うことを推奨します。record_function は関数を matmul_add として注釈付けし、後ほど述べるようにトレース内で簡単にナビゲートできるようにします。
def step():
with torch.profiler.record_function("matmul_add"):
return fn(x, w, b)
- torch.profiler.profile コンテキストマネージャーでコードを囲む
with torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU, # the cpu activities
torch.profiler.ProfilerActivity.CUDA, # the gpu activities
],
) as prof:
# it is recommended to run events multiple times to warm up the GPUs
for _ in range(5):
step()
prof.step()
- Export the profile
the profiler table
prof.key_averages().table(sort_by="cuda_time_total", row_limit=15)
the profiler trace
prof.export_chrome_trace(trace_path)
The profiler exports two distinct artifacts:
- The profiler table: Provides the statistical summary of the algorithm. It answers "What is taking the most time". This becomes really helpful to figure out hotspots. A hotspot would be events that take the most amount of time, can be a bottleneck of the pipeline, or an event that is triggered a lot of times.
- The profiler trace: Provides the temporal execution view. Answers "When and Why an operation happened", depicting the activities taking place on the CPU and the GPU. This is helpful when we want to investigate the kernel(s) that were launched, any delays in launching them, any overlap between CPU and GPU activities, etc.
Let's see the two in action with our first execution. (Here is the entire 01_matmul_add.py script)
It is recommended to run this script on a machine with a GPU.
uv run 01_matmul_add.py --size 64
上記のスクリプト(GPU マシン上で)を実行すると、2 つのアーティファクトを含む「traces/01_matmul_add」というフォルダが見つかります。
64_bf16_cold_eager.json
64_bf16_cold_eager.txt
Figure 1: Profiler table for matmul add on 64 sized matrices
.txt ファイルにはプロファイラーのテーブルが格納されています。このファイルを開くと、図 1 に示すように、最初の列に profile のスコープ内でトリガーされたイベントが一覧表示される大きなテーブルが表示されます。
他の列は、torch.profiler.profile で activities として指定された CPU、GPU、またはその他のデバイス上でイベントが実行されるのにかかる時間に関連しています。どのイベントが最も多くの時間を消費しているかを確認し、そのイベントが実際にそれだけの時間を要するべきかどうかを直感的に理解するようにしましょう。また、イベントがトリガーされた回数を示す「# of Calls」列も確認することが重要です。
ついでに、「Self CPU/CUDA」と「CPU/CUDA total」についても説明しておきましょう。「Self」列は、子イベントを除き、イベント自体内で消費された時間のみを測定します。一方、「total」列には、そのイベントとすべての子イベントの合計時間が含まれます。つまり、matmul_add の「CPU total」を見ると、これは自己実行にかかる時間と、それがトリガーした子イベントにかかった時間の合計で構成されていることがわかります。これは注意すべき重要なニュアンスです。
テーブルの最後の 2 行を見てみると、プロファイラーが以下を報告していることに気づくでしょう。
Self CPU time total: 2.314ms
Self CUDA time total: 23.104us
CPU の処理時間はミリ秒 (ms) で、GPU の処理時間はマイクロ秒 (us) です。状況を把握するために言えば、GPU での処理時間(カーネル ampere_bf16_s16816gemm...)は、CPU での処理時間(matmul_add 演算)の 1% も満たないことがわかります。GPU はほとんどの時間をアイドル状態にあり、これは即座に赤信号です。この現象が起こる理由は、GPU が小さな行列乗算を非常に高速に実行できるためで、コードはカーネルの準備、GPU への起動、乗算用のデータ送信、結果の収集に多くの時間を費やしているからです。この概念は「オーバーヘッドバウンド (overhead-bound) アルゴリズム」として知られています。
この状態から脱出する最も簡単な方法は、より大きな行列乗算を使用することです。
uv run 01_matmul_add.py --size 4096
Figure 2: Profiler table for matmul add on 4096 sized matrices
Figure 2 の最後の 2 行は以下の通りです。
Self CPU time total: 4.908ms
Self CUDA time total: 4.495ms
両方の時間はミリ秒 (ms) で表示されており、行列乗算のサイズを大きくするだけで、GPU での処理時間が大幅に増加したことを意味します。Figure 2 を見ると、CUDA の処理時間の大部分が、それを起動した CPU 側の演算(matmul_add)ではなく、GPU カーネル(ampere_bf16_s16816gemm_..)によって占められていることもわかります。これは、まさにオーバーヘッドバウンドから計算バウンド (compute bound) へ移行できたことを示しています。
次に、.json アーティファクト内に存在するディスパッチチェーンの可視化に移ります。これらは Perfetto UI にアップロードしてトレースを確認することもできますし、uvx trace-util traces -b traces を実行して Perfetto リンクを直接生成することも可能です。
64x64 のトレース
Figure 3: 行列乗算と加算の 64 サイズ行列に対するプロファイラートレース
Figure 3 では、行列乗算と加算のプロファイラートレースが示されています。ここで棒グラフの幅はイベントの継続時間を示し、垂直方向のネストは呼び出し階層を表します。CPU ラーンは CPU で発生するイベントを示し、GPU ラーンは実際のカーネル実行を示しています。また、空白部分は待機時間またはアイドル時間を表していることもお気づきいただけるでしょう。
このスクリプトは以下のデフォルト設定で実行されました:
- size 64: 入力、重み、バイアスはすべて (64, 64) のサイズです
- dtype bf16: データ型は bfloat16 です
- no compile: torch オペレーションのコンパイルは行っていません
- no warmup: プロファイリング前に GPU のウォームアップは行っていません
Perfetto を使用する際は、より迅速にトレースにアクセスできるようキーボード操作をお勧めします。"W A S D" キーを使用してトレースをナビゲートできます。
Figure 4: PyTorch プロファイラートレースの CPU ラーンと GPU ラーン
Figure 4 には、CPU アクティビティ用のレーンと GPU アクティビティ用のレーンの 2 つがあります。CPU ラーンでは、3 つのプロファイルステップ(ProfilerStep#2 から開始)が確認できます。これはスケジューリングによるものです。
schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)
待機中はノイズの多い初期化(ProfilerStep#0)をスキップし、ウォームアップではプロファイラーを実行しますが記録は行われません(ProfilerStep#1)、そしてアクティブな状態がトレースに現れます。使用されているスケジュールの詳細は、こちらのスクリプトで確認できます。
探偵帽をかぶり、トレースを調査していくつかの疑問に答えてみましょう。
なぜ ProfilerStep#2 はこれほど時間がかかるのでしょうか?
Figure 5: ProfileStep#2 はその後続くステップと比較して明らかに幅広いです
Figure 5 では、ProfileStep#2 が他のステップよりも多くの時間を要していることがわかります。よく見ると、matmul_add アノテーションにおいても同様のパターンが確認できます。決定的な証拠はアノテーションそのものではなく、アノテーション内部にあります:
Step
matmul_add start
aten::matmul start
gap
#2
138.736
366.493
227.757 µs
#3
517.926
523.447
5.521 µs
#4
610.039
614.527
4.488 µs
Figure 6: record_function("matmul_add") と aten::matmul の間の約 228 µs のデッドウィンドウ
図 6 に示される約 228 µs は、"matmul_add" という record_function の開始から、PyTorch が実際に aten::matmul をディスパッチするまでの間の「デッドウィンドウ」です。これは、ワークスペースの割り当て、cuBLAS(基本的な線形代数演算を行う NVIDIA 独自の GPU アクセラレーションライブラリ)のヒューリスティック、または遅延モジュール読み込みなど、複数の理由によって発生し得ます。私たちはこれを無視するか、プロファイリングを実行する前に いくつかのウォームアップステップ を実行する必要があります(これが標準的な手順です)。
プロファイリングの観点から、ウォームアップとは、実際にプロファイルする前にイベントを数回実行することを指します。GPU によって行われる事前作業(上記のポインタを含む)は一度きりの取り組みであり、これをプロファイルしたくありません。私たちの例では、2 つのウォームアップ段階があります。1 つはプロファイラーに入る前に関数を実際にループさせるものであり、もう 1 つはプロファイラー内部で行うもので、これは warmup 引数によって実現されます。このセクションでは、スケジュールとともに実際のイテレーションが有効になっています。
uv run 01_matmul_add.py --warmup
ウォームアップ付きの 64x64 Perfetto Trace
Figure 7: ウォームアップ後、すべてのプロファイルステップはほぼ同程度の時間を要します
図7を見ると、各プロファイリングステップにかかる時間は似ていますが、これは1回限りのオーバーヘッドを最適化できたことを意味するわけではありません。実行前にウォームアップを行ったため、オーバーヘッドはプロファイルされませんでした。このセクションを解決策へのヒントなしに突然終わらせるのは読者に対して失礼だと考え、さらにランチオーバーヘッドを最適化する方法についてはこちらのリンクをご覧ください。
CPUレーンとGPUレーンの間に約2.5msのオフセットがあるのはなぜか?
図8: CPUレーンとGPUレーンの間の約2.5msのオフセット
図8を見ると、CPUレーンとGPUレーンの間には約2.5msのオフセットがあります。これは、CPUがCUDAカーネルをサブミットしてから実際に実行が始まるまでの遅延時間です。ウォームアップ段階とスケジュールの待機・ウォームアップを組み合わせていればGPUは常に稼働し続け、このオフセットが縮小されるはずだと考えるかもしれません。
実際には何が起きているのか明らかにするために、スケジュールを少し変更してみましょう:
- schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)
+ schedule = torch.profiler.schedule(wait=0, warmup=0, active=3, repeat=1)
図9: wait=0 および warmup=0 とした場合、トレースにはアクティビティバッファリクエストが表示される
図9は、どの操作が行われる前にもGPUレーンに「Activity Buffer Request」が存在することを示しています。もう少し拡大してみましょう。
図10: プロファイルステップ1において、matmulカーネルとaddカーネルの間にギャップが生じる
GPU のトレースを拡大して確認すると、ProfileStep#0(その CPU トレースは図には表示されていません)の matmul および add カーネルが連続して実行されている一方で、ProfileStep#1 のカーネルの間には隙間があることがわかります。この現象に対する最も妥当な説明は、バッファのオーバーフローが発生し、カーネル実行中に別のバッファ要求(GPU VRAM 上のメモリ割り当てを要求するリクエスト)が発行されたためです。
他の可能性を排除するための最良の方法は、より多くのイテレーションでプロファイリングを行い、トレースの他の部分にも同様の隙間が現れるかを確認することです。そのために active=20 で実行します。
Figure 11: アクティブステップ数を 20 にすると、ギャップは一度だけ表示され、これがバッファ要求であることを確認できる
Figure 11 に示す通り、ProfileStep#1 でも同様の傾向が観察されます。これは以前の発見と整合しており、確かに別のバッファ要求であったと安全に結論付けられます。
イベントの連鎖
Figure 12: ディスパッチの連鎖
Figure 12 では、ネストされた CPU の呼び出しを確認できます。これは、ディスパッチの連鎖が実際にどのようなものかを理解するために非常に重要な可視化です。
まず、プロファイリングステップをカプセル化する ProfileStep#<id> から始めます。ステップに注釈を付けたため、matmul_add 行が表示されます。この matmul_add は 2 つの aten コール(matrix multiplication および matrix addition)で構成されています。
⟦CODE_0⟧
⟦CODE_1⟧
aten::matmul は、ユーザーが使用する PyTorch の matmul 呼び出しが到達する ATen レベル のディスパッチです。一方、aten::mm は 2 次元行列同士の乗算を行うバックエンドです。
PyTorch が行列にバッチ軸を追加すると aten::bmm(バッチ付き行列乗算)を呼び出す仕組みは非常に興味深いです。ここでは少し立ち止まり、実際に動作する aten::bmm を見てみましょう。
- x = torch.randn(args.size, args.size, device=device, dtype=dtype)
- w = torch.randn( args.size, args.size, device=device, dtype=dtype)
- b = torch.randn(args.size, args.size, device=device, dtype=dtype)
+ # バッチサイズを 8 に追加
+ x = torch.randn(8, args.size, args.size, device=device, dtype=dtype)
+ w = torch.randn(8, args.size, args.size, device=device, dtype=dtype)
+ b = torch.randn(8, args.size, args.size, device=device, dtype=dtype)
Figure 13: バッチ付き行列乗算
図 13 では、入力にバッチ軸を追加した結果、aten::matmul は aten::mm の代わりに aten::bmm とともに、一連の必須となる CUDA ランタイム呼び出しもカプセル化しています。これはまた、cuBLAS がプログラムに対して適切な(最も適した)カーネルをディスパッチするために実行するヒューリスティックの手がかりともなっています。
以降の記事では、特に断りのない限り、単純な 2 次元行列を用いて解説を進めます。
なぜ matmul には追加の CUDA ランタイム呼び出しが必要なのか?
Figure 14: matmul カーネル起動前に CUDA オキュパンシークエリが実行される
aten::mm では、cudaOccupancyMaxActiveBlocksPerMultiprocessor(図 14 で囲まれた部分)と cudaLaunchKernel の 2 つの CUDA Runtime 呼び出しがある一方、aten::add では cudaLaunchKernel のみが存在していることがわかります。
cudaOccupancyMaxActiveBlocksPerMultiprocessor は計画用の呼び出しであり、完全に CPU 側で実行されます。その問いは、「特定のカーネル関数と選択されたブロックサイズ、および選択された動的共有メモリサイズが与えられた場合、1 つの SM(Streaming Multiprocessor)に同時に存在できるこのカーネルのブロック数はどれくらいか?」というものです。
ここで疑問が生じます。なぜ行列積(matmul)には計画が必要で、加算(add)には必要ないのでしょうか?
これを理解するには、カーネルのリソースフットプリントを確認する必要があります。GPU カーネルをクリックすると、それぞれのカーネルに対するリソースフットプリントを検査できるようになります。
Figure 15: Matmul footprint
Figure 16: Add footprint
図 15 で注目すべきは、行列積においてはスレッドあたりのレジスタ数と共有メモリが動的(行列のサイズに基づく)である点です。cuBLAS は数百種類のカーネルバリアントを備えており、それぞれにハードウェア容量に関するランタイム情報を必要とするヒューリスティック駆動型の起動パスがあります。この occupancy 照会はそのヒューリスティックの一部です。概念的には、GPU アクセラレーションされた行列積は 独立したタイル上で動作している と考えることができます。使用するタイルの数と各タイルのサイズは、行列とハードウェアに依存します。現代のアルゴリズムはこの比ではるかに複雑ですが、それでも良い参照枠組みとなります。
図 16 から、加算演算のフットプリントはレジスタが 32 個、共有メモリがゼロであることがわかります。これは自明に合致しています。問い合わせるべきものはありません。なぜなら、ハードウェアリソースが稼働率を制限する要因となることはないからです。このカーネルは設計上、リソース消費が少ないものです。
これは、任意のトレースを読み解く際の迅速な診断ツールとして活用できます。CPU ラインで cudaOccupancyMaxActiveBlocksPerMultiprocessor をスキャンしてください。各出現箇所は、「重量級で適応的に起動された」カーネルを示すフラグとなります。通常、これらは GEMM(GEneral Matrix Multiplication:汎用行列乗算)、畳み込み演算、または同様の処理です。先行する稼働率クエリを持たないカーネル群は、PyTorch が機械的に起動する要素ごとの演算や還元演算の担い手たちです。
なぜ cudaDeviceSynchronize がこれほど大きいのか(約 1.78 ms)?
cudaDeviceSynchronize は、このデバイス上のすべての GPU 処理が完了するまで CPU をブロックします。プロファイラはイベントをフラッシュするために、アクティブなウィンドウの終わりにこの同期を発行します。これがなければ、カーネルの計時データは欠落してしまいます。
26 µs の実際の GPU 作業時間をカバーする 1.78 ms の同期時間は、今回の実行が 98% をアイドル状態であったことを示しています。これは教科書的なオーバーヘッド依存症候群です。
4096x4096 のトレース
上記のプロファイラテーブル分析から、アルゴリズムにより大きな行列を渡すことで、オーバーヘッド依存領域から計算依存領域へと移行することはすでにわかっています。
コマンドを実行して、トレースをさらに深く掘り下げてみましょう。
uv run 01_matmul_add.py --size 4096 --warmup
なぜ同じカーネルが他のものよりも多くの時間を要するのか?
図 17: 同じ入力にもかかわらず、ある行列乗算カーネルは他よりも長く実行される
図17において、ProfileStep#3の行列乗算カーネルが他のステップよりもGPU上で長時間実行されていることがわかります。これは特に興味深い点です。なぜなら、起動された他のカーネルは全く同じものであり、cuBLASのヒューリスティックは関与していないからです。スケジューリングのギャップはなく、CPUからの起動も正常であり、プロファイラのアートファクトでもありません。
図17のこのトレースは、理想化された例では見落としがちな有用な点を示しています:カーネルの実行時間は一定ではなく、同じハードウェア環境で同一のコードを同一のデータに対して実行している場合であっても変動します。
スクリプトを少し変更して、これをより具体的にしてみましょう。20回の反復を実行し、各ステップをキャプチャします。
- schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)
+ schedule = torch.profiler.schedule(wait=0, warmup=0, active=20, repeat=1)
- for _ in range(5):
+ for _ in range(20):
図18:20回の反復を通じて、同じ行列乗算カーネルが異なる速度で実行される
図18も同様の発見を明らかにしています。各カーネルは全く同じものですが、実行時間が異なります。計算時間の違いには、いくつかの理由が考えられます。
- アイドル時およびブースト時のGPUクロック
- GPUの発熱
- GPUのパワー管理
- ドライバ側のハウスキーピング
平均値だけを見た読者は、行列積演算(matmul)に約 1 ミリ秒かかったと結論付けるでしょう(5 回の平均で 1084 マイクロ秒)。一方、トレースを詳細に見た読者は、GPU が不調を起こさない限り行列積演算には約 580 マイクロ秒しかかからないことを知ることになります。これらは非常に異なる思考モデルであり、正しいのはそのうちの一つだけです。
torch.compile の動作を見てみましょう
torch.compile を扱うことは常に私たちを驚かせてくれます。通常の eager モードの PyTorch コードを書くだけで、PyTorch がテンソル処理が中心となる領域をキャプチャし、グラフに変換し、最適化して生成されたコードを実行しようとします。デフォルトのバックエンドは通常 TorchInductor であり、広範なパイプラインは以下の通りです。
- TorchDynamo が Python の実行を FX グラフとしてキャプチャする
- AOTAutograd が勾配計算が必要な場合に順伝播・逆伝播用のグラフを準備する
- Inductor がそのグラフを最適化された CPU または GPU コードに lowered(変換)する
このセクションではコンパイルについて話し、プロファイラーのトレースを見ていきます。
uv run 01_matmul_add.py --size 4096 --warmup --compile
引数の compile フラグが以下のコードをトリガーします:
def fn(x, w, b):
return torch.add(torch.matmul(x, w), b)
fn = torch.compile(fn) if args.compile else fn
Figure 19: コンパイルされた領域は、トレース上で TorchDynamo および Inductor フレームとして表示されます
Figure 19 では、「Torch-Compiled Region: 0/0」という新しい CPU の行が表示され、使用されているコンパイル済み関数を指し示しています。
行列積演算と加算カーネルを 1 つに融合(fuse)できましたか?
Figure 20: コンパイル実行では単一の aten::addmm がディスパッチされる
図 20 を見ると、乗算と加算の演算を実際に 1 つに融合させたのかという疑問が浮かびます。
これはグラフレベルにおけるオペレータ融合(operator fusion)です。Inductor は torch.add(torch.matmul(x, w), b) というコードを受け取り、それを単一の aten::addmm(b, x, w) 呼び出しへと書き換えました。ここで重要なのは、新しい融合された CUDA カーネルを生成したわけではないという点です。実際の GPU での処理は依然として ampere_bf16_s16816gemm_bf16_128x256_ldg8_f2f_stages_64x3_nn という cuBLAS カーネルであり、これは eager モードで使用されるものと同じです。つまり、ここでの「融合」はカーネルレベルではなく、ディスパッチャ(dispatcher)レベルで行われているのです。
PyTorch は torch.addmm 関数を提供しており、これは乗算と加算という 2 つのステップを一度に行うものです。読者の皆様には、この関数のトレース(traces)を確認し、その観察結果を以下のコメント欄にご投稿いただくことをお勧めします。
torch.compile のランタイムアーキテクチャ
理論的には関数をコンパイルした際に何が起こるかを理解していますが、それを実際に動作する様子を見ることも同様に重要です。torch.compile のランタイムアーキテクチャを反映する CPU サイドの階層構造を見てみましょう。
TorchDynamo Cache Lookup(キャッシュ検索)では、Dynamo が現在の呼び出しが、同じ入力形状、データ型(dtypes)、デバイス、テンソルメタデータとコンパイル時に一致しているかを確認します。何らかの不整合があれば Dynamo は再コンパイルを行います。このコストは、コンパイル後であってもすべての呼び出しで発生します。
Torch-Compiled Region は、コンパイルされたバージョンに「入る」ためのラッパーです。AOTDispatcher Runtime Wrapper Prologue は AOT Autograd のランタイムラッパーです。ここでは勾配が必要ないにもかかわらず、AOTDispatcher は常にスタック上にあり、テンソルのメタデータやビューの追跡を処理し、requires_grad が true であれば逆伝播パスを設定します。
## Call CompiledFxGraph は実際に生成されたコードが実行される場所です。「CompiledFxGraph」の後の文字列は FX グラフの内容ハッシュであり、3 つのアクティブなステップすべてで同じ値を示しており、キャッシュヒットを確認できます。
このハッシュをキーとして、/tmp/torchinductor_<user>/fxgraph 配下に生成されたコードがディスク上に保存されています。Inductor が実際に生成した Triton/C++ コードを読み取りたい場合に役立ちます。
CUDA の起動回数は半分になるのか?
Figure 21: 各コンパイルステップでは依然として 2 つの GPU カーネル、すなわち Device-to-Device memcpy と GEMM が起動されている
Figure 21 のトレースを見ると、ステップあたり cudaLaunchKernel が 1 回しか実行されていないことに非常に嬉しく思いました。この観察結果は、GPU トレースで見ていたものと直接矛盾していました。実際には、ステップあたり依然として 2 つのカーネルが起動されていました。それは Memcpy DtoD (Device -> Device) と GEMM です。CPU トレースに戻ると、cudaMemcpyAsync のディスパッチを完全に見落としていたことに気づきました。
addmm は out = α·A·B + β·C を計算しますが、cuBLAS の GEMM-バイアス追加エピローグは、すでにバイアスを含む必要がある宛先バッファに書き込みます。エピローグとは、GEMM の後に発生するすべての操作と考えることができます。深層学習の世界では、活性化関数、バイアスの加算、正規化など、さまざまな GEMM エピローグを常に考案しています。そのため、cuBLAS には GEMM-カーネルのバリアントが存在します。
torch.compile の異なるモードを使用すると、異なるカーネルバリアントが起動されていることに気づくでしょう。実際に試して、あなたの観察結果をコメント欄に追加してみてください!
したがって、Inductor が生成するコードは次のように動作します:
- out = copy(C) ← これは DtoD の memcpy です(32 MB、約 33 µs を要する)
- out = α·(A·B) + β·out ← GEMM でα=β=1、バイアス加算を書き戻しに融合
結果は数学的には依然として同じです。バイアスの加算は無料ではなく、事前に memcpy を実行するコストと、わずかに高価な GEMM エピローグのコストが発生します。
期待された真の融合実装、つまり x·w + b(ここでは out = α·A·B + β·C)が追加のメモリアクセスなしで単一のカーネルに圧縮されるようなことは起こりませんでした。Inductor は 2 つのメモリアクセスを伴う操作を維持したまま、バイアスのコピーを memcpy と名付け直し、加算を GEMM エピローグとして再分類しただけです。
真に融合された実装では memcpy をスキップします。これは FlashAttention スタイルの手書きカーネルが行っていることであり、Inductor も Triton コード生成を通じて実現できますが、4096×4096 の bf16 行列乗算においては、Inductor は明らかに「cuBLAS を使用し、バイアスはエピローグ設定経由で行う」ことが最善の道であると判断したようです。
CPU オーバーヘッドが増加した
この点は、Eager モードとコンパイル済み実行を比較する際に最も見落としやすいものです:
step
eager dur (ms)
compile dur (ms)
#2
0.1
0.2
#3
0.07
0.1
#4
0.07
0.1
コンパイルは、ステップあたり CPU 上で約 2 倍のコストがかかります。その理由は、既存の aten::addmm ディスパッチに加え、Dynamo > AOTAutograd > Inductor のスタック全体を呼び出しごとに走査する必要があるからです。このコンパイルパイプラインは数十個の演算子を持つ機械学習モデル向けに設計されており、1 回の呼び出しあたりのオーバーヘッドが分散されることを前提としています(単一の演算子のみの場合、これは税金のようなものです)。
torch.compile には mode 引数があります。読者への課題として、ドキュメントを読み込み、CPU オーバーヘッドを下げられるようなモードを見つけてほしいと思います。🤗
トレース読み取りチートシート
これまで見てきたパターンについての簡易リファレンスです。トレース内でこれらを目にした場合、通常は以下の意味を持つという考え方に基づいています。
プロファイラーテーブル
What you see
What it usually means
Self CPU time total ≫ Self CUDA time total (CPU は ms、GPU は µs)
オーバーヘッドがボトルネック。CPU が計算する時間よりもディスパッチに費やす時間の方が多くなっています。作業を大きくするか(行列のサイズを大きくする、バッチ処理を行う)、または呼び出しを融合してください。
Self CPU time total ≈ Self CUDA time total、両方とも ms 単位
計算がボトルネック。GPU がボトルネックとなっている状態です。通常はこれが望ましい結果です。
One event dominates CUDA total
それがホットスポットです。ここから最適化を開始してください。
One event has a huge # of Calls
1 回の呼び出しあたりのコストが低くても、潜在的なボトルネックになり得ます。融合やバッチ処理が可能か確認してください。
CPU 合計 ≫ 行ごとの自己 CPU
コストの大部分は子イベントに存在します。親ではなく、ネストされたイベントを詳しく掘り下げてください。
CPU ラーン
あなたが目にするもの 通常の意味
最初の ProfileStep が他よりも大幅に広い コールドスタートのオーバーヘッド:ワークスペース割り当て、cuBLAS ヒューリスティクス、遅延モジュール読み込み。ウォームアップ反復回数を追加するか、スケジュールの warmup 引数を使用してください。
record_function("...") の開始と、その内部で最初の aten::* が実行されるまでの間に大きなギャップがある 同じくコールドスタートの税(オーバーヘッド)です。単にズームインしただけです。注釈は入力されましたが、ディスパッチはまだ発生していません。
<co 実行前に cudaOccupancyMaxActiveBlocksPerMultiprocessor
原文を表示
What you cannot profile, you cannot optimize.
Whether you are trying to squeeze more tokens per second out of a Large Language Model (LLM), shave milliseconds off inference, or just understand why your training loop runs slower than the spec sheet promises, the path eventually runs through profiling.
The catch is that profiling has a steep on-ramp. The traces are dense walls of colored rectangles. The events carry intimidating names. Most tutorials assume you can already read them. So even when we *know* we should be profiling, opening a trace can feel like a chore best left for later (or for someone else). This post, and the series it kicks off, is our attempt to lower that on-ramp.
This is the opening post of Profiling in PyTorch, a series where we slowly build the skill of reading profiler traces and use it to drive optimization. The plan:
- Part 1 (this post): start with the simplest possible operation, a matrix multiplication followed by a bias add, and learn how to read what the profiler hands back.
- Part 2: scale up to nn.Linear and a small MLP, use the traces to motivate optimizations, and peek at the kernels underneath.
- Part 3: put it all together on Large Language Models with transformers.
We document the journey from a beginner's point of view. No prerequisites apart from basic PyTorch. Treat this as a leisurely read with some "Aha!" moments. The structure of the post is intentionally question-led: we open a trace, ask "wait, why is *that* happening?", and chase the answer until something clicks. By the end you should know:
- how to set up torch.profiler and what it actually hands back,
- how to read the profiler table and the trace (CPU lane, GPU lane, and the suspicious gaps in between),
- the chain of events from a Python call all the way down to a CUDA kernel,
- what changes (and, more interestingly, what does not change) when you slap torch.compile on top.
Before we begin, two definitions that will make everything below read better:
- A GPU kernel is a program that runs in parallel on many threads of the GPU.
- The CPU schedules and launches these kernels.
You don't usually have to write GPU kernels yourself; when you use a PyTorch operation, it is automatically translated to one or more kernels that do the job on GPU.
With those two ideas in your back pocket, let's start asking questions.
Here is the entire script that we use for the post: 01_matmul_add.py. We recommend opening this script in a separate tab and walk through the code step by step. We use the NVIDIA A100-SXM4-80GB GPU to run the scripts.
The matrix multiplication and addition operation
As correctly quipped by Dr. Sara Hooker, just as we are primarily made up of water, Deep Neural Networks are primarily made up of matrix multiplies. As fundamental as they are, it would be a shame to start our profiling journey with anything else.
def fn(x, w, b):
return torch.add(torch.matmul(x, w), b)
The matrix addition along with the matrix multiplication mimics how weights and biases interact in a neuron. This addition (pun intended) will help us understand how it paves the way for compilation later in the post.
To profile, we will be using the torch.profiler module. The steps involved are:
- Have the code to profile ready (here def fn, which wraps the matrix multiplication and matrix addition)
- Annotate the algorithm. While this is completely optional, we recommend doing this. The record_function annotates our function as matmul_add, which will be easy to navigate in the traces (as we note later)
def step():
with torch.profiler.record_function("matmul_add"):
return fn(x, w, b)
- Wrap the code with the torch.profiler.profile context manager
with torch.profiler.profile(
activities=[
torch.profiler.ProfilerActivity.CPU, # the cpu activities
torch.profiler.ProfilerActivity.CUDA, # the gpu activities
],
) as prof:
# it is recommended to run events multiple times to warm up the GPUs
for _ in range(5):
step()
prof.step()
- Export the profile
# the profiler table
prof.key_averages().table(sort_by="cuda_time_total", row_limit=15)
# the profiler trace
prof.export_chrome_trace(trace_path)
The profiler exports two distinct artifacts:
- The profiler table: Provides the statistical summary of the algorithm. It answers "What is taking the most time". This becomes really helpful to figure out hotspots. A hotspot would be events that take the most amount of time, can be a bottleneck of the pipeline, or an event that is triggered a lot of times.
- The profiler trace: Provides the temporal execution view. Answers "When and Why an operation happened", depicting the activities taking place on the CPU and the GPU. This is helpful when we want to investigate the kernel(s) that were launched, any delays in launching them, any overlap between CPU and GPU activities, etc.
Let's see the two in action with our first execution. (Here is the entire 01_matmul_add.py script)
It is recommended to run this script on a machine with a GPU.
uv run 01_matmul_add.py --size 64
If you run the above script (on a GPU machine) you will find a folder traces/01_matmul_add with the two artifacts:
64_bf16_cold_eager.json
64_bf16_cold_eager.txt
Figure 1: Profiler table for matmul add on 64 sized matrices
The .txt file holds the profiler table. Upon opening the file, as shown in Figure 1, one would be greeted with a big table with the first column consisting of the events that were triggered inside the scope of profile.
The other columns are related to the time the event takes on the CPU or GPU or any other device(s) specified in activities within torch.profiler.profile. Look at which events take the most amount of time, and try to intuitively understand if that event should in fact take that time. It is also important to look at the column "# of Calls" which dictates how many times the event was triggered.
While we are at it, let's also talk about "Self CPU/CUDA" vs "CPU/CUDA total". The "Self" columns measure time spent only inside the event itself, excluding its children. The "total" columns include the event and all of its children together. So if you look at the "CPU total" of matmul_add, it consists of the time it took on self plus the children events it triggered. This is an important nuance to note.
If you look at the last two lines out of the table you would notice that the profiler tells us that
Self CPU time total: 2.314ms
Self CUDA time total: 23.104us
The CPU time is in ms while the GPU time is in us. To put things in perspective, the time spent on GPUs (the kernel ampere_bf16_s16816gemm...) is less than 1% of the time spent on the CPU (the matmul_add operation). The GPU stays idle most of the time, which is an immediate red flag. The reason this happens is that the GPU can compute a small matmul very quickly, so our code spends most of the time preparing the kernels, launching them on the GPU, sending the data to multiply and gathering the results. This concept is known as an *overhead-bound* algorithm.
The easiest way to move out of this regime is to use bigger matrix multiplications.
uv run 01_matmul_add.py --size 4096
Figure 2: Profiler table for matmul add on 4096 sized matrices
The last two lines in Figure 2 are:
Self CPU time total: 4.908ms
Self CUDA time total: 4.495ms
Both times are in ms, which means we have materialized more GPU time just by increasing the size of the matrix multiplications. If you look at Figure 2 you would also notice that the most CUDA time is now taken by the GPU kernel (ampere_bf16_s16816gemm_..) and not by the CPU operation that launched it (matmul_add). This means that we were indeed able to move from overhead bound to compute bound.
We now move into visualising the dispatch chain, which lives inside the .json artifacts. You can upload them to Perfetto UI and see the traces, or you can use uvx trace-util traces -b traces to generate the Perfetto links directly.
64x64 traces
Figure 3: Profiler trace for matmul and add on 64 sized matrices
In Figure 3, we see the profiler trace for the matrix multiplication and addition. Here the bar width indicates the duration of an event, the vertical nesting is the call hierarchy, the CPU lane denotes the events that happen on the CPU, while the GPU lane shows the actual kernel executions. One might also notice the empty spaces which are the waiting or idle time.
The script was run with default configurations which are:
- size 64: The inputs, weights and biases are sized (64, 64)
- dtype bf16: The data type is bfloat16
- no compile: We have not compiled the torch operations
- no warmup: We have not warmed up the GPU before profiling
With Perfetto we suggest using the keyboard for quicker access to the trace. One could use "W A S D" for navigating the trace.
Figure 4: The CPU and GPU lanes of a PyTorch profiler trace
There are two lanes in Figure 4, one for the CPU activity and one for the GPU activity. In the CPU lane one would notice three profile steps (starting from ProfilerStep#2). This comes from the schedule.
schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)
The wait skips noisy initializations (ProfilerStep#0), warmup runs through the profiler without recording (ProfilerStep#1), and active is what shows up in trace. One can find the schedule being used in the script here.
Let's put on our detective hats and investigate the trace and ask some questions.
Why does the ProfilerStep#2 take so long?
Figure 5: ProfileStep#2 is visibly wider than the steps that follow it
In Figure 5, we notice that ProfileStep#2 takes more time compared to the other steps, and upon looking closely you would see a similar pattern with the matmul_add annotation as well. The smoking gun is inside the annotation, not the annotation itself:
Step
matmul_add start
aten::matmul start
gap
#2
138.736
366.493
227.757 µs
#3
517.926
523.447
5.521 µs
#4
610.039
614.527
4.488 µs
Figure 6: The ~228 µs dead window between record_function("matmul_add") and aten::matmul
That ~228 µs shown in Figure 6 is the "dead window" between entering record_function("matmul_add") and PyTorch actually dispatching aten::matmul. This can happen for multiple reasons, including workspace allocations, cuBLAS (NVIDIA’s proprietary, GPU-accelerated library for performing fundamental linear algebra operations) heuristics, or lazy module loading. We can either look away or run some more warmup steps before we profile (which is the standard)
In terms of profiling, warmup is when you run the events a couple of times before actually profiling it. The pre-work done by the GPU (including the above pointers) are one time efforts which we do not want to profile. In our example, we have two warmup stages, one where we actually loop over the function before entering the profiler, and two inside the profiler which is achieved by the warmup argument. In this section, we have enabled the actual iterations along with the schedule.
uv run 01_matmul_add.py --warmup
Perfetto Trace for 64x64 with Warmup
Figure 7: After warming up, every profile step takes a similar amount of time
In Figure 7 we see that each profile step takes a similar time, but this does not mean we were able to optimize the one time overheads. We warmed up the runs so that the overheads were not profiled. We think that closing this section abruptly without a hint to solving this would do injustice to the reader, so here is a link to read about further optimizing launch overheads.
Why is there an offset of ~2.5 ms between the CPU and GPU lanes?
Figure 8: The ~2.5 ms offset between the CPU and GPU lanes
In Figure 8, we see that the CPU and GPU lanes have an offset of around 2.5 ms: this is the delay after the CPU submits the CUDA kernels and the time they actually start executing. One might think the warmup stage combined with the schedule's wait and warmup should keep a GPU busy and would diminish the offset.
To uncover what is really happening, let's change our schedule a little:
- schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)
+ schedule = torch.profiler.schedule(wait=0, warmup=0, active=3, repeat=1)
Figure 9: With wait=0 and warmup=0, the trace reveals an Activity Buffer Request
Figure 9 shows us that there is an Activity Buffer Request in the GPU lane before any operation. Let's zoom in a little more.
Figure 10: A gap appears between the matmul and add kernels on profile step 1
Upon zooming into the GPU trace, we notice that the matmul and add kernels for ProfileStep#0 (the CPU trace of which is not visible in the Figure) happen one after the other, while the kernels for ProfileStep#1 have a window in between. The best explanation for this is that there was an overflow of buffers, and another buffer request (a request to allocate some memory on the GPU VRAM) was issued during the kernel execution.
The best way to rule out other possibilities is to profile for more iterations and see whether a similar window appears in other parts of the trace. To do that we run with active=20.
Figure 11: With 20 active steps the gap only shows up once, confirming it is a buffer request
As shown in Figure 11, we see a similar trend in ProfileStep#1. This is aligned with our previous findings, and we can safely conclude that it was indeed another buffer request.
The chain of events
Figure 12: The chain of dispatch
In Figure 12, we see the nested CPU calls. This is an important visualization, where one gets to understand what a chain of dispatch really looks like.
We begin with ProfileStep#<id> which encapsulates the profiling step. Due to us annotating the step, we see the matmul_add row. The matmul_add consists of two aten calls, one for matrix multiplication and one for matrix addition.
The aten::matmul is the ATen-level dispatch that those user-facing PyTorch matmul calls land on. aten::mm is the 2D matrix-matrix multiply backend.
It is very interesting to note how PyTorch calls aten::bmm (batched matrix multiplication) if we add the batch axis to our matrices. Let's take a detour and see the aten::bmm in action.
- x = torch.randn(args.size, args.size, device=device, dtype=dtype)
- w = torch.randn( args.size, args.size, device=device, dtype=dtype)
- b = torch.randn(args.size, args.size, device=device, dtype=dtype)
+ # adding a batch size of 8
+ x = torch.randn(8, args.size, args.size, device=device, dtype=dtype)
+ w = torch.randn(8, args.size, args.size, device=device, dtype=dtype)
+ b = torch.randn(8, args.size, args.size, device=device, dtype=dtype)
Figure 13: Batched Matrix Multiplication
In Figure 13, upon adding the batch axis to the inputs, aten::matmul now encapsulates a bunch of other prerequisite CUDA runtime calls along with aten::bmm (instead of aten::mm). This also hints at the heuristics that cuBLAS needs to do in order to dispatch the right (most suitable) kernel for the program.
In the rest of the post, we will be working with simple 2D matrices, unless otherwise mentioned.
Why does matmul have an extra CUDA runtime call?
Figure 14: A CUDA occupancy query fires before the matmul kernel launch
We notice that for aten::mm there are two CUDA Runtime calls, namely cudaOccupancyMaxActiveBlocksPerMultiprocessor (boxed in Figure 14) and cudaLaunchKernel, while for aten::add there is only the cudaLaunchKernel.
cudaOccupancyMaxActiveBlocksPerMultiprocessor is a planning call and is purely CPU side. It asks: "given a kernel function, a chosen block size, and a chosen dynamic shared memory size, how many blocks of this kernel can simultaneously reside on one SM (Streaming Multiprocessor)?"
This begs the question, why do we need planning for matmul and not for add?
To understand this, we have to look at the kernel's resource footprint. If you click on the GPU kernels, you will be able to inspect the resource footprint for the respective kernel.
Figure 15: Matmul footprint
Figure 16: Add footprint
In Figure 15, we note that for matrix multiplication the registers per thread and shared memory are dynamic (based on the size of the matrix). cuBLAS ships hundreds of kernel variants, and each has a heuristic-driven launch path that needs runtime information about hardware capacity. The occupancy query is part of that heuristic. Conceptually, we can think of GPU-accelerated matmuls as working on independent tiles: how many tiles we use and how big each tile needs to be depends on the matrices and the hardware. Modern algorithms are way more complicated than that, but this is still a good reference framework.
From Figure 16 we see that the footprint of addition says 32 registers and zero shared memory. That fits trivially. There's nothing to query, because no hardware resource is going to limit occupancy. The kernel is, by design, resource-light.
You can use this as a quick diagnostic when reading any trace. Scan the CPU lane for cudaOccupancyMaxActiveBlocksPerMultiprocessor. Each occurrence flags a "heavyweight, adaptively launched" kernel, usually a GEMM (GEneral Matrix Multiplication), conv, or similar. The kernels without a preceding occupancy query are the elementwise/reduction crowd that PyTorch launches mechanically.
Why is cudaDeviceSynchronize so big (~1.78 ms)?
cudaDeviceSynchronize blocks the CPU until all GPU work on this device finishes. The profiler emits this sync at the end of the active window to flush events. Without it, kernel timings would be missing.
A 1.78 ms sync covering 26 µs of real GPU work tells you this run was 98% idle. That's the textbook overhead-bound symptom.
4096x4096 traces
We already know from the profiler table analysis (above) that providing bigger matrices to our algorithm moves it out from the overhead-bound region to being compute-bound.
Let's run the command and dive deeper into the traces.
uv run 01_matmul_add.py --size 4096 --warmup
Why does the same kernel take more time compared to others?
Figure 17: One matmul kernel runs longer than the others despite identical inputs
In Figure 17, we notice that the matmul kernel for ProfileStep#3 takes longer on the GPU than the other steps. This is particularly interesting to note, because the other kernels launched were the exact same, which means there were no cuBLAS heuristics involved. There are no scheduling gaps, the CPU launches are normal, and it is not a profiler artifact.
This trace in Figure 17 makes a useful point that's easy to miss in idealized examples: kernel runtimes are not constants, even on the same hardware environment running identical code on identical data.
Let's make this more concrete by modifying the script a little. We run the iteration 20 times, capturing each of the steps.
- schedule = torch.profiler.schedule(wait=1, warmup=1, active=3, repeat=1)
+ schedule = torch.profiler.schedule(wait=0, warmup=0, active=20, repeat=1)
- for _ in range(5):
+ for _ in range(20):
Figure 18: Across 20 iterations the same matmul kernel runs at different speeds
Figure 18 reveals a similar finding. While each kernel was the exact same, they time differently. The different compute times can be blamed on a bunch of reasons:
- GPU clocks on idle and boost
- GPU heating
- GPU power management
- Driver side housekeeping
A reader who only saw the average would conclude that a matmul took ~1 ms (mean of 5 = 1084 µs); a reader who looked at the trace would see that the matmul takes ~580 µs except when the GPU throws a fit. Those are very different mental models, and only one of them is correct.
Let's see some torch compile at work
Working with torch.compile has always amazed us. One writes normal eager PyTorch code, but PyTorch tries to capture tensor-heavy regions, turn them into graphs, optimize them, and run generated code. The default backend is usually TorchInductor, and the broad pipeline is:
- TorchDynamo captures Python execution into an FX graph
- AOTAutograd prepares forward/backward graphs when gradients are involved
- Inductor lowers the graph into optimized CPU or GPU code.
In this section, we talk about compilation and look at the profiler traces.
uv run 01_matmul_add.py --size 4096 --warmup --compile
The args.compile flag triggers the following code:
def fn(x, w, b):
return torch.add(torch.matmul(x, w), b)
fn = torch.compile(fn) if args.compile else fn
Figure 19: The compiled regions show up as TorchDynamo and Inductor frames in the trace
In Figure 19, we see the new CPU rows named Torch-Compiled Region: 0/0 which points us to the compiled functions being used.
Did we fuse the matmul and add kernels into one?
Figure 20: Compiled run dispatches a single aten::addmm
Looking at Figure 20 we ask the question, did we actually fuse the multiplication and addition operations together into one?
This is operator fusion at the graph level. Inductor took our torch.add(torch.matmul(x, w), b) and rewrote it into a single aten::addmm(b, x, w) call. The important thing to note here is that it did not produce a new fused CUDA kernel. The actual GPU work is still ampere_bf16_s16816gemm_bf16_128x256_ldg8_f2f_stages_64x3_nn, the same cuBLAS kernel eager mode used. So the "fusion" here is at the dispatcher level, not at the kernel level.
PyTorch provides the torch.addmm function that does what we did into two steps, that is multiply and add. We encourage the reader to look at the traces of this function and comment your observations in the comments below!
torch.compile's runtime architecture
While we know in theory what happens when we compile our functions it is equally important to see it in action. Let's look at the CPU-side hierarchy which reflects torch.compile's runtime architecture.
TorchDynamo Cache Lookup is where Dynamo checks that the current call still matches what was compiled with the same input shapes, dtypes, devices, and tensor metadata. If anything mismatched, Dynamo would recompile. This cost is paid every call, even after compilation.
Torch-Compiled Region is the wrapper that "enters" the compiled version. AOTDispatcher Runtime Wrapper Prologue is AOT Autograd's runtime wrapper. Even though we don't need gradients here, AOTDispatcher is always in the stack handling tensor metadata, view tracking, and would set up the backward pass if requires_grad were true.
## Call CompiledFxGraph is where the actual generated code runs. The string after "CompiledFxGraph" is the content hash of the FX graph. It's the same across all three active steps, confirming cache hits.
You can find the generated code on disk under /tmp/torchinductor_<user>/fxgraph keyed by this hash, useful when you want to read the Triton/C++ that Inductor actually produced.
Do the CUDA launches go down by half?
Figure 21: Each compiled step still launches two GPU kernels, a Device-to-Device memcpy and the GEMM
Looking at the traces in Figure 21, we were really happy to notice only one cudaLaunchKernel per step. This observation was directly contradicting what we were seeing in the GPU trace. There were still two kernels being launched per step, namely the Memcpy DtoD (Device -> Device) and the GEMM. Going back to the CPU trace, we noticed that we had completely missed the cudaMemcpyAsync dispatch.
addmm computes out = α·A·B + β·C, and cuBLAS's GEMM-with-bias-add epilogue writes into a destination buffer that needs to already contain the bias. An epilogues can be thought of all the operations that happen *after* a GEMM. In the world of deep-learning we constantly come up with GEMM-Epilogues like activations, bias addition, normalization and many more. This is why there are cuBLAS GEMM-with- kernel variants.
If you use different modes for torch.compile you would notice different kernel variants being launched. You can try it for yourself and add a comment below about your observations!
So Inductor's generated code does:
- out = copy(C) ← that's the DtoD memcpy (32 MB, takes ~33 µs)
- out = α·(A·B) + β·out ← GEMM with α=β=1, fusing the bias add into the writeback
The result is mathematically still the same. The bias add isn't free, as we pay a memcpy upfront plus a slightly more expensive GEMM epilogue.
The fusion one might have hoped for, where x·w + b (here out = α·A·B + β·C) collapses into a single kernel with no extra memory traffic, isn't what happened. Inductor preserved the two memory-touching operations, it just relabeled the bias copy as a memcpy and the addition as a GEMM epilogue.
A truly fused implementation would skip the memcpy. That's what FlashAttention-style hand-written kernels do, and what Inductor can do via Triton codegen, but for a 4096×4096 bf16 matmul, Inductor evidently decided "use cuBLAS, do the bias via epilogue setup" was the best path.
CPU overhead went up, not down
This is the easiest thing to miss when comparing an eager and a compiled run:
step
eager dur (ms)
compile dur (ms)
#2
0.1
0.2
#3
0.07
0.1
#4
0.07
0.1
Compile is roughly 2× more expensive on the CPU per step. That's because every call walks the full Dynamo > AOTAutograd > Inductor stack, on top of the same aten::addmm dispatch we have anyway. The compile pipeline is built for ML models with dozens of ops where the per-call overhead amortizes (for a single op it's a tax).
torch.compile has a mode argument. It is for the reader to take home as an assignment to read the documentation and come up with a mode that could take the CPU overhead down. 🤗
Trace reading cheatsheet
A quick reference for the patterns we walked through. The idea is: if you see this in a trace, this is what it usually means.
Profiler table
What you see
What it usually means
Self CPU time total ≫ Self CUDA time total (CPU in ms, GPU in µs)
Overhead-bound. The CPU spends more time dispatching than the GPU spends computing. Make the work bigger (larger matrices, batched ops) or fuse calls.
Self CPU time total ≈ Self CUDA time total, both in ms
Compute-bound. The GPU is the bottleneck, which is usually what you want.
One event dominates CUDA total
That's your hotspot. Start the optimization there.
One event has a huge # of Calls
A potential bottleneck even if each call is cheap. Check whether it can be fused or batched.
CPU total ≫ Self CPU for a row
Most of the cost lives in children. Drill into the nested events, not the parent.
CPU lane
What you see
What it usually means
First ProfileStep much wider than the rest
Cold-start overhead: workspace allocation, cuBLAS heuristics, lazy module loading. Add warmup iterations and/or the schedule's warmup argument.
Big gap between record_function("...") start and the first aten::* inside it
Same cold-start tax, just zoomed in. The annotation entered, but the dispatch hadn't happened yet.
cudaOccupancyMaxActiveBlocksPerMultiprocessor before a <co
関連記事
PyTorch のプロファイリング(第 2 部):nn.Linear から融合 MLP へ
Hugging Face Blog は、PyTorch のプロファイリング手法について解説し、従来の nn.Linear レベルから、より効率的な融合 MLP 構造への最適化プロセスを詳述している。
AI エンジニアが知っておくべき Python の必須概念 5 つ
KDnuggets は、AI エンジニアが習得すべき Python の重要な概念を 5 つ紹介する記事を発表しました。
MONAI を用いた UNet による医療 CT ボリュームの脾臓セグメンテーションのためのコーディング実装
MarkTechPost は、MONAI フレームワークを使用して、Medical Segmentation Decathlon の Task09 データセット上の脾臓をセグメント化するエンドツーエンドの 3D 医療画像処理パイプライン構築チュートリアルを提供している。
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み