Finding Render Pass Bottlenecks with GPU Timestamps
A map that stutters on pan usually paints in several render passes — a basemap fill, a vector-line layer, a symbol/label layer, and a post-process composite — and the stutter lives in exactly one of them. The frame total tells you the map is slow; it does not tell you that the label pass is doing three times the fill work of everything else because of overdraw. To attribute the cost you give each render pass its own begin/end pair in a shared timestamp query set, resolve them together after submission, and difference each pair into a per-pass millisecond figure. This page is a runnable profiler that instruments an ordered list of render passes for a map, reads them back off the critical path, and reports which stage owns the frame; the query-set lifecycle it relies on is detailed in the parent reference on frame profiling with WebGPU timestamp queries.
Runnable reference implementation
The RenderPassProfiler takes a declarative list of render stages — each a label plus a record callback — allocates a query set sized to two slots per stage, and stamps every pass with a distinct begin/end index pair. After submission it resolves all counters in one resolveQuerySet, maps the readback buffer once, and differences each pair, so N render passes cost exactly one resolve, one copy, and one map per profiled frame. Every stage’s duration is smoothed independently and ranked, so hottest() names the bottleneck directly.
export interface RenderStage {
label: string;
// Record the stage's draws into the instrumented pass. Must set pipeline,
// bind groups, vertex/index buffers, and draw — but not call pass.end().
record: (pass: GPURenderPassEncoder) => void;
colorAttachments: GPURenderPassColorAttachment[];
depthStencilAttachment?: GPURenderPassDepthStencilAttachment;
}
interface StageStat { label: string; emaMs: number; lastMs: number; }
export class RenderPassProfiler {
private readonly querySet: GPUQuerySet;
private readonly resolveBuffer: GPUBuffer;
private readonly readbackBuffer: GPUBuffer;
private readonly supported: boolean;
private readonly slotBytes = 8;
private stats: StageStat[] = [];
private static readonly MAX_NS = 200_000_000n; // 200 ms ceiling rejects disjoint counters
private static readonly ALPHA = 0.15;
constructor(private readonly device: GPUDevice, private readonly stageCount: number) {
this.supported = device.features.has("timestamp-query");
const queryCount = stageCount * 2; // begin + end per render pass
if (!this.supported) {
this.querySet = undefined as unknown as GPUQuerySet;
this.resolveBuffer = undefined as unknown as GPUBuffer;
this.readbackBuffer = undefined as unknown as GPUBuffer;
return;
}
this.querySet = device.createQuerySet({ type: "timestamp", count: queryCount });
this.resolveBuffer = device.createBuffer({
size: queryCount * this.slotBytes,
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
});
this.readbackBuffer = device.createBuffer({
size: queryCount * this.slotBytes,
usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
});
}
/** Encode every render stage in order, each timed with its own slot pair. */
profileFrame(stages: RenderStage[]): void {
if (stages.length !== this.stageCount) {
throw new Error(`expected ${this.stageCount} stages, got ${stages.length}`);
}
const encoder = this.device.createCommandEncoder();
stages.forEach((stage, i) => {
const descriptor: GPURenderPassDescriptor = {
colorAttachments: stage.colorAttachments,
depthStencilAttachment: stage.depthStencilAttachment,
};
if (this.supported) {
// Stage i writes into slots 2i (begin) and 2i+1 (end).
descriptor.timestampWrites = {
querySet: this.querySet,
beginningOfPassWriteIndex: i * 2,
endOfPassWriteIndex: i * 2 + 1,
};
}
const pass = encoder.beginRenderPass(descriptor);
stage.record(pass);
pass.end();
});
if (!this.supported) {
this.device.queue.submit([encoder.finish()]);
return;
}
const total = this.stageCount * 2;
encoder.resolveQuerySet(this.querySet, 0, total, this.resolveBuffer, 0);
const canRead = this.readbackBuffer.mapState === "unmapped";
if (canRead) {
encoder.copyBufferToBuffer(
this.resolveBuffer, 0, this.readbackBuffer, 0, total * this.slotBytes,
);
}
this.device.queue.submit([encoder.finish()]);
if (canRead) void this.readBack(stages.map((s) => s.label));
}
private async readBack(labels: string[]): Promise<void> {
try {
await this.readbackBuffer.mapAsync(GPUMapMode.READ);
} catch {
return; // device lost mid-flight; drop this sample
}
const ns = new BigUint64Array(this.readbackBuffer.getMappedRange().slice(0));
this.readbackBuffer.unmap();
const next: StageStat[] = [];
for (let i = 0; i < labels.length; i++) {
const delta = ns[i * 2 + 1] - ns[i * 2];
const prev = this.stats.find((s) => s.label === labels[i]);
if (delta <= 0n || delta > RenderPassProfiler.MAX_NS) {
// Disjoint or quantized sample: keep the previous reading unchanged.
next.push(prev ?? { label: labels[i], emaMs: 0, lastMs: 0 });
continue;
}
const ms = Number(delta) / 1e6;
const ema = prev
? RenderPassProfiler.ALPHA * ms + (1 - RenderPassProfiler.ALPHA) * prev.emaMs
: ms;
next.push({ label: labels[i], emaMs: ema, lastMs: ms });
}
this.stats = next;
}
/** The render stage currently costing the most GPU time. */
hottest(): StageStat | null {
if (this.stats.length === 0) return null;
return this.stats.reduce((a, b) => (b.emaMs > a.emaMs ? b : a));
}
report(): StageStat[] {
return [...this.stats].sort((a, b) => b.emaMs - a.emaMs);
}
}
Driving it is a matter of declaring the map’s paint order once and handing it to profileFrame each frame. The profiler owns the pass boundaries; each stage’s callback records only its own draws and batches:
const profiler = new RenderPassProfiler(device, 4);
function paintMap(view: MapView): void {
profiler.profileFrame([
{ label: "basemap-fill", colorAttachments: [loadColor], record: (p) => {
p.setPipeline(fillPipeline);
p.setBindGroup(0, view.tileBind);
p.draw(view.fillVertexCount); // large area coverage — fill-bound suspect
} },
{ label: "vector-lines", colorAttachments: [keepColor], record: (p) => {
p.setPipeline(linePipeline);
p.setBindGroup(0, view.lineBind);
p.drawIndexed(view.lineIndexCount);
} },
{ label: "symbols-labels", colorAttachments: [keepColor], record: (p) => {
p.setPipeline(symbolPipeline);
p.setBindGroup(0, view.symbolBind);
p.draw(4, view.symbolCount); // instanced glyph quads — overdraw suspect
} },
{ label: "composite", colorAttachments: [screenColor], record: (p) => {
p.setPipeline(compositePipeline);
p.setBindGroup(0, view.compositeBind);
p.draw(3); // full-screen triangle
} },
]);
const hot = profiler.hottest();
if (hot) hud.set(`bottleneck: ${hot.label} @ ${hot.emaMs.toFixed(2)} ms`);
}
Splitting the label stage from the fill stage into separate passes is what makes the attribution possible: because each pass carries its own slot pair, a symbol pass gone hot from glyph overdraw shows up as its own bar instead of hiding inside a lumped frame total. When one stage dominates, the fix is stage-specific — reducing overdraw in the symbol pass, cutting draw-call count with instancing, or trimming fill by culling tiles before they reach the render list.
Reading the attribution
A render pass’s cost decomposes into two axes the timestamp total does not separate on its own, but which its shape across zoom and viewport size reveals. A fill-bound pass — a basemap covering the whole viewport — scales with pixel count: its milliseconds roughly double when the canvas area doubles and barely move when you add geometry. An overdraw-bound pass — translucent labels or stacked line casings — scales with how many times each pixel is shaded, so it climbs steeply as symbols crowd together on zoom-in while the viewport stays the same size. Watch how each stage’s emaMs responds to a resize versus a zoom, and the bar that grows with area is fill-bound while the bar that grows with density is overdraw-bound. That distinction chooses the remedy: a fill-bound composite wants a lower-resolution intermediate target, whereas an overdraw-bound symbol pass wants early depth rejection or fewer overlapping quads.
The vertex side hides a third case. A pass that is neither fill- nor overdraw-bound but still slow is usually draw-call-bound: thousands of tiny draw calls, each with its own bind-group switch, starve the GPU of parallel work. The timestamp shows a fat bar with low apparent pixel cost; the fix is to collapse the batch into an instanced or indirect draw, which the deck.gl integration work on indirect draw calls for instanced layers covers directly. Ranking the stages with report() first, then classifying the top stage by its scaling behaviour, turns a vague “the map is slow” into a specific, actionable target every time.
Parameter reference
Every tunable value in the profiler, with guidance for map render workloads. These tables scroll horizontally on narrow viewports.
| Parameter | Value | Guidance |
|---|---|---|
stageCount |
count of render passes | Fixed at construction; the query set is 2 × stageCount. Merge tiny passes rather than instrumenting each. |
queryCount |
stageCount * 2 |
One begin + one end slot per pass; stay within the device maxQuerySetCount-equivalent (≤ 4096 slots). |
| Slot indexing | 2i, 2i + 1 |
Stage i begins at 2i, ends at 2i+1; a mismatched index silently mis-attributes durations. |
ALPHA (EMA weight) |
0.15 |
Lower for a steadier bottleneck HUD; render passes are noisier than compute, so keep it low. |
MAX_NS ceiling |
200_000_000n (200 ms) |
Higher than the compute ceiling because a stalled render pass legitimately runs longer; still rejects disjoint counters. |
Attachment loadOp |
"load" vs "clear" |
Only the first pass clears; later passes load the existing target. A stray clear erases prior stages and skews their apparent cost. |
| Sample cadence | every frame, read when free | The mapState gate throttles readback automatically; never await the map on it. |
Splitting into separate beginRenderPass calls per stage has a real cost — each pass reloads and stores its attachments — so profile with the same pass structure you ship, not a finer one, or the instrument inflates the very numbers it reports. For authoritative wording on render pass descriptors and timestampWrites, consult the W3C WebGPU specification and the MDN GPURenderPassEncoder reference.
Failure modes
- A stage reads far too cheap because a later pass cleared its target. Giving a mid-list pass
loadOp: "clear"wipes what earlier stages drew, so their pixels never get sampled and their timed cost looks artificially low while the frame still looks wrong. Detection: an early stage’s milliseconds drop while the map visibly loses a layer. Fix: only the first pass writing a target clears it; every subsequent stage usesloadOp: "load". - All render deltas quantize to zero. Outside a cross-origin-isolated context, sub-100 µs passes — a cheap composite triangle — difference to
0nand the guard holds the previous reading, soreport()shows a stale or zero bar. Detection: cheap passes sit at exactly 0.00 ms while heavy ones read plausibly. Fix: serve withCOOP/COEPheaders, or accept that only the expensive stages are measurable without isolation. - Bottleneck ranking flickers between two stages. Two passes of similar cost swap the top spot every readback because per-frame render timing is noisy. Detection:
hottest()alternates label frame to frame. Fix: lowerALPHAtoward 0.1 so the moving average settles, and rank onemaMs, neverlastMs. GPUValidationErrorat resolve after a resize. Recreating attachments on a canvas resize without matching the profiler’sstageCount, or resolving more slots than the query set holds, raises a validation error. Detection: a synchronous throw atresolveQuerySetright after a resize. Fix: reconstruct the profiler when the pass list changes soqueryCountalways equals2 × stageCount.
Backend / Python interop note
When a tile-serving backend renders map previews headless under wgpu-py, the same profiler structure ports directly: create_query_set, resolve_query_set, and buffer mapping expose the identical per-pass slot pattern. Feed the server a fixed viewport and tile set on every run so stage timings are comparable across deploys, and export the sorted report() as a per-stage metric so a regression in, say, the symbol pass after a font-atlas change is caught in CI rather than in production. Keep the render list identical between the headless profiler and the shipped client, since a different pass split produces non-comparable attribution.
Related
- Frame Profiling with WebGPU Timestamp Queries — the query-set lifecycle and frame-budget overlay this render profiler builds on.
- Measuring Compute Pass Duration with Timestamp Query — the compute-side counterpart for timing a single spatial dispatch.
- VRAM Budget Management Across Tile Zoom Levels — the memory pressure that most often explains a render pass gone slow.
- WebGPU Compute vs Render Pipeline Fundamentals — why each render pass is a distinct boundary a timestamp can bracket.