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.
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.
// 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.
// 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.
// 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.
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:
- Frame Profiling with Timestamp Queries — instrument every pass boundary, resolve the query set without stalling, and turn 64-bit ticks into a stable per-pass millisecond trace.
- VRAM Budget Management Across Tile Zoom Levels — model per-zoom footprint, enforce a byte budget, and evict least-recently-used tile buffers before the driver starts paging.
- Workgroup Occupancy Optimization for Spatial Kernels — read the occupancy model, right-size workgroups, and keep register pressure below the point where resident workgroups drop.
- WebGPU vs WebGL2 for Spatial Workloads — benchmark the two backends on point rendering and compute so the fallback decision rests on measured numbers, not assumptions.
- Compute Shader vs CPU Spatial Indexing — decide when offloading R-tree and range queries to the GPU actually wins, and when the CPU index is the faster answer.
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.
Related
- WebGPU Architecture for Spatial Visualization — the device, adapter limits, and memory model whose ceilings this tuning defends.
- Spatial Compute Shaders & Geometry Pipelines — the WGSL kernels whose compute-pass duration the occupancy work here reduces.
- Framework Integration & Backend Synchronization — the frame-assembly and streaming path whose CPU submission cost the profiler measures.
- Spatial Aggregation in GPU Memory — a compute-heavy stage whose atomic contention this occupancy tuning directly targets.