WebGLコンテンツは意識せずに作ると、時に負荷の高いものが出来上がります。特に、スペックの良い環境で開発していると、エンドユーザーが閲覧する環境からの乖離が大きくなりがちです。そこで、重要になるのが最適化です。最適化は無闇な対応でなく、どこに時間がかかっているのか(ボトルネック)をきちんと計測して行うことが大切です。

WebGLでは大きく分けてCPUとGPU、2種類のボトルネックが考えられます。CPUのボトルネックプロファイリングについては今回置いておきましょう。いまさら説明するまでもなく、記事「WebGLのドローコール最適化手法」の対策のようにみなさんがいままでやってきたことと思います。では、GPUのボトルネックについて我々はどのようにして知れば良いでしょうか?

WebGL 2.0 で追加されたWebGLQueryと、EXT_disjoint_timer_query_webgl2拡張を使うとシェーダーのGPU実行時間を計測できます。本記事ではWebGLコンテンツの最適化に欠かせないGPUプロファイル、その最初のステップについて紹介します。

CPUとGPUは非同期に動作する

WebGLの機能のうち、GPUが実行する部分はどのように計測すれば良いでしょうか? たとえば、描画命令の実行時間を知りたいときは? JavaScriptではWebGLの描画命令APIを呼び出して画面を描画しています。この描画命令の実行前後で時間を測れば良いでしょうか?

実はこれは違います。JavaScriptで実行するWebGLの描画命令はCPUの処理であり、GPUへの命令コマンドを作るだけで、その後描画命令のコマンドを非同期にGPUが実行します。なのでJavaScriptで描画命令を実行し、制御が返ってきたとしても実際にGPUで画面が描画されているわけではありません。そのため、処理負荷の高いシェーダーを使用した描画命令であっても、描画API呼び出しの時間はまったくかからないことがあります。逆に、処理の軽いシェーダーであっても描画APIを何度も呼び出すことで(これが俗に言うドローコールの過多です)GPUの処理の時間はたいしたことがないのに描画命令の呼び出し(CPU処理)に時間がかかりFPSが落ちることもあります。

上記の通り、シェーダーの実行(GPUの処理)時間は通常のJavaScript APIでは計測することはできません。そこで、今回紹介するEXT_disjoint_timer_query_webgl2拡張の出番です。

WebGL 2.0 で追加されたWebGLQuery機能

WebGL 2.0 で追加されたWebGLQueryは、GPUのさまざまな状態を問い合わせ、取得する機能です。さまざまと言っても、WebGL 2.0 の仕様で使用できるのはたったの2種類です。

  • 深度テストに成功したピクセル数を取得できるANY_SAMPLES_PASSEDおよびANY_SAMPLES_PASSED_CONSERVATIVE
  • Transform Feedback機能で書き戻されたプリミティブの数を取得できるTRANSFORM_FEEDBACK_PRIMITIVES_WRITTEN

しかし、EXT_disjoint_timer_query_webgl2拡張を使用すると、上記の2種類に加えてWebGLのGPUコマンドの実行時間をクエリで問い合わせできるようになります。

正確には、「計測開始地点より前に呼び出されたGPUコマンドが終了したタイミング」から、「計測終了地点より前に呼び出されたGPUコマンドが終了したタイミング」の間の時間です

残念ながら2020年6月現在、EXT_disjoint_timer_query_webgl2拡張を使用できるブラウザはChromeのみのため、以降はChrome環境を前提とします。

サンプルの紹介

EXT_disjoint_timer_query_webgl2拡張を使ったサンプルを紹介します。

このサンプルでは、かんたんなレイマーチングの画面を表示しています。レイマーチングはフラグメントシェーダーの各ピクセルでレイを繰り返し評価しますが、繰り返し回数が多いほどシーンの遠くまで描画できます。しかし、繰り返し回数が増えると処理時間が増大します。

このループ回数をGUIで操作できるようにしたので、画面右上のスライダーを操作してください。ループ回数が少ないと近くの球のみが表示され、多いほど遠くまで表示されるかわりにFPSの低下が確認できます。

レイマーチングなので描画命令は1回です。この描画命令(drawElements())実行の前後でCPU(performance.now())およびGPU(EXT_disjoint_timer_query_webgl2)の実行時間を計測し、画面左上に表示しています。FPSはrequestAnimationFrame()間の差分から計算しています。

スライダーでループ回数を増やすとFPSが落ちますが、CPUの実行時間はまったく変わっていないことに気づくでしょう。シェーダーの負荷がいくら高くてもJavaScriptでコールした描画命令(drawElements())はすぐに実行が完了してJavaScriptに制御が戻っていることがわかります。また、FPSが落ちていることから、GPUの実行時間に同期して次回のrequestAnimationFrame()の実行が遅れていることが見てとれます。これが「CPU負荷は低いのにGPU負荷が高いせいでFPSが低下している状態」です。

APIの使い方

いつものように、最初にWebGL 2.0 のコンテキストを取得します。以下gl2は、ここで取得したWebGL2RenderingContextオブジェクトを指します。

// WebGL2コンテキスト(WebGL2RenderingContext)を取得
const gl2 = canvas.getContext('webgl2');

WebGLQueryの使い方

まずはWebGLQueryについて説明します。クエリオブジェクトはWebGL2RenderingContextから作成します。

// WebGLQueryを作成
const query = gl2.createQuery();

計測したいWebGLコマンド呼び出しの前後でbeginQuery()メソッドおよびendQuery()メソッドを実行します。第一引数targetには計測したい対象を指定します。たとえばフラグメントシェーダーで深度テストに成功したピクセル数だったらgl2.ANY_SAMPLES_PASSEDです。

// 計測を開始
gl2.beginQuery(target, query);

// 計測するWebGLコマンド
...

// 計測を終了
gl2.endQuery(target);

クエリ結果を取得するにはgl2.QUERY_RESULTgl2.getQueryParameter()メソッドの第二引数にして呼び出します。

// クエリ結果を取得
const result = gl2.getQueryParameter(query, gl2.QUERY_RESULT);

ここで注意があります。クエリ結果を取得するには、クエリオブジェクトがクエリ結果取得可能状態になっている必要があります。クエリ結果取得可能状態はgl2.QUERY_RESULT_AVAILABLEgl2.getQueryParameter()メソッドの第二引数にして呼び出して取得します。この値がtrueであればクエリ結果を取得できます。

// クエリ結果が取得可能か確認
const resultAvailable = gl2.getQueryParameter(query, gl2.QUERY_RESULT_AVAILABLE);

if (resultAvailable) {
  // クエリ結果を取得
  const result = gl2.getQueryParameter(query, gl2.QUERY_RESULT);
}

さらに大きな注意があります。WebGLの仕様上、クエリ結果が取得できるのは、次フレーム以降になります。これが面倒なところで、計測したクエリオブジェクトが次のフレームで結果取得可能になる保証がない(実際にやってみると3、4フレームほど遅れます)ので、次のフレームの計測に使用すると結果を上書きしてしまいます。そのため、複数のクエリオブジェクトを使用する必要があります。

サンプルでは使用中のクエリオブジェクトのリスト(usingList)と使用可能なクエリオブジェクトのリスト(availableList)を作りました。計測時には使用可能なリストからクエリオブジェクトを取得し、なければ新たに作成します。計測が終わったら使用中のリストに追加します。結果を取得するときは使用中のリストのクエリオブジェクトの結果取得可能かをチェックし、結果を取得できたら使用可能なリストに戻します。

というわけで下記がWebGLQueryを使用する完全なコードです。

▼ 計測時

// 使用可能なクエリをリストから取得(なければ新たに作成)
const availableQuery = availableList.length ? querySet.availableList.shift() : gl2.createQuery();

// 計測を開始
gl2.beginQuery(target, availableQuery);
    
// 計測するWebGLコマンド
...
   
// 計測を終了
gl2.endQuery(target);

// 使用中のクエリリストに移動
usingList.push(availableQuery);

▼ 結果取得時

// 使用中のクエリを取得
const usingQuery = usingList.length ? usingList[0] : null;

if (usingQuery) {
  // クエリ結果が取得可能か確認
  const resultAvailable = gl2.getQueryParameter(usingQuery, gl2.QUERY_RESULT_AVAILABLE);

  if (resultAvailable) {
    // クエリ結果を取得
    const result = gl2.getQueryParameter(usingQuery, gl2.QUERY_RESULT);
    
    // 使用が終わったクエリを使用可能リストに移動
    availableList.push(usingList.shift());
  }
}

EXT_disjoint_timer_query_webgl2拡張の使い方

今回の記事の主役であるEXT_disjoint_timer_query_webgl2拡張の使い方を説明します。といってもこれはWebGLQueryで使用できるtargetのひとつなので、WebGLQueryが分かれば難しいことはありません。

まずはWebGLの拡張機能のお約束、有効化です。getExtension()メソッドでEXT_disjoint_timer_query_webgl2拡張を取得します。実行環境が拡張機能に対応していればオブジェクトが返り、対応していなければnullが返ります。

// EXT_disjoint_timer_query_webgl2拡張を取得(有効化)
const ext = gl2.getExtension("EXT_disjoint_timer_query_webgl2");

EXT_disjoint_timer_query_webgl2拡張を有効化すると、WebGLQuerybeginQuery()メソッドの第一引数のtargetとしてTIME_ELAPSED_EXTが使用できるようになります。今回計測するのは描画コマンドなので、drawElements()メソッドの前後をbeginQueryendQuery()で囲みます。

// GPU実行時間計測開始
gl2.beginQuery(ext.TIME_ELAPSED_EXT, query);

// 描画コマンド
gl2.drawElements(...);

// GPU実行時間計測終了
gl2.endQuery(ext.TIME_ELAPSED_EXT);

結果取得時はWebGLQueryで説明したとおり……と言いたいところですが、EXT_disjoint_timer_query_webgl2拡張の場合のみ注意があります。 それは、GPUでdisjoint operationが発生していないかチェックする必要があることです。disjoint operationとは、フレーム内でGPUのクロック周波数が変化する場合などに起こるようです。これが発生するとGPUが不整合な状態となっており、クエリの結果が時間計測の値として有効ではなくなります。

disjoint operationの発生は、拡張機能で追加されたGPU_DISJOINT_EXT値をgetParameter()メソッドの引数に指定することで検知可能です。前回GPU_DISJOINT_EXTをチェックした時点からdisjoint operationが発生した場合、trueが返却されます。毎フレームdisjoint operationの発生有無をチェックし、発生していない場合のみクエリ結果を取得します。disjoint operationが発生していた場合は記録しているクエリが無効な値になっているということなので、破棄します。今回のサンプルではdeleteQuery()メソッドを使ってクエリごと破棄しています。

 // GPUの不整合をチェック
const disjoint = gl2.getParameter(ext.GPU_DISJOINT_EXT);
if (disjoint) {
  // 使用中のクエリを全て削除
  usingList.forEach(query => gl2.deleteQuery(query));
} else {
  // クエリ結果を取得
  ...
}

なかなか難しい概念ですが、通常発生することはない認識なので、とりあえずEXT_disjoint_timer_query_webgl2拡張の使用時には結果の取得前にGPU_DISJOINT_EXTをチェックするとだけ覚えておきましょう。

まとめると、EXT_disjoint_timer_query_webgl2拡張のクエリ結果取得は下記のステップで行います。

  1. gl2.getParameter(ext.GPU_DISJOINT_EXT)disjoint operationの発生有無をチェック
  2. gl2.getQueryParameter(query, gl2.QUERY_RESULT_AVAILABLE)でクエリ結果が取得可能となっているかチェック
  3. gl2.getQueryParameter(query, gl2.QUERY_RESULT)でクエリ結果を取得

以上でWebGLQueryおよびEXT_disjoint_timer_query_webgl2拡張の使い方を説明しました。みなさんも気になっているシェーダー処理の実行時間を計測し、さらなる最適化を目指しましょう。

TIPS

経過時間でなく現在時間を取得できるTIMESTAMP_EXT機能は現状使用できない

今回紹介したTIME_ELAPSED_EXTbeginQuery()endQuery()の間の経過時間を取得できるクエリですが、EXT_disjoint_timer_query_webgl2拡張の仕様にはもうひとつ、現在時間(タイムスタンプ)を取得できるTIMESTAMP_EXT値が用意されています。これを使用すると、呼び出し時点のGPU時間(正確には呼び出し直前のGPUコマンドが終了した時間)のタイムスタンプが取得できます。

GPUコマンドの前後でそれぞれタイムスタンプを計測し、差分をとることでそのコマンドの経過時間を取得できます。TIME_ELAPSED_EXTと違い、入れ子にして計測できるメリットがあるのでなかなか便利な機能です。なのですが、なぜ今回のサンプルで紹介しなかったかというと、現状、唯一EXT_disjoint_timer_query_webgl2拡張が使用可能なブラウザであるChromeでこのTIMESTAMP_EXTが使用できないためです。

Windows,macOSともにTIMESTAMP_EXTのWebGLへの移植ができないとのことです。今後サポートする予定もなさそうなので、諦めて推奨されているTIME_ELAPSED_EXTを使用しましょう。

WebGL 1.0 でも使えるEXT_disjoint_timer_query拡張

WebGL 2.0 でのみ使用できるEXT_disjoint_timer_query_webgl2拡張ですが、実はWebGL 1.0 にも同等の拡張機能EXT_disjoint_timer_queryがあります。使用方法は基本的に同じなのですが、WebGLQueryが WebGL 2.0 で追加されたAPIのため、 WebGL 1.0 にはありません。そこでWebGLQuery相当のWebGLTimerQueryEXT APIがEXT_disjoint_timer_query拡張に用意されています。

下記に本記事で使用している WebGL 2.0 のWebGLQuery APIとWebGL 1.0 で使用できるEXT_disjoint_timer_query拡張のWebGLTimerQueryEXTAPIの対応をまとめました。

WebGL 2.0(WebGLQuery WebGL 1.0(EXT_disjoint_timer_query 内容
createQuery() createQueryEXT() クエリの作成
deleteQuery() deleteQueryEXT() クエリの削除
beginQuery() beginQueryEXT() クエリの開始
endQuery() endQueryEXT() クエリの終了
getQueryParameter() getQueryObjectEXT() クエリ情報の取得
QUERY_RESULT_AVAILABLE QUERY_RESULT_AVAILABLE_EXT クエリ結果が取得可能かどうか
QUERY_RESULT QUERY_RESULT_EXT クエリ結果

名前が違うだけなので、 ぜひ WebGL 1.0 でも試してみてください。

EXT_disjoint_timer_query_webgl2拡張と脆弱性

EXT_disjoint_timer_query_webgl2の高精度な時間計測を悪用することでメモリ配置を特定し、Rowhammer攻撃を仕掛けるGLitchという攻撃方法があります。GLitch攻撃は2018年に大きな話題になったSpectreやMeltdownといった脆弱性攻撃のきっかけにもなりえるということで、各ブラウザは拡張機能を非公開にし、フラグ等の有効化によってのみ使用できるようにしました。

Chrome

Chromeでは現在はフラグの設定なしにEXT_disjoint_timer_query_webgl2拡張を使用できるようになりましたが、取得できるタイマーの精度を落とし、仕様のナノ(10の-9乗)秒ではなくマイクロ(10の-6乗)秒オーダーで提供することで脆弱性を回避しています。取得したQUERY_RESULTを出力すると下3桁は必ず000となっていることが確認できます。マイクロ秒でもプロファイルには充分な精度ですね。

Firefox

Firefoxでは現在もフラグの設定が必要で、about:configページからwebgl.enable-privileged-extensionsフラグをtrueにすることで前述のEXT_disjoint_timer_query拡張が使用できるようになります。ここで注意が必要なのが、Firefoxでは WebGL 2.0 で使用できるのはEXT_disjoint_timer_query_webgl2拡張ではないということです。WebGL 1.0 でも WebGL 2.0 でもEXT_disjoint_timer_query拡張のみが使用できます。上記脆弱性の観点から、このフラグは使用するときのみtrueにすることを推奨します。

リファレンス

EXT_disjoint_timer_query_webgl2拡張およびWebGLQueryについて、詳しくは下記のドキュメントを参照ください。