目次

webgpufundamentals.org

Fix, Fork, Contribute

WebGPU タイミングパフォーマンス

この記事はGemini Code Assistによって自動翻訳されました。翻訳に問題がある場合は、お手数ですがこちらからPull Requestを送信してください。

パフォーマンスのために時間を計りたいと思うかもしれないさまざまなことについて説明します。3つのことを計時します。

  • 1秒あたりのフレーム数(fps)でのフレームレート
  • フレームごとにJavaScriptで費やされた時間
  • フレームごとにGPUで費やされた時間

まず、頂点バッファに関する記事から円の例を取り上げ、物事にかかる時間の変化を簡単に見ることができるようにアニメーション化しましょう。

その例では、3つの頂点バッファがありました。1つは円の頂点の位置と明るさ用でした。1つはインスタンスごとですが静的なもので、円のオフセットと色が含まれていました。そして、最後の1つは、レンダリングするたびに変化するもので、この場合は、ユーザーがウィンドウのサイズを変更したときに円が楕円ではなく円のままであるように、円のアスペクト比を正しく保つためのスケールでした。

それらを動かしてアニメーション化したいので、オフセットをスケールと同じバッファに移動しましょう。まず、レンダーパイプラインを変更して、オフセットをスケールと同じバッファに移動します。

  const pipeline = device.createRenderPipeline({
    label: 'per vertex color',
    layout: 'auto',
    vertex: {
      module,
      buffers: [
        {
          arrayStride: 2 * 4 + 4, // 2 floats, 4 bytes each + 4 bytes
          attributes: [
            {shaderLocation: 0, offset: 0, format: 'float32x2'},  // position
            {shaderLocation: 4, offset: 8, format: 'unorm8x4'},   // perVertexColor
          ],
        },
        {
-          arrayStride: 4 + 2 * 4, // 4 bytes + 2 floats, 4 bytes each
+          arrayStride: 4, // 4 bytes
          stepMode: 'instance',
          attributes: [
            {shaderLocation: 1, offset: 0, format: 'unorm8x4'},   // color
-            {shaderLocation: 2, offset: 4, format: 'float32x2'},  // offset
          ],
        },
        {
-          arrayStride: 2 * 4, // 2 floats, 4 bytes each
+          arrayStride: 4 * 4, // 4 floats, 4 bytes each
          stepMode: 'instance',
          attributes: [
-            {shaderLocation: 3, offset: 0, format: 'float32x2'},   // scale
+            {shaderLocation: 2, offset: 0, format: 'float32x2'},  // offset
-            {shaderLocation: 3, offset: 0, format: 'float32x2'},   // scale
+            {shaderLocation: 3, offset: 8, format: 'float32x2'},   // scale
          ],
        },
      ],
    },
    fragment: {
      module,
      targets: [{ format: presentationFormat }],
    },
  });

次に、頂点バッファを設定する部分を変更して、オフセットをスケールと一緒に移動します。

  // 2つの頂点バッファを作成します
  const staticUnitSize =
-    4 +     // colorは4バイトです
-    2 * 4;  // offsetは2つの32ビット浮動小数点数(各4バイト)です
+    4;     // colorは4バイトです
  const changingUnitSize =
-    2 * 4;  // scaleは2つの32ビット浮動小数点数(各4バイト)です
+    2 * 4 + // offsetは2つの32ビット浮動小数点数(各4バイト)です
+    2 * 4;  // scaleは2つの32ビット浮動小数点数(各4バイト)です
  const staticVertexBufferSize = staticUnitSize * kNumObjects;
  const changingVertexBufferSize = changingUnitSize * kNumObjects;

  const staticVertexBuffer = device.createBuffer({
    label: 'static vertex for objects',
    size: staticVertexBufferSize,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
  });

  const changingVertexBuffer = device.createBuffer({
    label: 'changing storage for objects',
    size: changingVertexBufferSize,
    usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
  });

  // float32インデックスでのさまざまなユニフォーム値へのオフセット
  const kColorOffset = 0;
-  const kOffsetOffset = 1;
+
-  const kScaleOffset = 0;
+  const kOffsetOffset = 0;
+  const kScaleOffset = 2;

  {
    const staticVertexValuesU8 = new Uint8Array(staticVertexBufferSize);
-    const staticVertexValuesF32 = new Float32Array(staticVertexValuesU8.buffer);
    for (let i = 0; i < kNumObjects; ++i) {
      const staticOffsetU8 = i * staticUnitSize;
-      const staticOffsetF32 = staticOffsetU8 / 4;

      // これらは一度だけ設定されるので、今すぐ設定します
      staticVertexValuesU8.set(        // 色を設定します
          [rand() * 255, rand() * 255, rand() * 255, 255],
          staticOffsetU8 + kColorOffset);

-      staticVertexValuesF32.set(      // オフセットを設定します
-          [rand(-0.9, 0.9), rand(-0.9, 0.9)],
-          staticOffsetF32 + kOffsetOffset);

      objectInfos.push({
        scale: rand(0.2, 0.5),
+        offset: [rand(-0.9, 0.9), rand(-0.9, 0.9)],
+        velocity: [rand(-0.1, 0.1), rand(-0.1, 0.1)],
      });
    }
-    device.queue.writeBuffer(staticVertexBuffer, 0, staticVertexValuesF32);
+    device.queue.writeBuffer(staticVertexBuffer, 0, staticVertexValuesU8);
  }

レンダリング時に、円のオフセットを速度に基づいて更新し、それらをGPUにアップロードできます。

+  const euclideanModulo = (x, a) => x - a * Math.floor(x / a);

+  let then = 0;
-  function render() {
  function render(now) {
+    now *= 0.001;  // 秒に変換します
+    const deltaTime = now - then;
+    then = now;

...
      // 各オブジェクトのスケールとオフセットを設定します
-    objectInfos.forEach(({scale}, ndx) => {
-      const offset = ndx * (changingUnitSize / 4);
-      vertexValues.set([scale / aspect, scale], offset + kScaleOffset); // スケールを設定します
+    objectInfos.forEach(({scale, offset, veloctiy}, ndx) => {
+      // -1.5から1.5
+      offset[0] = euclideanModulo(offset[0] + velocity[0] * deltaTime + 1.5, 3) - 1.5;
+      offset[1] = euclideanModulo(offset[1] + velocity[1] * deltaTime + 1.5, 3) - 1.5;

+      const off = ndx * (changingUnitSize / 4);
+      vertexValues.set(offset, off + kOffsetOffset);
      vertexValues.set([scale / aspect, scale], off + kScaleOffset);
-    });
+    }

...

+    requestAnimationFrame(render);
  }
+  requestAnimationFrame(render);

  const observer = new ResizeObserver(entries => {
    for (const entry of entries) {
      const canvas = entry.target;
      const width = entry.contentBoxSize[0].inlineSize;
      const height = entry.contentBoxSize[0].blockSize;
      canvas.width = Math.max(1, Math.min(width, device.limits.maxTextureDimension2D));
      canvas.height = Math.max(1, Math.min(height, device.limits.maxTextureDimension2D));
-      // 再レンダリング
-      render();
    }
  });
  observer.observe(canvas);

rAFループにも切り替えました[1]

上記のコードは、オフセットを更新するためにeuclideanModuloを使用します。euclideanModuloは、除算の余りを返します。余りは常に正で、同じ方向になります。たとえば、

%とeuclideanModuloの2の剰余

別の言い方をすれば、%演算子とeuclideanModuloのグラフは次のとおりです。

euclideanModule(v, 2)
v % 2

したがって、上記のコードは、クリップ空間にあるオフセットを取得し、1.5を加算します。次に、3でユークリッド剰余を取り、0.0から3.0の間にラップされた数値を取得し、1.5を減算します。これにより、-1.5から+1.5の間の数値を維持し、反対側にラップさせることができます。円が画面外に出るまでラップしないように、-1.5から+1.5を使用します。[2]

調整するものを提供するために、描画する円の数を設定できるようにしましょう。

-  const kNumObjects = 100;
+  const kNumObjects = 10000;


...

  const settings = {
    numObjects: 100,
  };

  const gui = new GUI();
  gui.add(settings, 'numObjects', 0, kNumObjects, 1);

  ...

    // 各オブジェクトのスケールとオフセットを設定します
-    objectInfos.forEach(({scale, offset, veloctiy}, ndx) => {
+    for (let ndx = 0; ndx < settings.numObjects; ++ndx) {
+      const {scale, offset, velocity} = objectInfos[ndx];

      // -1.5から1.5
      offset[0] = euclideanModulo(offset[0] + velocity[0] * deltaTime + 1.5, 3) - 1.5;
      offset[1] = euclideanModulo(offset[1] + velocity[1] * deltaTime + 1.5, 3) - 1.5;

      const off = ndx * (changingUnitSize / 4);
      vertexValues.set(offset, off + kOffsetOffset);
      vertexValues.set([scale / aspect, scale], off + kScaleOffset);
-    });
+    }

    // すべてのオフセットとスケールを一度にアップロードします
-    device.queue.writeBuffer(changingVertexBuffer, 0, vertexValues);
+    device.queue.writeBuffer(
        changingVertexBuffer, 0,
        vertexValues, 0, settings.numObjects * changingUnitSize / 4);

-    pass.draw(numVertices, kNumObjects);
+    pass.draw(numVertices, settings.numObjects);

これで、アニメーション化され、円の数を設定して作業量を調整できるものができました。

それに、1秒あたりのフレーム数(fps)とJavaScriptで費やされた時間を追加しましょう。

まず、この情報を表示する方法が必要です。キャンバスの上に配置された<pre>要素を追加しましょう。

  <body>
    <canvas></canvas>
+    <pre id="info"></pre>
  </body>
html, body {
  margin: 0;       /* デフォルトのマージンを削除 */
  height: 100%;    /* html,bodyがページを埋めるようにする */
}
canvas {
  display: block;  /* canvasをブロックのように動作させる */
  width: 100%;     /* canvasがコンテナを埋めるようにする */
  height: 100%;
}
+#info {
+  position: absolute;
+  top: 0;
+  left: 0;
+  margin: 0;
+  padding: 0.5em;
+  background-color: rgba(0, 0, 0, 0.8);
+  color: white;
+}

1秒あたりのフレーム数を表示するために必要なデータはすでにあります。上記で計算したdeltaTimeです。

JavaScriptの時間については、requestAnimationFrameが開始された時間と終了した時間を記録できます。

  let then = 0;
  function render(now) {
    now *= 0.001;  // 秒に変換します
    const deltaTime = now - then;
    then = now;

+    const startTime = performance.now();

    ...

+    const jsTime = performance.now() - startTime;

+    infoElem.textContent = `\
+fps: ${(1 / deltaTime).toFixed(1)}
+js: ${jsTime.toFixed(1)}ms
+`;

    requestAnimationFrame(render);
  }
  requestAnimationFrame(render);

そして、最初の2つのタイミング測定値が得られます。

GPUのタイミング

WebGPUは、GPUでの操作にかかる時間を確認するためのオプション'timestamp-query'機能を提供します。オプション機能なので、制限と機能に関する記事で説明したように、それが存在するかどうかを確認して要求する必要があります。

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
-  const device = await adapter?.requestDevice();
+  const canTimestamp = adapter.features.has('timestamp-query');
+  const device = await adapter?.requestDevice({
+    requiredFeatures: [
+      ...(canTimestamp ? ['timestamp-query'] : []),
+     ],
+  });
  if (!device) {
    fail('need a browser that supports WebGPU');
    return;
  }

上記では、アダプターが'timestamp-query'機能をサポートしているかどうかに基づいてcanTimestampをtrueまたはfalseに設定します。サポートしている場合は、デバイスを作成するときにその機能を要求します。

この機能を有効にすると、レンダーパスまたはコンピュートパスのタイムスタンプをWebGPUに要求できます。これを行うには、GPUQuerySetを作成し、コンピュートまたはレンダーパスに追加します。GPUQuerySetは、事実上、クエリ結果の配列です。パスが開始された時間を記録する配列内の要素と、パスが終了したときに記録する配列内の要素をWebGPUに伝えます。次に、それらのタイムスタンプをバッファにコピーし、バッファをマップして結果を読み取ることができます。[3]

したがって、まずクエリセットを作成します。

  const querySet = device.createQuerySet({
     type: 'timestamp',
     count: 2,
  });

開始と終了の両方のタイムスタンプを書き込むことができるように、カウントは少なくとも2である必要があります。

クエリセット情報をアクセス可能なデータに変換するためのバッファが必要です。

  const resolveBuffer = device.createBuffer({
    size: querySet.count * 8,
    usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
  });

クエリセットの各要素は8バイトかかります。QUERY_RESOLVEの使用法を指定する必要があり、JavaScriptで結果を読み取ることができるようにしたい場合は、結果をマップ可能なバッファにコピーできるようにCOPY_SRCの使用法が必要です。

最後に、結果を読み取るためのマップ可能なバッファを作成します。

  const resultBuffer = device.createBuffer({
    size: resolveBuffer.size,
    usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
  });

このコードを、機能が存在する場合にのみこれらのものを作成するようにラップする必要があります。そうしないと、'timestamp'クエリセットを作成しようとするとエラーが発生します。

+  const { querySet, resolveBuffer, resultBuffer } = (() => {
+    if (!canTimestamp) {
+      return {};
+    }

    const querySet = device.createQuerySet({
       type: 'timestamp',
       count: 2,
    });
    const resolveBuffer = device.createBuffer({
      size: querySet.count * 8,
      usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
    });
    const resultBuffer = device.createBuffer({
      size: resolveBuffer.size,
      usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
    });
+    return {querySet, resolveBuffer, resultBuffer };
+  })();

レンダーパス記述子で、使用するクエリセットと、開始と終了のタイムスタンプを書き込むクエリセット内の要素のインデックスを指定します。

  const renderPassDescriptor = {
    label: 'our basic canvas renderPass with timing',
    colorAttachments: [
      {
        // view: <- レンダリング時に設定されます
        clearValue: [0.3, 0.3, 0.3, 1],
        loadOp: 'clear',
        storeOp: 'store',
      },
    ],
    ...(canTimestamp && {
      timestampWrites: {
        querySet,
        beginningOfPassWriteIndex: 0,
        endOfPassWriteIndex: 1,
      },
    }),
  };

上記では、機能が存在する場合、レンダーパス記述子にtimestampWritesセクションを追加し、クエリセットを渡し、開始をセットの要素0に、終了を要素1に書き込むように指示します。

パスを終了した後、resolveQuerySetを呼び出す必要があります。これにより、クエリの結果が取得され、バッファに入れられます。クエリセット、解決を開始するクエリセットの最初のインデックス、解決するエントリの数、解決先のバッファ、および結果を格納するバッファ内のオフセットを渡します。

    pass.end();

+    if (canTimestamp) {
+      encoder.resolveQuerySet(querySet, 0, querySet.count, resolveBuffer, 0);
+    }

また、resolveBufferresultsBufferにコピーして、マップしてJavaScriptで結果を確認できるようにしたいです。ただし、問題があります。マップされている間はresultsBufferにコピーできません。幸いなことに、バッファには確認できるmapStateプロパティがあります。unmapped(開始時の値)に設定されている場合は、コピーしても安全です。他の値は'pending'mapAsyncを呼び出した瞬間の値)と'mapped'mapAsyncが解決されたときの値)です。unmapすると、'unmapped'に戻ります。

    if (canTimestamp) {
      encoder.resolveQuerySet(querySet, 0, 2, resolveBuffer, 0);
+      if (resultBuffer.mapState === 'unmapped') {
+        encoder.copyBufferToBuffer(resolveBuffer, 0, resultBuffer, 0, resultBuffer.size);
+      }
    }

コマンドバッファを送信した後、resultBufferをマップできます。上記と同様に、'unmapped'の場合にのみマップしたいです。

+  let gpuTime = 0;

   ...

   function render(now) {

    ...

    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);

+    if (canTimestamp && resultBuffer.mapState === 'unmapped') {
+      resultBuffer.mapAsync(GPUMapMode.READ).then(() => {
+        const times = new BigInt64Array(resultBuffer.getMappedRange());
+        gpuTime = Number(times[1] - times[0]);
+        resultBuffer.unmap();
+      });
+    }

クエリセットの結果はナノ秒単位であり、64ビット整数で格納されます。JavaScriptでそれらを読み取るには、BigInt64Array型付き配列ビューを使用できます。BigInt64Arrayを使用するには、特別な注意が必要です。BitInt64Arrayから要素を読み取ると、型はnumberではなくbigintになるため、多くの数学関数では使用できません。また、数値に変換すると、numberは53ビットのサイズの整数しか保持できないため、精度が失われる可能性があります。したがって、まず2つのbigintを減算します。これはbigintのままです。次に、結果を数値に変換して、通常どおり使用できるようにします。

上記のコードでは、マップされていない場合にのみ、結果をresultBufferにコピーしています。つまり、一部のフレームでのみ時間を読み取ることになります。おそらく他のすべてのフレームですが、mapAsyncが解決されるまでにかかる時間については厳密な保証はありません。そのため、いつでも最後に記録された時間を取得するために使用できるgpuTimeを更新します。

    infoElem.textContent = `\
fps: ${(1 / deltaTime).toFixed(1)}
js: ${jsTime.toFixed(1)}ms
+gpu: ${canTimestamp ? `${(gpuTime / 1000).toFixed(1)}µs` : 'N/A'}
`;

そして、WebGPUからGPU時間を取得します。

私の場合、数値が頻繁に変化するため、有用なものは何も見えません。これを修正する1つの方法は、移動平均を計算することです。移動平均を計算するのに役立つクラスを次に示します。

// 注:これはタイムスタンプクエリに使用されるため、負の値は許可しません。
// クエリが終了時間より大きい開始時間を返す可能性があるためです。参照:https://gpuweb.github.io/gpuweb/#timestamp
class NonNegativeRollingAverage {
  #total = 0;
  #samples = [];
  #cursor = 0;
  #numSamples;
  constructor(numSamples = 30) {
    this.#numSamples = numSamples;
  }
  addSample(v) {
    if (!Number.isNaN(v) && Number.isFinite(v) && v >= 0) {
      this.#total += v - (this.#samples[this.#cursor] || 0);
      this.#samples[this.#cursor] = v;
      this.#cursor = (this.#cursor + 1) % this.#numSamples;
    }
  }
  get() {
    return this.#total / this.#samples.length;
  }
}

値の配列と合計を保持します。新しい値が追加されると、新しい値が追加されるときに、最も古い値が合計から減算されます。

次のように使用できます。

+const fpsAverage = new NonNegativeRollingAverage();
+const jsAverage = new NonNegativeRollingAverage();
+const gpuAverage = new NonNegativeRollingAverage();

function render(now) {
  ...

    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);

    if (canTimestamp && resultBuffer.mapState === 'unmapped') {
      resultBuffer.mapAsync(GPUMapMode.READ).then(() => {
        const times = new BigInt64Array(resultBuffer.getMappedRange());
        gpuTime = Number(times[1] - times[0]);
+        gpuAverage.addSample(gpuTime / 1000);
        resultBuffer.unmap();
      });
    }

    const jsTime = performance.now() - startTime;

+    fpsAverage.addSample(1 / deltaTime);
+    jsAverage.addSample(jsTime);

    infoElem.textContent = `\
-fps: ${(1 / deltaTime).toFixed(1)}
-js: ${jsTime.toFixed(1)}ms
-gpu: ${canTimestamp ? `${(gpuTime / 1000).toFixed(1)}µs` : 'N/A'}
+fps: ${fpsAverage.get().toFixed(1)}
+js: ${jsAverage.get().toFixed(1)}ms
+gpu: ${canTimestamp ? `${gpuAverage.get().toFixed(1)}µs` : 'N/A'}
`;

    requestAnimationFrame(render);
  }
  requestAnimationFrame(render);
}

そして、今では数値が少し安定しています。

ヘルパーの使用

私にとって、これはすべて少し面倒で、何かを間違えやすいと思います。クエリセットと2つのバッファの3つを作成する必要がありました。レンダーパス記述子を変更する必要がありました。結果を解決し、マップ可能なバッファにコピーする必要がありました。

これをあまり面倒でなくする1つの方法は、タイミングを行うのに役立つクラスを作成することです。これらの問題のいくつかに役立つ可能性のあるヘルパーの1つの例を次に示します。

function assert(cond, msg = '') {
  if (!cond) {
    throw new Error(msg);
  }
}

// コマンドバッファを追跡して、コマンドバッファが実行される前に
// 結果を読み取ろうとするとエラーを生成できるようにします。
const s_unsubmittedCommandBuffer = new Set();

/* global GPUQueue */
GPUQueue.prototype.submit = (function(origFn) {
  return function(commandBuffers) {
    origFn.call(this, commandBuffers);
    commandBuffers.forEach(cb => s_unsubmittedCommandBuffer.delete(cb));
  };
})(GPUQueue.prototype.submit);

// https://webgpufundamentals.org/webgpu/lessons/webgpu-timing.html を参照してください
export default class TimingHelper {
  #canTimestamp;
  #device;
  #querySet;
  #resolveBuffer;
  #resultBuffer;
  #commandBuffer;
  #resultBuffers = [];
  // stateは'free'、'need resolve'、'wait for result'のいずれかになります
  #state = 'free';

  constructor(device) {
    this.#device = device;
    this.#canTimestamp = device.features.has('timestamp-query');
    if (this.#canTimestamp) {
      this.#querySet = device.createQuerySet({
         type: 'timestamp',
         count: 2,
      });
      this.#resolveBuffer = device.createBuffer({
        size: this.#querySet.count * 8,
        usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
      });
    }
  }

  #beginTimestampPass(encoder, fnName, descriptor) {
    if (this.#canTimestamp) {
      assert(this.#state === 'free', 'state not free');
      this.#state = 'need resolve';

      const pass = encoder[fnName]({
        ...descriptor,
        ...{
          timestampWrites: {
            querySet: this.#querySet,
            beginningOfPassWriteIndex: 0,
            endOfPassWriteIndex: 1,
          },
        },
      });

      const resolve = () => this.#resolveTiming(encoder);
      const trackCommandBuffer = (cb) => this.#trackCommandBuffer(cb);
      pass.end = (function(origFn) {
        return function() {
          origFn.call(this);
          resolve();
        };
      })(pass.end);

      encoder.finish = (function(origFn) {
        return function() {
          const cb = origFn.call(this);
          trackCommandBuffer(cb);
          return cb;
        };
      })(encoder.finish);

      return pass;
    } else {
      return encoder[fnName](descriptor);
    }
  }

  beginRenderPass(encoder, descriptor = {}) {
    return this.#beginTimestampPass(encoder, 'beginRenderPass', descriptor);
  }

  beginComputePass(encoder, descriptor = {}) {
    return this.#beginTimestampPass(encoder, 'beginComputePass', descriptor);
  }

  #trackCommandBuffer(cb) {
    if (!this.#canTimestamp) {
      return;
    }
    assert(this.#state === 'need finish', 'you must call encoder.finish');
    this.#commandBuffer = cb;
    s_unsubmittedCommandBuffer.add(cb);
    this.#state = 'wait for result';
  }

  #resolveTiming(encoder) {
    if (!this.#canTimestamp) {
      return;
    }
    assert(
      this.#state === 'need resolve',
      'you must use timerHelper.beginComputePass or timerHelper.beginRenderPass',
    );
    this.#state = 'need finish';

    this.#resultBuffer = this.#resultBuffers.pop() || this.#device.createBuffer({
      size: this.#resolveBuffer.size,
      usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
    });

    encoder.resolveQuerySet(this.#querySet, 0, this.#querySet.count, this.#resolveBuffer, 0);
    encoder.copyBufferToBuffer(this.#resolveBuffer, 0, this.#resultBuffer, 0, this.#resultBuffer.size);
  }

  async getResult() {
    if (!this.#canTimestamp) {
      return 0;
    }
    assert(
      this.#state === 'wait for result',
      'you must call encoder.finish and submit the command buffer before you can read the result',
    );
    assert(!!this.#commandBuffer); // internal check
    assert(
      !s_unsubmittedCommandBuffer.has(this.#commandBuffer),
      'you must submit the command buffer before you can read the result',
    );
    this.#commandBuffer = undefined;
    this.#state = 'free';

    const resultBuffer = this.#resultBuffer;
    await resultBuffer.mapAsync(GPUMapMode.READ);
    const times = new BigInt64Array(resultBuffer.getMappedRange());
    const duration = Number(times[1] - times[0]);
    resultBuffer.unmap();
    this.#resultBuffers.push(resultBuffer);
    return duration;
  }
}

アサートは、このクラスを間違って使用しないようにするためのものです。たとえば、パスを終了しても解決しない場合や、解決して結果を読み取ろうとしても送信していない場合などです。

このクラスを使用すると、以前にあったコードの多くを削除できます。

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  const canTimestamp = adapter.features.has('timestamp-query');
  const device = await adapter?.requestDevice({
    requiredFeatures: [
      ...(canTimestamp ? ['timestamp-query'] : []),
     ],
  });
  if (!device) {
    fail('need a browser that supports WebGPU');
    return;
  }

+  const timingHelper = new TimingHelper(device);

  ...

-  const { querySet, resolveBuffer, resultBuffer } = (() => {
-    if (!canTimestamp) {
-      return {};
-    }
-
-    const querySet = device.createQuerySet({
-       type: 'timestamp',
-       count: 2,
-    });
-    const resolveBuffer = device.createBuffer({
-      size: querySet.count * 8,
-      usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
-    });
-    const resultBuffer = device.createBuffer({
-      size: resolveBuffer.size,
-      usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
-    });
-    return {querySet, resolveBuffer, resultBuffer };
-  })();

  ...

  function render(now) {

    ...

-    const pass = encoder.beginRenderPass(renderPassEncoder);
+    const pass = timingHelper.beginRenderPass(encoder, renderPassDescriptor);

    ...

    pass.end();

    -if (canTimestamp) {
    -  encoder.resolveQuerySet(querySet, 0, querySet.count, resolveBuffer, 0);
    -  if (resultBuffer.mapState === 'unmapped') {
    -    encoder.copyBufferToBuffer(resolveBuffer, 0, resultBuffer, 0, resultBuffer.size);
    -  }
    -}

    const commandBuffer = encoder.finish();
    device.queue.submit([commandBuffer]);

+    timingHelper.getResult().then(gpuTime => {
+        gpuAverage.addSample(gpuTime / 1000);
+    });

    ...

TimingHelperクラスに関するいくつかの点:

  • デバイスを作成するときに、'timestamp-query'機能をまだ手動で要求する必要がありますが、クラスはデバイスに存在するかどうかを処理します。

  • timerHelper.beginRenderPassまたはtimerHelper.beginComputePassを呼び出すと、パス記述子に適切なプロパティが自動的に追加されます。また、end関数がクエリを自動的に解決するパスエンコーダーも返します。

  • 間違って使用すると文句を言うように設計されています。

  • 1つのパスしか処理しません。

    ここには多くのトレードオフがあり、さらに調査しないと、何が最善かは明らかではありません。

    複数のパスを処理するクラスは便利かもしれませんが、理想的には、パスごとに1つのGPUQuerySetではなく、すべてのパスに十分なスペースを持つ単一のGPUQuerySetを使用します。

    しかし、そのためには、ユーザーに使用するパスの最大数を事前に伝えるか、コードをより複雑にして、小さなGPUQuerySetで開始し、さらに使用する場合はそれを削除して新しい大きなものを作成する必要があります。しかし、少なくとも1フレームについては、複数のGPUQuerySetを持つことを処理する必要があります。

    これらすべてはやり過ぎに思えたので、今のところは1つのパスを処理するようにし、変更する必要があると判断するまで、その上に構築できます。

NoTimingHelperを作成することもできます。

class NoTimingHelper {
  constructor() { }
  beginRenderPass(encoder, descriptor = {}) {
    return encoder.beginTimestampPass(descriptor);
  }

  beginComputePass(encoder, descriptor = {}) {
    return encoder.beginComputePass(descriptor);
  }
  async getResult() { return 0; }
}

タイミングを追加して、あまり多くのコードを変更せずにオフにできるようにする1つの可能な方法として。

いずれにせよ、TimingHelperクラスを使用して、画像ヒストグラムを計算するためのコンピュートシェーダーの使用に関する記事のさまざまな例を計時しました。それらのリストは次のとおりです。ビデオの例のみが継続的に実行されるため、おそらく最良の例です。

残りは一度だけ実行され、結果をJavaScriptコンソールに出力します。

重要:timestamp-queryの結果は実装定義です

これは、デバッグや手法の比較に使用できますが、すべてのユーザーに対して同様の結果を返すことを信頼できないことを意味します。相対的な結果さえも想定できません。異なるGPUは異なる方法で動作し、パス全体でレンダリングと計算を最適化できます。つまり、あるマシンでは、最初のパスで100個のものを描画するのに200µsかかり、2番目のパスで200個のものを描画するのに200µsかかる場合がありますが、別のGPUでは、最初の100個のものを描画するのに100µs、2番目の100個のものを描画するのに200µsかかる場合があります。したがって、最初のGPUの相対的な差は0µsでしたが、2番目のGPUの相対的な差は100µsでした。両方のGPUに同じものを描画するように依頼したにもかかわらずです。

デフォルトでは、'timestamp-query'の時間値は100µ秒に量子化されます。Chromeでは、about:flags「enable-webgpu-developer-features」を有効にすると、時間値が量子化されない場合があります。これにより、理論的にはより正確なタイミングが得られます。とはいえ、通常、100µ秒の量子化された値は、パフォーマンスのためにシェーダー手法を比較するのに十分なはずです。

  1. rAFrequestAnimationFrameの略です。 ↩︎

  2. これは、円の半径が0.5未満の場合にのみ機能しますが、サイズの複雑なチェックでコードを肥大化させないのが最善だと思われました。 ↩︎

  3. クエリ結果をマップ可能なバッファにコピーするのは、JavaScriptから値を読み取る目的でのみです。ユースケースが結果をGPU上に保持することのみを必要とする場合、たとえば、他の何かの入力として、結果をマップ可能なバッファにコピーする必要はありません。 ↩︎

問題点/バグ? githubでissueを作成.
comments powered by Disqus