WebGPUのtimestamp-queryでGPU処理時間を測る|CPU時間との違いも解説

計測波形を表示したGPUチップでtimestamp-queryを表した図 WebGPU・性能計測
カテゴリー
WebGPU・性能計測
公開日
2026.09.07

はじめに

performance.now()queue.submit()の前後を囲むだけでは、GPUが実際に処理していた時間は分かりません。WebGPUはJavaScriptからGPUへコマンドを非同期に送るため、「JavaScriptがコマンドを投入した時間」と「GPUがコマンドを実行した時間」は別の値です。

この記事は、WebGPUを初めて計測する人を対象にしています。timestamp-queryへの対応を確認し、空のcompute passを計測するHTMLを作ります。読み終えると、GPU時間とCPU側の完了待ち時間を区別し、非対応環境でも誤った値を表示しない実装ができます。

作るもの

1つのindex.htmlを作り、ボタンを押すと次の結果を表示します。

  • timestamp-query対応環境:GPU区間とCPU側の完了待ち時間
  • 非対応環境:CPU側の完了待ち時間だけ
  • WebGPU自体が使えない環境:理由を含むエラーメッセージ

空の処理は実行時間が短く、タイマーの精度調整によってGPU区間が0 msになる場合があります。これは直ちに失敗を意味しません。

仕組み

timestamp-queryでは、compute passの開始時と終了時の2か所へタイムスタンプを書き込みます。query setはJavaScriptから直接読めないため、次の順番で結果を取り出します。

  1. passの開始時と終了時にタイムスタンプを書き込む
  2. query setの結果をresolve用バッファへ解決する
  3. MAP_READを付けた読取用バッファへコピーする
  4. 読取用バッファをmapし、2つの64ビット値の差を求める

仕様上、タイムスタンプの単位はナノ秒です。ただし値の決め方や精度は実装依存で、セキュリティとプライバシーへの対策として精度が下げられる場合があります。

必要環境

  • WebGPUを利用できるブラウザとGPU環境
  • HTTPSで配信したページ、またはhttp://localhostなどの安全なローカル開発環境
  • テキストエディタ
  • ローカルHTTPサーバー(この記事ではPythonの例を使用)

timestamp-queryはWebGPUのオプション機能です。WebGPUが使えても、この機能が使えるとは限りません。そのため、必ずadapter.features.has("timestamp-query")で確認します。

手順1:HTMLを作る

作業用フォルダにindex.htmlを作り、次の内容を保存します。コードは、機能検出、GPUコマンドの作成、GPU時間とCPU時間の計測、エラー表示までを1つにまとめています。

<!doctype html>
<html lang="ja">
<meta charset="utf-8">
<title>WebGPU timestamp-query</title>
<body>
  <button id="measure">計測する</button>
  <pre id="result">未計測</pre>

  <script type="module">
    const button = document.querySelector("#measure");
    const result = document.querySelector("#result");

    button.addEventListener("click", async () => {
      button.disabled = true;
      result.textContent = "計測中...";

      let device;
      let querySet;
      let resolveBuffer;
      let readBuffer;

      try {
        if (!navigator.gpu) {
          throw new Error("この環境ではWebGPUを利用できません");
        }

        const adapter = await navigator.gpu.requestAdapter();
        if (!adapter) throw new Error("GPUAdapterを取得できません");

        const canTimestamp = adapter.features.has("timestamp-query");
        device = await adapter.requestDevice({
          requiredFeatures: canTimestamp ? ["timestamp-query"] : [],
        });

        const shaderModule = device.createShaderModule({
          code: `@compute @workgroup_size(1) fn main() {}`,
        });
        const pipeline = device.createComputePipeline({
          layout: "auto",
          compute: { module: shaderModule, entryPoint: "main" },
        });

        if (canTimestamp) {
          querySet = device.createQuerySet({ type: "timestamp", count: 2 });
          resolveBuffer = device.createBuffer({
            size: 16,
            usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
          });
          readBuffer = device.createBuffer({
            size: 16,
            usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
          });
        }

        const encoder = device.createCommandEncoder();
        const pass = encoder.beginComputePass(
          canTimestamp
            ? {
                timestampWrites: {
                  querySet,
                  beginningOfPassWriteIndex: 0,
                  endOfPassWriteIndex: 1,
                },
              }
            : {},
        );
        pass.setPipeline(pipeline);
        pass.dispatchWorkgroups(1);
        pass.end();

        if (canTimestamp) {
          encoder.resolveQuerySet(querySet, 0, 2, resolveBuffer, 0);
          encoder.copyBufferToBuffer(resolveBuffer, 0, readBuffer, 0, 16);
        }

        const commandBuffer = encoder.finish();
        const started = performance.now();
        device.queue.submit([commandBuffer]);
        await device.queue.onSubmittedWorkDone();
        const cpuWaitMs = performance.now() - started;

        if (!canTimestamp) {
          result.textContent = [
            "timestamp-query: 非対応",
            `CPU側の完了待ち: ${cpuWaitMs.toFixed(3)} ms`,
            "GPU時間としては表示しません",
          ].join("\n");
          return;
        }

        await readBuffer.mapAsync(GPUMapMode.READ);
        const values = new BigUint64Array(
          readBuffer.getMappedRange().slice(0),
        );
        readBuffer.unmap();

        if (values[1] < values[0]) {
          throw new Error("タイムスタンプの順序が不正です");
        }

        const elapsedNs = values[1] - values[0];
        result.textContent = [
          "timestamp-query: 対応",
          `GPU区間: ${Number(elapsedNs) / 1_000_000} ms`,
          `CPU側の完了待ち: ${cpuWaitMs.toFixed(3)} ms`,
        ].join("\n");
      } catch (error) {
        result.textContent = `エラー: ${error.message}`;
      } finally {
        querySet?.destroy();
        resolveBuffer?.destroy();
        readBuffer?.destroy();
        device?.destroy();
        button.disabled = false;
      }
    });
  </script>
</body>
</html>

手順2:ローカルサーバーで開く

index.htmlを保存したフォルダで、次のコマンドを実行します。

python -m http.server 8000

ブラウザでhttp://localhost:8000を開き、「計測する」を押します。file://で直接開くのではなく、ローカルHTTPサーバーを使ってください。

コードの解説

機能を確認してからデバイスを作る

timestamp-queryが使えるときだけrequiredFeaturesへ指定します。非対応環境で無条件に要求すると、requestDevice()が失敗します。

const canTimestamp = adapter.features.has("timestamp-query");
const device = await adapter.requestDevice({
  requiredFeatures: canTimestamp ? ["timestamp-query"] : [],
});

16バイトのバッファを2つ使う

タイムスタンプは64ビット値が2個なので、必要なサイズは16バイトです。resolve用バッファへ結果を解決し、CPUからmapできる読取用バッファへコピーします。

const resolveBuffer = device.createBuffer({
  size: 16,
  usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
});
const readBuffer = device.createBuffer({
  size: 16,
  usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});

bigintのまま差を求める

BigUint64Arrayから得る値はbigintです。絶対値を先にNumberへ変換すると精度を失う可能性があるため、差を求めてからミリ秒へ変換します。

const elapsedNs = values[1] - values[0];
const elapsedMs = Number(elapsedNs) / 1_000_000;

GPU時間とCPU側の待ち時間を分ける

timestamp-queryは指定したpassのGPU実行区間を測ります。一方、performance.now()onSubmittedWorkDone()の組み合わせは、JavaScriptがsubmitしてから先に投入済みのキューを含む作業が完了するまでの待ち時間です。転送やキュー待ちなども関係するため、2つの値が一致する必要はありません。

動作確認

対応環境では、次のような結果が表示されます。数値は端末や負荷によって変わります。

timestamp-query: 対応
GPU区間: 0 ms
CPU側の完了待ち: 1.234 ms

確認するポイントは、エラーが出ないこと、2つのタイムスタンプが取得できること、終了値が開始値以上であることです。空のcompute passでは、精度調整によりGPU区間: 0 msとなる場合があります。

非対応分岐では次の形式になります。この値をGPU時間と呼ばないことが重要です。

timestamp-query: 非対応
CPU側の完了待ち: 1.234 ms
GPU時間としては表示しません

実用コードでの注意

  • 1回目はパイプライン準備などの影響を受けやすいため、ウォームアップとして分ける
  • 1回だけで判断せず、複数回測って中央値や分布を見る
  • query setとバッファは毎回作らず、必要に応じて再利用する
  • map中の読取バッファを次のコピー先にしない
  • ページの表示状態、電源状態、GPU負荷、端末情報を検証メモに残す
  • デバイス喪失が起きた試行や、終了値が開始値より小さい試行を結果から除外する

長時間の重い処理を繰り返すと、ブラウザやOSの応答性を落とす可能性があります。負荷を段階的に増やし、開発中のデータを保存してから試してください。

トラブル対処

  • navigator.gpuがない:対応ブラウザか、安全なコンテキストで開いているか確認します。
  • timestamp-queryがない:GPU時間とは表示せず、CPU側の完了待ち時間だけを使います。
  • mapに失敗する:読取バッファがmap中でないか、usageとコピーサイズが一致するか確認します。
  • GPU区間が0になる:空の処理では起こり得ます。実際の処理を測り、ウォームアップ後に複数回確認します。
  • 値が不自然:ページの表示状態やGPU負荷をそろえ、デバイス喪失がなかったか確認します。

実運用では、次のようにデバイス喪失も監視します。

device.lost.then((info) => {
  console.error("GPUDevice lost", info.reason, info.message);
});

まとめ

GPUの処理時間とJavaScriptから見た完了待ち時間は別物です。timestamp-queryを機能検出し、passの開始と終了を測ることでGPU区間を取得できます。非対応時はCPU側の値へフォールバックし、その値を「GPU時間」と表示しないようにすると、WebGPUの性能を誤解しにくくなります。

参考リンク

タイトルとURLをコピーしました