その実行は、ステップあたり 0.04 ms で終わった。そのサイズのシミュレーションが本来かかるはずの時間より、およそ 500 倍速い。ヘルスレポートはきれいだった。NaN もなく、無限大もなく、最大の力はゼロで、どの粒子も近傍はゼロだった。きれいだったのは、何も計算されていなかったからだ。
これは protocell-genesis で起きた。粗視化した「原始スープ」が、水を内側に閉じ込めた二重膜、つまり閉じた小胞へと自己集合できるかを試すために作った WebGPU シミュレーションだ。このモデルではできない。その理由は測定して、リポジトリに残してある。最大の実行は 483,268 粒子だった。プロジェクトを通じて、六つのバグが上と同じ振る舞いをした。例外は出ず、ページにもエラーはなく、数値はもっともらしいか、むしろ良く見えた。そのうち三つは WebGPU 特有のものだ。ここでは、それぞれがどう見えたか、そして何を変えたかを示す。
1. デバイスの許容量を超えるバッファ
ボックスサイズ 85 σ では、Verlet 近傍リストに 5,484,474,528 バイトが必要だった。デバイスの maxStorageBufferBindingSize は 4,294,967,292 だった。
WebGPU は、createBuffer がバリデーションに失敗しても例外を投げない。コンソールに警告を出して無効なバッファを返し、そのバッファを使うバインドグループへの以後のディスパッチはすべて破棄される。シミュレーションのループはステップを進め続け、ゼロを読み戻し続けた。max|F| = 0、非有限値 0 個、近傍 0、ステップあたり 0.0405 ms。
修正は、確保の前のチェックだ。createSoup は、粒子数に比例してサイズが増えるすべてのバッファを計算し、デバイス自身の上限と比べる。
const bindingLimit = device.limits.maxStorageBufferBindingSize
const bufferLimit = device.limits.maxBufferSize
for (const [what, bytes] of candidates) {
if (bytes > bindingLimit || bytes > bufferLimit) {
throw new Error(
`buffer ${what} needs ${bytes} bytes at N=${capacityN}; ` +
`maxStorageBufferBindingSize=${bindingLimit}, ` +
`maxBufferSize=${bufferLimit}`,
)
}
}2. COPY_SRC なしでバッファを読み戻す
GPU からデータを読むには、マップ可能なステージングバッファにコピーする必要がある。元のバッファが GPUBufferUsage.COPY_SRC なしで作られていると、そのコピーはバリデーションエラーになる。それでコマンドバッファが無効になり、submit は何もしない。ステージングバッファは作られたばかりで、WebGPU は新しいバッファをゼロで初期化し、mapAsync は普通に解決する。データのように見えるゼロが手に入る。
乱数用の二つのバッファで、このフラグが抜けていた。ディスク上の 72 個のチェックポイントはすべて、乱数の状態をゼロだけで保存していた。そのため、再開したどの実行でも、サーモスタットはすべての粒子に 0 をシードとして与えていた。すべての粒子に同一のランジュバン雑音がかかると、系全体が一つの物体のように漂う。平均二乗変位は七つの種すべてで同じく 62.32 σ² になり、凝集体どうしが互いを見つけるための相対拡散は消えていた。修正後、ステップ 90,000 での最大の凝集体は 268 から 588 に増えた。
usage は GPUBuffer の読み取り可能なプロパティなので、この種のバグ全体はビット演算一回のテストで防げる。
if ((src.usage & GPUBufferUsage.COPY_SRC) === 0) {
throw new Error(
'readBack: the buffer was created without ' +
`GPUBufferUsage.COPY_SRC (usage=${src.usage})`,
)
}3. requiredLimits なしの requestDevice()
requiredLimits を指定せずに要求したデバイスは、アダプタが対応する上限ではなく、デフォルトの上限を受け取る。デフォルトでは、シェーダーステージあたり 8 個のストレージバッファまでしか使えない。結合のカーネルがそれを超え、'auto' レイアウトでのパイプライン作成はコンソールの警告だけを残して失敗し、影響を受けたパスはどれも何もしなかった。結合は一つもできなかった。
サイズの上限も同じように失敗する。ただし、きっかけはコードの変更ではなく粒子数だ。デフォルトの maxStorageBufferBindingSize は 128 MiB。粒子あたり 1,000 の近傍スロットで、リストは 28,400 粒子(113.6 MB)では収まり、36,400 粒子(145.6 MB)では黙って何もしなかった。
今ではデバイスは、アダプタ自身の最大値を要求する。
const { limits } = adapter
const device = await adapter.requestDevice({
requiredLimits: {
maxStorageBuffersPerShaderStage:
limits.maxStorageBuffersPerShaderStage,
maxStorageBufferBindingSize: limits.maxStorageBufferBindingSize,
maxBufferSize: limits.maxBufferSize,
},
})4. 決して発火しない NaN ガード
安全チェックは、粒子が Verlet スキンの半分より遠くまで動いたときに例外を投げるもので、sqrt(maxDriftSq) > skin / 2 を使っていた。NaN との比較はすべて偽になる。位置が NaN になると移動量も NaN になり、チェックは毎ティック通ってしまった。ガードはずっと動いていたのに、それが書かれた目的の失敗を見ることができなかった。
修正では、同じティックで、古い比較の前に、すべての位置の IEEE-754 指数ビットを走査する。コストは、331.68 ms かかる 1,000 ステップのかたまりあたり 0.212 ms で、0.064% だ。静かに発散していた組成では、実行はステップ 1,000 で例外を投げ、60,750 成分のうち 6,957 個が非有限だと示すようになった。
5. ボックスのサイズが変わる前の力
乾燥と再水和はボックスのサイズを変える。コードは座標をスケールし直し、近傍グリッドと Verlet リストを作り直してから、スケール前に計算した力で次のステップを進めていた。結果は間違った数値で、ゼロよりも見つけにくい。
今はテストが両側から固定している。修正ありでは、GPU 上の力のバッファは、力のスケール 513 に対して 1.5e-4 以内で新しい計算と一致する。修正なしでは、バッファは古い力とビット単位で等しく、313.7 ずれる。
これで、すでに書き上げていた結果が動いた。ボックス 30 では、力が正しくなると最大の凝集体は 2.28 倍小さくなり(各側で三回ずつ実行し、範囲は重ならない)、乾燥サイクルについて報告していた効果は 12.5–14.5× から 8.1–8.4× に縮んだ。216 回中 9 回発火していたガードは、修正後 36 回中 0 回になった。
6. 入力を名前で識別していたゲート
これは GPU とは関係ない。測定値を合格か不合格かの判定に変えるパーコレーションのゲートは、コードに書き込まれたキャンペーンラベル zfB54 で入力を見つけていた。別のラベルを持つ最初のキャンペーンで、ゲートはエラーもなく「未証明」に切り替わった。今では行が role フィールドを持ち、ラベルは予備の手段にすぎない。
入力を名前で識別するパイプラインは、誰かが新しい実験を走らせたとき、つまりその判定が一番重要なときに黙り込む。
七つ目:計測器がゼロを読んでいた
ページが描画しているかを確かめるテストは、WebGPU のキャンバスを drawImage で 2D キャンバスに読み込んでいた。そのテストは、661 インスタンスで 119 フレームを描いたばかりのページで、30,000 ピクセル中 0 ピクセルが変化したと報告した。今のテストは、シーン自身のインスタンスカウンターと本物の page.screenshot() を使う。
いま失敗として扱うもの
力がゼロ、近傍がゼロ、結合がゼロ。これらは、物理がそれを生み出せると何かが示すまでは失敗だ。誰も施していない高速化も同じだ。一度も発火したことのないガードも、わざと発火させて確かめるまでは同じ扱いだ。
WebGPU はバリデーションエラーをコンソールとデバイスの uncapturederror イベントに報告し、エラースコープ(device.pushErrorScope('validation'))を使えば、ひとまとまりの処理の前後でそれを待てる。このプロジェクトでは、例外を投げ、バッファ名とバイト数を示す明示的なチェックを使っている。どちらを選ぶにせよ、デフォルトではコンソールの警告と、問題なさそうに見える結果が返ってくる。
コード、ゲートのレポート、判定の全文はリポジトリにあり、スナップショットギャラリーは、実際のチェックポイントを最新のブラウザで描画する。