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.

Lifecycle of a WebGPU timestamp query, from query set to differenced nanosecond deltas A single GPUQuerySet of type timestamp with a capacity of six queries sits in GPU memory. Each pass in the command encoder carries a timestampWrites descriptor that names the query set plus a beginning-of-pass write index and an end-of-pass write index: the compute scatter pass writes indices 0 and 1, the cull pass writes indices 2 and 3, and the render pass writes indices 4 and 5. At submit time the GPU stamps its nanosecond counter into each named slot as it crosses that pass boundary. After submission, resolveQuerySet copies the raw counters into a resolve buffer flagged QUERY_RESOLVE and COPY_SRC. That resolve buffer is copied into a second staging buffer flagged MAP_READ, which is mapped asynchronously with mapAsync. On the CPU the eight-byte BigUint64 values are read, and consecutive pairs are subtracted — index 1 minus index 0, index 3 minus index 2, index 5 minus index 4 — to yield the duration in nanoseconds of each pass, which is divided by one million for milliseconds and drawn as a live frame-budget overlay. GPUQuerySet · type "timestamp" · count 6 slot 0 · begin slot 1 · end slot 2 · begin slot 3 · end slot 4 · begin slot 5 · end compute scatter pass timestampWrites → 0, 1 compute cull pass timestampWrites → 2, 3 render pass timestampWrites → 4, 5 resolveQuerySet → resolve buf · QUERY_RESOLVE copy → staging buf MAP_READ · mapAsync() difference BigUint64 pairs slot[1]−slot[0] … → ns → ms overlay after queue.submit(), off the render-blocking path
One query set records begin and end counters per pass; resolve, copy, map, and difference turn them into per-pass milliseconds.

Prerequisites

Before wiring timestamps into a frame, you should have:

  1. A GPUDevice created with requiredFeatures: ['timestamp-query']. The feature must be listed in the adapter’s features set and requested at requestDevice time; you cannot enable it later. Negotiating optional features is part of initializing WebGPU devices for GIS workloads.
  2. 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.
  3. A cross-origin-isolated document for fine resolution. Without COOP/COEP headers 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.
  4. 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.
  5. A staging buffer strategy. Reading timestamps back requires a MAP_READ buffer, 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.

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

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

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

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

typescript
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"}) throws OperationError when 'timestamp-query' was not in requiredFeatures, or the whole device request rejects when the adapter lacks the feature. Detection: the throw fires synchronously at query-set creation, or requestDevice rejects. Fix: check adapter.features.has("timestamp-query") before requesting and only pass it when present, then fall back to performance.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 with COOP/COEP headers 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 - begin can 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, or mapAsync on a buffer lacking MAP_READ (or one still mapped from last frame), raises GPUValidationError. Detection: a synchronous validation error at resolveQuerySet or mapAsync. Fix: give the resolve buffer QUERY_RESOLVE | COPY_SRC, the staging buffer COPY_DST | MAP_READ, and gate the copy on mapState === "unmapped" as in Step 3.
  • Reading after unmap. Touching the BigUint64Array after unmap() throws because the backing memory is detached. Detection: TypeError on array access right after unmapping. Fix: copy the mapped range with .slice(0) before unmap, then read from the copy (Step 4).

Continue in this section

Up: Performance Tuning & Profiling for WebGPU Spatial Pipelines