Frame Statistics

Reading Frame Statistics Without Stalling the Pipeline

Awaiting mapAsync() in a frame makes the CPU wait for the GPU: the stall you meant to measure. Instead, resolve each frame into an idle read buffer from a pool, submit, and let mapAsync().then() record the time and return the buffer a frame or two later (Reading Results). demos/ch04/frame-timer.html timed 60 frames of a fragment shader looping 256 times per pixel, then 60 looping 1,024 times: means of 0.120 and 0.484 ms, a ratio of 4.03, through just two read buffers (three in a later run), with no frame waiting.

Every sample was a multiple of 0.066 ms, yet the means matched the unrounded figures (0.124 and 0.484 ms with the developer flag): rounding errors average out. Five runs on this shared machine gave ratios of 3.78 to 4.03, so compare means and ratios, never one frame's absolute time.

Per-frame GPU timings gathered through a pool of read buffers, never awaited, as the workload switches between 256 and 1,024 loopsHTMLLive
<!doctype html>
<style>
  body { margin: 0; background: #f7f4ee; font: 14px system-ui, sans-serif; }
  .stage { position: relative; width: 100%; max-width: 600px; }
  .stage canvas { display: block; width: 100%; }
  .stage canvas + canvas { position: absolute; inset: 0; pointer-events: none; }
</style>
<div class="stage">
  <canvas id="view" width="600" height="340"></canvas>
  <canvas id="labels" width="600" height="340"></canvas>
</div>
<script>
const canvas = document.getElementById('view');
const ink = document.getElementById('labels').getContext('2d');

function showMessage(text) {                     // 2D fallback when WebGPU is missing
  const ctx = canvas.getContext('2d');
  ctx.fillStyle = '#fbeaea'; ctx.fillRect(0, 0, canvas.width, canvas.height);
  ctx.fillStyle = '#8a2b2b'; ctx.font = '18px system-ui, sans-serif'; ctx.textAlign = 'center';
  ctx.fillText(text, canvas.width / 2, canvas.height / 2);
}

const code = /* wgsl */ `
override work: u32 = 256;
@vertex fn vs(@builtin(vertex_index) v: u32) -> @builtin(position) vec4f {
  let p = array(vec2f(-1, -1), vec2f(3, -1), vec2f(-1, 3))[v];
  return vec4f(p, 0, 1);
}
@fragment fn fs(@builtin(position) pos: vec4f) -> @location(0) vec4f {
  var c = 0.0;
  for (var k = 0u; k < work; k++) { c += sin(pos.x * 0.02 + pos.y * 0.03 + f32(k) * 0.1); }
  return vec4f(0.85 + 0.1 * sin(c * 0.05), 0.88, 0.92, 1);
}`;

async function main() {
  const adapter = await navigator.gpu?.requestAdapter();
  if (!adapter) return showMessage('WebGPU is not available in this browser');
  const hasTimestamps = adapter.features.has('timestamp-query');
  const device = await adapter.requestDevice({ requiredFeatures: hasTimestamps ? ['timestamp-query'] : [] });
  const context = canvas.getContext('webgpu');
  const format = navigator.gpu.getPreferredCanvasFormat();
  context.configure({ device, format });
  const module = device.createShaderModule({ code });
  const pipelines = [256, 1024].map((work) => device.createRenderPipeline({ layout: 'auto', vertex: { module },
    fragment: { module, targets: [{ format }], constants: { work } } }));
  const querySet = hasTimestamps ? device.createQuerySet({ type: 'timestamp', count: 2 }) : null;
  const resolve = device.createBuffer({ size: 16, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
  const pool = [];                                   // idle read buffers; grows only if all are busy
  const samples = [];                                // [workload, ms]
  let created = 0, frames = 0;

  function frame() {
    const heavy = Math.floor(frames / 60) % 2 === 1; // 60 frames of each workload
    frames++;
    const encoder = device.createCommandEncoder();
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
      clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }],
      ...(querySet && { timestampWrites: { querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 } }) });
    pass.setPipeline(pipelines[heavy ? 1 : 0]);
    pass.setViewport(0, 40, 600, 150, 0, 1);
    pass.draw(3);
    pass.end();
    let target = null;
    if (querySet) {
      target = pool.pop() ?? (created++, device.createBuffer({ size: 16, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ }));
      encoder.resolveQuerySet(querySet, 0, 2, resolve, 0);
      encoder.copyBufferToBuffer(resolve, 0, target, 0, 16);
    }
    device.queue.submit([encoder.finish()]);
    if (target) {
      target.mapAsync(GPUMapMode.READ).then(() => {  // recorded a frame or two later; nothing waits
        const [begin, end] = new BigUint64Array(target.getMappedRange());
        samples.push([heavy, Number(end - begin) / 1e6]);
        if (samples.length > 240) samples.shift();
        target.unmap();
        pool.push(target);                           // back to the pool
      });
    }
    if (frames % 10 === 0) draw();
    requestAnimationFrame(frame);
  }
  function draw() {
    ink.clearRect(0, 0, 600, 340);
    ink.font = '12.5px system-ui, sans-serif'; ink.fillStyle = '#222';
    if (!querySet) { ink.fillText("No 'timestamp-query' feature here: nothing to read back.", 12, 24); return; }
    ink.fillText(`Top: the measured pass. Read buffers created: ${created}, idle now: ${pool.length}`, 12, 24);
    const max = Math.max(0.2, ...samples.map((s) => s[1]));
    samples.forEach(([heavy, ms], i) => {            // one bar per frame
      const h = ms / max * 90;
      ink.fillStyle = heavy ? '#d08a1c' : '#1f5f8b'; ink.fillRect(12 + i * 2.4, 300 - h, 2, h);
    });
    const mean = (w) => { const v = samples.filter((s) => s[0] === w).map((s) => s[1]); return v.length ? v.reduce((a, b) => a + b) / v.length : 0; };
    const light = mean(false), heavy = mean(true);
    ink.fillStyle = '#222';
    ink.fillText(`GPU ms per frame: blue 256 loops (mean ${light.toFixed(3)}), orange 1,024 loops (mean ${heavy.toFixed(3)})`, 12, 205);
    ink.fillStyle = '#1e6b3a';
    if (light > 0 && heavy > 0) ink.fillText(`ratio of means: ${(heavy / light).toFixed(2)}  (compare means and ratios, never one frame)`, 12, 326);
  }
  requestAnimationFrame(frame);
}
main();
</script>