01協調計算は何が違うのか
#1(独立並列)と#2(行列演算)では、各スレッドが自分の出力を計算して書き込めば終わりだった。スレッド同士は互いを気にしない。だが協調計算は違う。全スレッドの結果を1つに集め、それを全員に配り直し、次のステップに進む。
協調計算:各スレッドが注文 → 全部を集約 → 配布 → 次ステップ(反復)
今回の市場モデルでは、各エージェント(スレッド)が「買いたい/売りたい量」を出し、その合計(超過需要)から価格が決まる。価格が動けば全エージェントが新価格を見て次の判断をする。この「集約して配る」を時間ステップ分くり返すのが、協調計算の実装の本質だ。難所は2つ——どうやって全スレッドの値を集約するかと、どうやってステップ間で同期するかである。
02全体構造 — 2カーネル × ステップ反復
1ステップを2つのコンピュートシェーダー(カーネル)に分けた。
カーネル①はN体ぶんのスレッドで並列に走り、各エージェントの注文を計算してアトミックに加算する。カーネル②はたった1スレッドで、集まった超過需要から新価格を計算する。この非対称さ(大量並列→1スレッド)も協調計算の特徴だ。
03需要計算カーネル — 各エージェントの注文
カーネル①では、各スレッド(=1エージェント)が自分のタイプに応じて注文量 d を計算する。
価格履歴 LOGP は全スレッドが読む共有データだ。現在価格とkステップ前の価格からトレンドを計算し、タイプ別に注文を出す。ファンダは適正値 logF との差で逆張り、トレンドは trend で順張り、ノイズは乱数。最後の atomicAdd が集約の入口だ。
STATE[i] を更新しながら一様乱数を作り、Box-Muller法で正規乱数に変換している。
04集約の核心 — アトミック加算でfloatを集める
協調計算の最大の難所がこれだ。N体すべての注文 d を、1つの超過需要に合計する。何千ものスレッドが同時に同じ変数へ足し込むので、単純な DEMAND = DEMAND + d では競合(レースコンディション)が起きて値が壊れる。
解決策がアトミック操作だ。atomicAdd は「読んで足して書く」を不可分(atomic)に行い、複数スレッドが同時に呼んでも正しく合計される。だが問題がある——WGSLのアトミックは整数(i32/u32)専用で、floatには使えない。注文量 d は小数なのに。
注文量を ×1,000,000 して整数に丸め、その整数をアトミック加算する。集計後に ÷1,000,000 して元のスケールに戻す。固定小数点のようなテクニックだ。d が 0.000003 でも、×1e6 すれば 3 という整数になり、アトミック加算できる。これは#1で扱った「アトミックu32でカウントする」手法の応用で、カウントから加重和へ拡張したものだ。
05価格更新カーネル — 1スレッドで集計結果を反映
集約が終われば、価格更新は単純だ。超過需要から新価格を計算する。これは1スレッドだけで行う(更新する値は1つだから並列化の必要がない)。
atomicLoad で集計値を読み、SCALEで割って元に戻す。超過需要をN(エージェント数)で正規化し、価格感応度 lambda を掛けて価格変化 dlog を得る。clamp は1ステップの変化を±0.2に制限するもので、フィードバックループの暴走(価格が無限大やゼロに飛ぶ)を防ぐ安全弁だ。最後に次ステップの対数価格を書き込む。対数で扱うのは、価格が必ず正に保たれ、変化率が対称になるからだ。
06ステップ反復 — JS側の同期ループ
ここが#1#2と決定的に違う。これまでは「1回ディスパッチして終わり」だったが、協調計算ではJS側でステップ数だけループを回し、毎回ディスパッチする。
毎ステップ、現在のステップ番号をuniformに書き、アトミック変数をクリアし、2つのカーネルを順に実行する。同じコマンドエンコーダ内でカーネル①→②を並べることで、①の全スレッドが終わってから②が走る(GPU内で順序が保証される)。500ステップなら、このループが500回回る。
writeBuffer(CPU→GPU転送)と submit(GPUへの命令発行)が入り、CPUとGPUが500回やり取りする。1回あたりのオーバーヘッドは小さくても、500回積み重なると無視できない。これが結果編で見た「GPU時間が同期回数で決まる」「デバイスによって速度が大きく違う」の正体だ。
07なぜ同期コストが効くのか
結果編のベンチマークで、協調計算は独立並列・行列演算と違う振る舞いを見せた。エージェント数を増やしてもGPU時間がほぼ一定で、しかもデバイスによって速度が桁違いに違った。実装を見れば、その理由がわかる。
| 計算タイプ | ディスパッチ | 律速 |
|---|---|---|
| #1 独立並列 | 1回 | 試行数(並列度) |
| #2 行列演算 | 1回 | 行列サイズ(並列度) |
| #3 協調計算 | 500回(ステップ数) | 同期回数 |
#1#2は1回のディスパッチで計算が完結する。だから「どれだけ並列に処理できるか」が性能を決め、最適化(タイル化など)はGPUを問わず通用した。一方、協調計算は500回のディスパッチと同期を伴う。1ステップの計算量は小さく(エージェントが増えてもGPUは並列に吸収する)、支配的なのはCPU-GPU間を500回往復するオーバーヘッドだ。
このオーバーヘッドは、GPUアーキテクチャに強く依存する。Apple GPU(iPad)はCPUとGPUがメモリを共有する設計で、往復が軽い。一方、別アーキテクチャのGPUでは、同期のたびのコストが積み上がる。だから同じコードが、iPadで8倍速、別のデバイスでほぼ等速という結果になった。計算の中身ではなく、同期の実装とハードウェアが性能を決めるのだ。
workgroupBarrierでGPU内同期する」といった手法がある。ただし後者はエージェント数がワークグループ上限(1024)に制限される。汎用性(エージェント数を増やせる)と速度(同期を減らす)のトレードオフ——協調計算の設計は、この綱引きになる。
以上が、エージェント市場のバブルを生んだ協調計算の実装だ。要点をまとめると——スレッド間の集約はアトミック加算(floatは整数化して対応)、ステップ間の同期はJS側の反復ディスパッチ。そして、この反復同期こそが性能とデバイス依存性の源泉だった。#1の独立並列が「並列度がすべて」だったのに対し、協調計算は「いかに同期を減らすか」が腕の見せ所になる。計算の質が変われば、最適化の発想もまるごと変わる——それがこのシリーズで一貫して見えてきたことだ。
※ 初回はシェーダーのコンパイル時間を含み遅めに出ることがあります。2回目以降が実力値に近くなります。小さいエージェント数ではCPUが速く、大きくなるとGPUが有利に——そのクロスオーバーがどこに来るかは、デバイス次第です。