- Authors

- Name
- Youngju Kim
- @fjvbn20031
- なぜすべてのAIエンジニアがGPU内部を理解すべきか
- 1. CPUとGPU:根本的に異なる設計哲学
- 2. GPU内部の階層構造:DeviceからCUDAコアまで
- 3. SIMT:Single Instruction, Multiple Threads
- 4. CUDAメモリ階層:レイテンシと帯域幅のトレードオフ
- 5. 行列乗算の並列化:CUDAカーネルの核心
- 6. Tensor Core:行列乗算専用ハードウェア
- 7. Grid、Block、Thread:CUDAの実行階層
- 8. CUDAの歴史:なぜNVIDIAがAIインフラを独占したのか
- 9. 実践的最適化:H100でGEMMの最大性能を引き出す
- 10. まとめ:なぜGPUがAIを支配するのか
なぜすべてのAIエンジニアがGPU内部を理解すべきか
GPT-4の推論には数百枚のA100 GPUが同時に稼働している。LLaMA-3のファインチューニングにはH100クラスターが必要だ。これらすべての計算の中心にNVIDIAのGPUアーキテクチャとCUDAプログラミングモデルが存在する。しかし、「なぜGPUがこれほど速いのか」を正確に説明できるエンジニアは意外と少ない。本記事では、H100のスペック、SIMTの実行モデル、Warp Divergence、共有メモリタイリング、Tensor Coreを実際のCUDAコードとともに徹底的に解剖する。
1. CPUとGPU:根本的に異なる設計哲学
CPUとGPUの違いは単に「コア数が多い」ということではない。設計哲学そのものが根本的に異なる。
CPU:レイテンシの最適化
CPUは単一スレッドを可能な限り高速に実行するよう設計されている。そのために以下の機構を搭載している:
- 分岐予測(Branch Prediction):if/elseの分岐結果を事前に予測してパイプラインストールを防ぐ
- アウト・オブ・オーダー実行(Out-of-Order Execution):依存関係のない命令をプログラム順序と無関係に並列実行
- 投機的実行(Speculative Execution):分岐結果を予測して事前に計算(Spectre/Meltdownの原因でもある)
- 大容量キャッシュ:単一スレッドのメモリレイテンシを隠すための数十MBのL1/L2/L3キャッシュ
GPU:スループットの最適化
GPUは数千のスレッドが同時実行されるときの総スループットを最大化するよう設計されている。個々のスレッドのレイテンシは犠牲にする代わり、あるスレッドがメモリ待ちでストールすると、別のスレッドに即座にコンテキストスイッチする。レイテンシは並列性によって隠蔽される。
CPUアーキテクチャ(レイテンシ最適化):
┌────────────────────────────────────────────┐
│ Core 0 │ Core 1 │ Core 2 │ Core 3 │
│(高性能) │(高性能) │(高性能) │(高性能) │
│ OOO実行 │ OOO実行 │ OOO実行 │ OOO実行 │
├─────────┴─────────┴─────────┴────────────┤
│ L3キャッシュ(32MB以上) │
├────────────────────────────────────────────┤
│ メインメモリ(DDR5, 約50 GB/s) │
└────────────────────────────────────────────┘
GPUアーキテクチャ(スループット最適化)— H100:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
│SM│SM│SM│SM│SM│SM│SM│SM│SM│SM│...│ 132個のSM
│ │ │ │ │ │ │ │ │ │ │ │ 各SM = 128個のCUDAコア
└──┴──┴──┴──┴──┴──┴──┴──┴──┴──┴───┘
L2キャッシュ(50MB)
HBM3(80GB、3.35 TB/s)
核心的な考え方:CPUは1スレッドを1nsで処理しようとする。GPUは1スレッドが700nsかかっても、10万スレッドが同時に進めるため、総スループットが圧倒的に勝る。
2. GPU内部の階層構造:DeviceからCUDAコアまで
H100の内部は階層的に構成されている。最上位のDeviceから最下位のCUDAコアまで階層を辿ってみよう。
GPU Device(H100 SXM5)
│
├── SM 0(Streaming Multiprocessor)
│ ├── 4個のWarpスケジューラ
│ ├── 4個のDispatch Unit
│ ├── 128個のCUDAコア(FP32)
│ ├── 64個のFP64コア
│ ├── 4個のTensor Core(第4世代:FP8/FP16/BF16/TF32対応)
│ ├── 1個の特殊関数ユニット(SFU:sin、cos、sqrtなど)
│ ├── LD/STユニット(Load/Store)
│ ├── L1キャッシュ + 共有メモリ = 228KB(比率設定可能)
│ └── レジスタファイル(65536個 × 32ビットレジスタ)
│
├── SM 1 ...
├── SM 2 ...
│ ...
├── SM 131 ...
│
├── L2キャッシュ(50MB)
└── HBM3メモリ(80GB、3.35 TB/s帯域幅)
H100 SXM5の主要スペック:
| 仕様 | 数値 |
|---|---|
| SM数 | 132個 |
| SM当たりのCUDAコア | 128個(FP32) |
| 総CUDAコア数 | 16,896個 |
| SM当たりのTensor Core | 4個(第4世代) |
| 総Tensor Core数 | 528個 |
| L2キャッシュ | 50MB |
| HBM3メモリ | 80GB |
| メモリ帯域幅 | 3.35 TB/s |
| FP16 Tensor Core性能 | 989 TFLOPS |
3. SIMT:Single Instruction, Multiple Threads
GPUのコア実行モデルは**SIMT(Single Instruction Multiple Threads)**だ。CPUのSIMD(Single Instruction Multiple Data)に似ているが、重要な違いがある:SIMTでは各スレッドが独自のプログラムカウンタとレジスタを持ち、独立したスレッドの幻想を与えながら実行ハードウェアを共有する。
Warp:GPUの基本実行単位
CUDAにおいてWarpは32スレッドのグループであり、この32スレッドが常に同じ命令を同時に実行する。SM内のWarpスケジューラはWarp単位で命令をディスパッチする。
Thread Block(例:256スレッド)
├── Warp 0: スレッド 0-31 → 同じ命令をロックステップで実行
├── Warp 1: スレッド 32-63 → 同じ命令をロックステップで実行
├── Warp 2: スレッド 64-95 → 同じ命令をロックステップで実行
├── ...
└── Warp 7: スレッド 224-255
Warp Divergence:パフォーマンスの天敵
Warp内のスレッドが異なる分岐を取るとWarp Divergenceが発生する。SIMTでは32スレッド全員が同じ命令を実行しなければならないため、GPUは分岐パスをシリアライズする:
// このコードはWarp Divergenceを引き起こす
__global__ void divergent_kernel(float* data, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
if (idx % 2 == 0) {
// Warp内の偶数インデックスのスレッドだけがここを実行
data[idx] = data[idx] * 2.0f; // ステップ1
} else {
// Warp内の奇数インデックスのスレッドだけがここを実行
data[idx] = data[idx] + 1.0f; // ステップ2
}
// 結果:ステップ1実行中、奇数スレッドはアイドル(マスクオフ)
// ステップ2実行中、偶数スレッドはアイドル(マスクオフ)
// 実効スループット:理論値の50%
}
Divergenceのないバージョンとの比較:
// Divergenceなし:Warp内すべてのスレッドが同じパスを取る
__global__ void coherent_kernel(float* data, int n) {
int idx = blockIdx.x * blockDim.x + threadIdx.x;
// ブロック内のすべてのスレッドは同じblockIdx.xを共有するため
// Warp内32スレッドは全員同じ分岐を取る
if (blockIdx.x % 2 == 0) {
data[idx] = data[idx] * 2.0f;
} else {
data[idx] = data[idx] + 1.0f;
}
}
実践的なルール:分岐条件はthreadIdxではなくblockIdxを基準に設計することでWarp内のDivergenceを回避できる。
4. CUDAメモリ階層:レイテンシと帯域幅のトレードオフ
CUDAカーネルのパフォーマンスの大部分はメモリアクセスパターンによって決まる。各メモリ階層の特性を正確に把握することが不可欠だ。
メモリ階層(高速 → 低速):
┌─────────────────────────────────────────────────────┐
│ レジスタ(32ビット、SM当たり65536個) │
│ レイテンシ:約1サイクル 帯域幅:膨大(ローカルアクセス)│
│ スコープ:単一スレッドのみ │
├─────────────────────────────────────────────────────┤
│ L1キャッシュ / 共有メモリ(H100ではSM当たり228KB) │
│ レイテンシ:約32サイクル 帯域幅:約19 TB/s │
│ スコープ:Thread Block内のすべてのスレッド │
├─────────────────────────────────────────────────────┤
│ L2キャッシュ(H100では50MB) │
│ レイテンシ:約200サイクル 帯域幅:約12 TB/s │
│ スコープ:全SMが共有 │
├─────────────────────────────────────────────────────┤
│ グローバルメモリ HBM3(H100では80GB) │
│ レイテンシ:約700サイクル 帯域幅:3.35 TB/s │
│ スコープ:デバイス上のすべてのスレッド │
└─────────────────────────────────────────────────────┘
| メモリ種別 | レイテンシ | 帯域幅 | サイズ | スコープ |
|---|---|---|---|---|
| レジスタ | 約1サイクル | 膨大 | 256KB/SM | 単一スレッド |
| 共有メモリ | 約32サイクル | 約19 TB/s | 228KB/SM | Thread Block |
| L2キャッシュ | 約200サイクル | 約12 TB/s | 50MB | GPU全体 |
| HBM3(グローバル) | 約700サイクル | 3.35 TB/s | 80GB | GPU全体 |
共有メモリはプログラマが直接管理するキャッシュだ。 __shared__キーワードで宣言した変数は共有メモリに格納され、同じThread Block内のすべてのスレッドがアクセスできる。HBMより20倍以上低いレイテンシで利用可能だ。
5. 行列乗算の並列化:CUDAカーネルの核心
深層学習の支配的な演算は行列乗算(GEMM:General Matrix Multiply)だ。これをGPU上でどのように並列化するかをステップごとに見ていこう。
Naive実装:グローバルメモリへの猛攻
// Naive行列乗算カーネル — なぜ遅いかを理解するためのバージョン
__global__ void matmul_naive(float* A, float* B, float* C, int N) {
// 各スレッドが出力行列の1要素を担当
int row = blockIdx.y * blockDim.y + threadIdx.y;
int col = blockIdx.x * blockDim.x + threadIdx.x;
if (row < N && col < N) {
float sum = 0.0f;
for (int k = 0; k < N; k++) {
// 各イテレーションでグローバルメモリに2回アクセス → 約700サイクルのレイテンシ
sum += A[row * N + k] * B[k * N + col];
}
C[row * N + col] = sum;
}
}
// 起動設定の例
dim3 blockDim(16, 16); // 256スレッド/ブロック
dim3 gridDim((N + 15) / 16, (N + 15) / 16);
matmul_naive<<<gridDim, blockDim>>>(A, B, C, N);
問題点:N=4096の行列では各スレッドがグローバルメモリから4096回読み取る。グローバルメモリ読み取り総数 = N^3 = 680億回。HBMレイテンシ700サイクル × 680億 = 惨憺たる性能。
Tiled実装:共有メモリでグローバルアクセスを最小化
#define TILE_SIZE 16
// 共有メモリを活用したタイリング行列乗算
__global__ void matmul_tiled(float* A, float* B, float* C, int N) {
// 共有メモリにタイルを宣言(HBMの約700サイクルに対し約32サイクル)
__shared__ float tile_A[TILE_SIZE][TILE_SIZE];
__shared__ float tile_B[TILE_SIZE][TILE_SIZE];
int tx = threadIdx.x, ty = threadIdx.y;
int row = blockIdx.y * TILE_SIZE + ty;
int col = blockIdx.x * TILE_SIZE + tx;
float sum = 0.0f;
// K次元をタイルサイズ単位でイテレート
for (int t = 0; t < N / TILE_SIZE; t++) {
// ステップ1:各スレッドが1要素ずつ共有メモリにロード
tile_A[ty][tx] = A[row * N + t * TILE_SIZE + tx];
tile_B[ty][tx] = B[(t * TILE_SIZE + ty) * N + col];
// ステップ2:すべてのスレッドのロード完了を待つ
__syncthreads();
// ステップ3:タイル内の内積を計算
// すべてのアクセスが共有メモリ(約32サイクル)にヒット
for (int k = 0; k < TILE_SIZE; k++) {
sum += tile_A[ty][k] * tile_B[k][tx];
}
// ステップ4:次のタイルをロードする前に同期
__syncthreads();
}
C[row * N + col] = sum;
}
なぜ速いのか:
タイルサイズ16×16の場合:
- Naive:グローバルメモリアクセス = 2 × N^3 回
- Tiled:グローバルメモリアクセス = 2 × N^3 / TILE_SIZE 回 = 16倍の削減
タイルを一度共有メモリにロードすることで、K次元方向の16スレッドが同じデータを共有・再利用する。メモリ帯域幅効率が劇的に向上する。
タイリングの原理:
行列A: 行列B:
┌──┬──┬──┬──┐ ┌──┬──┬──┬──┐
│T0│T1│T2│T3│ │T0│T1│T2│T3│
├──┼──┼──┼──┤ ├──┼──┼──┼──┤
│ │ │ │ │ │ │ │ │ │
└──┴──┴──┴──┘ └──┴──┴──┴──┘
各Thread BlockはCの16×16出力タイルを1つ担当。
Aの行タイルとBの列タイルを順番に共有メモリにロードし、
計算後に次のタイルへ進む → グローバルメモリ転送量をTILE_SIZE倍削減。
6. Tensor Core:行列乗算専用ハードウェア
CUDAコアがスカラーのFP32演算を1サイクルに1つ処理するのに対し、Tensor Coreは16×16の行列全体を単一命令で処理する。
第4世代Tensor Core(H100):
| 演算 | Tensor Core | CUDAコア | 速度比 |
|---|---|---|---|
| FP16行列乗算 | 256 ops/サイクル | 2 ops/サイクル | 128倍 |
| BF16行列乗算 | 256 ops/サイクル | 非対応 | — |
| FP8行列乗算 | 512 ops/サイクル | 非対応 | — |
| TF32行列乗算 | 128 ops/サイクル | 非対応 | — |
WMMA(Warp Matrix Multiply-Accumulate)APIを使ってTensor Coreに直接アクセスできる:
// WMMAを使ったTensor Core直接アクセス
#include <mma.h>
using namespace nvcuda::wmma;
__global__ void tensor_core_matmul(half* a_ptr, half* b_ptr,
float* c_ptr, int M, int N, int K) {
// 各WarpがCの16×16出力タイル1つを担当
// fragment = Tensor Coreが操作する行列の断片
fragment<matrix_a, 16, 16, 16, half, row_major> a_frag;
fragment<matrix_b, 16, 16, 16, half, col_major> b_frag;
fragment<accumulator, 16, 16, 16, float> c_frag;
// アキュムレータをゼロ初期化
fill_fragment(c_frag, 0.0f);
// K次元をイテレート
for (int k = 0; k < K; k += 16) {
// グローバルメモリからfragmentをロード
load_matrix_sync(a_frag, a_ptr + /* offset */, K);
load_matrix_sync(b_frag, b_ptr + /* offset */, N);
// 単一命令で16×16×16の行列乗算累算
// 内部でTensor Coreハードウェアを直接呼び出す
mma_sync(c_frag, a_frag, b_frag, c_frag);
}
// 結果をグローバルメモリに書き戻す
store_matrix_sync(c_ptr + /* offset */, c_frag, N, mem_row_major);
}
実際にはcuBLAS、cuBLASLt、CUTLASSがWMAA APIよりもはるかに効率的にTensor Coreを活用するが、このコードはTensor Coreの動作原理を理解するのに適している。
7. Grid、Block、Thread:CUDAの実行階層
CUDAカーネルを起動する際、プログラマは**実行構成(Execution Configuration)**としてグリッドとブロックの次元を指定する。
CUDA実行階層:
Grid(カーネル全体の実行)
├── Block (0,0): 256スレッド
│ ├── Warp 0: スレッド 0-31 ── SMで同時実行
│ ├── Warp 1: スレッド 32-63
│ └── ...
├── Block (1,0): 256スレッド
├── Block (2,0): 256スレッド
└── ...
各BlockはちょうどひとつのSMに割り当てられる。
ひとつのSMは複数のBlockを同時にホストできる(Occupancy)。
PyTorchはこれをすべて単一のオペレーターの裏側に隠す:
import torch
A = torch.randn(4096, 4096, device='cuda', dtype=torch.float16)
B = torch.randn(4096, 4096, device='cuda', dtype=torch.float16)
# この1行の内部で起きていること:
# 1. cuBLASまたはCUTLASSが最適なカーネルを選択
# 2. 実行構成: 例) Grid(256,256)、Block(16,16)
# 3. Tensor Coreを活用したTiled GEMM実行
# 4. 約4096個のThread Block、各256スレッド
result = torch.matmul(A, B) # 内部でcublasSgemmExを呼び出す
Occupancy最適化: SMが同時にホストできるWarp数はブロックごとのレジスタと共有メモリ使用量によって決まる。ブロックがリソースを使いすぎると1つのSMに1ブロックしか乗れなくなり、実行ユニットが遊んでしまう。
SMリソース制限(H100):
- 同時Thread Block最大数:32
- 同時スレッド最大数:2048
- レジスタ:65536個(常駐Warpの全スレッドで分割)
- 共有メモリ:228KB(常駐Block間で分割)
Occupancy = アクティブWarp数 / 最大可能Warp数
目標:50%以上(Warpスイッチングでメモリレイテンシを隠蔽するため)
8. CUDAの歴史:なぜNVIDIAがAIインフラを独占したのか
CUDAの台頭を理解することで、2024年においてもNVIDIAがAIアクセラレーター市場の80%以上を占める理由がわかる。
2006年以前:OpenGLシェーダーハックの時代
GPUが高速であることは知られていたが、汎用計算に使うにはOpenGLテクスチャサンプリングやシェーダー言語で計算を「偽装」する必要があった。行列乗算を画像フィルタとして見せかけてGPUに投入するような手法だった。
2006年:CUDA 1.0のリリース
Jensen Huangは「GPUをCで書けるようにする」という賭けに出た。ゲーム用GPUに汎用コンピューティングロジックを追加するためにNVIDIAの取締役会が承認しなければならなかった。
初期のCUDAユーザー:物理シミュレーション研究者、CFDチーム、分子動力学グループ。深層学習はまだ登場していなかった。
2012年:AlexNetの瞬間
Alex KrizhevskyがコンシューマーゲームGPUであるGTX 580を2枚使ってAlexNetを学習させ、ImageNet競技で圧倒的な差をつけて1位を獲得した。この1件が深層学習革命の引き金となった。
CUDAとOpenCL:エコシステム戦争
Khronos GroupのOpenCLはAMD、Intel、NVIDIAすべてをサポートするオープン標準だった。にもかかわらずCUDAが勝った理由:
- 開発者ツール:cuDNN、cuBLAS、Nsightプロファイラ — NVIDIAのエコシステムが圧倒的に豊富
- 先行者利益:研究者がCUDAコードを共有したことで論文コードが全てCUDAベースになった
- ハードウェア独占性:Tensor Core、NVLinkなどNVIDIA専用機能はCUDAでしかアクセスできない
結果:PyTorch、TensorFlow、JAXはすべてデフォルトでCUDAを使用する。AMDのROCmやIntelのoneAPIが追いつこうとしているが、まだ大きな差がある。
9. 実践的最適化:H100でGEMMの最大性能を引き出す
H100の理論上のFP16 Tensor Core性能は989 TFLOPSだ。この数値に近づくためには:
import torch
import time
# 1. Tensor Coreを有効化するためFP16またはBF16を使用
A = torch.randn(8192, 8192, device='cuda', dtype=torch.bfloat16)
B = torch.randn(8192, 8192, device='cuda', dtype=torch.bfloat16)
# 2. ウォームアップ:最初の実行にはカーネルのコンパイル/選択に時間がかかる
for _ in range(5):
_ = torch.matmul(A, B)
torch.cuda.synchronize()
# 3. 計測
start = time.perf_counter()
for _ in range(100):
C = torch.matmul(A, B)
torch.cuda.synchronize()
end = time.perf_counter()
# 4. TFLOPSを計算
# 8192x8192の行列乗算:2 * N^3 = 2 * 8192^3 = 1.1e12 FLOP
flops = 2 * 8192**3 * 100
elapsed = end - start
tflops = flops / elapsed / 1e12
print(f"達成性能:{tflops:.1f} TFLOPS")
# 目標:700+ TFLOPS(理論値989の70%以上が達成可能)
性能を左右する要因:
- メモリアライメント:行列は最適なメモリコアレッシングのために128バイト境界に整列している必要がある
- 行列サイズ:Tensor Coreがパディングなしで動作するために16の倍数でなければならない
torch.backends.cudnn.benchmark = True:cuDNNが初回実行時に最適なアルゴリズムを自動選択- NVLink:マルチGPU環境でのGPU間通信帯域幅(H100 NVLink = 900 GB/s)
10. まとめ:なぜGPUがAIを支配するのか
GPUがAI計算を支配している理由は単に「コアが多い」からではない。3つの要素の組み合わせだ:
- ハードウェア設計:数千の単純なコア + HBM高帯域幅メモリ + 行列乗算専用ハードウェアであるTensor Core
- プログラミングモデル:CUDAのSIMTモデル + 階層的メモリ(共有メモリ、レジスタ) + 細粒度の制御
- エコシステム:18年間に蓄積されたcuDNN、cuBLAS、NCCL、Nsight、PyTorch/TF統合
深層学習演算の95%は行列乗算に帰着し、H100のTensor Coreはまさにそのために完全に最適化されたハードウェアだ。これこそがNVIDIAがAI時代のインフラの要を握っている理由だ。
次の記事では、cuDNNが畳み込みを内部でどのように処理するか、WinogradアルゴリズムとIm2col変換、FlashAttentionのタイリングトリックを解剖する。