RustではじめるCPUとGPU

カーネル最適化の体系

この章でわかること:

  • GPUで「全要素を1つの値に集約する」計算(reduction)の実装の定石3段階
  • atomic競合、バリアのコスト、スレッド粗粒度化というカーネル設計の基本概念
  • 教科書の定石が実測で覆される例と、その解釈
  • subgroup命令という次の選択肢
  • なぜ体系に必須か: 12章の行列積は「要素ごとに 独立」な計算でした。もう1つの基本形である「全体を1つの値に集約する」 計算を書けなければ、GPUカーネルの基本形の半分しか扱えません

この章の実験はリポジトリのexamples/ch23-reductionです。

cd examples
cargo run --release -p ch23-reduction

計測環境は筆者のMac(Apple M4)です。共有環境ではないものの 実行ごとに数十%のばらつきがあるため、概数で示します。

題材: reduction

reduction(リダクション、集約)は、配列全体を1つの値に集約する 計算の総称です。総和、最大値、平均は、どれも同じ形をしています。 CPUならiter().sum()の一行ですが(4章で見たとおり、 そこでもSIMDとILPが使われています)、GPUでは 数百万のスレッドの結果を1つに集めるという、9章のモデルには そのまま当てはまらない問題になります。各要素の計算は独立に 並列化できますが、「集める」処理は本質的に共有と同期 (5章)を必要とするからです。

題材は1677万要素(64MB)のu32の総和です。CPU(1コア)の実測は 約2〜3ミリ秒で、自動ベクトル化された実装が比較の基準です。

v1: 全スレッドがatomicに加算する

最も単純な設計では、スレッド1本が1要素を担当し、グローバルな 合計変数にatomicAddします。

@compute @workgroup_size(256)
fn reduce_atomic(...) {
    let i = /* 自分の担当添字 */;
    if (i < arrayLength(&input)) {
        atomicAdd(&result, input[i]);
    }
}

1677万回のatomic操作が1つの変数に集中します。 17章で見たRMW操作の競合の、 1677万スレッド版です。古典的なGPUの教科書では 「絶対にやってはいけない設計」の筆頭に挙げられます。

実測は約2〜4ミリ秒で、CPU1コアとほぼ同等です。 理由は後で考察します。

v2: 共有メモリの木で集約する

教科書の定石では、ワークグループ(256スレッド)内で 共有メモリ(10章)に値を置き、 「256→128→64→…→1」と半分ずつ足し合わせる木(ツリー)で集約し、 グループの代表1本だけがatomicAddします。atomic操作は 65,536回(グループ数)まで減ります。

var<workgroup> partial: array<u32, 256>;

@compute @workgroup_size(256)
fn reduce_shared(...) {
    partial[lid.x] = /* 担当要素 */;
    workgroupBarrier();
    var stride = 128u;
    while (stride > 0u) {
        if (lid.x < stride) {
            partial[lid.x] += partial[lid.x + stride];
        }
        workgroupBarrier(); // 段ごとに全スレッドを同期する
        stride = stride / 2u;
    }
    if (lid.x == 0u) { atomicAdd(&result, partial[0]); }
}

実測は約5〜15ミリ秒で、どの実行でもv1より2倍以上遅くなりました

定石と逆の結果です。このカーネルは 1スレッドが1要素しか処理しないのに、8段の木の各段で workgroupBarrier()を実行します。バリアは「グループ内の全スレッドが 到達するまで待つ」操作なので、ワープ(9章)の 実行がそのたびに止まります。処理(ロード1回)に対して 同期(バリア8回)が多すぎます12章で見た 「1スレッドあたりの処理量が少なすぎる」問題の同期版です。

v3: レジスタで部分和を取ってから集約する

対策は12章と同じで、スレッドの粗粒度化(thread coarsening)です。 起動するスレッドを26万本に減らし、1スレッドがまず64要素を レジスタ上で足します(同期もatomicも不要な、純粋な逐次加算)。 そのあとで初めて共有メモリの木とatomicを使います。

var sum = 0u;
var i = /* 自分の開始位置 */;
while (i < n) {
    sum += input[i];
    i += 総スレッド数; // グリッドストライドループ
}
// ここまでで加算の 98% が終わっている。残りだけツリーで集約する

添字を「総スレッド数」ずつ進めるグリッドストライドループ (grid-stride loop)にしているのは、隣のスレッドが常に隣の要素を 読む形、つまりコアレッシング(10章)を 保つためです。

実測は約1〜2ミリ秒(最速0.98ミリ秒)で、v1の約3倍、v2の約8倍、 CPU1コアの2倍前後の速さです。64MBを約1ミリ秒で処理しており、 実効帯域は毎秒50〜65GBです。ここで初めてメモリ帯域律速 (10章のルーフラインの帯域側の上限)に 達しました。総和の算術強度は ベクトル加算並みに低いので、これがこの問題の上限近くです。

考察: 定石はハードウェアのコスト比に依存する

この実験で最も重要な点は、「v1が遅くない」「v2が遅い」という結果が 古典的なGPUの常識の逆だという事実であり、順位そのものではありません。

古典的な常識は「グローバルatomicの衝突は数百倍の直列化を生む」 「共有メモリの木こそ正解」でした。それはNVIDIAの古い世代などで 実測された事実です。一方、筆者のApple M4では、同一アドレスへの atomic加算をハードウェアが極めて効率よく併合するらしく (毎秒数十億回を処理しています)、むしろバリアの相対コストが 目立ちました。

つまり、カーネル最適化の定石は、特定ハードウェアの コスト比を前提にしています。移植先ではコスト比が変わり、 順位が入れ替わります。それでもv3が最速だったことに注目してください。 「1スレッドの処理量を増やし、同期と共有を減らす」方向は、 多くのコスト比で成立する、最も頑健な定石です(それでも万能では なく、粗粒度化しすぎると並列度と占有率が下がります。 グループ数を変えて計測し、最適点を決めます)。 検証の方法は実測だけです(26章で GPU側の正確な計測法を導入します)。

subgroup命令

ワープ(9章)の中は元々ロックステップ なので、ワープ内の集約はバリアなしの専用命令1つでできます。 WGSLではenable subgroups;を宣言するとsubgroupAddなどが 使え、共有メモリの木をほぼ置き換えられます。筆者が wgpu 30.0.0で試したところ、この機能のMetal向け変換が未実装の 経路に該当したため、本書では計測を見送りました。wgpuの subgroup対応は執筆時点で開発途上です。CUDAでは同じ概念が warp shuffle(__shfl_down_sync)として長く定番です。

まとめ

  • reductionは「並列に計算し、同期して集める」というGPUの もう1つの基本形です
  • 3つの実装の実測(M4)では、全atomicが約2〜4ms、共有メモリの木が 約5〜15ms、粗粒度化+木が約1〜2msでした。教科書の定石(v2)が 最下位という結果です
  • 1スレッドの処理量が少ないと、バリアと同期のコストが支配的になります。 レジスタで部分和を取ってから集約する粗粒度化が、最も頑健な定石です
  • 定石はハードウェアのコスト比に依存します。atomicが速いGPUも、 バリアのコストが大きいGPUもあります。移植したら測り直してください

次章は、カーネルの外側にあるCPUとGPUの間の転送と同期を 最適化します。11章で見た往復のオーバーヘッドを、 設計でどこまで隠蔽できるかを実測します。