Performance Tuning & Profiling for WebGPU Spatial Pipelines

Profile frame time with timestamp queries, budget VRAM across tile zoom levels, and tune workgroup occupancy for spatial GPU kernels.

Continental-scale spatial visualization pushes a browser to the edge of two independent budgets at once: a 16.6 ms frame window that governs whether a pan feels smooth, and a fixed VRAM ceiling that governs how many tiles can stay resident before the driver starts thrashing. A pipeline that renders one metropolitan extent at 120 fps can collapse to single-digit frame rates the moment the viewport widens to a whole country and ten million features stream in behind it. The difference between those two outcomes is almost never a single hot function — it is the interaction of CPU submission overhead, compute-pass duration, render-pass overdraw, and buffer residency, each of which moves independently as the user zooms. Tuning that system means measuring it precisely, attributing each millisecond to the pass that spent it, and pulling the one lever that actually governs the current bottleneck rather than the one that is easiest to reach. This reference establishes the measurement methodology, the budgeting model, and the kernel-level tuning discipline required to hold interactive frame rates on production spatial deployments, and it indexes the deeper walkthroughs that cover each technique in isolation.

The work here assumes the foundations are already in place. It builds directly on the device and memory model of WebGPU Architecture for Spatial Visualization, profiles the WGSL kernels developed in Spatial Compute Shaders & Geometry Pipelines, and measures the frame that Framework Integration & Backend Synchronization assembles from framework state and streamed backend payloads. Those three areas define what runs each frame; this one defines how to prove where the time and memory go, and how to get them back.

Annotated WebGPU frame timeline with timestamp query points and the 60 fps budget ceiling A single frame runs left to right along a millisecond axis from zero to sixteen point six milliseconds. The CPU timeline shows one encode-and-submit bar occupying the first three milliseconds. The GPU timeline shows three consecutive bars: a compute pass for spatial kernels, a render pass, and a present step. Four timestamp query points mark the pass boundaries: Q0 at the start of the compute pass, Q1 at its end, Q2 at the start of the render pass, and Q3 at its end. The compute-pass duration is Q1 minus Q0 and the render-pass duration is Q3 minus Q2. A dashed vertical ceiling at sixteen point six milliseconds marks the sixty frames-per-second present deadline; all GPU work must finish to its left. CPU GPU encode + submit compute pass spatial kernels render pass rasterize + shade present Q0 Q1 Q2 Q3 0 4 8 12 16 ms 16.6 ms — 60 fps present deadline Δ compute = Q1 − Q0   ·   Δ render = Q3 − Q2   ·   CPU submit measured host-side Every pass boundary carries a query; the frame is over budget the instant any bar crosses the ceiling.
One frame, instrumented: timestamp queries at each pass boundary attribute GPU time to the compute and render passes, while the present deadline fixes the ceiling every bar must fit under.

The timeline above is the mental model the rest of this reference operates on. CPU submission time is measured on the host with ordinary timers; GPU pass durations are recovered from timestamp queries written at pass boundaries; and the present deadline is a hard ceiling, not a target. The three core sections below each take one axis of this picture — measuring it, budgeting the memory that feeds it, and tuning the kernels that fill it — and the production section ties them into a single decision procedure.

Measuring the Frame with Timestamp Queries

CPU-side timers around queue.submit tell you when work was handed off, not when the GPU finished it, because submission is asynchronous. The only trustworthy way to attribute GPU time to a specific pass is a GPUQuerySet of type timestamp, which records a device-side clock value at the beginning and end of each pass. Recovering those values is a self-contained discipline covered in full under frame profiling with timestamp queries; the mechanics matter because a naive implementation stalls the pipeline it is trying to measure.

The feature must be requested at device creation (requiredFeatures: ["timestamp-query"]), and the resolve target cannot also be the mappable buffer — QUERY_RESOLVE and MAP_READ are incompatible usages — so the results are resolved into one buffer and copied into a second, CPU-readable one. Critically, the read is mapped asynchronously and consumed a frame late, so profiling never blocks submission.

typescript
// frame-profiler.ts — GPU-side timing via a timestamp query set.
const CAPACITY = 4; // Q0..Q3: compute begin/end, render begin/end

export class FrameProfiler {
  private querySet: GPUQuerySet;
  private resolveBuffer: GPUBuffer; // QUERY_RESOLVE target, u64 per slot
  private readBuffer: GPUBuffer;    // separate CPU-mappable copy

  constructor(private device: GPUDevice) {
    // Throws unless the device was created with requiredFeatures: ["timestamp-query"].
    this.querySet = device.createQuerySet({ type: "timestamp", count: CAPACITY });
    this.resolveBuffer = device.createBuffer({
      size: CAPACITY * 8, // 8 bytes per u64 timestamp
      usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
    });
    this.readBuffer = device.createBuffer({
      size: CAPACITY * 8, // MAP_READ cannot coexist with QUERY_RESOLVE — hence two buffers
      usage: GPUBufferUsage.COPY_DST | GPUBufferUsage.MAP_READ,
    });
  }

  // Hand these descriptors to beginComputePass / beginRenderPass.
  computeWrites(): GPUComputePassTimestampWrites {
    return { querySet: this.querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 };
  }
  renderWrites(): GPURenderPassTimestampWrites {
    return { querySet: this.querySet, beginningOfPassWriteIndex: 2, endOfPassWriteIndex: 3 };
  }

  // Resolve + stage a copy once per frame, after both passes have ended.
  resolve(encoder: GPUCommandEncoder): void {
    encoder.resolveQuerySet(this.querySet, 0, CAPACITY, this.resolveBuffer, 0);
    encoder.copyBufferToBuffer(this.resolveBuffer, 0, this.readBuffer, 0, CAPACITY * 8);
  }

  // Read the PREVIOUS frame's timings; never awaited on the submit path.
  async read(): Promise<{ computeMs: number; renderMs: number }> {
    await this.readBuffer.mapAsync(GPUMapMode.READ);
    const t = new BigUint64Array(this.readBuffer.getMappedRange());
    const computeMs = Number(t[1] - t[0]) / 1e6; // ns ticks → ms
    const renderMs = Number(t[3] - t[2]) / 1e6;
    this.readBuffer.unmap();
    return { computeMs, renderMs };
  }
}

The performance implications are subtle. Timestamp values are 64-bit nanosecond ticks, so the subtraction must happen in BigUint64Array before narrowing to a Number — collapsing to Float64 first silently loses the low bits on a busy GPU whose clock has drifted far from zero. The resolve step itself costs GPU time, so it belongs after both passes in the same encoder, resolved once rather than per pass. And because the browser quantizes timestamps to blunt timing side channels, treat individual deltas as relative and average across several frames before acting on them; a single frame’s computeMs is noisy, but a hundred-frame trailing mean cleanly separates a compute-bound frame from a render-bound one. That separation is exactly the input the production decision procedure consumes, and it is what tells you whether to reach for occupancy tuning or overdraw reduction. When the numbers say the GPU is idle and the CPU is saturated, the question shifts to whether the work belongs on the GPU at all — the trade studied in compute shader versus CPU spatial indexing.

Budgeting VRAM Across Tile Zoom Levels

Frame time is only half the budget. A continental viewport at high zoom can request more tile geometry than the adapter’s VRAM can hold, and once allocations exceed physical memory the driver either fails the allocation or silently pages over the bus, converting a rendering problem into a stuttering one. The defense is an explicit residency budget: estimate the byte footprint of every tile before it is uploaded, keep a running total, and evict least-recently-used buffers the moment a new upload would breach the ceiling. The full treatment — including per-zoom footprint models and eviction hysteresis — lives under VRAM budget management across tile zoom levels.

The footprint model is the load-bearing part. Vector tile vertex counts scale roughly fourfold per zoom level as each tile subdivides into four children, so a fixed count of resident tiles at high zoom costs far more VRAM than the same count at low zoom. A budget expressed in tiles is therefore meaningless; it must be expressed in bytes.

typescript
// vram-budget.ts — bound resident tile VRAM with an LRU across zoom levels.
interface TileKey { z: number; x: number; y: number; }

const BYTES_PER_VERTEX = 20; // vec2<f32> pos (8) + u32 id (4) + vec2<f32> attr (8)

// Vertex count grows ~4x per zoom level as tiles subdivide; z10 is the reference.
function estimateTileBytes(z: number, featuresAtRefZoom: number): number {
  const growth = Math.pow(4, Math.max(0, z - 10));
  return Math.ceil(featuresAtRefZoom * growth) * BYTES_PER_VERTEX;
}

export class VramTileCache {
  // A JS Map preserves insertion order, so its head is the LRU eviction victim.
  private resident = new Map<string, { buffer: GPUBuffer; bytes: number }>();
  private used = 0;

  constructor(private budgetBytes: number) {}

  private key(k: TileKey): string { return `${k.z}/${k.x}/${k.y}`; }

  insert(k: TileKey, buffer: GPUBuffer, bytes: number): void {
    this.evictUntil(this.budgetBytes - bytes); // make room BEFORE inserting
    this.resident.set(this.key(k), { buffer, bytes });
    this.used += bytes;
  }

  touch(k: TileKey): void { // promote on access so hot tiles survive eviction
    const id = this.key(k);
    const e = this.resident.get(id);
    if (e) { this.resident.delete(id); this.resident.set(id, e); }
  }

  private evictUntil(target: number): void {
    for (const [id, e] of this.resident) {
      if (this.used <= target) break;
      e.buffer.destroy();          // free VRAM synchronously — do not wait for GC
      this.resident.delete(id);
      this.used -= e.bytes;
    }
  }
}

The memory implications compound with the frame budget. Calling destroy() on eviction returns the allocation to the driver immediately rather than waiting for garbage collection, which is what keeps the resident total honest — relying on finalizers to reclaim VRAM lets the true footprint drift far above the budget the code believes it is enforcing. Because the JavaScript Map iterates in insertion order, touch re-inserting an entry promotes it to the tail, so a full viewport pan that revisits recently seen tiles keeps them hot and evicts only the truly cold ones on the frontier. The one hazard is thrash at a zoom transition, where a whole layer of parent tiles is evicted just before their children are requested; the mitigation is a small hysteresis margin so the budget is not defended so tightly that adjacent frames fight over the same slots. Estimating the constant featuresAtRefZoom accurately per layer, and choosing the eviction margin, are the two tunables that determine whether the cache holds a steady footprint or oscillates — both are worked through in the child guides on building an LRU cache and estimating per-zoom footprint.

Tuning Workgroup Occupancy for Spatial Kernels

When the profiler attributes the frame’s cost to the compute pass, the lever is occupancy: the fraction of the GPU’s thread capacity that a kernel keeps busy. A spatial kernel that declares too small a workgroup, spills too many registers, or over-allocates shared memory leaves execution units idle even while the dispatch is nominally saturated, and the pass runs at a fraction of the hardware’s throughput. The systematic approach — choosing a workgroup size, controlling register pressure, and reading back-pressure from the scheduler — is developed under workgroup occupancy optimization for spatial kernels. The kernel below is a viewport cull written with occupancy in mind: a subgroup-friendly workgroup size and a deliberately branch-light body that keeps register live ranges short.

wgsl
// viewport_cull.wgsl — occupancy-tuned spatial cull kernel.
// 64 threads = one subgroup on most desktop GPUs; keeps scheduling granular.
@group(0) @binding(0) var<storage, read>       bounds:  array<vec4<f32>>; // per-feature AABB
@group(0) @binding(1) var<storage, read_write> visible: array<u32>;       // compacted survivor ids
@group(0) @binding(2) var<storage, read_write> cursor:  atomic<u32>;       // output write head
@group(0) @binding(3) var<uniform>             view:    vec4<f32>;         // min_x,min_y,max_x,max_y

@compute @workgroup_size(64)
fn cull(@builtin(global_invocation_id) gid: vec3<u32>) {
    let i = gid.x;
    if (i >= arrayLength(&bounds)) { return; } // ragged-tail guard, cheap and uniform

    let b = bounds[i];
    // Single boolean AABB overlap test. No early sub-returns and no per-branch
    // temporaries, so the compiler keeps the live register count low and packs
    // more concurrent workgroups per multiprocessor.
    let outside = b.z < view.x || b.x > view.z || b.w < view.y || b.y > view.w;
    if (!outside) {
        let slot = atomicAdd(&cursor, 1u); // one atomic per survivor, not per feature
        visible[slot] = i;
    }
}

The performance implications trace straight back to the occupancy model. A @workgroup_size(64) maps cleanly onto a single subgroup on most desktop hardware, so partially filled workgroups waste at most one subgroup’s lanes; jumping to 256 amortizes launch overhead but only pays off when register pressure stays low enough that four subgroups still fit per multiprocessor concurrently. The branch-light body is not stylistic — every additional live temporary in a WGSL kernel raises per-thread register demand, and when demand crosses the register-file limit the hardware reduces the number of resident workgroups, cratering occupancy even though nothing about the dispatch changed. Emitting one atomicAdd per survivor rather than one per feature keeps contention proportional to output size, which matters when a tight viewport rejects the overwhelming majority of a continental feature set. Choosing the size that maximizes occupancy for a given survival ratio, and diagnosing register spills, are the two child topics on choosing workgroup size and reducing register pressure. Before committing to the GPU path at all, it is worth confirming the workload actually benefits from it rather than from a faster raster backend — the head-to-head in WebGPU versus WebGL2 for spatial workloads quantifies where the compute-driven pipeline pulls ahead and where it does not.

Production Deployment

A tuned deployment defends explicit numbers, not vibes. The table below is the working budget a continental spatial pipeline is measured against each frame; every row names the lever that recovers it when a metric drifts into the red, and each lever is the subject of one of the sections above.

Constraint Target Lever when breached
Total frame time (60 fps) < 16.6 ms Present deadline — everything below must sum under it
CPU encode + submit < 4 ms Coalesce writeBuffer, indirect draws, fewer command buffers
Compute pass duration (Q1 − Q0) < 6 ms Raise workgroup occupancy, cut register pressure
Render pass duration (Q3 − Q2) < 5 ms Reduce overdraw, tighten level-of-detail
Resident tile VRAM < 512 MiB (budgeted) LRU eviction with destroy() across zoom levels
Per-tile upload off main thread mappedAtCreation + worker decode
Timestamp readback lag ≤ 1 frame Double-buffered async map, never awaited on submit

The compute-pass row is where the occupancy math earns its place in the budget. Occupancy is the ratio of resident threads to the hardware maximum, and it is capped by whichever resource — registers, shared memory, or the hard workgroup limit — runs out first:

Here is the declared workgroup size, the threads a multiprocessor can hold, the registers each thread needs and the register file, the var<workgroup> bytes a workgroup allocates against a budget , and the hard cap on concurrent workgroups. The formula makes the failure modes legible: shave by simplifying a kernel and the register term rises; over-allocate workgroup scratch and the shared-memory term collapses occupancy regardless of how lean the registers are. A kernel is well tuned when no single term dominates the min far below the others — that is the balance point the profiler’s compute-pass number is really chasing.

Reading the budget alone does not tell you which lever to pull, because a frame over 16.6 ms could be CPU-bound, compute-bound, or render-bound, and each demands a different fix. The decision procedure below is the one to run every time the trailing-mean frame time crosses the ceiling: compare the host submit time against the summed GPU pass time to split CPU from GPU, then compare the two GPU deltas to split compute from render, and only then act.

Decision procedure for locating and fixing the frame bottleneck Profiling the frame yields a CPU submit time and per-pass GPU timestamp deltas. The first decision asks whether CPU submit time exceeds total GPU pass time. If yes, the frame is CPU-bound and the fix is to coalesce writeBuffer calls, use indirect draws, and issue fewer submits. If no, a second decision asks whether the compute-pass delta exceeds the render-pass delta. If yes, the frame is GPU compute-bound and the fix is to raise occupancy, cut register pressure, and right-size workgroups. If no, the frame is GPU render-bound and the fix is to cut overdraw, tighten level-of-detail, and enforce the VRAM budget with LRU eviction. The procedure loops back to re-profile after any change. Profile the frame timestamp Δ + host CPU timer CPU submit > GPU pass time? yes CPU-bound coalesce writeBuffer · indirect draws · fewer submits no compute Δ > render Δ? yes GPU compute-bound raise occupancy · cut register pressure no GPU render-bound cut overdraw · tighter LOD VRAM budget · LRU eviction re-profile each change
Locate before you tune: split CPU from GPU on submit-versus-pass time, split compute from render on the two timestamp deltas, then pull only the lever the measurement points to — and re-profile.

Two deployment hazards sit outside the per-frame loop but govern whether it stays stable. First, the queue must not accumulate more command buffers than the GPU can retire; gating submission on onSubmittedWorkDone rather than firing unconditionally each animation frame keeps latency and VRAM from inflating under a heavy backend stream. Second, an oversized compute dispatch can trip a driver’s timeout-detection watchdog and lose the device outright, so per-dispatch work is capped to stay inside the watchdog window and the manager holds an idempotent rebuild path. Both discipline the same thing the budget table does: keeping the pipeline inside limits it has measured rather than ones it hopes are true. For deployments that still need a non-WebGPU path on unsupported adapters, the routing and parity questions are quantified in WebGPU versus WebGL2 for spatial workloads.

Where to Go Next

This area is organized around five measurement and tuning surfaces. Each page below is a self-contained walkthrough with runnable code and its own diagnostics:

Conclusion

Holding interactive frame rates on continental spatial data is a measurement problem before it is an optimization problem. Timestamp queries convert an asynchronous, opaque GPU timeline into per-pass numbers; a byte-accurate VRAM budget converts an unbounded tile stream into a bounded resident set; and the occupancy model converts a slow compute pass into a specific resource limit to relieve. With those three instruments in place, the production decision procedure becomes mechanical — split CPU from GPU, split compute from render, pull the indicated lever, re-profile — and tuning stops being guesswork. These profiling and budgeting disciplines are what let a WebGPU spatial pipeline scale from a single city to a whole continent without ever leaving the frame budget.

Up: spatialvisualization.org