GPUのメモリと転送
この章でわかること:
- GPUのメモリ階層(レジスタ、共有メモリ、VRAM)とCPUとの違い
- メモリコアレッシング: ワープ単位でのメモリアクセスの束ね方
- CPUとGPUの間のデータ転送コストと、ユニファイドメモリ
- 算術強度とルーフラインモデル: 処理の性能上限を実装前に見積もる方法
GPUのメモリ階層
GPUにもメモリの階層がありますが、CPUとは設計方針が異なります。 次の表と図に全体像を示します。
| 記憶場所 | 共有範囲 | 容量の目安 | 特徴 |
|---|---|---|---|
| レジスタ | スレッドごと | 数十KB/実行単位 | 常駐全スレッド分を物理保持(9章) |
| 共有メモリ | ワークグループ内 | 数十〜百KB/実行単位 | プログラマが明示的に使う |
| L2キャッシュ | GPU全体 | 数MB〜数十MB | 自動 |
| VRAM | GPU全体 | 数GB〜数十GB | 帯域数百GB/s〜、レイテンシ数百サイクル |
GPU専用のメモリであるVRAM(video RAM)には、GDDRやHBMといった 広帯域のメモリが使われます。帯域はCPUのDRAMの数倍から数十倍 (数百GB/s〜数TB/s)ありますが、レイテンシはCPUのDRAMと同等か、 それ以上に遅い点が重要です。前章のとおり、GPUはレイテンシを ワープの切り替えで隠す設計なので、メモリも帯域を最優先にしています。
コアレッシング: ワープ単位の連続アクセス
2章で「メモリはキャッシュライン単位で転送されるから、 連続アクセスが速い」と学びました。GPUには同じ原則の ワープ版があります。
ワープの32スレッドは同じロード命令を同時に実行します。 このとき32個のアドレスが隣接していれば、ハードウェアは それらを少数のまとまった転送に束ねます。これを メモリコアレッシング(memory coalescing、合流)と呼びます。 アドレスがばらばらだと、転送は束ねられず何倍にも増えます。
つまりGPUで速いアクセスパターンは
「隣のスレッドが、隣のデータを読む」形です。
スレッドiがdata[i]を読むのが理想で、
スレッドiがdata[i * stride]を読む形は遅くなります。
2章のSoA(struct of arrays)は、ここで大きな効果を持ちます。 AoSでは「全スレッドが同じフィールドを読む」とき、アドレスが 構造体サイズの間隔で離れます。SoAならフィールドごとに 連続配列なので、完全にコアレッシングされます。 GPU向けのデータ設計でSoAが基本とされるのはこのためです。
共有メモリ: プログラマが管理するキャッシュ
前章の表にあった共有メモリ(shared memory、WGSLでは workgroup memory)は、ワークグループ内のスレッドだけが アクセスできる、小容量で高速なメモリです。CPUのL1キャッシュに 近い速度ですが、決定的な違いがあります。CPUのキャッシュは ハードウェアが自動で管理するのに対し、共有メモリはプログラマが 明示的に読み書きすることです。
構文は11章で導入するWGSLのものですが、使い方だけ先に説明します。
var<workgroup>で宣言し、「VRAMから共有メモリにタイルをコピーし、
グループ内で繰り返し読み、次のタイルに進む」という使い方をします。
グループ内の全スレッドの進行を揃えるにはバリア
(workgroupBarrier()。全スレッドがこの行に到達するまで待つ同期点)を
挟みます。バリアは全スレッドが必ず実行する位置に置く必要があり、
ifの片側にだけ書くことはできません。
12章の行列積で実例を示します。
CPUとGPUの間の転送
ここまでのメモリはGPUの内部の話でした。しかしデータは元々 CPU側のメインメモリにあります。CPUとGPUの間の転送が、 GPU利用の最大の制約要因です。
一般的なPCでは、GPUはPCIeというバスでCPUと接続されています。 PCIe 4.0 x16の帯域は約32GB/sです。VRAM内部の数百GB/sと比べて 1桁以上小さい帯域です。データを送り、計算し、結果を戻すという 往復の転送時間は、GPUで短縮した計算時間を上回ってしまう ことが珍しくありません。次章の実測では、100万要素のベクトル加算が CPUの15倍遅い、という結果を示します。
一方、Apple SiliconやゲームコンソールのようにCPUとGPUが 同じメモリを共有する構成もあり、ユニファイドメモリ (unified memory)と呼ばれます。この構成ではPCIe経由の物理的な コピーを省けます(ただしAPI上のバッファ間コピーや同期が すべて消えるわけではありません)。代わりに、メモリ帯域を CPUとGPUで共有することになります。
どちらの構成でも設計指針は同じです。
- 転送の回数と量を最小にします
- 一度GPUに置いたデータには、複数の処理を連続して適用します (毎回CPUに戻しません)
- 計算だけをGPUに移せば速くなる、という想定は転送のコストを 見落としています
算術強度とルーフライン
「この処理はGPUで速くなるか」を、実装前に見積もる方法があります。
まず単位を1つ導入します。FLOP(floating-point operation)は 浮動小数点演算1回のことで、毎秒10億回をGFLOP/s、 毎秒1兆回をTFLOP/sと書きます。
そのうえで、処理の算術強度(arithmetic intensity)を、 「演算の回数 ÷ メモリとやり取りするバイト数」(FLOP/byte)と定義します。
- ベクトル加算(
c[i] = a[i] + b[i]): 要素あたり1演算に対し、 読み出し8バイトと書き込み4バイトが必要です。 算術強度は 1/12 ≈ 0.08 FLOP/byteです - 行列積(n×n): 出力n²個のそれぞれにn回の乗算と加算があるため、 演算は2n³回です。データはn²個の要素を持つ行列3つです。 同じデータを繰り返し読むため、理想的には算術強度はnに比例して 大きくなります
この値と、ハードウェアの「演算ピーク性能」「メモリ帯域」を 1枚のグラフにしたのがルーフラインモデル(roofline model)です。 次の図がその模式図です。
グラフの屋根(roof)は2つの線分からなります。算術強度が低い処理は 左の斜めの線分の下に位置し、この状態をメモリ帯域律速 (memory-bound)と呼びます。演算ユニットをいくら増やしても、 帯域で決まる上限以上には速くなりません。算術強度が高い処理は 右の水平な線分に到達し、この状態を演算律速(compute-bound)と 呼びます。演算律速の処理だけが、GPUの演算能力を使い切れます。
ベクトル加算のような算術強度0.1以下の処理は、GPUで実行しても 帯域の上限で性能が決まります。行列積のように、同じデータを 繰り返し読む(算術強度が高い)処理が、GPUに適した処理です。 12章では、同じ行列積でもカーネルの書き方しだいで 「演算とメモリアクセスの比率」が変わり、性能が変わることを実測します。
実験: 算術強度の違いを観察する
手元にリポジトリがあれば、12章の行列積プログラムで この章の概念を先に観察できます。
cd examples
cargo run --release -p ch12-matmul -- 512
出力のGFLOP/s(素朴なCPU版〜GPU版)を見比べてください。 同じ2n³回の演算でも、メモリの使い方によって 実効性能が2桁変わることがわかります。読み解き方は12章で説明します。
まとめ
- GPUのメモリは帯域優先の設計です。レイテンシはワープ切り替えで隠します
- ワープ内の隣接スレッドが隣接アドレスを読むとき、アクセスは 束ねられます(コアレッシング)。SoAはGPUの基本形です
- 共有メモリはプログラマが明示的に管理するキャッシュで、 バリアで同期しながら使います
- CPU-GPU間の転送(PCIe)は帯域が小さく、転送回数の最小化が 設計の中心です。ユニファイドメモリでは物理的な転送がなくなる 代わりに帯域を共有します
- 算術強度とルーフラインモデルで、性能の上限を実装前に見積もれます
概念の準備は整いました。次章では、Rustからwgpuを使って 実際にGPUでコードを動かします。