CUDA カーネルを実行すると何が起きるか(35 分読)
TLDR AI は、CUDA カーネルの実行プロセスを 35 分かけて詳細に解説し、ハードウェアレベルでの動作原理と最適化の重要性を浮き彫りにした。
キーポイント
カーネル起動から実行までの完全なフロー
ホスト側からの呼び出しから、GPU の SM(Streaming Multiprocessor)への命令転送、スレッドブロックの割り当て、そして実際の計算開始に至るまでの一連のステップを時系列で解説している。
ハードウェアリソースの競合と最適化
レジスタ、共有メモリ、キャッシュ、および帯域幅といった限られたリソースがどのように競合し、パフォーマンスに直結するかを具体的な数値例とともに分析している。
ボトルネックの特定手法と改善策
計算バウンドかメモリアクセスバウンドかを判断する指標や、コードレベルでの最適化が実際のハードウェア動作にどう影響を与えるかを具体的に示している。
影響分析・編集コメントを表示
影響分析
この記事は、AI エンジニアや研究者が CUDA の黒箱化された部分を可視化し、より深いレベルでパフォーマンスチューニングを行うための重要な指針となる。特に大規模モデルのトレーニングや推論における効率化を目指す現場において、ハードウェアリソースの限界を理解する重要性を再認識させる内容である。
編集コメント
理論と実装のギャップを埋めるための非常に質の高い技術解説であり、CUDA プログラミングの基礎から応用まで深く理解したいエンジニアに強く推奨される。
これはシンプルな CUDA プログラムです。2 つのベクトルを加算します。
__global__ void vadd(const float* a, const float* b, float* c, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) c[i] = a[i] + b[i];
}
int main() {
int n = 1 << 20; // 100 万個の浮動小数点数(1,048,576)
size_t bytes = n * sizeof(float);
float *a = (float*)malloc(bytes), *b = (float*)malloc(bytes),
*c = (float*)malloc(bytes);
for (int i = 0; i < n; i++) a[i] = b[i] = 1.0f;
float *da, *db, *dc;
cudaMalloc(&da, bytes); // GPU メモリ確保(Device Memory)
cudaMalloc(&db, bytes);
cudaMalloc(&dc, bytes);
cudaMemcpy(da, a, bytes, cudaMemcpyHostToDevice); // ホストからデバイスへ転送
cudaMemcpy(db, b, bytes, cudaMemcpyHostToDevice);
vadd<<<4096, 256>>>(da, db, dc, n); // 4096 * 256 = n スレッド、浮動小数点数ごとに 1 つ
cudaMemcpy(c, dc, bytes, cudaMemcpyDeviceToHost); // デバイスからホストへ転送
printf("c[0]=%f c[n-1]=%f\n", c[0], c[n-1]);
}
RTX 4090 でコンパイルして実行すると、正しく 1+1=2 が計算されることが確認できます。これを 100 万回繰り返します。すべてをチェックしたわけではありませんが。
$ nvcc -arch=sm_89 -o vadd vadd.cu && ./vadd
c[0]=2.000000 c[n-1]=2.000000
CPU 命令が数千万回、デバイスファイルが数個、IOCTL が 900 回、そしてメモリマップドされたドアベルレジスタが 1 つ関与していることをお伝えしました。今回の記事では、このカーネルをコードからウォープ(warps)まで、そして再び答え 22 へと戻るまでを追跡します。
余談ですが、この記事はエージェントが生み出した「可視性の転換」の一例です。好奇心と(機械支援された)粘り強ささえあれば、コンピュータに関する事実で知ることができないことはほとんどありません。AI が私たちに何を教えることができるかという点における可視性の影響についての興味深い議論 こちら。
nvcc でプログラムをコンパイルする§
まず、この CUDA プログラムをデバイスが実際に読み取れる形に変換する方法から始めましょう。そのためにはコンパイラが必要です。実は、多くのコンパイラが必要になります。
nvcc は、他の複数のコンパイラを実行してその出力を結合するドライバプログラムです。--keep オプションを指定すると、処理パイプライン全体がディスク上に残され、読み取れるようになります:
$ nvcc --keep -arch=sm_89 -o vadd vadd.cu && ls
...
vadd.ptx # PTX 形式のデバイスコード (cicc から)
vadd.sm_89.cubin # SASS 形式のデバイスコード (ptxas から)
vadd.fatbin # cubin と PTX をバンドルしたもの (fatbinary から)
vadd.cudafe1.stub.c # ホスト起動用スタブとカーネル登録
vadd.o # 最終的なホストオブジェクト、fatbin が埋め込まれる
...
ホストコードはホストコンパイラに渡されます。一方、デバイスコード(vadd)はより多くのステップを経ます:cicc という LLVM ベースのコンパイラがこれを PTX に変換し、その後 ptxas が PTX を SASS に変換します。
PTX は仮想の ISA(命令セットアーキテクチャ) です。無限に存在する型付きレジスタを持ち、ハードウェアが実際にいくつのレジスタを持っているかという概念はありません。以下は PTX における vadd の本体(一部省略)です:
$ cat vadd.ptx
...
mad.lo.s32 %r1, %r3, %r4, %r5; // レジスタ r1 に ctaid*ntid + tid を設定
setp.ge.s32 %p1, %r1, %r2; // i >= n の場合、述語 p1 を設定
@%p1 bra $L__BB0_2; // 範囲外の場合、終了へジャンプ
cvta.to.global.u64 %rd4, %rd1; // 汎用ポインタ %rd1 をグローバルアドレスに変換し、%rd4 に格納
mul.wide.s32 %rd5, %r1, 4; // r1 に 4 を乗算し、結果を %rd5 に格納
add.s64 %rd6, %rd4, %rd5; %rd4 と %rd5 を加算し、結果を %rd6 に格納
ld.global.f32 %f2, [%rd6]; a[i] を %f2 にロード
...
add.f32 %f3, %f2, %f1; %f1 と %f2 を加算し、結果を %f3 に格納
st.global.f32 [%rd10], %f3; c[i] = ... をグローバルメモリにストア
仮想レジスタは%rd1~%rd10、%f1~%f333のように見えます。プレフィックスは型を示します:%r は 32 ビット整数、%rd は 64 ビット整数、%f は 32 ビット浮動小数点数、%p は 1 ビットの述語です。
PTX は予想よりも「手書き」的です。例えば、%rd6 にアドレスを 1 つ形成するには 3 つの PTX 命令が必要です。これは、PTX がデバイス非依存であるためです。
なぜ 3 つなのか?CUDA ポインタはデフォルトで「ジェネリック」であり、グローバルメモリ、共有メモリ、ローカルメモリのいずれかを指す可能性があります。cvta.to.global はポインタがグローバルウィンドウ内にあることを宣言し、その後より安価な ld.global を使用できるようにします。次に mul.wide.s32 がインデックス i に 4(sizeof(float))を乗算してバイトオフセットに変換し、同時に 32 ビットから 64 ビットへ拡張します。最後に add.s64 でこれをベースポインタに加算します。
次に、ptxas はデバイス非依存の PTX を変換し、アーキテクチャ固有の SASS に変換します。生成される SASS は次のように異なります:
$ cuobjdump -sass vadd
/*0000*/ MOV R1, c[0x0][0x28] ; // set up the stack pointer (ABI; unused here)
/*0010*/ S2R R6, SR_CTAID.X ; // R6 = blockIdx.x
/*0020*/ S2R R3, SR_TID.X ; // R3 = threadIdx.x
/*0030*/ IMAD R6, R6, c[0x0][0x0], R3 ; // i = ctaid*ntid + tid
/*0040*/ ISETP.GE.AND P0, PT, R6, c[0x0][0x178], PT ;// P0 = (i >= n)
/*0050*/ @P0 EXIT ; // if so, exit
/*0060*/ MOV R7, 0x4 ; // load literal 4 (sizeof(float)) into R7 as multiplier
/*0070*/ ULDC.64 UR4, c[0x0][0x118] ; // uniform load of a driver-provided system value
/*0080*/ IMAD.WIDE R4, R6, R7, c[0x0][0x168] ; // &b[i]
/*0090*/ IMAD.WIDE R2, R6, R7, c[0x0][0x160] ; // &a[i]
/*00a0*/ LDG.E R4, [R4.64] ; // b[i]
/*00b0*/ LDG.E R3, [R2.64] ; // a[i]
/*00c0*/ IMAD.WIDE R6, R6, R7, c[0x0][0x170] ; // &c[i]
/*00d0*/ FADD R9, R4, R3 ; // a[i] + b[i]
/*00e0*/ STG.E [R6.64], R9 ; // c[i] = ...
/*00f0*/ EXIT ;
What the S2R lines are doing S2R は「特殊レジスタからレジスタへ」を意味し、ハードウェアがスレッドごとに維持する*特殊*レジスタ(ここでは SR_CTAID.X:ブロックのインデックスである blockIdx.x および SR_TID.X:ブロック内のレーンのインデックスである threadIdx.x)の内容を、通常のレジスタにコピーします。これにより、IMAD がその値に対して算術演算を実行できるようになります。
10 弱の仮想レジスタが 7 つの実態レジスタに圧縮されました。ncu のレポートでは launch__registers_per_thread = 16 と報告されています。ディスアセンブリでは R9 までしか名前が示されていませんが、アロケータは ABI と整列のためにさらにいくつかを予約しています。
2 つの mul.wide に加算するシーケンスが融合され、単一の IMAD.WIDE になりました。cvta の変換はなくなり、アドレス指定に吸収されています。
c[0x0][…] オペランドは定数バンク 0にあり、これはドライバーが管理する小領域内にあります。これらはカーネルの引数であり、ポインタ a, b, c とサイズ n、および起動ジオメトリです。このバンクへの書き込みは、ドライバーが起動時に GPU に渡す QMD という構造体の役割です。これについては、起動自体がカードに到達した時点で詳しく説明します。
なぜ引数が定数バンク 0 に置かれているのか、またその場所について:それらが定数メモリにあるのは、これが*ブロードキャスト*読み込みであるためです。グリッド内のすべてのスレッドが同一のポインタを必要とし、定数キャッシュは 32 ラーンすべてを一度にサービスできます。レイアウトは固定されており、0x160, 0x168, 0x170 がそれぞれポインタ a, b, c を表し、0x178 は n です。起動ジオメトリも隣接して 0x0 に配置されています(blockDim.x)。バンク 0 には ABI パラメータも保持されており、例えば c[0x0][0x28] はスタックベースであり、MOV R1, c[0x0][0x28] がエントリー時に読み込む値です。ホストのスタブが起動用に引数をパックする際にも、これらのオフセットを再度確認することになります。
この SASS を保持する「cubin」ファイルは、ELF ファイルです。これは Linux が通常の実行可能ファイルや共有ライブラリに使用するのと同じオブジェクトファイルコンテナです55.cuobjdump -elf を実行すると、シンボルテーブルが表示され、機械コードを保持する .text.vadd セクションに加え、CUDA 固有のセクションである .nv.callgraph.. などが見られます。fatbinary 実行可能ファイルは、cubin と PTX を単一の「fatbin」にバンドルしており、その結果に対して cuobjdump を実行すると、バイナリに埋め込まれた fatbin が*両方*を内包していることが明らかになります。
$ cuobjdump vadd
...
Fatbin elf code: arch = sm_89 # 先ほど読み取った SASS
Fatbin ptx code: arch = sm_89 compressed # 同梱される PTX
SASS は実際にこの 4090 で実行されるものですが、PTX は将来の互換性を保つためのフォールバックとして一緒に添付されています。もしその後、cubin がカバーしていないアーキテクチャを持つ GPU にこのバイナリを転送すると、ドライバーはロード時に PTX を JIT(Just-In-Time)コンパイルして、新しい SASS を生成します。
最後に、その fatbin はホスト実行可能ファイルの中にネストされており、readelf -S で確認すると、独自のセクションとして占有していることがわかります。
$ readelf -S vadd
...
[18] .nv_fatbin PROGBITS ...
[19] __nv_module_id PROGBITS ...
[29] .nvFatBinSegment PROGBITS ...
...
nvcc が出力する vadd バイナリは、ホストコード、Ada SASS を含む完全な ELF オブジェクト、そして PTX のコピーを単一の実行ファイルにまとめたものです。PTX は冗長なプレーンテキストであるため、バイナリのサイズを小さく保つために nvcc はデフォルトでこれを圧縮します。ドライバーは、事前コンパイルされた SASS でカバーされていないアーキテクチャ上でこのバイナリが実行された場合にのみ、それを展開して JIT コンパイルを行います。
ホストが GPU をトリガーする方法§
コンパイル済みの GPU 機械コードは、現在 ./vadd 実行ファイルの .nv_fatbin セクション内に静止状態として存在しています。ホストでプログラムを起動する際、私たちは PCIe バスを挟んで対峙する二つの世界、すなわちホスト CPU と GPU の間をつなぐ必要があります。
この橋渡しを行う方法を理解したホストバイナリを設定するために、フロントエンドコンパイラ(cudafe++)は main 関数が開始される前に実行される隠れたコンストラクタをコードに挿入します。その役割は、埋め込まれた fatbinary を CUDA ランタイムに登録し、ランタイムが後で使用するためのマッピングを記録することです。具体的には、ホスト側の関数ポインタ vadd と、fatbin 内の mangled な名前を持つコンパイル済みのデバイスカーネルとの関連付けを行います。
コンパイラが vadd<<<4096, 256>>>(da, db, dc, n) という記述に出会うと、それを生成されたホスト起動スタブに置き換えます。このスタブは、カーネル引数をホストメモリのバッファにパックします。ポインタ da, db, dc と整数 n は、バイトオフセット 0, 8, 16, 24 にそれぞれ整列されます。これらのオフセットは、先ほど SASS マシンコードが定数バンク 0 から読み込んでいた定数バンクオフセット 0x160, 0x168, 0x170, 0x178 です。
// from vadd.cudafe1.stub.c
void __device_stub__Z4vaddPKfS0_Pfi(const float *__par0, const float *__par1,
float *__par2, int __par3) {
__cudaLaunchPrologue(4);
__cudaSetupArgSimple(__par0, 0UL); // arg buffer offset 0
__cudaSetupArgSimple(__par1, 8UL); // offset 8
__cudaSetupArgSimple(__par2, 16UL); // offset 16
__cudaSetupArgSimple(__par3, 24UL); // offset 24
__cudaLaunch((char*)(void(*)(const float*, const float*, float*, int))vadd);
}
引数がパッキングされると、スタブは __cudaLaunch を呼び出し、ホスト側のダミー vadd 関数のメモリアドレスを渡します。このホスト関数は CPU 上では単なる空のシェルに過ぎないため、そのホストメモリアドレスがルックアップキーとして機能します。ランタイムはこのアドレスを使って登録テーブルを検索し、対応するデバイス側のシンボル名を見つけると、クローズドソースのユーザーモードドライバ(libcuda.so.1)77 へと境界を越えます。このドライバのユーザーモード部分は CUDA ツールキットではなく、GPU のカーネルドライバに付属しています:strace で解決される libcuda.so.1 は、本マシンのドライバリリースである libcuda.so.590.48.01 にリンクされます。これにより、そのカーネルの実行開始がトリガーされます。
ランタイムは、プログラム内の最初の GPU 呼び出し時にこのドライバを動的にオープンします。これは strace を使用して捕捉できます:
$ strace -f -e trace=openat ./vadd
...
openat(..., "/lib/x86_64-linux-gnu/libcuda.so.1", O_RDONLY|O_CLOEXEC) = 3
...
この最初の呼び出しが行われると、ドライバがデバイスと通信するために必要なすべてのインフラストラクチャを含む「コンテキスト」が作成されます。これには、CPU が GPU と通信するための *チャンネル* も含まれます。これについては次のセクションで詳しく説明します。
この段階では、コンパイルされたマシンコードはまだ GPU に到達していません。CUDA 12.2 以降、モジュール読み込みはデフォルトで遅延ロード(lazy loading)88 になっています。これは CUDA_MODULE_LOADING によって制御されており、CUDA 11.7 でオプトインとして導入され、長年 EAGER がデフォルトでしたが、12.x シリーズではデフォルトが LAZY に切り替えられました(必要に応じて upfront で読み込みコストを支払うようにオーバーライドすることも可能です)。—つまり、ドライバは特定のカーネルが実際に起動される最初の瞬間まで、そのカーネルの SASS cubin をカードのメモリにアップロードすることを先延ばしにします。
libcuda の下にはカーネルモードドライバである nvidia.ko が存在し、libcuda はデバイスファイルに対して ioctl を呼び出すことでこれにアクセスします。cuLaunchKernel が実際に GPU に作業を割り当てる必要があるとき、それはそのカーネルモジュールとの対話になります。以下はその対話のメカニズムです。
GPU への転送§
GPU は CPU のように関数呼び出しを受け付けません。ジャンプするエントリポイントも存在せず、CPU から引数をスタックにプッシュすることもできません。GPU は PCIe バス上にあり、ホストメモリからドライバの命令ストリームを読み取ります。この時点以降 cuLaunchKernel が行うことはすべて、完全に形成された起動コマンドをそのストリームに投入し、GPU に対してそれが完了したことを伝えることに役立ちます。
まず行わなければならないのは、GPU コードをデバイスにロードすることです。vadd を初めて実行する際、ドライバはカーネルのコードをコピーします:バッファを確保し、SASS をコピーします。
コードが GPU に転送された後、CPU は GPU にそれを読み込んで実行を開始させる必要があります。これはホストとデバイスのメモリ間をまたぐ複雑な手順を通じて行われます。ホストと GPU の双方は互いのメモリ空間の領域をマッピングできますが、PCIe バスを経由したアクセスにはペナルティが発生します。カーネル起動を実現するため、両者は両方の空間に存在するさまざまな構造体に書き込みを行います。これらの構造体が *チャネル* を構成し、これは GPU の操作を実行する作業キューです。
ホスト RAM に存在する重要な構造体が 2 つあります。プッシュバッファとGPFIFOで、これらが組み合わさって GPU が実行すべき作業のリストを表します。
プッシュバッファは、ドライバーが GPU に対して *メソッド* と呼ばれるコマンドを書き込むメモリ領域です。メソッドとは、GPU のネイティブなコマンドエンコーディングにおけるレジスタアドレスと値のペアであり、このペアによって GPU が実行すべきアクションが定義されます。
GPFIFOは、リングバッファ形式のポインタリストで、GPU と CPU によって使用され、GPU がまだ読み込む必要があるものと、すでに読み込んだものを調整します。GPFIFO の各エントリは 2 つの 32 ビットワードで構成されており、プッシュバッファ内のスパン(範囲)を記述します。99 この場合、base はホストメモリを指す GPU 仮想アドレスです (base, length)。
GPU は GPFIFO を継続的に走査して作業を見つけます。ドライバと GPU の間では、2 つのカーソルを維持する必要があります:GP_GET(GPU が消費した位置)と GP_PUT(ドライバが生成した位置)。両方のカーソルは USERD に存在し、これはチャネルごとの小さな構造体で、ここではデバイスメモリ上に配置されています。カーネルを実行するには、ドライバが関連するメソッドをプッシュバッファの範囲に埋め込み、GPFIFO エントリをその位置に指し示して GP_PUT を進めます。GPU がそのエントリを消費すると、GP_GET が進みます。
各構成要素の配置場所。
CPU と GPU の PCIe を介したホスト RAM へのプッシュバッファへの書き込み方法 + QMD(Queue Meta Data)と GPFIFO リング、USERD — GP_GET / GP_PUT、ドアベル(MMIO)、HOST エンジン、DMA
CPU · ホスト RAM → プッシュバッファ — メソッド + QMD と GPFIFO リング
PCIe 書き込み
DMA
GPU USERD — GP_GET / GP_PUT ドアベル (MMIO) HOST エンジン
私たちの起動は、まず SET_INLINE_QMD_ADDRESS_A/B という一連のメソッド(1010)によってトリガーされます。libcuda がクローズドソースであるにもかかわらず、なぜこれがそのメソッドだとわかるのかについては、付録を参照してください。
その後、LOAD_INLINE_QMD_DATA の実行が続きます。
これらのメソッドは、「キューメタデータ」(QMD)と呼ばれるオブジェクトをプッシュバッファにストリーミングする役割を果たします。
QMD は計算グリッドの起動記述子です。ここには、グリッドとブロックの次元(.cu コードから得られる 4096 と 256)、スレッドあたりのレジスタ数、必要な共有メモリ、そして 2 つのアドレスが格納されています。1 つはプログラムの開始位置(GPU メモリに最初にロードされた SASS)であり、もう 1 つはカーネル引数を保持する定数バンクです。このバンクには、ホスト側のスタブがパッキングした引数が置かれます。ドライバがそれらをコピーし、QMD にそのバンクのアドレスを記録します。QMD は GPU に対して、SASS がどこにあるか、どのようにして SASS を並列プログラムに変換するか、そしてプログラムの完了をどこでシグナルするかを伝えます。
GPU が実行を開始するために必要なものはすべて整いました。問題は、GPU のホストエンジン1111.(ホストとインターフェースする GPU 制御論理の一部)がまだ動作していないことです。現代のカードではカーソルを監視していません1212.(かつてはそうでした:古い GPU は USERD をスヌープしていたため、GP_PUT の書き込みだけで十分でした。Turing およびそれ以降のアーキテクチャではそのようではなく、そのためドライバがドアベルを鳴らします)。したがって、GP_PUT への変更は、エンジンに確認するよう指示があるまでそのまま放置されます。
それはドアベルを通じて指示を受けます。GPU はレジスタのごく一部のウィンドウをプロセスにマッピングしており、その一つがドアベルです。ドライバはチャンネルのワークサブミットトークンをそこに書き込みます。このトークンは、どのチャンネルに新しい仕事があるかを伝えます。
ドアベルが鳴ると、ホストエンジンは更新された GP_PUT を読み取り、新しい GPFIFO エントリに従ってプッシュバッファのスパンへ移動し、DMA によってそこからメソッドを引き出します。計算メソッド(QMD を含む)に到達すると、その記述子を「計算ワークディストリビュータ」に引き渡します。これについては後ほど詳しく説明します。
CPU の側から見ると、起動は完了です。cuLaunchKernel はドアベルが鳴った瞬間に返却しました。この呼び出しは非同期であるため、制御はプログラムに戻り、GPU が作業している間も CPU は実行を続けます。カーネルの実行が終わったら、再びホスト側の処理を引き継ぎます。
さて、GPU がその役割を果たす時が来ました。
命令ごとの実行§
CUDA カーネルを実行すると、GPU の各ストリーミングマルチプロセッサ(SM)が並列に動作し、スレッドブロック単位で命令をフェッチ・デコード・実行します。まず、カーネル起動時に指定されたパラメータがレジスタやグローバルメモリに転送され、各スレッドの ID が計算されます。その後、各 SM はキューからスレッドブロックを取得し、ウォープ(warp)単位で命令を実行していきます。
各ウォープは 32 スレッドから構成され、SIMD(Single Instruction, Multiple Data)アーキテクチャに基づいて同時に動作します。命令がデコードされると、レジスタファイルや共用メモリ、キャッシュにアクセスし、演算ユニット(CUDA コア)で計算を行います。メモリアクセスのレイテンシを隠すために、スレッドブロック間の切り替え(switchover)が行われ、GPU の処理能力を最大化します。
カーネルの実行中は、エラーチェックや同期ポイントが設定されている場合、その条件を満たすまで待機します。また、ダイナミックパラメータや共有メモリの初期化もこの段階で行われます。実行完了後、結果はグローバルメモリに書き込まれ、ホスト側へ転送される準備が整います。
このプロセスは、カーネルの複雑さやデータ依存性によって変動しますが、基本的には命令ごとの逐次処理と並列実行の組み合わせで成り立っています。効率的な CUDA プログミングには、この命令レベルの実行モデルを理解し、メモリアクセスパターンや同期のオーバーヘッドを最小化することが不可欠です。
詳細なステップ解説§
- カーネル起動: ホスト側から
cudaLaunchKernel関数などが呼び出され、パラメータが GPU に転送されます。この際、ブロック数とスレッド数が設定され、各 SM に割り当てられるスレッドブロックの数が決定されます。 - スレッド ID の計算: 各スレッドは、ブロック ID とスレッド ID から一意なグローバル ID を計算します。これは
blockIdx,threadIdxなどの組み込み変数を用いて行われます。 - 命令フェッチとデコード: GPU の命令キャッシュから命令が取得され、デコーダで解析されます。この段階で、命令の種類(演算、メモリアクセス、分岐など)が識別されます。
- 実行ユニットへの割り当て: 解析された命令は、CUDA コアや特殊機能ユニット(SFU)に割り当てられ、実際の計算が行われます。レジスタファイルから必要なデータを読み込み、結果を保存します。
- メモリアクセスと同期: グローバルメモリや共有メモリへのアクセスが発生し、キャッシュのヒット/ミスが影響します。また、
__syncthreads()などの同期命令が実行されると、ブロック内の全スレッドが待機状態になります。 - エラーチェックと終了条件の確認: 指定されたエラーチェックや分岐条件が満たされるまで処理が続行され、完了すると次のステップへ進みます。
- 結果の書き込み: 計算結果はグローバルメモリに書き込まれ、必要に応じてホスト側へ転送されます。この際、DMA(Direct Memory Access)技術が用いられることが一般的です。
これらのステップは、カーネルの実行中に繰り返し発生し、GPU の並列処理能力を最大限に引き出すために最適化されています。特に、メモリアクセスの効率化と同期オーバーヘッドの最小化が、パフォーマンス向上の鍵となります。
注意点とベストプラクティス§
- メモリアクセスのパターン: グローバルメモリへのアクセスはランダムよりも連続的である方が高速です。共用的なデータ構造を適切に設計し、キャッシュ効率を高めることが重要です。
- 同期のオーバーヘッド:
__syncthreads()などの同期命令は、ブロック内の全スレッドが到達するまで待機するため、過剰な使用はパフォーマンス低下の原因となります。必要な最小限の同期に留めるよう心がけましょう。 - 分岐の回避: スレッド間で分岐条件が異なる場合(divergence)、ウォープの一部が待機状態となり、効率が低下します。可能な限り分岐を避け、条件付き演算を活用することが推奨されます。
- レジスタの使用量: 各スレッドのレジスタ使用量が増えると、SM 上で同時に実行できるスレッド数が減少します。変数の最適化やループ展開などにより、レジスタ使用量を抑制しましょう。
これらのポイントを意識して CUDA カーネルを設計することで、GPU の性能を最大限に引き出すことができます。また、NVIDIA のプロファイリングツール(Nsight Compute など)を活用し、ボトルネックを特定・改善することも有効です。
まとめ§
CUDA カーネルの実行は、命令ごとの詳細な処理と並列実行の組み合わせによって成り立っています。各スレッドブロックが独立して動作しつつも、同期やメモリアクセスを通じて協調して処理を進めます。このモデルを理解し、最適化を行うことで、高性能な GPU 計算を実現できます。
次回の解説では、カーネルの実行時間測定とプロファイリング手法について詳しく取り上げます。
ホストエンジンが QMD を計算ワークディストリビュータに渡します。1313. 時折、ギガスレッドエンジンと呼ばれることもあります。このコンポーネントは GPU 全体に 1 つ存在します。VRAM 内には SASS 命令の単一の線形リストが存在し、計算ワークディストリビュータと QMD は、その線形のスレッド命令リストをすべてのストリーミングマルチプロセッサ (SMs) にわたる大規模並列プログラムに変換する方法をハードウェアに指示する最初のステップです。
スタックを下っていく私たちの旅において、現在の計算ワークディストリビュータは 256 スレッドからなる 4096 ブロックを記述する QMD を保持しています。私たちがターゲットとしているカードは、128 の SMs を持つ GeForce RTX 4090 チップです。1414. NVIDIA の AD102-300-A1 SKU では、製造歩留まりを最大化するために、フルダイ上の物理的な 144 の SMs のうち 16 が無効化されています。これは NVIDIA Ada GPU アーキテクチャのホワイトペーパーに詳述されています。ディストリビュータの任務は、すべての 128 を作業で飽和状態に保つことです。
コンパイルされたマシンコードは、グローバルメモリ内の単一の線形シーケンスとして存在します。各 SM は独自のローカル命令キャッシュ (I-cache) を持ち、GPU 上のすべてのアクティブなワープはそれぞれ独自のプライベートプログラムカウンタ (PC)1515. Volta 以降、モデルはさらに細分化されました — 各スレッドが独自のプログラムカウンタとコールスタック(独立スレッドスケジューリング)を持ち、ワープ内のスレッドが自由に分岐・再結合できるようになります。ただし、発行は依然としてワープ単位です — 各サイクルでスケジューラは 1 つのワープを選択し、現在共通の PC にあるレーンに発行します。。SM 上のスケジューラはその後、その線形シーケンスから命令を個別にフェッチするため、異なるワープが同じ SASS コードを異なる速度で実行したり、異なる分岐パスを下ったりすることが可能になります。
VRAM 内の 1 つの命令ストリームが SM ごとにローカルにキャッシュされます。SM は最大 48 のワープ(グリッド)をレジデント状態に保ちますが、その 4 つのスケジューラは各サイクルで最大 1 命令ずつしか発行できません。ここではほぼすべてのワープが LDG.E ロード(オレンジ色)に待機しており、FADD(緑色)を発行しているのは 1 スロットのみです。
VRAM × 128 SMs の命令ストリーム (SASS)
S2R R6, CTAID.X
S2R R3, TID.X
IMAD R6, R6, ntid, R3
ISETP.GE P0, R6, n@P0 EXIT
IMAD.WIDE R4, …
LDG.E R4, [R4]
LDG.E R3, [R2]
FADD R9, R4, R3
STG.E [R6], R9
EXIT
SM (128 のうち 1 つ)
I-cache
レジデントワープ 48
4 スケジューラ · 1 命令/サイクル
VRAM
命令ストリーム (SASS)
S2R R6, CTAID.X
S2R R3, TID.X
IMAD R6, R6, ntid, R3
ISETP.GE P0, R6, n@P0 EXIT
IMAD.WIDE R4, …
LDG.E R4, [R4]
LDG.E R3, [R2]
FADD R9, R4, R3
STG.E [R6], R9
EXIT
× 128 SMs
SM (128 のうち 1 つ)
I-cache
レジデントワープ 48
4 スケジューラ · 1 命令/サイクル
私たちの SM のハードウェア制約が、同時に実行できるブロックの数を決定します。1616. cudaGetDeviceProperties はこの情報を提供します:
+------------------------------------------------------------+
| AD102 SM リソースキャップ |
+------------------------------------------------------------+
| Max Active Threads/SM | 1,536 スレッド (48 ワープ) |
| Register File/SM | 65,536 32 ビットレジスタ (256 KB) |
| Shared Memory/SM | 100 KB |
+------------------------------------------------------------+
私たちの起動構成は、256 スレッド(8 ワープ)からなるブロックを指定しており、ptxas はスレッドあたり 16 のレジスタを予約しました。
レジスタ容量:各ブロックは 256×16=4,096 のレジスタを必要とします。
レジスタのみを考慮すると、SM は最大で 65,536/4,096=16 の定着ブロックを収容できます。
- スレッド容量:ハードウェアは各 SM を最大 1,536 のアクティブスレッドに制限しています。
この値をブロックサイズで割ると、1,536/256=6 の定着ブロックが得られます。
スレッド容量の方がより厳しいボトルネックであるため、各 SM は同時に最大で6 ブロック(48 ワープ)しか保持できません。
ディス tributor はこれらの 6 つの定着ブロックを SM に割り当てます。各 SM は4 つのプロセッシングブロック(サブパーティション)に分割されています。各サブパーティションは、独立した実行パイプラインです。
SM は、48 の定着ワープをこれら 4 つのサブパーティションに均等に分配します。したがって、SM が満杯の状態では、各ワープスケジューラが管理するアクティブなワープ数は 12(48/4)となります。毎サイクル、ワープスケジューラは自身の 12 の候補を評価し、条件を満たす*適格な*ワープを 1 つ選択して、その実行スライスの 32 の物理レーンに対して次の命令をディスパッチします。
ワープが"適格"であるとは何を意味するのか?§
GPU は命令の実行準備完了を判断するタイミングが CPU とは異なります。現代のアウト・オブ・オーダー CPU は、Tomasulo のアルゴリズムのように実行時に動的に依存関係を発見し、リオーダーバッファやレジスタ名付けロジックといった機構にシリコンを費やすことで、単一のスレッドから並列性を抽出します。一方、GPU はそのような機構を必要としません:多くのワープ(warp)を常駐させておき、それらがストールした際に切り替えることでレイテンシを隠蔽します。並列性が最優先される環境では、重厚な依存関係処理機構はシリコンの無駄遣いとなります。そのため、ハードウェアはタイミングを予測可能なすべての命令のスケジューリングをコンパイラに委ね、予測できないものについては軽量なハードウェアスコアボード(scoreboard)に頼ります。
128 ビットの SASS 命令には、ptxas1717 によって記述されたパケット化された制御コードペイロードが含まれています。最も明確な公開再構築例は、Citadel マイクロベンチマーク論文(Jia らによる「マイクロベンチマーキングを通じた NVIDIA Volta GPU アーキテクチャの解明」https://arxiv.org/abs/1804.06826)と、Maxwell 向けのmaxas 制御コードノートです。これらのスケジューリング制御ビットはハードウェアのタイミングを直接規定しており、3 つの重要な指示を含んでいます:
- 静的ストールカウント:標準的な整数演算や浮動小数点計算のような固定レイテンシ命令の場合、コンパイラは ALU が結果を書き戻すタイミングを正確に把握しています。これは、このワープが次の命令を発行する前にどの程度待機すべきかをスケジューラに正確に伝えるサイクル数をエンコードしたものです。
- イールドヒント:このワープがスケジューリングの優先権を譲るべきかどうかをスケジューラに示す 1 ビットのフラグです。コンパイラがこのワープがボトルネックに直面しようとしていると判断した場合、このヒントを設定して、次のクロックサイクルで他のアクティブなワープを優先させるようにスケジューラに指示します。
- 依存関係バリアインデックス:コンパイル時に実行時間を予測できない可変レイテンシ演算(特にグローバルメモリの読み込み (LDG) や特殊関数 (MUFU))の場合、ハードウェアは各ワープに対して 6 つの物理的なスコアボードバリア(0 から 5 の番号付き)を提供します。
ディスアセンブリでこれらのビットが見られない理由 NVIDIA の標準ツールである nvdisasm を使用してバイナリをディスアセンブルすると、生の制御コードはデフォルトで隠蔽され、ツールはそれらを除去して標準的でクリーンな SASS マネモニクスを表示します。しかし、これらは命令のすぐ隣に直接保存されています。cuobjdump -sass を使用して生バイナリを検査し、16 進数の命令コメント(例:/* 0x... */)を注意深く確認すると、これらの制御ビットを格納したパッキングされた生の 16 進数ワードが見えます。
⟦CODE_0⟧
彼らの正確なレイアウトに関する情報は、マイクロベンチマークコミュニティによる逆エンジニアリングの努力から得られています。ビットフィールドは Maxwell、Volta、Ampere、Ada Lovelace の間でシフトし進化してきましたが、コアとなるアーキテクチャの概念は
原文を表示
Here’s a simple CUDA program. It adds two vectors.
__global__ void vadd(const float* a, const float* b, float* c, int n) {
int i = blockIdx.x * blockDim.x + threadIdx.x;
if (i < n) c[i] = a[i] + b[i];
}
int main() {
int n = 1 << 20; // a million floats (1,048,576)
size_t bytes = n * sizeof(float);
float *a = (float*)malloc(bytes), *b = (float*)malloc(bytes),
*c = (float*)malloc(bytes);
for (int i = 0; i < n; i++) a[i] = b[i] = 1.0f;
float *da, *db, *dc;
cudaMalloc(&da, bytes);
cudaMalloc(&db, bytes);
cudaMalloc(&dc, bytes);
cudaMemcpy(da, a, bytes, cudaMemcpyHostToDevice);
cudaMemcpy(db, b, bytes, cudaMemcpyHostToDevice);
vadd<<<4096, 256>>>(da, db, dc, n); // 4096 * 256 = n threads, one per float
cudaMemcpy(c, dc, bytes, cudaMemcpyDeviceToHost);
printf("c[0]=%f c[n-1]=%f\n", c[0], c[n-1]);
}Compiled for an RTX 4090, and launched, it does correctly work out that
1+1=21+1=2, a million times11.I didn’t check all of them..
$ nvcc -arch=sm_89 -o vadd vadd.cu && ./vadd
c[0]=2.000000 c[n-1]=2.000000Telling you that involved tens of millions of CPU instructions, a couple of
device files, nine hundred ioctls, and one memory-mapped doorbell register. In
this post, we’ll follow this one kernel from the code down to the warps, and
back up to the answer22.An aside, this post is an instance of the ‘legibility transition’ that
agents have engendered. There really is very little about computers you can’t
find out with curiosity and (machine-enhanced) persistence. An interesting
discussion of the implications of legibility for what AI can help us to know
here..
Compiling our program with nvcc§
We ought to start with how to turn this CUDA program into something that the
device can actually read. To do that we need a compiler. Really, we need many
compilers.
nvcc is a driver program that runs several other compilers and combines their
output. If you pass --keep it leaves the whole pipeline on disk for you to
read:
$ nvcc --keep -arch=sm_89 -o vadd vadd.cu && ls
...
vadd.ptx # device code as PTX (from cicc)
vadd.sm_89.cubin # device code as SASS (from ptxas)
vadd.fatbin # cubin + PTX, bundled (from fatbinary)
vadd.cudafe1.stub.c # host launch stub + kernel registration
vadd.o # final host object, fatbin embedded
...The host code goes to your host compiler. The device code (vadd) takes more
steps: cicc, an LLVM-based compiler,
turns it into
PTX,
and then ptxas turns the PTX into
SASS.
PTX is a virtual
ISA. It has
infinitely many typed registers, and no notion of how many of them the hardware
actually has. Here is the (elided) body of vadd in PTX:
$ cat vadd.ptx
...
mad.lo.s32 %r1, %r3, %r4, %r5; // set register r1 to ctaid*ntid + tid
setp.ge.s32 %p1, %r1, %r2; // set predicate p1 if i >= n
@%p1 bra $L__BB0_2; // if out of bounds, skip to exit
cvta.to.global.u64 %rd4, %rd1; // convert generic pointer %rd1 to a global address, store in %rd4
mul.wide.s32 %rd5, %r1, 4; // multiply r1 by 4, store the result in %rd5
add.s64 %rd6, %rd4, %rd5; // add %rd4, %rd5, result in %rd6
ld.global.f32 %f2, [%rd6]; // load a[i] into %f2
...
add.f32 %f3, %f2, %f1; // add %f1 and %f2, result in %f3
st.global.f32 [%rd10], %f3; // store c[i] = ... in global memoryThe virtual registers look like %rd1–%rd10, %f1–%f333.The prefix is the type: %r is a 32-bit integer, %rd a 64-bit one,
%f a 32-bit float, %p a one-bit predicate..
PTX is more ‘longhand’ than you might expect. For example, forming one address
in %rd6 takes three PTX instructions. This happens because PTX is device
agnostic.
Why three? CUDA pointers are “generic” by default, meaning they could name global, shared,
or local memory. cvta.to.global asserts the pointer lives in the global
window, so a cheaper ld.global can be used later. mul.wide.s32 then turns
the index i into a byte offset by multiplying by 4 (sizeof(float)) and
widening 32→64 bits in one step. add.s64 adds that to the base pointer.
Next, ptxas transforms our PTX, which is device agnostic, into the SASS for
your architecture, which isn’t. The SASS it emits looks different:
$ cuobjdump -sass vadd
/*0000*/ MOV R1, c[0x0][0x28] ; // set up the stack pointer (ABI; unused here)
/*0010*/ S2R R6, SR_CTAID.X ; // R6 = blockIdx.x
/*0020*/ S2R R3, SR_TID.X ; // R3 = threadIdx.x
/*0030*/ IMAD R6, R6, c[0x0][0x0], R3 ; // i = ctaid*ntid + tid
/*0040*/ ISETP.GE.AND P0, PT, R6, c[0x0][0x178], PT ;// P0 = (i >= n)
/*0050*/ @P0 EXIT ; // if so, exit
/*0060*/ MOV R7, 0x4 ; // load literal 4 (sizeof(float)) into R7 as multiplier
/*0070*/ ULDC.64 UR4, c[0x0][0x118] ; // uniform load of a driver-provided system value
/*0080*/ IMAD.WIDE R4, R6, R7, c[0x0][0x168] ; // &b[i]
/*0090*/ IMAD.WIDE R2, R6, R7, c[0x0][0x160] ; // &a[i]
/*00a0*/ LDG.E R4, [R4.64] ; // b[i]
/*00b0*/ LDG.E R3, [R2.64] ; // a[i]
/*00c0*/ IMAD.WIDE R6, R6, R7, c[0x0][0x170] ; // &c[i]
/*00d0*/ FADD R9, R4, R3 ; // a[i] + b[i]
/*00e0*/ STG.E [R6.64], R9 ; // c[i] = ...
/*00f0*/ EXIT ;What the S2R lines are doing S2R is “special register to register”: it copies a *special* register the
hardware maintains per thread — here SR_CTAID.X (the block’s index,
blockIdx.x) and SR_TID.X (the lane’s index within the block, threadIdx.x)
— into an ordinary register so IMAD can do arithmetic on it.
Ten-odd virtual registers have collapsed onto seven real ones44.ncu reports launch__registers_per_thread = 16. The disassembly only
names up to R9, but the allocator reserves a few more for the ABI and
alignment.. The two
mul.wide plus add sequences have fused into a single IMAD.WIDE. The
cvta conversions are gone, absorbed into the addressing.
The c[0x0][…] operands are constant bank 0, in a small, driver-managed
region. These are the kernel’s arguments — the pointers a, b, c and the
size n — along with the launch geometry. Filling the bank is the job of a
structure called the QMD that the driver hands the GPU at launch, which we’ll
come to once the launch itself reaches the card.
Why the arguments sit in constant bank 0, and where They’re in constant memory because this is a *broadcast* read: every thread in
the grid needs the identical pointers, and the constant cache is able to serve
all 32 lanes in one shot. The layout is fixed — 0x160, 0x168, 0x170 are
the pointers a, b, c, and 0x178 is n, with the launch geometry
alongside them at 0x0 (blockDim.x). Bank 0 also holds ABI parameters such
as c[0x0][0x28], the stack base that MOV R1, c[0x0][0x28] loads at entry.
We’ll see these same offsets again when the host stub packs the arguments for
launch.
The ‘cubin’ file holding this SASS is an
ELF file — the
same object-file container Linux uses for ordinary executables and shared
libraries55.cuobjdump -elf shows a symbol table, a .text.vadd section holding the
machine code, plus CUDA-specific sections like .nv.callgraph.. The fatbinary executable bundles the cubin together with the
PTX into a single ‘fatbin’, and cuobjdump on the result reveals that the
fatbin embedded in our binary contains *both*:
$ cuobjdump vadd
...
Fatbin elf code: arch = sm_89 # the SASS we just read
Fatbin ptx code: arch = sm_89 compressed # the PTX, shipped tooThe SASS is what actually runs on this 4090, but the PTX rides along as a
forward-compatibility fallback. If you then take this binary to a GPU whose
architecture the cubin doesn’t cover, the driver can JIT the PTX into fresh
SASS at load time.
Finally, that fatbin is nested in the host executable, where readelf -S finds
it occupying its own sections:
$ readelf -S vadd
...
[18] .nv_fatbin PROGBITS ...
[19] __nv_module_id PROGBITS ...
[29] .nvFatBinSegment PROGBITS ...
...The vadd binary that nvcc spits out is a single executable containing host
code, a complete ELF object containing the Ada SASS, and a copy of the PTX.
Because PTX is verbose plain text, nvcc compresses it by default to keep the
binary size small; the driver will only decompress and JIT-compile it if the
binary is run on an architecture that the pre-compiled SASS doesn’t cover.
How the host triggers the GPU§
The compiled GPU machine code is now sitting inert inside the .nv_fatbin
section of our ./vadd executable. When you launch the program on the host, we
have to bridge two worlds: the host CPU, and the GPU sitting across the PCIe
bus.
To set up a host binary that knows how to cross the bridge, the frontend
compiler (cudafe++) inserts a hidden constructor into your code, running
before the main function starts. Its job is to register our embedded
fatbinary with the CUDA runtime and record a mapping that the runtime will
later use: associating the host-side function pointer vadd with the compiled
device kernel’s mangled name in the fatbin.
When the compiler encounters vadd<<<4096, 256>>>(da, db, dc, n), it replaces
that high-level expression with a generated host launch stub. This stub packs
our kernel arguments into a buffer in host memory. The pointers da, db,
dc and the integer n are aligned at byte offsets 0, 8, 16, and 24
66.These offsets are the constant bank offsets 0x160, 0x168, 0x170,
and 0x178 that we saw our SASS machine code reading from constant bank 0
earlier.:
// from vadd.cudafe1.stub.c
void __device_stub__Z4vaddPKfS0_Pfi(const float *__par0, const float *__par1,
float *__par2, int __par3) {
__cudaLaunchPrologue(4);
__cudaSetupArgSimple(__par0, 0UL); // arg buffer offset 0
__cudaSetupArgSimple(__par1, 8UL); // offset 8
__cudaSetupArgSimple(__par2, 16UL); // offset 16
__cudaSetupArgSimple(__par3, 24UL); // offset 24
__cudaLaunch((char*)(void(*)(const float*, const float*, float*, int))vadd);
}Once the arguments are packed, the stub calls __cudaLaunch, passing it
the memory address of the host-side dummy vadd function. Because this host
function is just an empty shell on the CPU, its host memory address serves as a
lookup key. The runtime queries its registration table with this address to
find the corresponding device-side symbol name, and then crosses the boundary
into the closed-source user-mode driver (libcuda.so.1)77.The usermode bit of the driver comes with the GPU’s kernel driver, not
with the CUDA toolkit: the libcuda.so.1 from the strace resolves to
libcuda.so.590.48.01, the driver release on this machine. to initiate the
launch of that kernel.
The runtime opens this driver dynamically on the first GPU call in our program,
which we can catch using strace:
$ strace -f -e trace=openat ./vadd
...
openat(..., "/lib/x86_64-linux-gnu/libcuda.so.1", O_RDONLY|O_CLOEXEC) = 3
...When this first call is performed, a ‘context’ is created, containing all the
infrastructure the driver needs to talk to the device, including the *channel*
through which the CPU speaks to the GPU. We’ll talk more about that in the next
section.
At this stage, the compiled machine code still hasn’t reached the GPU. Since
CUDA 12.2, module loading is lazy by default88.Controlled by CUDA_MODULE_LOADING. It shipped opt-in in CUDA 11.7 and
defaulted to EAGER for years; the 12.x series flipped the default to LAZY
(which can be overridden if you want loading costs paid up front).—the driver defers uploading a
kernel’s SASS cubin to the card’s memory until the very first time that
specific kernel is actually launched.
Underneath libcuda sits the kernel-mode driver, nvidia.ko, which libcuda
reaches by invoking ioctl on device files. When cuLaunchKernel finally
needs to put work on the GPU, it becomes a conversation with that kernel
module. What follows is the mechanics of that conversation.
Getting it onto the GPU§
A GPU does not take function calls like a CPU does. There is no entry point to
jump to, and no stack to push arguments onto from the CPU. The GPU sits across
a PCIe bus and reads a stream of driver commands out of host memory. Everything
cuLaunchKernel does past this point is in service of getting one fully formed
launch command into that stream, and then telling the GPU it has done so.
The first thing that needs to be done is loading the GPU code onto the device.
The first time you run vadd, the driver copies across the kernel’s code: it
allocates a buffer and copies the SASS in.
Once the code is on the GPU, the CPU needs to get the GPU to read it and start
executing it. It does so via a complex dance, across host and device memory.
Both the host and the GPU can map regions of each other’s memory spaces, but
accesses across the PCIe bus pay a penalty. To achieve a kernel launch, both
write to various structures, living across both spaces. These structures
comprise the *channel* — the work queue that runs the GPU’s operations.
There are two important such structures living in host RAM: the pushbuffer,
and the GPFIFO, representing between them the list of work the GPU has to
perform.
The pushbuffer is a region of memory into which the driver writes commands
to the GPU, called *methods*. A method is a register address and a value in
the GPU’s native command encoding — the pair defines what action the GPU should
perform.
The GPFIFO is a ring buffer of pointers, used by the GPU & CPU to
coordinate what the GPU still needs to read, and what it’s read already. Each
entry in the GPFIFO is made up of two 32-bit words, describing a span of the
pushbuffer99.In this case, base is a GPU virtual address pointing to host memory (base, length).
The GPU continually walks the GPFIFO to find work. Between the driver and the
GPU, two cursors need to be maintained: GP_GET (how far the GPU has
consumed), and GP_PUT (how far the driver has produced). Both cursors live
in USERD, a small per-channel structure that here sits in device memory. To
launch a kernel, the driver fills a pushbuffer span with the relevant methods,
points a GPFIFO entry at it, and advances GP_PUT. Once the GPU consumes the
entry, it advances GP_GET.
Where the different pieces live.
CPUGPUPCIe- host RAMpushbuffermethods + QMDGPFIFO ringUSERD — GP_GET / GP_PUTdoorbell (MMIO)HOST engineDMA
CPU · host RAMpushbuffer — methods + QMDGPFIFO ringPCIewritesDMAGPUUSERD — GP_GET / GP_PUTdoorbell (MMIO)HOST engine
Our launch is triggered by a burst of methods, first
SET_INLINE_QMD_ADDRESS_A/B1010.How I know it’s this method, given that libcuda is closed source: see
the appendix.
followed by a run of LOAD_INLINE_QMD_DATA.
These methods serve to stream an object called the “Queue Meta Data” (QMD)
into the pushbuffer.
The QMD is the launch descriptor for a compute grid. It holds the grid and
block dimensions — our 4096 and 256, from the .cu code — the registers per
thread and shared memory it needs, and two addresses: the program’s start (the
SASS the first launch loaded into GPU memory) and the constant bank holding the
kernel’s arguments. That bank is where the arguments the host stub packed land:
the driver copies them in and records the bank’s address in the QMD. The QMD
tells the GPU where the SASS is, how to turn that SASS into a parallel program,
and where to signal its completion of that program.
Everything is now in place for the GPU to start running. The problem is that
the GPU’s host engine1111.The part of the GPU’s control logic that interfaces with the host. hasn’t acted: it doesn’t watch the cursor on modern
cards1212.They used to: older GPUs snooped USERD,
so writing GP_PUT was enough. Turing and later don’t, so the driver rings the
doorbell instead., so the change to GP_PUT just sits there until something tells the
engine to look.
It is told to look through the doorbell. The GPU maps a small window of its
registers into the process, and one of them is the doorbell; the driver writes
the channel’s work-submit token to it. The token tells it which channel has
new work.
When its doorbell gets rung, the host engine reads the updated GP_PUT,
follows the new GPFIFO entry to the pushbuffer span, and pulls the methods out
of it by DMA. When it reaches the compute method carrying our QMD, it hands
that descriptor to the “compute work distributor”, about which more shortly.
From the CPU’s side the launch is done: cuLaunchKernel returned the moment
the doorbell was rung. The call was asynchronous, so control returns to the
program and the CPU runs on while the GPU works; we pick the host side back up
once the kernel has run.
It’s time for the GPU to start doing its job.
Instruction by instruction§
The host engine hands the QMD to the compute work distributor1313.Sometimes still called the GigaThread Engine. There is
one of these on the whole GPU. There is one linear list of SASS instructions in
VRAM, and the compute work distributor + the QMD is the first step in telling
the hardware how to make that linear list of thread instructions into a
massively parallel program across all the Streaming Multiprocessors (SMs).
In our journey down the stack, our compute work distributor now has a QMD
describing 4096 blocks of 256 threads. The card we are targeting is a GeForce
RTX 4090 chip with 128 SMs1414.NVIDIA’s AD102-300-A1 SKU disables 16 of the physical 144 SMs on the full
die to maximize manufacturing yield, as detailed in the NVIDIA Ada GPU Architecture whitepaper.. The distributor’s task is to keep all 128
saturated with work.
The compiled machine code sits as a single linear sequence in global memory.
Each SM contains its own local Instruction Cache (I-cache), and every active
warp on the GPU maintains its own private Program
Counter (PC)1515.Since Volta, the model goes finer still — each thread carries its own
program counter and call stack (Independent Thread
Scheduling),
letting threads in a warp diverge and reconverge freely. Issue is still
per-warp, though: each cycle the scheduler picks one warp and issues to the
lanes currently at a common PC.. Schedulers on the
SM then fetch instructions from that linear sequence independently, allowing
different warps to execute the same SASS code at different speeds, or down
different branch paths.
One instruction stream in VRAM, cached locally per SM. An SM keeps up to
48 warps resident (the grid), but its four schedulers issue at most one
instruction each per cycle. Here nearly every warp is parked on the LDG.E load
(orange) and only one slot is issuing the FADD (green).
VRAM× 128 SMsinstruction stream (SASS)S2R R6, CTAID.XS2R R3, TID.XIMAD R6, R6, ntid, R3ISETP.GE P0, R6, n@P0 EXITIMAD.WIDE R4, …LDG.E R4, [R4]LDG.E R3, [R2]FADD R9, R4, R3STG.E [R6], R9EXITSM (one of 128)I-cache48 resident warps4 schedulers · 1 instruction / cycle
VRAMinstruction stream (SASS)S2R R6, CTAID.XS2R R3, TID.XIMAD R6, R6, ntid, R3ISETP.GE P0, R6, n@P0 EXITIMAD.WIDE R4, …LDG.E R4, [R4]LDG.E R3, [R2]FADD R9, R4, R3STG.E [R6], R9EXIT× 128 SMsSM (one of 128)I-cache48 resident warps4 schedulers · 1 instr / cycle
The hardware constraints of our SMs set the number of blocks that can run at
the same time1616.cudaGetDeviceProperties tells you this information:
+------------------------------------------------------------+
| AD102 SM Resource Caps |
+------------------------------------------------------------+
| Max Active Threads/SM | 1,536 threads (48 warps) |
| Register File/SM | 65,536 32-bit registers (256 KB) |
| Shared Memory/SM | 100 KB |
+------------------------------------------------------------+Our launch configuration specifies blocks of 256 threads (8 warps), and
ptxas reserved 16 registers per thread.
Register capacity: Each block needs 256×16=4,096256 \times 16 = 4,096 registers.
On registers alone, an SM could fit 65,536/4,096=1665,536 / 4,096 = 16 resident blocks.
- Thread capacity: The hardware caps each SM at 1,536 active threads.
Divided by our block size, this yields 1,536/256=61,536 / 256 = 6 resident blocks.
Because thread capacity is the tighter bottleneck, each SM holds at most **6
blocks (48 warps) at once**.
The distributor assigns these 6 resident blocks to an SM. Each SM is divided
into four processing blocks (sub-partitions). Each sub-partition is a
self-contained execution pipeline.
The SM distributes our 48 resident warps evenly across these four
sub-partitions, so when the SM is full each warp scheduler has 12 active warps
(48/448 / 4) to manage. Every cycle, a warp scheduler
evaluates its 12 candidates, selects one *eligible* warp, and dispatches its
next instruction across the 32 physical lanes of its execution slice.
What does it mean for a warp to be eligible?§
A GPU decides when an instruction is ready to run differently from a CPU. A
modern out-of-order CPU discovers dependencies [dynamically at
runtime](https://en.wikipedia.org/wiki/Tomasulo%27s_algorithm), with [reorder
buffers](https://en.wikipedia.org/wiki/Re-order_buffer) and [rename
logic](https://en.wikipedia.org/wiki/Register_renaming) spending silicon on
extracting parallelism from a single thread. A GPU doesn’t need that: it hides
latency by keeping many warps resident and switching between them when they
stall. With parallelism the order of the day, too much heavyweight dependency
machinery is the wrong use of silicon. So the hardware leans on the compiler to
schedule everything whose timing it can predict, falling back to lightweight
hardware scoreboards for whatever it can’t.
Every 128-bit SASS instruction carries a packed control-code payload written by
ptxas1717.The clearest public reconstructions are the Citadel microbenchmarking
papers ([Jia et al., “Dissecting the NVIDIA Volta GPU Architecture via
Microbenchmarking”](https://arxiv.org/abs/1804.06826)) and [these
maxas control-code notes](https://github.com/NervanaSystems/maxas/wiki/Control-Codes)
for Maxwell.. These scheduling control bits dictate hardware timing directly and
contain three key directives:
- A static stall count: For fixed-latency instructions—like standard
integer or floating-point maths—the compiler knows exactly when the ALUs will
write back. It encodes a precise cycle count telling the scheduler exactly
how long to park this warp before issuing its very next instruction.
- A yield hint: A single bit telling the scheduler whether this warp
should yield its scheduling priority. If the compiler knows this warp is
about to hit a bottleneck, it sets this hint to let the scheduler prioritize
other active warps on the next clock cycle.
- Dependency-barrier indices: For variable-latency operations whose
duration cannot be predicted at compile time—most notably global memory
loads (LDG) and special functions (MUFU)—the hardware provides six
physical scoreboard barriers (numbered 0 to 5) per warp.
Why you won't see these bits in the disassembly When you disassemble a binary using NVIDIA’s standard nvdisasm tool, the raw
control codes are hidden by default; the tool strips them away to show you
standard, clean SASS mnemonics. However, they are stored directly alongside the
instructions. If you inspect the raw binary using cuobjdump -sass and look
closely at the hexadecimal instruction comments (e.g., /* 0x... */), you will
see the packed, raw hex words that house these control bits.
What we know about their exact layout comes from the microbenchmarking
community’s reverse-engineering efforts. Although the bit fields have shifted
and evolved between Maxwell, Volta, Ampere, and Ada Lovelace, the core
architectural con
関連記事
今日のまとめ
AI日報で今日の重要ニュースをまとめ読み