自分をだまさずに 2D レンダラーをベンチマークする
レンダラーのベンチマークは簡単に偏ります。しかも、たいていは誰も意図していません。あるライブラリは遅いデフォルト設定のまま放置され、あるシーンはたまたま特定の設計に有利で、vsync が本当のコストを隠し、たった1回の運のいい実行から数字が出てくる。結果は厳密そうに見えても、ほとんど何も意味していない、ということがよくあります。
私たちは spryte を作っています。だから自分たちに都合のいいベンチマークを欲しがる理由はいくらでもありますし、自分たちで書いたベンチマークを疑う理由もいくらでもあります。2dbench は、偏らせるのを難しく、検証を簡単にしようという試みです。この記事では、そのルールとそれぞれが存在する理由、そしてあなた自身の計測に持ち帰れることを順に紹介します。詳しい根拠は METHOD.md にまとめてあります。
1. 仕組みとして処理内容を同一にする
ベンチマークが最初に失敗するのは、ライブラリごとに同じ処理をしていないときです。あるアダプターはオブジェクトの更新方法が違い、別のアダプターはプロパティを1つ飛ばしている。そうなると、レンダラーではなくセットアップの差を計測することになります。
2dbench では、シーンがシード付きのシミュレーションを持っています。シーンは毎フレーム、structure-of-arrays 形式の状態を書き込みます。x、y、scale、rotation、color(0xRRGGBBAA)、texture です。アダプターはこの配列を描画呼び出しに変換するだけで、シミュレーションは一切しません。
これには3つの効果があります。
- シミュレーションのコストは全員同じです。実際のゲームと同じく、フレーム時間には含まれます。
- アダプターはシミュレーションの中身を知っていることに頼れません。フレームごとの配列を読むだけで、シーンを事前計算できません。
- 複雑なシーンでも、ゲームロジックはすべて共通コードに置かれます。
raycasterシーンでは、シミュレーション側がレイを飛ばし、敵の陰影付けと深度クリップを行い、アダプターは結果のスライスを描くだけです。
それ以外の条件も固定です。同じテクスチャ、デバイスピクセル比 1 の 960×540 キャンバス、ニアレストフィルタリング、アンチエイリアスなし、回転なしのスプライトは整数ピクセル上に配置。どのデバイスでも描画するピクセル数は同じです。
2. 動的なシーンは本当に動的にする
ベンチマークの初期バージョンには欠陥がありました。シーンがアニメーションすると称していたプロパティのいくつかが、実際にはあまり頻繁に変化していなかったのです。変化していない処理を省くライブラリなら、動かない値をキャッシュするだけで勝ててしまいます。それ自体は本物の最適化ですが、そのシーンが計測しようとしていたものではありません。
現在のルールはエージェントガイドにある通りです。シーンが宣言したプロパティは実行中ずっと変化し続けなければならず、変化のない処理を省くことでは勝てないようにします。動的なシーンでは、回転は毎フレーム、アルファは 2 フレームごと、スケールは 4 フレームごと、色合いは 8 フレームごとに変わります(METHOD.md)。アダプターはシーンが使うプロパティだけを設定し、プロパティを静的と指定できる一括 API は、シーンが実際に変える内容に合わせて設定します。
位置だけの描画を計測したいシーンなら、そう名乗ればいいのです(2dbench の bunny がまさにそれです)。スプライトごとの色合いとアルファを計測したいなら、その値は動いていなければなりません。
3. 計測する前に出力を確認する
最速のレンダラーは、何も描かないレンダラーです。スプライトの欠落、間違ったブレンドモード、数が多いときだけ働くカリング。どれもライブラリを速く見せてしまいます。そこで、ピクセル一致ゲートを通過するまでは何も計測しません。
計測の前に、各アダプターは同じシードのフレーム(10 ステップ目)を 2,000 と 50,000 スプライトで描画します。そのピクセルを基準ライブラリのものと比較し、2つの数値で合否を決めます。
| 指標 | 内容 | 検出するもの | 不合格の条件 |
|---|---|---|---|
| カバレッジ | アダプターの描画面積を基準の描画面積で割ったもの | 省略・カリング・欠落したスプライト | 99.5〜100.5% の範囲外 |
| 内部の差異 | いずれかのチャンネルで 8 レベルを超えて異なるピクセルのうち、両方のフレームで 3×3 の近傍が単色のもの | スプライト内部の色合い・アルファ・ブレンド・テクスチャの誤り | 0.5% を超える |
大事なのは単色近傍のルールです。回転したスプライトの角の丸め方は、ライブラリごとに正当な理由で異なります。たとえば PixiJS の roundPixels は回転した角をスナップします。エッジがあれば、たとえスプライトの上に別のスプライトが重なっているだけでも単色ではなくなるので、エッジの丸め方は誰の不利にもなりません。一方で、スプライトの真ん中の色が違えば不利になります。
この指標は、あるライブラリにわざと間違った色合いを与えて検証しました。内部の差異は 0% から 22% に跳ね上がりました。失敗するところを見たことのないチェックは、まだチェックとは呼べません。
透明性のため、全体の差異ピクセルも報告しています。公開しているデスクトップの結果では、カバレッジは 99.8〜100%、スプライト内部で異なるピクセルは 0% でした。回転のあるシーンでは、描画ピクセルの 8〜13% がスプライトのエッジで異なっていました。
基準の選び方
基準は PixiJS Sprite です。最も広く使われている 2D Web レンダラーで、すべてのシーンに対応しており、しかも私たちが作ったものではありません。
最初からこれだったわけではありません。以前のバージョンでは、手書きの WebGL2 実装を基準にしていました。それをやめたのは、spryte 自身の設計に近すぎたからです。審判としては独立しておらず、出場者としては新しい情報を何も加えませんでした。基準がある出場者と前提を共有していると、その出場者がこっそり有利になります。
4. 送信までではなく GPU 完了までを計測する
WebGL の呼び出しは、処理がキューに積まれた時点ですぐに戻ってきます。そこで時計を止めると、CPU がどれだけ速くコマンドを積めるかを計測していることになり、GPU 側の負担は消えてしまいます。
2dbench は各フレームを、シミュレーション開始から GPU 完了までで計測します。同期には 1 ピクセルの readPixels を使い、送信済みの GPU 処理がすべて終わるのを待ちます(Canvas 2D では createImageBitmap を使います)。フレームは requestAnimationFrame を通さずに直接計測するので、vsync やバックグラウンドタブのスロットリングの影響を受けません。
各実行では最初に 20 フレームのウォームアップを行い、JIT、テクスチャのアップロード、バッファの拡張、リテインドモードのライブラリが作るオブジェクトなどを済ませます。その後 100 フレームを計測し、スプライト数ごとにフレームの中央値、p95、そして同期前のメインスレッド時間の中央値を報告します。最後の数値はそれ単体でも役に立ちます。spryte が速くなるまででは、メインスレッド時間と合計時間を分けたことで、差の正体がスプライトごとの JavaScript だとわかりました。
同期にはコストがあり、私たちはこれを妥当性への脅威として挙げています。実際のゲームはリードバックで止まったりせず、フレームを重ねて処理します。そのため絶対時間には CPU と GPU の往復 1 回分がまるごと含まれ、その重なりに頼るレンダラーは、ここよりもゲームの中で相対的に良い結果になるはずです。ただ、どのアダプターも同じ停止を払うので、比率は公正なままです。同期なしで計測するという代替案は、はるかに悪い結果になります。
5. ラウンドを交互に回し、回数はデータで決める
マシンは熱くなり、バックグラウンドタスクは目を覚まし、最初に走ったものはより冷えたチップの恩恵を受けます。ライブラリ A を全部走らせてからライブラリ B を走らせると、そのすべてが比較に焼き込まれてしまいます。
2dbench は複数のラウンドを実行します。各ラウンドでは、すべてのアダプターを新しいページで、順番をずらしながら開始するので、熱によるスロットリングや実行順の影響が均等に分散されます。報告するのはラウンド間の中央値と最小〜最大の範囲で、各ラウンドの値はすべて roundsFrameMs として結果ファイルに残します。
ラウンド数はばらつき調査から決めました。対象は bunny と particles の2つのシーンで、それぞれ 7 ラウンド、RTX 5060 搭載のノート PC 1 台で実施しました。わかったことは次の通りです。
- 公開している数(10k、50k、200k)では、ラウンドごとのフレーム中央値の変動係数が、中央のセルで 9.2%、最大で 35.8% でした。
- 連続する 3 ラウンドのあらゆる組について見ると、3 ラウンドの中央値は 7 ラウンドの中央値から、中央のセルで 9.8%、最大で 28.6% ずれていました。
- どんな主張も結局はライブラリ間の比率についてのものです。その比率は、3 ラウンドだと 50k でも 200k でも最大 15% ずれました。
そのため、デフォルトは 3 ではなく 5 ラウンドにしています。フレーム時間には長い右裾(GC の停止や OS の割り込み)があるので、全体を通して中央値を使います。100 フレームの中央値は裾を無視し(裾は p95 で別に報告します)、ラウンドごとの中央値のさらに中央値を取ることで、1回の悪いラウンドに強くなります。
6. タイマーの分解能を知る
調査で最もノイズが大きかったのは、最も速いセルでした。おおよそ 2 ms 未満のフレームは、ブラウザのタイマー分解能やスケジューリングの揺らぎがフレームの大きな割合を占めるため、最もばらつきます。10k スプライトでは、1 フレームあたり約 0.5〜1.5 ms の一括 API が、ラウンド間で 24〜36% ばらつきました。
ブラウザはフィンガープリント対策として performance.now() の精度を粗くしています。スマートフォン用のランナーは観測できる最小の刻みを計測しますが、デスクトップの Chrome では約 0.1 ms、iOS のすべてのブラウザが使う WebKit では 1 ms でした。1 ms のタイマーで計った 2 ms のフレームは、ほとんど計測とは言えません。
2dbench は 2 ms 未満のセルに † を付けます。間違いではありませんが精度が低いので、比率はフレームが 2 ms 以上の場合にしか公開しません。調査の一例がその理由を示しています。10k での一括 API の比率は、フレームが 1 ms 未満で、ラウンドごとの範囲が 1.00〜4.00× もありました。同じ組み合わせでも 200k では 2.55〜3.38× に収まっていました。README の iPhone の結果に「参考値」と書いてあるのも同じ理由です。
小さな数値で精度が必要なら、解決策は小数点以下の桁を増やすことではなく、1 フレームあたりの処理を増やすことです。
7. ソフトウェア GPU での計測は拒否する
Linux のヘッドレス Chrome は、GPU フラグに何を指定しても、CPU で GPU をエミュレートする SwiftShader にフォールバックします。そこで得られるタイミングは「GPU のふりをする CPU」を計測したものですが、見た目はいかにももっともらしいのです。
2dbench は WEBGL_debug_renderer_info から GPU の文字列を読み取り、ソフトウェアレンダラーと一致したら、計測付きの実行を拒否します。対象は SwiftShader、llvmpipe、softpipe、Microsoft Basic Render です。ピクセル一致のチェックはソフトウェアレンダリングでも問題なくできます。CI はすべてのプルリクエストで、正しさの確認のためだけにまさにそれを行っています。Linux で計測するには、ウィンドウを表示して実行してください(HEADED=1)。そうすれば本物の GPU が使われます。明示的に上書きする方法もありますが、その場合は結果にソフトウェアの印が付きます。
Windows と macOS のヘッドレス Chrome は本物の GPU を使い、どちらにしても GPU の文字列は記録されます。ヘッドレス実行の代償は、コンポジットと表示が計測されないことで、これは残存リスクとして挙げています。
8. 電源を含めて環境を記録する
バッテリー駆動や省電力プランのノート PC はスロットリングします。その記録がなければ、正直に行った2回の実行の結果が食い違っても、誰にも理由がわかりません。
すべての結果には、GPU、ブラウザのバージョン、OS、CPU、メモリ、ライブラリのバージョン、2dbench のコミット、そして電源と電源プランが記録されます。Windows ではランナーが OS からこれらを読み取り、それ以外の環境では NOTE で指定します。エージェントガイドははっきりと書いています。バッテリー駆動のノート PC でのタイミングは報告しないこと、計測付きの実行を2つ同時に走らせないこと。
それでも、同じマシンでも日によって絶対時間は変わります。信号になるのは、1回の実行の中でのライブラリ間の比率です。異なるマシンの結果は直接比較できませんし、異なるベンチマークの比率も同様です。2dbench の structure-of-arrays のシミュレーションは debuff 自身のベンチのものより軽いので、各フレームのより多くの部分がレンダラーに残り、同じ2つのライブラリでも差が大きく出ます。200k の particles で、こちらでは約 4.7×、あちらでは 3.8× です。どちらもそれぞれのベンチマークにおいては正しい数字です。
9. どのライブラリも作者が設定するように設定する
設定の悪いライブラリは遅く見えます。そして、そのライブラリをよく知らないときに最も見落としやすい不公平でもあります。
2dbench の各アダプターは、ライブラリ自身のドキュメントがパフォーマンスのために推奨する方法でライブラリを使い、そのセットアップを setup フィールドに記述します。この内容はすべての結果にコピーされます。ライブラリに妥当な道筋が2つある場合は両方を含めます。PixiJS の Sprite と ParticleContainer は別々のアダプターなので、汎用は汎用と、特化型は特化型と比較されます。計測した代替案も書き残しています。PixiJS は、これらのシーンでは WebGPU レンダラーより WebGL レンダラーのほうが速かったので WebGL で動かしており、roundPixels は速度に計測可能な差をもたらしませんでした。アダプターがシーンを同一に描けない場合は、ごまかさずに supports() で辞退し、その理由を述べます。Canvas 2D には描画ごとの色合いがないので、bunny だけを実行します。
10. 利益相反には公の場で向き合う
2dbench は debuff プロジェクトが始めたもので、spryte は出場者の一人です。この利益相反は取り除けないので、ルールでそれを回避しています。
- ピクセル一致の基準は spryte ではなく PixiJS
Spriteです。 - spryte のアダプターは特別扱いされません。
- spryte の結果だけを特に良くする変更は、マージ前に debuff プロジェクト外の誰かのレビューが必要です。
- ライブラリのメンテナーには、自分のアダプターのレビューをお願いしています。ピクセル一致を保ち、そのライブラリらしい書き方の修正はマージされ、結果は再計測されます。
- 計測・ピクセル一致・統計に関する変更には、根拠の説明、同じ PR での METHOD.md の更新、そして変更履歴への記載が必要です。変更の前後で結果を比較できなくなるからです。
- 手法、アダプター、ラウンドごとの生データは公開されており、結果データは CC0 です。
だからといって、宣言しただけで私たちの数字が信頼できるものになるわけではありません。信頼は時間をかけて得るものですし、最良の確認方法は自分で再実行してみることです。
あなた自身のベンチマークのために
- シミュレーションを共有する。 描画以外の処理はすべて共通コードに置き、違うのはレンダラーだけにします。
- 出力でゲートする。 何かを計測する前に、独立した基準とピクセルを比較します。エッジは無視して内部を確認し、わざと仕込んだバグでチェックが失敗することを確かめます。
- 自分が管理していない基準を選ぶ。 そして、特定の出場者と設計を共有していないものを。
- GPU と同期する。 1 ピクセルのリードバックは雑なやり方ですが、それなしの計測はコマンドのキュー積みを計っているだけです。
- アニメーションするプロパティは本当に動かす。 実行中ずっと変化させ、変化のない値のキャッシュで勝てないようにします。
- 交互に回し、順番をずらす。 比率が安定するだけのラウンドを回し、その回数は習慣ではなくばらつき調査で決めます。
- 中央値をばらつきと一緒に報告する。 各ラウンドの生の値はすべて残し、カクつきは p95 で読みます。
- タイマーの分解能に近い結果には印を付ける。 おおよそ 2 ms 未満、そして WebKit の 1 ms タイマーが関わる場面では、数値は参考値として扱います。
- GPU の文字列を確認する。 ソフトウェアレンダリングは拒否します。
- 電源も含めて環境を記録する。 マシンやベンチマークをまたいだ絶対時間ではなく、1回の実行の中での比率を比較します。
- 妥当性への脅威を書き出す。 METHOD.md には、それぞれの残存リスクを添えた一覧表があります。書いてみるのが、まだ対処していないことを見つける一番の近道です。
どんな合成ベンチマークも、特定のゲームの結果を予言できません。レンダラーを選んだら、あなた自身のゲームを、あなたが狙うデバイスでプロファイリングしてください。