PyTorch のプロファイリング(第 3 部):アテンションこそがプロファイルの全てである
Hugging Face Blog は、大規模言語モデルの計算リソースを支配するアテンション機構に特化した PyTorch プロファイリング手法を詳細に解説し、開発者の最適化を支援している。
キーポイント
アテンション機構への焦点
LLM の推論コストの大部分を占めるアテンション層に特化したプロファイリング手法が紹介され、ボトルネック特定のための具体的なアプローチが示されている。
PyTorch ツールの活用
PyTorch 標準のプロファイリング機能や Hugging Face が提供するツールを組み合わせることで、アテンション層の計算量とメモリ使用量を可視化する手順が解説されている。
実装最適化への道筋
プロファイル結果に基づき、アテンションメカニズム(例:Flash Attention やスライディングウィンドウ)を切り替えるなど、具体的なコードレベルでの最適化戦略が提案されている。
影響分析・編集コメントを表示
影響分析
この記事は、大規模言語モデルの開発現場において、リソース効率化のための具体的な技術的指針を提供する重要な役割を果たします。特にアテンション機構の最適化は、コスト削減と高速化の鍵となるため、実務者にとって即座に適用可能な価値の高い知見です。
編集コメント
LLM のパフォーマンスチューニングにおいて、アテンション層の分析は避けて通れない重要なステップです。本記事はそのための実践的なロードマップを提供しており、開発効率を高める上で必読と言えます。
「PyTorch におけるプロファイリング」とシリーズは、プロファイラーのトレースやテーブルを読みこなせるようになることを目指したものです。Part 1 では、足し算や掛け算といった基本的な数学演算のプロファイリングを行いました。プロファイラーのテーブルがホットスポットをどのように明らかにするか、またプロファイラーのトレースがアルゴリズムが時間経過とともにどのように実行されるかを示す様子を確認しました。
Part 2 では、その足し算と掛け算を torch の線形層(linear layer)にラップしました。その後、複数の線形層を重ねて多層パーセプトロン(multilayer perceptron)を構築し、それのプロファイリングを行いました。その過程で、融合されたカーネルや手動チューニングされたカーネルもプロファイルしました。
Transformer アーキテクチャの観点からすると、次に私たちがプロファイルすべき論理的なステップは、もう一つの基本的なアルゴリズムであるアテンション(attention)です。その二次時間計算量(quadratic-time complexity)で悪名高いものの、この問題を緩和し高速化するための多くの巧妙なトリックが存在します。ここではすべてのトリックを詳細に解説するのではなく、それぞれがプロファイラーの下でどのように異なる様子を示すかを確認することを目的としています。
**
このブログ記事のスクリプトはここにあります:04_a_naive_attention.py、04_b_inplace_ops_attention.py、04_c_sdpa_attention.py、および 04_d_kernels_attention.py。前回の投稿と同様に、これら別タブで開きながらコードを追いかけて読むと理解が深まります。スクリプトの実行には NVIDIA A100-SXM4-80GB GPU を使用しています。Hugging Face のインフラストラクチャ上で GPU をセットアップし、Dev Mode with Spaces を用いてスクリプトを実験するのは非常に簡単です。また、Hugging Face Jobs pipeline を使用してスクリプトを実行することも可能です。
Naive attention(素朴なアテンション)
アテンションは、Queries (q)、Keys (k)、Values (v) との相互作用に基づいて動作します。この相互作用は、以下の短いステップシーケンスとして記述できます:
- アテンションスコア scores を構築する:matmul(q, k.T)
- スコアをスケーリングする:scores * scale
- 因果マスク(causal mask)をスコアに適用する:scores.masked_fill(mask, "-inf")
- ソフトマックスでスコアを正規化して、アテンション重み attn を取得する:softmax(scores)
- その重みを用いて値を再加重する:matmul(attn, v)
つまり、アテンションは実際には一連のプリミティブ演算の集合です。その一部(行列積など)はすでに知っていますが、残りの部分も簡単に見つけることができます。PyTorch で単純なアテンションモジュールを書いて、プロファイリングしてみましょう。
class NaiveCausalAttention(nn.Module):
def __init__(self, head_dim):
super().__init__()
self.scale = 1.0 / math.sqrt(head_dim)
def forward(self, q, k, v, mask):
scores = torch.matmul(q, k.transpose(-2, -1))
scores = scores * self.scale
scores = scores.masked_fill(mask, float("-inf"))
attn = torch.softmax(scores, dim=-1)
out = torch.matmul(attn, v)
return out
トレースを開く前に、いつもの演習として、何が見えるべきかを推測してみましょう。このモジュールの forward メソッドをトレースすると、以下が期待されます。
- 行列積カーネル (q . k.T)
- 乗算カーネル(スケーリング)
- マスク処理のための演算
- ソフトマックスカーネル
- 行列積カーネル (attn . v)
uv run 04_a_naive_attention.py
uvx trace-util -f traces/ -b <hf_uname>/traces
Figure 1: The CPU lane of the profile trace for naive attention highlighting the discrete operations
Figure 1 は、プロファイルトレースの CPU ラーン(GPU ラーンは折りたたまれており、視覚的に圧倒されないようにしています)を示しています。attn_fwd(注釈付きの forward 呼び出し)の中身を見ると、推測した演算がまさに確認できます。行列積はもう古くからの友人ですが、新しい演算も簡単に見分けられます。
- mul: スケーリング
- masked_fill: 因果マスク処理
- softmax: ソフトマックスカーネル
では、GPU のレーンを展開して、実際に起動されたカーネルを見てみましょう。
Figure 2: GPU と CPU のレーンからなるプロファイラトレースで、1 つのプロファイラステップに対応する一連のカーネルを強調表示した図。
Figure 2 は、CPU レーンの隣に GPU レーンを示しています。GPU レーン上の単一の attn_fwd ブロックにズームインして、カーネルを一つずつ見ていきましょう。
Figure 3: ナイーブなアテンション実装のプロファイラトレースにおける、拡大表示された GPU レーン。
Figure 3 を見ると、1 つのプロファイラステップに対応する個々のカーネルを読み取ることができます:
- matmul (クエリとキー)
- mul (スケーリング)
- メモリコピー 🤔
- 因果マスク処理
- softmax (アテンション重みを生成)
- matmul (アテンション重みと値)
このうち 5 つは予想通りです。メモリコピーだけが例外ですが、これはどこから来るのでしょうか?ヒントは、PyTorch がインプレイス操作をサポートしている点にあります。テンソルに対して通常の(アウトオブプレイスの)方法で演算を行う場合、PyTorch はしばしばコピーを作成し、要求された演算をそのコピーに適用して、そのコピーを返します。一連の操作を追跡すると、今回の犯人は masked_fill です。
これをインプレイス操作に置き換えることはできないでしょうか?
インプレイス因果マスク処理を備えたナイーブなアテンション
変更するのは masked_fill を masked_fill_ にするだけです(末尾のアンダースコアに注意してください。これは PyTorch のインプレイス操作における慣例です)。そして、同じスクリプトを実行します。
def forward(self, q, k, v, mask):
# q, k, v: [batch, heads, seq, head_dim]
scores = torch.matmul(q, k.transpose(-2, -1)) # [batch, heads, seq, seq]
scores = torch.mul(scores, self.scale)
- scores = scores.masked_fill(mask, float("-inf"))
+ scores.masked_fill_(mask, float("-inf"))
attn = torch.softmax(scores, dim=-1)
out = torch.matmul(attn, v) # [batch, heads, seq, head_dim]
return out
トレースを見て、何らかの変化があったか確認してみましょう。
uv run 04_b_inplace_ops_attention.py
uvx trace-util -f traces/ -b <hf_uname>/traces
Type
CPU stream
Figure 4: Naive masking
Figure 5: In place masking
インプレイス版(図 5)は、マスク処理ステップ内でアウトオブプレイス版(図 4)よりもはるかに少ない CPU オペレーションをラップしています。これは励みになるシグナルです。GPU ラーンで何が起きたかを確認するために、そちらも展開してみましょう。
Type
GPU stream
Figure 6: Naive masking
Figure 7: In place masking
GPU ラーンでは、Memcpy カーネル(メモリ転送カーネル)が完全に消滅しました(図 6 と 7)。たった一行の変更で、順伝播のたびに一つのカーネルを削減できました。これ自体はあまり大きく見えないかもしれませんが、これは単一のアテンション演算に過ぎないことを覚えておいてください。トランスフォーマーベースの大規模モデル(LLM、Diffusion モデルなど)の文脈では、これは層ごとに一度繰り返され、層が多数存在するため、削減効果がすぐに蓄積していきます(もしこれが昇給につながるなら、そのうちの少なくとも 10% を私たちに還元していただくのは公平だと感じます)。
アウトオブプレイスは、PyTorch がデフォルトとしているのには理由があります。勾配を計算するために、autograd は順伝播時に観測したテンソル値を記憶しておく必要があります。なぜなら、多くの逆伝播式でそれらが再利用されるからです。インプレイス演算はメモリ上のこれらの値を上書きしてしまうため、逆伝播パスでは間違った数値を読み込んでしまいます。ただし、torch.no_grad の下で順伝播を実行しているため、逆伝播パスが存在せず、破損するものもないという点で、私たちが行うインプレイス操作は安全です。また、インプレイス演算は時間だけでなく(今回のケースで見られるように)、メモリも節約します(追加のコピーが発生しないため)。これは logits のような大規模なテンソルにとって素晴らしいことです。
スケーリングドットプロダクトアテンション (Scaled Dot Product Attention)
私たちはプリミティブからアテンションを構築し、さらに Memcpy を削減しました。しかし、良いニュースは、PyTorch チームがこれらすべてを私たちに代わって行い、全体のパイプラインを単一の関数にパッケージ化していることです。
from torch.nn import functional as F
F.scaled_dot_product_attention(q, k, v, is_causal=True)
この一行で、手書きのモジュールが置き換えられ、is_causal=True を指定することで、マスクを手動で構築する必要すらなくなります。この一つの呼び出しが隠しているものがどれほど多いか、一瞬立ち止まって感嘆する価値があります。そして、それはコード行数だけでなく、さらに多くのものを隠しています。スケーリングドットプロダクトアテンション (SDPA) には単一の実装しか存在しません。内部では、複数のバックエンドのいずれかに *ディスパッチ* し、入力(データ型、ヘッド次元、マスク、ハードウェアなど)をサポートする最も高速なものを選択します。
公式の SDPA チュートリアル official SDPA tutorial ではこの選択方法が解説されており、バックエンド自体は torch.nn.attention.SDPBackend 列挙型にリストされています:
from torch.nn.attention import SDPBackend
BACKENDS = {
"math": SDPBackend.MATH,
"flash": SDPBackend.FLASH_ATTENTION,
"efficient": SDPBackend.EFFICIENT_ATTENTION,
"cudnn": SDPBackend.CUDNN_ATTENTION,
}
通常、SDPA は私たちに代わって選択を行いますが、torch.nn.attention.sdpa_kernel コンテキストマネージャーを使用して特定のバックエンドを固定することも可能です。これがスクリプトで行っていることです。これにより、各バックエンドを個別にプロファイリングし、トレース上でどのように大きく異なるかを確認できます。一つずつ見ていきましょう。
Math backend
uv run 04_c_sdpa_attention.py --backend math
uvx trace-util -f traces/ -b <hf_uname>/traces
何も開く前に、推測してみましょう。手書きのアテンション(行列積、乗算、マスク、ソフトマックス、行列積)を単一のワンライナーに置き換えたため、トレースは*よりシンプルで高速になる*と予想されます。カーネル数が減り、CPU のディスパッチが少なくなり、場合によっては融合されたカーネルになるかもしれません。まずプロファイラーの表を確認してみましょう。
Metric
どこを見るべきか?
Naive in-place
SDPA math
*_fwd CUDA time avg
*_fwd オペレーションの"CUDA time avg"列
1.955 ms
7.239 ms
Self CUDA time total
プロファイラー表の最下部
7.194 ms
27.279 ms
これが最初の驚きです。ワンライナーは 3.7 倍も遅いです。
Profiler Trace
図8:単純なインプレイスアテンションのプロファイラトレースは、1回の順伝播で5つのGPUカーネル起動を示しています
図9:SDPA数値バックエンドのプロファイラトレースは、単一の注意機構の順伝播で20個のGPUカーネル起動を示しています
トレースを開く(図9)と、なぜ警鐘が鳴るのかがわかります。数値バックエンドでは、単純なアテンション実装(図8)で起動される5つのカーネルではなく、1回の順伝播あたり20個のGPUカーネルを起動します。これは私たちが予想したのと正反対の結果です。なぜこうなるのか、その理由を探りましょう。
Tensor cores left vacant
Part 2では、カーネル名を指紋のように読む方法を学びました。ここではその習慣を使いましょう:
実行
行列乗算(matmul)カーネル
図10:単純なアテンション
図11:数値バックエンドを使用するSDPA
これらのトレースをキャプチャするために使用したA100には、Tensor Cores(行列乗算の高速化に特化した専用ハードウェアで、通常のCUDAコアよりもはるかに高速であることが知られています)が搭載されています。これがなぜ重要なのかを理解するには、GPU内部の構造を知る必要があります。ストリーミングマルチプロセッサ(SM:Streaming Multiprocessor)はGPUの計算ユニットであり、各SMには2種類の演算ユニットがあります。すなわちCUDAコアとTensor Coresです。CUDAコアは汎用目的で、一度に数要素を処理するのに対し、Tensor Coresは1つの命令で小さな行列タイル全体を乗算・加算します。つまり、質問は単純です。「各バックエンドは実際に高速パスを使用しているのか?」
カーネル名が答えを示しています。naive カーネル(図 10)における s16816 は、bfloat16 Tensor Core 行列積(16x8x16 の Tensor Core 命令)のシグネチャであり、naive バージョンは高速パス上にあります。sgemm(図 11)は、通常の CUDA コア上で動作する古典的な単精度 (FP32) 行列積です。つまり、数値計算バックエンドは Tensor Cores に全くアクセスしません:速度と数値精度のトレードオフとして、テンソルを FP32 にアップキャストし(入力データが bf16 の場合でも移動するデータ量が倍増)、より低速な CUDA コアにフォールバックします。
Causal masks built
naive バージョンでは因果マスクを一度構築して再利用していました。ここでは is_causal=True を渡したため、数値計算バックエンドが毎回呼び出しのたびに独自にマスクを生成しました。CPU ラインでその様子を確認できます:
図 12: マスキング操作を示す CPU ライン
図 12 で確認できるのは以下の通りです。
aten::ones -> aten::tril [seq, seq] の下三角行列を構築する
aten::scalar_tensor -> aten::fill_ -inf を埋め込む値とする
aten::where これを加算バイアス(0 または -inf)に変換する
GPU では、これは triu_tril_kernel、いくつかの where カーネル、および add_ として現れます。マスクについて考える必要をなくしてくれた便利なフラグは、作業自体を削除したわけではなく、単にその処理を一段階下層へ移動させただけです。つまり、各フォワードパスでマスクが最初から再構築されることになります。
The safe softmax
私たちが手書きしたバージョンは plain aten::softmax と呼ばれます。数値計算バックエンドは aten::_safe_softmax を呼び出し、その違いは追加されるカーネルとして再び明確に確認できます(図 13):
Figure 13: Safe softmax highlighting the extra kernels compared to generic softmax
すべてのエントリが -inf で完全にマスクされた行では、通常のソフトマックスは exp(-inf)/sum(exp(-inf)) = 0/0 = NaN を計算してしまいます。_safe_softmax はまさにそのケースを防ぐために存在します。私たちの素朴なカーネルはそのような注意を払っておらず、そのような隅のケースで静かに NaN を生成していたでしょう。
それでは、数値計算バックエンドは何のためにあるのか?
まとめると、数値計算バックエンドはリファレンス実装です。これは、アテンションをプリミティブな ATen 演算に分解する、単純でデータ型安全かつ NaN 安全な実装です。本質的には私たちが手書きした素朴なアテンションですが、より慎重に作られています。その慎重さが、まさにこのバックエンドが極めて遅い理由となっています。
その役割は速く動くことではなく、*常に正しく動作すること*にあります。これが完璧なベースラインとなります。次にプロファイリングするすべてのバックエンド(flash, efficient, cudnn)は、20 個の GPU カーネルを本質的に 1 つの融合カーネルに圧縮し、bf16 のまま維持して中間行列を一切生成しないことを目指しています。
Efficient バックエンド
uv run 04_c_sdpa_attention.py --backend efficient
uvx trace-util -f traces -b <hf_uname>/traces
Figure 14: The profiler trace for sdpa with efficient backend
数学バックエンドではプロファイラーの 1 ステップで 2 つのカーネルを起動しましたが、効率的なバックエンドでは fmha_cutlassF_bf16_aligned_64x64_rf_sm80 という 1 つのカーネルのみが起動します(図 14 参照)。
このカーネル名の意味を解読しましょう:
- fmha (fused multi-head attention): アテンションにおけるすべてのプリミティブ演算が、現在は 1 つの演算に「融合」されています。
- cutlassF: NVIDIA のテンソルコア GEMM(行列乗算)用のオープンソーステンプレートである CUTLASS に基づいて構築されており、「F」はフォワードパスを意味します。
- bf16_aligned: bfloat16 で動作し、数学バックエンドのような FP32 へのアップキャストを行いません。
- 64x64: タイルサイズです。
- rf (register file): 作業セットがレジスタに保持されます。これはチップ上で最も高速なメモリです。
- sm80: Ampere アーキテクチャ(A100 の計算能力 8.0)向けにコンパイルされています。
これは Meta の xformers ライブラリから派生し、PyTorch にアップストリームされたメモリエフィシェントなアテンションカーネルです。人々が「xformers バックエンド」と言う場合、この fmha_cutlassF カーネルを指しています。
Flash バックエンド
uv run 04_c_sdpa_attention.py --backend flash
uvx trace-util -f traces -b <hf_uname>/traces
Figure 15: The flash backend trace, one fused pytorch_flash kernel per forward
図 15:Flash バックエンドのトレース、フォワードパスごとに 1 つの融合された pytorch_flash カーネル
空の void な pytorch_flash カーネル(図 15)は FlashAttention-2(Tri Dao の実装)であり、PyTorch にベンダーされています。
トレースをさらに読み進める前に、今頃になって質問すべきであろうこの疑問に答えておく価値があります:*なぜ「flash」という名前のバックエンド全体が存在するのか、そしてそれがなぜこれほど重要なのか?*
なぜ Flash Attention は存在するのか?
少し数学的なバックエンドの話に戻りましょう。その本当の問題はカーネルの数が 20 個だったことではなく、それらのカーネルが互いに何を渡したかにありました。
ステップ 1 では、アテンション行列 attn = q . k.T の完全なスコア行列を構築します。これはヘッドごとに [シーケンス長,シーケンス長] のサイズです。シーケンス長が 4096 の場合、単一のヘッドで 4096 x 4096 ≈ 1,600 万個の数値になります。この行列は HBM(GPU のメインメモリ)に書き出されます。もしそこに十分なスペースがあればですが。その後、スケーリングのために読み戻され、マスクのために再度書き込まれ、ソフトマックスのために再度読み込まれます。アテンションのコストを支配しているのは、これらの行列演算そのものではなく、HBM への行き来によるトラフィックです。
FlashAttention はまさにこの点を攻撃します。全体の s 行列を計算してからそれを縮約するのではなく、k と v をタイル単位で走査し、進行中に累積的なソフトマックス(「オンライン・ソフトマックス」というトリック)を維持しながら、出力をタイルごとに蓄積していきます。完全な [シーケンス長,シーケンス長] のスコア行列はHBM に書き出されることは決してなく、オンチップ上のみに存在します。これが、アテンションパイプライン全体を 1 つの融合カーネルに集約し、Tensor コア上で bf16(半精度浮動小数点)のまま維持させるという単一のアイデアです。
プロファイラの下で Flash が「間違っている」ように見える理由
Figure 16: Estimated occupancy of flash kernel is seen to be 13%
ここが、プロファイラのフットプリントを読む人々を Flash が驚かせる場所です。それは最速のバックエンドですが、プロファイラはそれを非常に低いオキュパンシー(occupancy)として報告します(Figure 16 に示されています)。これがなぜ問題ないのかを理解するには、3 つの簡単な定義が必要です。
GPU カーネルは、本質的には多数の小さな実行ユニットによって実行される一連の命令です。これらの個々の実行ユニット(スレッド)は、変数の読み込み、加算、結果の書き戻しなどを担当します。各カーネルに対しては非常に多くのスレッドを起動しますが、それらを管理するためにブロック単位でグループ化されます。
ブロックは、GPU の主要な計算ユニットであるストリーミングマルチプロセッサ(SM: Streaming Multiprocessors)にスケジューリングされます。1 つのブロックは常に 1 つの SM のみ上に存在し、SM は十分なリソースがあれば同時に複数のブロックをホストできます。そのリソースには、レジスタ、共有メモリ、最大残留スレッド数、および最大残留ワープ数が含まれます。したがって、「カーネルのオキュパンシー(occupancy)が低い」と言う場合、それは各 SM が理論上サポート可能な数のワープよりも少ない残留ワープしか持っていないことを意味します。
**
スレッド、ブロック、グリッドなどについてさらに詳しく知りたい場合は、こちら 素晴らしいリソース をご覧ください。
トレース内のフラッシュカーネルをクリックすると、そのフットプリントが物語を語ります(図 17)。
図 17: フラッシュカーネルのフットプリント。ブロックあたりのレジスタと共有メモリの使用量が非常に多いことがわかります。
Flash はスレッドごとのレジスタを多く使用し、ブロックあたりの共有メモリも大量に必要とします。例えば、128 スレッドのブロックで各スレッドが 255 レジスタを使用する場合、そのブロックには 128 × 255 = 32,640 のレジスタが必要です。Ampere SM では 65,536 のレジスタしか利用できないため、一度にそのようなブロックを 2 つしか配置できません。各 128 スレッドのブロックは 128 / 32 = 4 のワープ(warp)を含むため、2 つのブロックで稼働可能なワープはわずか 8 です。最大 64 の稼働可能ワープに対して、これは約 13% のオキュパンシーに相当します。Flash のオキュパンシーが低いのは最適化が不十分だからではなく、各ブロックがオンチップリソースの使用において意図的に非常に「重く」設計されているためです。
それがまさに核心です。高いオキュパンシーは多くのワープを準備状態に保つことでレイテンシ(latency)を隠すのに役立ちますが、作業自体の効率性を高めるわけではありません。Flash は、アテンションタイルをオンチップ内に保持し、データを積極的に再利用し、グローバルメモリに完全なアテンション行列をマテリアライズする必要を避けるために、レジスタと共有メモリを意図的に使用しています。
cuDNN backend
uv run 04_c_sdpa_attention.py --backend cudnn
uvx trace-util -f traces -b <hf_uname>/traces
Figure 18: The cuDNN backend trace, a single generated attention kernel per forward.
すでにこのパターンはよく知られています。Flash や Efficient と同様に、cuDNN はフォワードパスごとに 1 つの融合された Flash スタイルのカーネル(kernel)を提供します(Figure 18)。そこで自然な疑問が生じます:Flash はすでにアテンションを融合しているのに、なぜ PyTorch はさらに別の Flash ベックエンドを搭載しているのか?その答えは「誰がカーネルを書き、どのように構築するか」にあり、この違いこそがトレースの見た目を変える要因です。
cuDNN カーネルの違い
Flash や Efficient は、PyTorch にバンドルされた固定された事前コンパイル済みカーネルです。毎回同じバイナリが提供されます。一方、cuDNN は NVIDIA 独自の深層学習ライブラリであり、そのアテンションカーネルは現在の特定の課題に対して生成・調整されたものです。これは、固定された cuBLAS バイナリというよりは、torch.compile のコード生成に近い精神を持っています。このことは、(非常に長い)カーネル名をそのまま読むことで明白になります:
cudnn_generated_fort_native_sdpa_sm80_flash_fprop_wmma_f16_knob_6_128x64x64_4x1x1_cga1x1x1_kernel0_0
- cudnn_generated: 事前出荷されたバイナリではなく、cuDNN によって生成されました。
- flash_fprop: Flash Attention スタイルの順伝播パスです。つまり、アルゴリズムは Flash バックエンドと同じファミリーに属します。
- wmma_f16: ウォープレベル行列乗算累積(WMMA)API を使用しており、これは 16 ビット浮動小数点パイプラインにおける Tensor-core の経路です。
- knob_6: cuDNN は事前調整された設定セット("knobs")から選択します。異なる形状が異なるノブを選択し、cuBLAS がタイル変種を選ぶのと同様の仕組みです。
- 128x64x64: それが選択したタイルの次元です。
この「問題ごとに生成される」という一点の事実こそが、トレース上で不自然に見える他のすべてのことを説明しています。
- トランスポーズなし:CPU ラインは _cudnn_attention_forward から直接、いくつかの aten::empty 割り当てを経てカーネルへと至り、ゼロ個の aten::transpose が発生します(図 19, 20, 21)。Flash と Efficient はそれぞれ 4 つの(メタデータ)トランスポーズを挿入してテンソルの形状を変換しますが、cuDNN はネイティブの [B, H, S, D] レイアウトをそのまま消費します。これは、そのレイアウト向けのカーネルを生成器が出力するためです。
Variant
Trace
図 19: Flash
図 20: Efficient
図 21: cuDNN
- cudaLaunchKernel ではなく cuLaunchKernelEx を経由して起動:このシリーズの他のすべてのカーネルはランタイム API の cudaLaunchKernel を通じていましたが、cuDNN はドライバーレベルの拡張起動(cuLaunchKernelEx)を使用しており、これには起動属性が含まれています(図 22)。
図 22: cuLaunchKernelEx というドライバーレベルの起動を cudaLaunchKernel の代わりに示す cuDNN バックエンドの CPU ライン
- プロファイラーは達成済みオキュパンシーが 0% と報告:これは表面的に受け取ってはいけません。これは測定上のギャップであり、GPU が停止しているわけではありません。プロファイリングバックエンドである CUPTI は、cudaLaunchKernel の場合のようにドライバー API(cuLaunchKernelEx)の起動に対してオキュパンシーを割り当てることができないため、そのフィールドは 0 を読み取ります。フットプリントが真実を明らかにします(図 23):1 ブロックあたり 61,440 レジスタ(240 レジスター × 256 スレッド)に対し、SM の容量は 65,536 です。したがって、1 つの SM あたり 1 ブロックしか収容できず(8 ウォープ ≒ 12.5%)、これは Flash と完全に一致しています。
図 23: スレッドあたり 240 レジスター、ブロックあたり 256 スレッドで、達成済みオキュパンシーが 0% と報告される cuDNN カーネル
コストは CPU 側に移動した
「転置演算なし」という物語は、cuDNN が CPU 上で最も軽量なバックエンドであるはずだと期待させます。しかし実際は逆です。
backend
CUDA avg time
CPU avg time
efficient
277.9 µs
117 µs
flash
146.8 µs
138 µs
cudnn
186.3 µs
214 µs
転置演算をゼロにしても、cuDNN は CPU 上で順伝播あたり約 214 µs を要し、FlashAttention (138) や efficient (117) よりも多くなります。そのほとんどは aten::scaled_dot_product_attention の自己実行時間(全体の 26%)と _cudnn_attention_forward に費やされています。これは cuDNN のランタイムエンジンが呼び出しごとに計画の選択と準備(「ノブ」探索)を行っていることを意味します。
目に見える ATen 演算が減ったからといって、CPU での作業量が減ったわけではありません。作業はライブラリ内部に移動し、プロファイラではそれが一つの太く不透明な棒グラフとしてしか表示されません。トレースが突然「きれい」になったとしても、作業が消えたわけではなく、単にプロファイラが分解できない場所へ移動しただけの場合があります。
GPU 上では、cuDNN (186.3 µs) は efficient と flash の間に位置します。この FlashAttention に非常に適した形状においては、手書きの FlashAttention-2 がわずかに上回ります。cuDNN は他の形状(より大きなヘッド次元や異なるシーケンス長など)ではしばしば勝利しますが、それはまさにそのジェネレータが問題ごとに再調整を行うからです。しかし、その再調整こそが、先ほど CPU 上で支払ったコストなのです。
まとめ:一目で振り返る全項目
締めくくる前に、プロファイリングしたすべてのアテンション変種と、各トレースから得た教訓をまとめた表を一つ紹介します。
Variant
What we changed
Kernels / forward
What the trace revealed
素朴なアテンション
プリミティブ(行列積、乗算、マスク、ソフトマックス、行列積)から手動で構築されたアテンション
6
out-of-place の masked_fill からの隠れた Memcpy。
素朴な in-place
masked_fill → masked_fill_
5
1 行で Memcpy カーネルが完全に削除されます。
SDPA 数学
F.scaled_dot_product_attention が数学バックエンドに固定
20
参照実装:CUDA コア上の FP32、呼び出しごとにマスク再構築、_safe_softmax。正解だが約 3.7 倍遅い。
SDPA 効率的
効率的な(xformers)バックエンド
1
融合された fmha_cutlassF カーネル 1 つのみ、Tensor コア上で bf16 を維持。
SDPA フラッシュ
フラッシュバックエンド
1
融合された pytorch_flash カーネル 1 つ(FlashAttention-2)。"不自然に見える" 13% のoccupancy にもかかわらず最速。
SDPA cuDNN
cuDNN バックエンド
1
問題ごとに生成されるカーネル:トランスポーズなし、cuLaunchKernelEx を使用だが、コストが太い CPU バーに移行した。
シリーズのまとめ
このシリーズ全体からただ一つだけ覚えておくべきことがあるとすれば、各トレースの前に行っていた習慣、すなわちまず推測し、その後確認することです。
トレースに何が含まれるかを予想して声に出し、実際に開いて、不一致があればそれを画面で最も興味深いものとして扱ってください。この 3 つの投稿におけるすべての真の洞察、隠れた Memcpy、addmm epilogue、20 のカーネル数学バックエンド、フラッシュの"不自然に見える"occupancy、cuDNN の太い CPU バーはすべて、トレースと一致しなかった推測から生まれました。
プロファイリングは、GPU の専門家だけに限られた別個の恐ろしいスキルではありません。それは、「なぜそれが起こっているのか?」と問い続け、答えがひらめくまで注意深く観察するという discipline(規律)に過ぎません。あなたはすでに、自分自身のモデルでそれを行うための語彙と反射神経を身につけました。トレースを開き、仮説を立てて、不一致を見つけに行きましょう。
Profiling in PyTorch シリーズをお読みいただきありがとうございます。さあ、何かプロファイリングしてみましょう。🤗
記事の初期草案に対するレビューを提供してくださった Noe Flandre 氏に感謝いたします!
このブログ投稿は LLM(大規模言語モデル)を使用して推敲されました。これは、エージェントをバックグラウンドで実行させてブログを生成させたという意味ではありません。チームのメンバーの中には英語話者でない人もおり、LLM(主に英語で訓練されているため)が、些細な文法ミスを修正したり、より恐ろしくなく清潔感のある表現に文章を書き換えたりできるのではないかと考えています。「もしこれが LLM によって生成されたものなら、なぜ読む必要があるのか」という疑問に対するヒントになれば幸いです。🤗
原文を表示
The series "Profiling in PyTorch" is meant to make you comfortable reading profiler traces and tables. In Part 1 we profiled basic math operations like addition and multiplication. We saw how the profiler table uncovers hotspots, and how the profiler trace shows the order in which an algorithm runs over time.
In Part 2 we wrapped that addition and multiplication into a torch linear layer. We then stacked several linear layers on top of each other (a multilayer perceptron) and profiled that. Along the way we also profiled fused and hand-tuned kernels.
From the perspective of the Transformer architecture, the next logical step for us to profile is yet another fundamental algorithm, attention. While being infamous for its quadratic-time complexity, many clever tricks exist to mitigate that issue and make it fast. Our goal here is not to cover every trick in detail. Instead, we want to see how each one looks different under the profiler.
The scripts for this blog post live here: 04_a_naive_attention.py, 04_b_inplace_ops_attention.py, 04_c_sdpa_attention.py, and 04_d_kernels_attention.py. Like before, it helps to open them in a separate tab and walk through the code as you read. We use an NVIDIA A100-SXM4-80GB GPU to run the scripts. It is really easy to set up a GPU on the Hugging Face infrastructure and experiment with the scripts using Dev Mode with Spaces. One could also run the scripts with the Hugging Face Jobs pipeline.
Naive attention
Attention works with Queries (q), Keys (k), and Values (v). The interaction between them can be written as a short sequence of steps:
- Build the attention scores scores: matmul(q, k.T)
- Scale the scores: scores * scale
- Apply a causal mask to the scores: scores.masked_fill(mask, "-inf")
- Normalize the scores with softmax to get the attention weights attn: softmax(scores)
- Reweight the values with those weights: matmul(attn, v)
So attention is really a collection of primitive operations. Some of them we already know (the matmuls), and the rest are easy to spot. Let's write a naive attention module in PyTorch and profile it.
class NaiveCausalAttention(nn.Module):
def __init__(self, head_dim):
super().__init__()
self.scale = 1.0 / math.sqrt(head_dim)
def forward(self, q, k, v, mask):
scores = torch.matmul(q, k.transpose(-2, -1))
scores = scores * self.scale
scores = scores.masked_fill(mask, float("-inf"))
attn = torch.softmax(scores, dim=-1)
out = torch.matmul(attn, v)
return out
Before opening the trace, let's do our usual exercise and guess what we should see. Tracing the forward of this module, we expect:
- a matmul kernel (q . k.T)
- a mul kernel (the scaling)
- an operation for the masking
- a softmax kernel
- a matmul kernel (atten . v)
uv run 04_a_naive_attention.py
uvx trace-util -f traces/ -b <hf_uname>/traces
Figure 1: The CPU lane of the profile trace for naive attention highlighting the discrete operations
Figure 1 shows the CPU lane of the profile (the GPU lane is folded so it does not overwhelm us). Inside attn_fwd (our annotated forward call) we can see exactly the operations we guessed. The matmul is an old friend by now, and the new operations are easy to spot:
- mul: the scaling
- masked_fill: the causal masking
- softmax: the softmax kernel
Now let's unfold the GPU lane and see which kernels were actually launched.
Figure 2: GPU and CPU lanes of the profile trace for naive attention highlighting a collection of kernels corresponding to one profiler step.
Figure 2 shows the GPU lane next to the CPU lane. Let's zoom into a single attn_fwd block on the GPU lane to look at the kernels one by one.
Figure 3: Zoomed in GPU lane of the profiler trace for naive attention implementation.
Figure 3 lets us read off the individual kernels for one profiler step:
- matmul (query and key)
- mul (scaling)
- memory copy 🤔
- causal masking
- softmax (produces the attention weights)
- matmul (attention weights and values)
Five of these are expected. The memory copy is the odd one out, so where does this come from? The clue is that PyTorch has in-place operations. When you operate on a tensor the ordinary (out-of-place) way, PyTorch often makes a copy, applies the requested operation to it, and returns the copy. Following the sequence of operations, the culprit here is our masked_fill.
What if we replaced this with an in-place operation?
Naive attention with inplace causal masking
All we change is masked_fill to masked_fill_ (note the trailing underscore, PyTorch's convention for in-place operations), and we run the same script.
def forward(self, q, k, v, mask):
# q, k, v: [batch, heads, seq, head_dim]
scores = torch.matmul(q, k.transpose(-2, -1)) # [batch, heads, seq, seq]
scores = torch.mul(scores, self.scale)
- scores = scores.masked_fill(mask, float("-inf"))
+ scores.masked_fill_(mask, float("-inf"))
attn = torch.softmax(scores, dim=-1)
out = torch.matmul(attn, v) # [batch, heads, seq, head_dim]
return out
Let's look at the trace and see if something changed.
uv run 04_b_inplace_ops_attention.py
uvx trace-util -f traces/ -b <hf_uname>/traces
Type
CPU stream
Figure 4: Naive masking
Figure 5: In place masking
The in-place version (Figure 5) wraps far fewer CPU ops inside the masking step than the out-of-place version (Figure 4). This is an encouraging signal. Let's unfold the GPU lane to confirm what happened there.
Type
GPU stream
Figure 6: Naive masking
Figure 7: In place masking
On the GPU lane the Memcpy kernel is gone for good (Figures 6 and 7). With a one line change we shaved a whole kernel off each forward pass. This may not look like much on its own, but remember this is a single attention operation. In the context of a transformer based large model (LLMs, Diffusion models, etc.), it repeats once per layer, and there are many layers, so the saving adds up quickly (and if it earns you a raise, sharing at least 10% with us feels only fair).
Out-of-place is PyTorch's default for a reason. To compute gradients, autograd has to remember the tensor values it saw on the forward pass, because many backward formulas reuse them. An in-place operation overwrites those values in memory, so the backward pass would read the wrong numbers. Due to the fact that we run forward under torch.no_grad, in-place is safe for us, with no backward pass and nothing to corrupt. It is also noteworthy that in-place operations do not only save time (like we see in our case) but also memory (due to no extra copy) which is great for large tensors like logits!
Scaled Dot Product Attention
We just built attention from primitives, and even shaved off a Memcpy. The good news is that the PyTorch team has done all of this for us, and packaged the whole pipeline into a single function:
from torch.nn import functional as F
F.scaled_dot_product_attention(q, k, v, is_causal=True)
This one line replaces our hand written module, and is_causal=True even saves us from building the mask by hand. It is worth pausing to appreciate how much this one call hides. And it hides more than just code lines. Scaled Dot Product Attention (SDPA) does not have a single implementation. Under the hood it *dispatches* to one of the several backends and picks the fastest one that supports our inputs (dtype, head dimension, mask, hardware, etc.).
The official SDPA tutorial walks us through this selection, and the backends themselves are listed in the torch.nn.attention.SDPBackend enum:
from torch.nn.attention import SDPBackend
BACKENDS = {
"math": SDPBackend.MATH,
"flash": SDPBackend.FLASH_ATTENTION,
"efficient": SDPBackend.EFFICIENT_ATTENTION,
"cudnn": SDPBackend.CUDNN_ATTENTION,
}
Normally SDPA chooses for us, but we can pin a specific backend with the torch.nn.attention.sdpa_kernel context manager. This is what we do in our scripts. This lets us profile each backend on its own and read how differently they show up in the trace. Let's go one at a time.
Math backend
uv run 04_c_sdpa_attention.py --backend math
uvx trace-util -f traces/ -b <hf_uname>/traces
Before we open anything, let's guess. We have replaced hand written attention (matmul, mul, mask, softmax, matmul) with a single one liner, so we should expect the trace to get *simpler and faster*. Fewer kernels, less CPU dispatch, maybe even a fused kernel. Let's check the profiler table first.
Metric
Where to look?
Naive in-place
SDPA math
*_fwd CUDA time avg
The "CUDA time avg" column for the *_fwd op
1.955 ms
7.239 ms
Self CUDA time total
At the bottom of the profiler table
7.194 ms
27.279 ms
This is our first surprise, the one liner is 3.7x slower.
Profiler Trace
Figure 8: Profiler trace of naive in-place attention showing five GPU kernel launches for one forward
Figure 9: Profiler trace of the SDPA math backend showing 20 GPU kernel launches for a single attention forward
Opening the trace (Figure 9) shows why the alarm bells ring, the math backend launches 20 GPU kernels per forward instead of the 5 launched with our naive attention implementation (Figure 8). This is the opposite of what we guessed. Let's figure out why this happens.
Tensor cores left vacant
In Part 2 we learned to read a kernel name like a fingerprint. Let's use that habit here:
Run
matmul kernel
Figure 10: Naive attention
Figure 11: SDPA with math backend
The A100s we used to capture these traces ship with Tensor Cores, specialised hardware for accelerated matmuls that is known to be far faster than the ordinary CUDA cores. To see why that matters here, it helps to know what lives inside a GPU. A Streaming Multiprocessor (SM) is the compute unit of a GPU, and each SM has two kinds of arithmetic units, the CUDA cores and the Tensor Cores. CUDA cores are general purpose and process a handful of elements at a time, while Tensor Cores multiply and accumulate a whole small matrix tile in a single instruction. So the question is simple, "Is each backend actually using the fast path?"
The kernel names answer it. The s16816 in the naive kernel (Figure 10) is the signature of a bfloat16 Tensor Core matmul (the 16x8x16 Tensor Core instruction), so the naive version is on the fast path. sgemm (Figure 11) is the classic single precision (FP32) matmul that runs on the ordinary CUDA cores. In other words, the math backend never touches the Tensor Cores at all: to trade speed for numerical accuracy it upcasts tensors to FP32 (doubling the data moved, even when the inputs are in bf16) and falls back to the slower CUDA cores.
Causal masks built
In the naive version we built the causal mask once and reused it. Here we passed is_causal=True and the math backend materialized one for us, on *every* single call. You can watch it happen on the CPU lane:
Figure 12: CPU lane showing the ops for masking
Here is what we see in Figure 12
aten::ones -> aten::tril build a [seq, seq] lower-triangular matrix
aten::scalar_tensor -> aten::fill_ make the -inf fill value
aten::where turn it into an additive bias (0 or -inf)
On the GPU this shows up as a triu_tril_kernel, several where kernels, and an add_. The convenience flag that let us stop thinking about the mask did not remove the work, it just moved it one layer down, where the mask is rebuilt from scratch every forward.
The safe softmax
Our hand written version called plain aten::softmax. The math backend calls aten::_safe_softmax, and the difference is again visible as extra kernels (Figure 13):
Figure 13: Safe softmax highlighting the extra kernels compared to generic softmax
A row that is fully masked (every entry -inf) would make an ordinary softmax compute exp(-inf)/sum(exp(-inf)) = 0/0 = NaN. _safe_softmax guards against exactly that. Our naive kernel never bothered, and would have quietly produced NaNs in that corner case.
So what is the math backend for?
Put together, the math backend is the reference implementation. It is a straightforward, dtype-safe, NaN-safe decomposition of attention into primitive ATen ops. It is essentially the naive attention we wrote by hand, but more careful. That carefulness is exactly what makes it extremely slow.
Its job is not to be fast, but to *always* work. This makes it the perfect baseline. Every backend we profile next (flash, efficient, cudnn) is trying to collapse the 20 GPU kernels into essentially one fused kernel that stays in bf16 and never materializes the intermediate matrices at all.
Efficient backend
uv run 04_c_sdpa_attention.py --backend efficient
uvx trace-util -f traces -b <hf_uname>/traces
Figure 14: The profiler trace for sdpa with efficient backend
Where the math backend launched 20 kernels across one profiler step, the efficient backend launches only one fmha_cutlassF_bf16_aligned_64x64_rf_sm80 (as seen in Figure 14).
Let's decode the name of the kernel:
- fmha (fused multi-head attention): All the primitive ops in attention is "fused" in one op now.
- cutlassF: built on CUTLASS (NVIDIA's open-source templates for tensor-core GEMMs), F for forward.
- bf16_aligned: runs in bfloat16 (no FP32 upcast, unlike math).
- 64x64: the tile size.
- rf (register file): the working set is kept in registers, the fastest memory on the chip.
- sm80: compiled for Ampere (the A100's compute capability 8.0).
This is the memory efficient attention kernel that grew out of Meta's xformers library and was upstreamed into PyTorch. When people say "the xformers backend," this fmha_cutlassF kernel is what they mean.
Flash backend
uv run 04_c_sdpa_attention.py --backend flash
uvx trace-util -f traces -b <hf_uname>/traces
Figure 15: The flash backend trace, one fused pytorch_flash kernel per forward
The void pytorch_flash kernel (Figure 15) is FlashAttention-2 (Tri Dao's implementation), vendored into PyTorch.
Before we read the trace any further, it is worth answering the question you should be asking by now: *why is there a whole backend named "flash", and why does it matter so much?*
Why flash attention exists?
Let's go back to the math backend for a moment. Its real problem was not the count of 20 kernels, it was what those kernels handed to each other.
Step 1 builds the full score matrix attn = q . k.T, which is [seq, seq] per head. For a sequence length of 4096 that is 4096 x 4096 ≈ 16 million numbers for a single head. That matrix is written out to the HBM (the GPU's main memory), if there is even enough space to do so. Then, it is read back to be scaled, written again for the mask, read again for the softmax, and so on. Attention's cost is dominated by this back and forth traffic to HBM, not by the matmuls themselves.
FlashAttention attacks exactly this. Instead of computing the whole s matrix and only then reducing it, it walks over k and v in tiles, keeps a running softmax as it goes (the "online softmax" trick), and accumulates the output one tile at a time. The full [seq, seq] score matrix is never written to HBM, it only ever lives on-chip. This is the single idea that lets the entire attention pipeline collapse into one fused kernel that stays in bf16 on the Tensor cores.
Why flash looks "wrong" under the profiler
Figure 16: Estimated occupancy of flash kernel is seen to be 13%
Here is where flash surprises people who read profiler footprints. It is the fastest backend, yet the profiler reports it with very low occupancy (shown in Figure 16). To see why that is fine, we need three quick definitions.
A GPU kernel is essentially a series of instructions executed by many small execution units. These individual execution units (threads) take care of loading variables, adding them together, storing them back, etc. For each kernel, we launch many, many threads, and to keep track of them, we group them by blocks.
Blocks are scheduled onto Streaming Multiprocessors (SMs), the main compute units of a GPU. A block lives entirely on one SM, and an SM can host multiple blocks at once *if it has enough resources*. Those resources include registers, shared memory, maximum resident threads, and maximum resident warps. So when we say a kernel has low occupancy, we mean each SM has fewer resident warps than it could theoretically support.
If you want to know more about threads, blocks, grids, etc. here is a great resource.
If you click the flash kernel in the trace, its footprint tells the story (Figure 17).
Figure 17: The flash kernel footprint, heavy on registers and shared memory per block.
Flash uses a lot of per-thread registers and a large amount of shared memory per block. For example, if a block has 128 threads and each thread uses 255 registers, that block needs 128 × 255 = 32,640 registers. On an Ampere SM with 65,536 registers, only two such blocks fit at once. Each 128-thread block has 128 / 32 = 4 warps, so two blocks give only 8 resident warps. Against a maximum of 64 resident warps, that is roughly 13% occupancy. Flash has low occupancy not because it is poorly optimized, but because each block is deliberately very "heavy" in on-chip resource usage.
And that is the whole point. High occupancy helps *hide latency* by keeping many warps ready to run, but it does not make the work itself efficient. Flash spends those registers and that shared memory on purpose, to keep attention tiles on-chip, reuse data aggressively, and avoid ever materializing the full attention matrix in global memory.
cuDNN backend
uv run 04_c_sdpa_attention.py --backend cudnn
uvx trace-util -f traces -b <hf_uname>/traces
Figure 18: The cuDNN backend trace, a single generated attention kernel per forward.
By now the pattern is familiar. Like flash and efficient, cuDNN gives us one fused, flash-style kernel per forward (Figure 18). So the natural question is: if flash already fuses attention, why does PyTorch ship yet another flash backend? The answer is *who writes the kernel and how it is built*, and that difference is what makes the trace look different.
How is cuDNN kernel different
Flash and efficient are fixed, pre-compiled kernels vendored into PyTorch. You get the same binary every time. cuDNN is NVIDIA's own deep learning library, and its attention kernel is generated and tuned for the specific problem at hand. It is closer in spirit to torch.compile's codegen than to a fixed cuBLAS binary. You can read that straight off the (very long) kernel name:
cudnn_generated_fort_native_sdpa_sm80_flash_fprop_wmma_f16_knob_6_128x64x64_4x1x1_cga1x1x1_kernel0_0
- cudnn_generated: not a pre-shipped binary, it was generated by cuDNN.
- flash_fprop: a flash attention style forward pass. So the algorithm is the same family as the flash backend.
- wmma_f16: it uses the warp-level matrix multiply-accumulate (WMMA) API, the Tensor-core path on the 16-bit float pipeline.
- knob_6: cuDNN picks from a set of pre-tuned configurations ("knobs"). Different shapes select different knobs, much like cuBLAS picking a tile variant.
- 128x64x64: the tile dimensions it chose.
That one fact, *generated per problem*, explains everything else that looks unusual in the trace.
- No transposes: The CPU lane goes from _cudnn_attention_forward straight to a couple of aten::empty allocations and then the kernel, with zero aten::transpose (Figures 19, 20 and 21). Flash and efficient each insert four (metadata) transposes to reshape the tensors while cuDNN consumes the native [B, H, S, D] layout directly because its generator emits a kernel for that layout.
Variant
Trace
Figure 19: Flash
Figure 20: Efficient
Figure 21: cuDNN
- It launches through cuLaunchKernelEx, not cudaLaunchKernel: Every other kernel in this whole series went through the runtime API cudaLaunchKernel. cuDNN uses the driver-level extended launch, which carries launch attributes (Figure 22).
Figure 22: CPU lane of the cuDNN backend showing the cuLaunchKernelEx driver-level launch instead of cudaLaunchKernel
- The profiler reports 0% achieved occupancy: Do not take that at face value, it is a measurement gap, not a stalled GPU. CUPTI (the profiling backend) cannot attribute occupancy to a driver-API (cuLaunchKernelEx) launch the way it does for cudaLaunchKernel, so the field reads 0. The footprint fills in the truth (Figure 23): 240 registers × 256 threads = 61,440 registers per block against the SM's 65,536, so only one block fits per SM (8 warps ≈ 12.5%), right in line with flash.
Figure 23: cuDNN kernel reporting 0% achieved occupancy, with 240 registers per thread and 256 threads per block
The cost moved to the CPU
The "no transposes" story tempts us to expect cuDNN to be the *leanest* backend on the CPU. It is the opposite.
backend
CUDA avg time
CPU avg time
efficient
277.9 µs
117 µs
flash
146.8 µs
138 µs
cudnn
186.3 µs
214 µs
Even with zero transpose ops, cuDNN spends about 214 µs per forward on the CPU, more than flash (138) or efficient (117). Almost all of it sits in aten::scaled_dot_product_attention self time (26% of the whole run) and _cudnn_attention_forward. That is cuDNN's runtime engine selecting and preparing the plan (the "knob" search) on every call.
Fewer visible ATen ops did not mean less CPU work, it moved the work into the library, where the profiler can only show it as one fat, opaque bar. When a trace suddenly gets *cleaner*, the work has not always disappeared, sometimes it has just moved somewhere the profiler cannot break down.
On the GPU, cuDNN (186.3 µs) lands between efficient and flash. On this very flash-friendly shape, hand-written FlashAttention-2 edges it out. cuDNN often wins on *other* shapes (larger head dimensions, different sequence lengths) precisely because its generator retunes per problem, but that retuning is also what you just paid for on the CPU.
Everything we covered, at a glance
Before we wrap up, here is a single table to review every attention variant we profiled and the one lesson each trace taught us.
Variant
What we changed
Kernels / forward
What the trace revealed
Naive attention
Attention built by hand from primitives (matmul, mul, mask, softmax, matmul)
6
A hidden Memcpy from the out-of-place masked_fill.
Naive in-place
masked_fill → masked_fill_
5
One line drops the Memcpy kernel entirely.
SDPA math
F.scaled_dot_product_attention pinned to the math backend
20
The reference: FP32 on CUDA cores, mask rebuilt every call, _safe_softmax. Correct but ~3.7x slower.
SDPA efficient
Efficient (xformers) backend
1
One fused fmha_cutlassF kernel, stays in bf16 on Tensor cores.
SDPA flash
Flash backend
1
One fused pytorch_flash kernel (FlashAttention-2). Fastest, despite "wrong-looking" 13% occupancy.
SDPA cuDNN
cuDNN backend
1
A per-problem generated kernel: no transposes, cuLaunchKernelEx, but the cost moved to a fat CPU bar.
Concluding the series
If you take away only one thing from the whole series, let it be the habit we repeated before every single trace which is to guess first, then look.
State out loud what you expect the trace to contain, open it, and treat any mismatch as the most interesting thing on the screen. Every real insight in these three posts, the hidden Memcpy, the addmm epilogue, the 20 kernel math backend, flash's "wrong-looking" occupancy, cuDNN's fat CPU bar, came from a guess that did not match the trace.
Profiling is not a separate, intimidating skill reserved for GPU experts. It is just the discipline of looking closely and asking "wait, why is *that* happening?" until the answer clicks. You now have the vocabulary and the reflexes to do that on your own models. Open a trace, form a guess, and go find the mismatch.
Thanks for reading the Profiling in PyTorch series. Now go profile something. 🤗
Thanks to Noe Flandre for their reviews on the early draft of the post!
The blog post was polished using an LLM. This in no way means that we have let an agent run in the background and let it generate the blog. Some of us in the team are non-english speakers and think LLMs (which are mostly trained in the English Language) can rectify silly grammar mistakes or rephrase sentences that sound less intimidating and cleaner. Hope this helps with the idea of "why should I read, if this was LLM generated". 🤗
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み