05
動作原理:並列リダクション
基本編では各パスのペイオフをバッファに書き出し、CPU側で合計していました。
しかしパスが数百万になると、この「GPUからCPUへの全データ転送 + CPU集計」がボトルネックになります。
そこで合計をGPU上で求めるのが並列リダクションです。
3141
5926
↓ 隣接ペアを加算(並列)
45148
····
↓ さらに加算
922
··
↓
31
8要素の合計を log₂(8)=3 ステップで計算。要素が増えても段数は対数的にしか増えない。
方式 A
ナイーブ(CPU集計)
GPUで全ペイオフを計算 → 全データをCPUに転送 → CPUでforループ合計。基本編の方式。転送量が大きいと遅い。
方式 B
並列リダクション
各ワークグループ内で部分和を計算し、atomicAdd で全体の合計に足し込む。CPUに戻すのは「合計値1つだけ」。転送がほぼゼロ。
鍵となる技術
workgroup 共有メモリ
ワークグループ内のスレッドが共有メモリで部分和を集計。最後に1スレッドが atomicAdd で全体に反映する。
06
コードのポイント解説
並列リダクションの実装を中心に解説します。基本編の乱数生成・株価計算はそのまま使います。
workgroup 共有メモリでの部分和
// ワークグループ内で共有するメモリ(64スレッド分)
var<workgroup> partialSum: array<f32, 64>;
// 各ワークグループの部分和を書き出す配列(要素数=ワークグループ数)
@group(0) @binding(0) var<storage, read_write> partials: array<f32>;
@compute @workgroup_size(64)
fn main(@builtin(global_invocation_id) gid: vec3u,
@builtin(local_invocation_id) lid: vec3u,
@builtin(workgroup_id) wid: vec3u,
@builtin(num_workgroups) nwg: vec3u) {
let totalThreads = nwg.x * 64u;
// ① このスレッドが担当する全パスのペイオフを合計(grid-stride)
var localSum = 0.0;
var i = gid.x;
loop {
if (i >= p.numPaths) { break; }
localSum = localSum + computePayoff(i); // 乱数→株価→ペイオフ
i = i + totalThreads;
}
partialSum[lid.x] = localSum;
各スレッドはまず自分の担当パスのペイオフを localSum に集めます。
grid-stride で複数パスを担当するので、ここで既にかなりのデータが圧縮されます。
ツリー状の集計とバリア同期
// ② ワークグループ内でツリー状に合計(64→32→16→...→1)
workgroupBarrier();
var stride = 32u;
loop {
if (stride == 0u) { break; }
if (lid.x < stride) {
partialSum[lid.x] = partialSum[lid.x] + partialSum[lid.x + stride];
}
workgroupBarrier(); // 各段で全スレッドの完了を待つ
stride = stride / 2u;
}
// ③ 代表スレッド(lid.x==0)が全体合計に加算
if (lid.x == 0u) {
// f32 を u32 にスケール変換して atomicAdd(後述のハマりどころ)
atomicAdd(&globalSum, u32(partialSum[0] * 1000.0));
}
}
ハイライト部分がツリー状リダクションの核心です。
stride を半分ずつ減らしながら、各段で隣接要素を加算します。
workgroupBarrier() で全スレッドの足並みを揃えないと、
まだ書かれていない値を読んでしまい結果が壊れます。
ワークグループ部分和の書き出し(atomics回避)
// ③ 代表スレッドが「このワークグループの部分和」を配列に書き出す
if (lid.x == 0u) {
partials[wid.x] = partialSum[0]; // atomic不要・f32精度を維持
}
}
// CPU側: 部分和の配列を合計(要素数 = ワークグループ数 ≤ 65535)
let sum = 0;
for (let i = 0; i < numWG; i++) sum += partials[i];
const price = (sum / numPaths) * Math.exp(-r * t);
当初は atomicAdd で1つの合計に足し込む設計でしたが、
WGSLのatomicは整数型のみで、f32を固定小数点化すると大パス数で
u32オーバーフローが起きます(平均50・2000万パスで合計1e12 ≫ 43億)。
そこで各ワークグループの部分和を配列の別スロットに書き出し、
CPU側で合計する方式にしました。書き出すのは最大65,535個だけなので、
CPU合計も一瞬です。GPUで大部分を圧縮し、最後の僅かな集計だけCPUが担う実用的な折衷案です。
07
ハマりどころまとめ
PITFALL 01
f32 の atomic 不可
WGSLのatomicは整数型のみ。f32合計をatomicでやろうと固定小数点化すると、大パス数でu32オーバーフローします。部分和を配列に書き出す方式が安全です。
PITFALL 02
workgroupBarrier 忘れ
リダクションの各段でバリアを忘れると、未書き込みの値を読んで結果が壊れます。エラーは出ません。
PITFALL 03
計測の同期
submit() 直後の時刻はコマンド送信時刻。mapAsync または onSubmittedWorkDone を await して実際の完了を待ちます。
PITFALL 04
ワークグループ数の上限
dispatchWorkgroups は最大65,535。これを超えるパス数は grid-stride で1スレッドが複数パスを担当して対応します(基本編参照)。
PITFALL 05
ウォームアップ
初回実行はシェーダコンパイルを含むため遅い。複数回実行の中央値/最小値で評価します。
PITFALL 06
CPUの巨大パス
CPUは1000万パスで数秒かかり、UIが固まります。大パス数ではCPUを自動スキップする配慮が必要です。
⚠️ 免責事項
本デモは WebGPU とモンテカルロ法の学習・技術実験を目的としたものです。
計測値はデバイス・ブラウザ・負荷状況により変動します。
価格モデルは理論上の仮定に基づく簡略化されたものであり、
実際の投資判断・オプション取引の根拠として使用しないでください。
NEXT — シリーズ第3回
③ 応用編:エキゾチック・オプション — 解析解のない世界へ
→