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:
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.
<!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>