Timestamp Queries

Timestamp Queries and GPU Timing

performance.now() around submit() measures only how long JavaScript took to record the work; the GPU runs it later (Encoders and Queues). To time the GPU, request the timestamp-query feature and have a pass write the GPU clock into a query set as it begins and ends:

The three pieces of a GPU timestamp measurementJavaScript
const device = await adapter.requestDevice({ requiredFeatures: ['timestamp-query'] });
const querySet = device.createQuerySet({ type: 'timestamp', count: 2 });
const pass = encoder.beginRenderPass({ colorAttachments,          // or beginComputePass()
  timestampWrites: { querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 } });
// ... draw and end the pass, then turn the two ticks into bytes a buffer can hold:
encoder.resolveQuerySet(querySet, 0, 2, resolveBuffer, 0);        // QUERY_RESOLVE usage
encoder.copyBufferToBuffer(resolveBuffer, 0, readBuffer, 0, 16);  // then mapAsync()

The values are 64-bit nanoseconds: read them with a BigUint64Array and subtract. Timing one full-screen pass eight times returned 131072 seven times and 65536 once, because Chrome 1 rounds every timestamp down to a multiple of 65,536 ns (about 0.066 ms) against timing attacks. (Chrome 121's announcement said 100 microseconds; the step measured on Chrome 154 is 2^16 ns.) With --enable-webgpu-developer-features, the switch behind chrome://flags/#enable-webgpu-developer-features, the same pass read 121,248 to 124,928 ns. Use the flag for your own profiling; users' browsers keep the rounding. A query set holds at most 4,096 queries, and resolveQuerySet() offsets must be multiples of 256.

Timing three render passes of growing cost with timestamp queries, the raw 64-bit ticks and their quantization shownHTMLLive
<!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);
}

// A fragment shader that loops 'work' times per pixel: the cost we want to measure.
const code = /* wgsl */ `
override work: u32 = 64;
@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 = vec3f(0);
  for (var k = 0u; k < work; k++) { c += sin(pos.xyx * 0.01 + f32(k) * 0.37) * 0.004; }
  return vec4f(vec3f(0.08, 0.40, 0.75) + c, 1);
}`;
const loops = [64, 256, 1024];

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 = loops.map((work) => device.createRenderPipeline({ layout: 'auto', vertex: { module },
    fragment: { module, targets: [{ format }], constants: { work } } }));
  const target = device.createTexture({ size: [256, 256], format, usage: GPUTextureUsage.RENDER_ATTACHMENT });

  let ticks = null, cpuMs = [];
  const encoder = device.createCommandEncoder();
  let querySet, resolveBuffer, readBuffer;
  if (hasTimestamps) {
    querySet = device.createQuerySet({ type: 'timestamp', count: 6 });   // begin + end per pass
    resolveBuffer = device.createBuffer({ size: 48, usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC });
    readBuffer = device.createBuffer({ size: 48, usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ });
  }
  pipelines.forEach((pipeline, i) => {
    const pass = encoder.beginRenderPass({ colorAttachments: [{ view: target.createView(), loadOp: 'clear', storeOp: 'store' }],
      ...(hasTimestamps && { timestampWrites: { querySet, beginningOfPassWriteIndex: i * 2, endOfPassWriteIndex: i * 2 + 1 } }) });
    pass.setPipeline(pipeline);
    pass.draw(3);
    pass.end();
  });
  if (hasTimestamps) {
    encoder.resolveQuerySet(querySet, 0, 6, resolveBuffer, 0);        // ticks -> bytes a buffer can hold
    encoder.copyBufferToBuffer(resolveBuffer, 0, readBuffer, 0, 48);
  }
  const t0 = performance.now();
  device.queue.submit([encoder.finish()]);
  cpuMs = performance.now() - t0;                                      // only JavaScript's recording time
  if (hasTimestamps) {
    await readBuffer.mapAsync(GPUMapMode.READ);
    ticks = [...new BigUint64Array(readBuffer.getMappedRange())];      // 64-bit nanoseconds
    readBuffer.unmap();
  }

  // Show the three results as bars, rendered with the cheapest pipeline into the canvas.
  const ns = ticks ? loops.map((_, i) => Number(ticks[i * 2 + 1] - ticks[i * 2])) : [0, 0, 0];
  const max = Math.max(...ns, 1);
  const out = device.createCommandEncoder();
  const pass = out.beginRenderPass({ colorAttachments: [{ view: context.getCurrentTexture().createView(),
    clearValue: [0.97, 0.96, 0.93, 1], loadOp: 'clear', storeOp: 'store' }] });
  pass.setPipeline(pipelines[0]);
  ns.forEach((value, i) => {
    const w = Math.max(value / max * 300, 3);
    pass.setViewport(170, 60 + i * 60, w, 34, 0, 1);
    pass.draw(3);
  });
  pass.end();
  device.queue.submit([out.finish()]);

  ink.font = 'bold 13px system-ui, sans-serif'; ink.fillStyle = '#222';
  ink.fillText(hasTimestamps ? 'GPU time per pass from timestampWrites (256 x 256 target)' : "No 'timestamp-query' feature on this adapter", 12, 30);
  ink.font = '12px system-ui, sans-serif';
  loops.forEach((n, i) => {
    ink.fillStyle = '#222'; ink.textAlign = 'right'; ink.fillText(`${n} loops per pixel`, 160, 82 + i * 60);
    ink.textAlign = 'left'; ink.fillStyle = '#444';
    if (ticks) ink.fillText(`${(ns[i] / 1e6).toFixed(3)} ms  (${ns[i].toLocaleString('en-US')} ns)`, 176 + Math.max(ns[i] / max * 300, 3), 82 + i * 60);
  });
  ink.fillStyle = '#555'; ink.font = '11.5px system-ui, sans-serif';
  if (ticks) {
    const quantum = ticks.map((t) => t % 65536n === 0n).every(Boolean);
    ink.fillText(`raw ticks, pass 1: ${ticks[0]} -> ${ticks[1]}${quantum ? ' (all multiples of 65,536 ns: coarsened for privacy)' : ''}`.slice(0, 96), 12, 262);
  }
  ink.fillText(`performance.now() around submit(): ${cpuMs.toFixed(2)} ms, which only measures JavaScript recording.`, 12, 284);
  ink.fillText('resolveQuerySet() into a QUERY_RESOLVE buffer, copy, mapAsync(), then BigUint64Array and subtract.', 12, 306);
}
main();
</script>