Frame Profiling with WebGPU Timestamp Queries
A spatial pipeline that drops below 60 fps rarely tells you where the frame went. The browser’s performance.now() measures only CPU wall time — the interval spent recording commands — and returns long before the GPU has finished draining the queue you submitted. On a map that scatters ten million points into a density grid, culls a vector-tile set, and then paints three render passes, a submit() that returns in 0.4 ms can still cost 22 ms on the GPU. To see the real distribution you have to ask the device itself to stamp the clock at the boundary of each pass and hand those stamps back. That is exactly what the timestamp-query feature provides: a GPUQuerySet that the GPU writes nanosecond counters into as it crosses pass boundaries, which you resolve into a buffer, read back, and difference into per-pass durations.
This section is one stage of Performance Tuning & Profiling for WebGPU Spatial Pipelines. Profiling sits upstream of every other tuning decision here: you cannot rationally choose a workgroup occupancy target or a VRAM budget across zoom levels without first knowing which pass owns the frame. Because a timestamp is written at the boundary between GPU programs, the mental model here builds on the compute versus render pipeline fundamentals, and it assumes you already hold a GPUDevice negotiated during WebGPU device initialization for GIS workloads — because the feature must be requested at that step or it is unavailable for the life of the device.
Prerequisites
Before wiring timestamps into a frame, you should have:
- A
GPUDevicecreated withrequiredFeatures: ['timestamp-query']. The feature must be listed in the adapter’sfeaturesset and requested atrequestDevicetime; you cannot enable it later. Negotiating optional features is part of initializing WebGPU devices for GIS workloads. - A guard for absence. Not every adapter exposes
timestamp-query, and Safari gates it. Detect it before requesting, and degrade to CPU-only timing when it is missing — the detection recipe lives in feature-detecting timestamp-query support. - A cross-origin-isolated document for fine resolution. Without
COOP/COEPheaders many browsers quantize timestamps to a coarse bucket (often 100 µs) as a side-channel mitigation, which is too blunt to profile a sub-millisecond compute pass. - Passes you actually want to isolate. Decide up front which passes get a begin/end pair — a scatter pass, a cull pass, each render batch — because the query-set capacity is fixed at creation and every pass consumes two slots.
- A staging buffer strategy. Reading timestamps back requires a
MAP_READbuffer, and mapping stalls if you await it on the same buffer every frame; a small ring of staging buffers keeps readback off the critical path.
API and feature reference
The fields and constraints below govern every timestamp buffer and query set. The capacity and usage flags are the constraints that bite first.
| Field / rule | Value | Why it matters for frame profiling |
|---|---|---|
| Feature name | 'timestamp-query' |
Must appear in adapter.features and be passed to requiredFeatures; absent it, createQuerySet({type:'timestamp'}) throws. |
GPUQuerySet.type |
'timestamp' | 'occlusion' |
Use 'timestamp' for pass timing; 'occlusion' counts fragments and is unrelated here. |
GPUQuerySet.count |
2 × passes (≤ 4096) |
Each pass needs a begin and an end slot; size the set to the passes you profile, not per frame. |
timestampWrites |
{querySet, beginningOfPassWriteIndex, endOfPassWriteIndex} |
Attached to a compute or render pass descriptor; the GPU stamps those slots at pass boundaries. |
GPUCommandEncoder.writeTimestamp |
encoder-level, feature-gated | Writes one stamp between passes; gated behind timestamp-query and not universally shipped — prefer timestampWrites. |
| Resolve buffer usage | QUERY_RESOLVE | COPY_SRC |
resolveQuerySet writes here; COPY_SRC stages it to a mappable buffer. |
| Staging buffer usage | COPY_DST | MAP_READ |
The only buffer you may mapAsync; a QUERY_RESOLVE buffer cannot be mapped directly. |
| Timestamp value | BigUint64, nanoseconds |
Each slot is 8 bytes; resolved values are absolute nanosecond counters, differenced into durations. |
| Resolution | ns, may be quantized | Without cross-origin isolation the low bits are zeroed; deltas can read as 0 for short passes. |
For authoritative wording on query sets, resolveQuerySet, and the isolation requirement, consult the W3C WebGPU specification and the MDN GPUQuerySet reference.
Implementation walkthrough
The flow is five moves: create the query set, attach timestampWrites to each pass, resolve the raw counters into a buffer, copy that buffer to a mappable staging buffer, then map and difference. Everything after submit runs asynchronously, off the frame’s critical path.
Step 1 — Create the query set and its buffers once
Allocate the query set and both buffers at startup, sized to the worst case. A query set is cheap but not free to recreate, and mid-frame createBuffer fragments VRAM — the same allocator-churn trap covered in the aggregation work. Two slots per pass; here, three passes means a count of six.
const PASS_COUNT = 3; // scatter, cull, render
const QUERY_COUNT = PASS_COUNT * 2; // one begin + one end per pass
const querySet = device.createQuerySet({
type: "timestamp",
count: QUERY_COUNT,
});
// resolveQuerySet writes 8 bytes per query here.
const resolveBuffer = device.createBuffer({
size: QUERY_COUNT * 8,
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
});
// The only buffer we may mapAsync; a QUERY_RESOLVE buffer cannot be mapped.
const readbackBuffer = device.createBuffer({
size: QUERY_COUNT * 8,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
Keeping the query set independent of point count is deliberate: profiling overhead must not scale with the ten-million-point payload, or the instrument distorts the measurement.
Step 2 — Attach timestampWrites to each pass
The modern, portable way to stamp a pass is the timestampWrites descriptor on beginComputePass and beginRenderPass. It names the query set and the two slot indices to write at the pass’s start and end. The GPU fills them as it crosses those boundaries — no per-draw instrumentation, no shader changes.
function recordFrame(): void {
const encoder = device.createCommandEncoder();
// Pass 0 → slots 0,1. The spatial scatter/aggregation pass.
const scatter = encoder.beginComputePass({
timestampWrites: { querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 },
});
scatter.setPipeline(scatterPipeline);
scatter.setBindGroup(0, scatterBindGroup);
scatter.dispatchWorkgroups(Math.ceil(pointCount / 256));
scatter.end();
// Pass 1 → slots 2,3. Viewport culling of vector tiles.
const cull = encoder.beginComputePass({
timestampWrites: { querySet, beginningOfPassWriteIndex: 2, endOfPassWriteIndex: 3 },
});
cull.setPipeline(cullPipeline);
cull.setBindGroup(0, cullBindGroup);
cull.dispatchWorkgroups(Math.ceil(tileCount / 64));
cull.end();
// Pass 2 → slots 4,5. The map render pass.
const render = encoder.beginRenderPass({
colorAttachments: [colorAttachment],
timestampWrites: { querySet, beginningOfPassWriteIndex: 4, endOfPassWriteIndex: 5 },
});
render.setPipeline(renderPipeline);
render.setBindGroup(0, renderBindGroup);
render.draw(vertexCount);
render.end();
resolveAndSubmit(encoder);
}
Each pass carries its own begin/end pair, so the deltas isolate exactly that pass — the scatter’s atomic contention, the cull’s branch divergence, the render’s fill cost — instead of one opaque frame total. This per-pass attribution is the whole point: it is what lets you decide whether to spend effort on workgroup occupancy for spatial kernels or on the render side.
Step 3 — Resolve the query set into a buffer
resolveQuerySet is a command, recorded on the same encoder after the passes end. It copies the raw counters out of the opaque query set into your QUERY_RESOLVE buffer, then a copyBufferToBuffer stages them into the mappable readback buffer. Both are queue operations, so they run in submission order after the passes they measure.
function resolveAndSubmit(encoder: GPUCommandEncoder): void {
// Move counters out of the opaque query set into a readable buffer.
encoder.resolveQuerySet(querySet, 0, QUERY_COUNT, resolveBuffer, 0);
// Only mappable buffers can be read on the CPU; stage the resolved bytes across.
// Guard the copy: mapAsync leaves the buffer unmappable until unmap() completes.
if (readbackBuffer.mapState === "unmapped") {
encoder.copyBufferToBuffer(resolveBuffer, 0, readbackBuffer, 0, QUERY_COUNT * 8);
}
device.queue.submit([encoder.finish()]);
readTimestamps(); // async; does not block this frame
}
The mapState guard matters: if the previous frame’s mapAsync has not resolved, the readback buffer is still mapped and the copy would raise a validation error. Skipping a frame’s readback is harmless — a profiler samples, it does not need every frame.
Step 4 — Map, difference, and derive per-pass milliseconds
After submission, mapAsync resolves once the GPU has drained past the copy. Read the mapped range as a BigUint64Array, subtract each pass’s begin from its end, and convert nanoseconds to milliseconds. Because the values are absolute counters, only the difference is meaningful; the counters’ origin is arbitrary and may reset.
interface PassTimings {
scatterMs: number;
cullMs: number;
renderMs: number;
}
async function readTimestamps(): Promise<void> {
if (readbackBuffer.mapState !== "unmapped") return; // still in flight
await readbackBuffer.mapAsync(GPUMapMode.READ);
const ns = new BigUint64Array(readbackBuffer.getMappedRange().slice(0));
readbackBuffer.unmap(); // release immediately so the next frame can copy
const toMs = (end: bigint, begin: bigint): number => Number(end - begin) / 1e6;
const timings: PassTimings = {
scatterMs: toMs(ns[1], ns[0]),
cullMs: toMs(ns[3], ns[2]),
renderMs: toMs(ns[5], ns[4]),
};
updateBudgetOverlay(timings);
}
Calling .slice(0) copies the mapped bytes before unmap invalidates the view — reading the BigUint64Array after unmap throws. Unmapping immediately is what lets Step 3’s guard copy into the same buffer next frame without a growing ring.
Step 5 — Overlay a live frame budget
Per-pass milliseconds are only actionable against a budget. At 60 fps the whole frame must fit inside 16.7 ms; subtract vsync and compositor overhead and the GPU budget is closer to 14 ms. Draw each pass as a segment of a stacked bar against that ceiling so a regression is visible the instant a pass crosses its share.
const FRAME_BUDGET_MS = 14.0; // GPU share of a 60 fps frame after compositor overhead
function updateBudgetOverlay(t: PassTimings): void {
const total = t.scatterMs + t.cullMs + t.renderMs;
const overBudget = total > FRAME_BUDGET_MS;
const pct = (ms: number): string => `${((ms / FRAME_BUDGET_MS) * 100).toFixed(0)}%`;
overlay.render([
{ label: "scatter", width: pct(t.scatterMs), color: "coral" },
{ label: "cull", width: pct(t.cullMs), color: "gold" },
{ label: "render", width: pct(t.renderMs), color: "violet" },
], { total, overBudget }); // flag red when the stack exceeds 100% of the budget
}
Because the readback trails the frame it measured by one or two frames, the overlay shows a rolling recent history rather than the in-flight frame — which is exactly what you want when hunting an intermittent spike triggered by a pan or a zoom.
Memory and performance implications
The instrument’s footprint is tiny and fixed: a six-query set plus two 48-byte buffers, independent of the ten-million-point payload flowing through the passes. That invariance is the design rule — profiling cost must never scale with data, or the measurement drifts as the dataset grows. The real cost is the readback latency, not bytes: mapAsync completes only after the GPU drains past the copyBufferToBuffer, so the timings you read describe a frame one to three submissions in the past. Sampling every frame is unnecessary and, if you await on the render-blocking path, actively harmful; sample every few frames and keep the map off the critical path.
Timestamp writes themselves add a small, real overhead — the GPU flushes to record the counter at each boundary, which lightly serializes adjacent passes. On most spatial workloads this is under a few percent and constant, so relative comparisons between passes stay valid, but absolute numbers run slightly pessimistic. Profile in a build you can toggle off, and never ship the readback loop enabled by default. The one place the overhead distorts conclusions is a chain of very short passes: stamping around a 20 µs pass can cost as much as the pass, so coarsen the granularity — one pair around a group of tiny dispatches — rather than instrumenting each. Tuning the passes the timings expose, especially the compute side, is the work of workgroup occupancy optimization for spatial kernels, and the render side often traces back to VRAM pressure covered in VRAM budget management across tile zoom levels.
Failure modes and diagnostics
- Feature not supported.
device.createQuerySet({type:"timestamp"})throwsOperationErrorwhen'timestamp-query'was not inrequiredFeatures, or the whole device request rejects when the adapter lacks the feature. Detection: the throw fires synchronously at query-set creation, orrequestDevicerejects. Fix: checkadapter.features.has("timestamp-query")before requesting and only pass it when present, then fall back toperformance.now()CPU timing — the guard pattern is in feature-detecting timestamp-query support. - Quantized to zero (coarse timestamp period). Outside a cross-origin-isolated context, browsers zero the low bits of the counter as a side-channel defense, so a sub-100 µs pass differences to exactly
0n. Detection: short passes read as 0 ms while long ones look plausible. Fix: serve the document withCOOP/COEPheaders to earn fine resolution, or coarsen instrumentation so each measured span is well above the quantization bucket. - Disjoint / non-monotonic timestamps. The spec permits a counter to be invalid across a GPU clock or power-state change, so
end - begincan be negative or absurdly large. Detection: a duration that is negative or spikes into seconds for one sample. Fix: discard any delta that is negative or exceeds a sane ceiling (say 100 ms) and keep the last valid sample; never divide by or accumulate a disjoint value. - Validation error on resolve or map. Resolving into a buffer without
QUERY_RESOLVE, ormapAsyncon a buffer lackingMAP_READ(or one still mapped from last frame), raisesGPUValidationError. Detection: a synchronous validation error atresolveQuerySetormapAsync. Fix: give the resolve bufferQUERY_RESOLVE | COPY_SRC, the staging bufferCOPY_DST | MAP_READ, and gate the copy onmapState === "unmapped"as in Step 3. - Reading after unmap. Touching the
BigUint64Arrayafterunmap()throws because the backing memory is detached. Detection:TypeErroron array access right after unmapping. Fix: copy the mapped range with.slice(0)beforeunmap, then read from the copy (Step 4).
Continue in this section
- Measuring Compute Pass Duration with Timestamp Query — a complete, runnable reference that times a single spatial compute pass end to end, from query set to a stable millisecond reading.
- Finding Render Pass Bottlenecks with GPU Timestamps — attributing frame time across multiple render passes and draw batches to locate the fill-bound or overdraw-bound stage of a map.
Related
- Performance Tuning & Profiling for WebGPU Spatial Pipelines — the wider tuning workflow that these timings feed every decision in.
- Workgroup Occupancy Optimization for Spatial Kernels — where a compute pass the timestamps flag as hot gets tuned.
- VRAM Budget Management Across Tile Zoom Levels — the memory pressure that often explains a render pass gone slow.
- Feature-Detecting timestamp-query Support — guarding for the feature and degrading to CPU timing when it is absent.
- WebGPU Device Initialization for GIS Workloads — where
timestamp-querymust be requested for any of this to work.
Up: Performance Tuning & Profiling for WebGPU Spatial Pipelines