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