Skip to content
Published on

NVIDIA GPUとCUDAの完全解剖:なぜGPUがAIを支配するのか

Authors

なぜすべての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 0Core 1Core 2Core 3(高性能) (高性能) (高性能) (高性能)OOO実行 │ OOO実行 │ OOO実行 │ OOO実行  │
├─────────┴─────────┴─────────┴────────────┤
L3キャッシュ(32MB以上)          │
├────────────────────────────────────────────┤
│       メインメモリ(DDR5,50 GB/s)       │
└────────────────────────────────────────────┘

GPUアーキテクチャ(スループット最適化)— H100:
┌──┬──┬──┬──┬──┬──┬──┬──┬──┬──┬──┐
SMSMSMSMSMSMSMSMSMSM...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 Core4個(第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が共有                               │
├─────────────────────────────────────────────────────┤
│  グローバルメモリ HBM3H100では80GB)              │
│  レイテンシ:約700サイクル  帯域幅:3.35 TB/s        │
│  スコープ:デバイス上のすべてのスレッド              │
└─────────────────────────────────────────────────────┘
メモリ種別レイテンシ帯域幅サイズスコープ
レジスタ約1サイクル膨大256KB/SM単一スレッド
共有メモリ約32サイクル約19 TB/s228KB/SMThread Block
L2キャッシュ約200サイクル約12 TB/s50MBGPU全体
HBM3(グローバル)約700サイクル3.35 TB/s80GBGPU全体

共有メモリはプログラマが直接管理するキャッシュだ。 __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:
┌──┬──┬──┬──┐  ┌──┬──┬──┬──┐
T0T1T2T3│  │T0T1T2T3├──┼──┼──┼──┤  ├──┼──┼──┼──┤
│  │  │  │  │  │  │  │  │  │
└──┴──┴──┴──┘  └──┴──┴──┴──┘

各Thread BlockはC16×16出力タイルを1つ担当。
Aの行タイルとBの列タイルを順番に共有メモリにロードし、
計算後に次のタイルへ進む → グローバルメモリ転送量をTILE_SIZE倍削減。

6. Tensor Core:行列乗算専用ハードウェア

CUDAコアがスカラーのFP32演算を1サイクルに1つ処理するのに対し、Tensor Coreは16×16の行列全体を単一命令で処理する。

第4世代Tensor Core(H100):

演算Tensor CoreCUDAコア速度比
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が勝った理由:

  1. 開発者ツール:cuDNN、cuBLAS、Nsightプロファイラ — NVIDIAのエコシステムが圧倒的に豊富
  2. 先行者利益:研究者がCUDAコードを共有したことで論文コードが全てCUDAベースになった
  3. ハードウェア独占性: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%以上が達成可能)

性能を左右する要因:

  1. メモリアライメント:行列は最適なメモリコアレッシングのために128バイト境界に整列している必要がある
  2. 行列サイズ:Tensor Coreがパディングなしで動作するために16の倍数でなければならない
  3. torch.backends.cudnn.benchmark = True:cuDNNが初回実行時に最適なアルゴリズムを自動選択
  4. NVLink:マルチGPU環境でのGPU間通信帯域幅(H100 NVLink = 900 GB/s)

10. まとめ:なぜGPUがAIを支配するのか

GPUがAI計算を支配している理由は単に「コアが多い」からではない。3つの要素の組み合わせだ:

  1. ハードウェア設計:数千の単純なコア + HBM高帯域幅メモリ + 行列乗算専用ハードウェアであるTensor Core
  2. プログラミングモデル:CUDAのSIMTモデル + 階層的メモリ(共有メモリ、レジスタ) + 細粒度の制御
  3. エコシステム:18年間に蓄積されたcuDNN、cuBLAS、NCCL、Nsight、PyTorch/TF統合

深層学習演算の95%は行列乗算に帰着し、H100のTensor Coreはまさにそのために完全に最適化されたハードウェアだ。これこそがNVIDIAがAI時代のインフラの要を握っている理由だ。

次の記事では、cuDNNが畳み込みを内部でどのように処理するか、WinogradアルゴリズムとIm2col変換、FlashAttentionのタイリングトリックを解剖する。