Feature-Detecting Timestamp Query Support
The failure this page prevents is a device request that throws on a machine you cannot see. timestamp-query is the WebGPU feature that lets you write GPU-side timestamps around a compute or render pass and measure its duration in nanoseconds — the foundation of any real profiler. But it is an optional feature: many browsers gate it behind a flag, some drivers omit it entirely, and privacy-hardened configurations disable it to blunt timing side channels. If you pass 'timestamp-query' in requiredFeatures without first checking that the adapter advertises it, requestDevice rejects outright and your entire application fails to initialize — trading a missing profiler for a blank screen. The correct pattern is a two-step probe: read adapter.features to see whether the string is present, request it in requiredFeatures only when it is, and carry a boolean through the app so every profiling call degrades to a no-op (or a coarse CPU-side wall-clock estimate) when the feature is absent. Profiling becomes a progressive enhancement, not a hard dependency.
This is a routing decision, so it belongs with the browser support and fallback routing strategies that already handle WebGL2 fallbacks when WebGPU fails. The feature it gates is what powers frame profiling with timestamp queries, and it runs during WebGPU device initialization for GIS workloads, where features must be resolved before the device exists.
Runnable reference implementation
The initializer probes the adapter, requests the feature only when advertised, and returns a small profiler object that either issues real timestamp writes or falls back to performance.now() bracketing. The rest of the app calls the same interface regardless of support.
interface ProfilerContext {
device: GPUDevice;
timestampsSupported: boolean;
querySet?: GPUQuerySet; // present only when supported
resolveBuffer?: GPUBuffer; // COPY_DST | QUERY_RESOLVE staging for resolved counts
readBuffer?: GPUBuffer; // MAP_READ target for CPU-side inspection
}
async function initProfiler(canvasFeatures: GPUFeatureName[] = []): Promise<ProfilerContext> {
const adapter = await navigator.gpu?.requestAdapter();
if (!adapter) throw new Error("no WebGPU adapter — route to the WebGL2 fallback");
// 1. PROBE before requesting. adapter.features is a read-only GPUSupportedFeatures set.
const timestampsSupported = adapter.features.has("timestamp-query");
// 2. Request the feature ONLY when advertised; requesting an absent feature rejects.
const required: GPUFeatureName[] = [...canvasFeatures];
if (timestampsSupported) required.push("timestamp-query");
const device = await adapter.requestDevice({ requiredFeatures: required });
if (!timestampsSupported) {
// 3a. Degrade: no query set. Callers fall back to CPU wall-clock timing.
console.info("timestamp-query unavailable — profiling degrades to performance.now()");
return { device, timestampsSupported: false };
}
// 3b. Supported: allocate a two-slot query set (begin/end) plus resolve + read buffers.
const querySet = device.createQuerySet({ type: "timestamp", count: 2 });
const resolveBuffer = device.createBuffer({
size: 2 * 8, // two 64-bit timestamps
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
});
const readBuffer = device.createBuffer({
size: 2 * 8,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
return { device, timestampsSupported: true, querySet, resolveBuffer, readBuffer };
}
// Time a compute pass. When supported, attach timestampWrites to the pass descriptor;
// otherwise bracket the submit with performance.now() as a coarse fallback.
async function timeComputePass(
ctx: ProfilerContext,
record: (pass: GPUComputePassEncoder) => void,
): Promise<number> {
const enc = ctx.device.createCommandEncoder();
if (ctx.timestampsSupported && ctx.querySet && ctx.resolveBuffer && ctx.readBuffer) {
const pass = enc.beginComputePass({
timestampWrites: { querySet: ctx.querySet, beginningOfPassWriteIndex: 0, endOfPassWriteIndex: 1 },
});
record(pass);
pass.end();
enc.resolveQuerySet(ctx.querySet, 0, 2, ctx.resolveBuffer, 0);
enc.copyBufferToBuffer(ctx.resolveBuffer, 0, ctx.readBuffer, 0, 16);
ctx.device.queue.submit([enc.finish()]);
await ctx.readBuffer.mapAsync(GPUMapMode.READ);
const ts = new BigUint64Array(ctx.readBuffer.getMappedRange());
const deltaNs = Number(ts[1] - ts[0]); // GPU nanoseconds between begin and end
ctx.readBuffer.unmap();
return deltaNs / 1e6; // → milliseconds
}
// Fallback: CPU wall-clock around submit. Coarser (includes queue latency) but non-fatal.
const pass = enc.beginComputePass();
record(pass);
pass.end();
const t0 = performance.now();
ctx.device.queue.submit([enc.finish()]);
await ctx.device.queue.onSubmittedWorkDone();
return performance.now() - t0;
}
The single decision that keeps the app alive on unsupported hardware is building requiredFeatures conditionally: the feature string is appended only after adapter.features.has confirms it, so requestDevice never sees a feature the adapter cannot grant. Everything downstream reads the timestampsSupported boolean and either issues real timestampWrites or brackets the submit with performance.now() — the caller’s code path is identical.
Degrading profiling gracefully
Feature detection is only half the job; the other half is deciding what the application does with the answer. Treat profiling as three tiers of fidelity so the missing feature never becomes a missing app. The top tier is GPU timestamps — nanosecond-accurate, per-pass, and the only source precise enough to attribute cost between a scatter pass and a smooth pass. The middle tier, active whenever timestampsSupported is false, is the performance.now() bracket around queue.submit plus onSubmittedWorkDone: it measures wall-clock latency including queue and submission overhead, so it over-reports a pass’s true GPU cost but still tracks relative changes across code revisions. The bottom tier is disabling the profiling surface entirely — hiding the per-pass timing overlay and any budget assertions that depend on real GPU durations — so a debug panel does not display numbers it cannot trust.
type ProfilingTier = "gpu-timestamps" | "cpu-wallclock" | "disabled";
function selectTier(ctx: ProfilerContext, showDebugOverlay: boolean): ProfilingTier {
if (ctx.timestampsSupported) return "gpu-timestamps"; // full per-pass attribution
if (showDebugOverlay) return "cpu-wallclock"; // coarse, relative-only timings
return "disabled"; // no numbers rather than wrong numbers
}
// The UI reads the tier to label its readings honestly instead of implying precision.
function timingLabel(tier: ProfilingTier, ms: number): string {
switch (tier) {
case "gpu-timestamps": return `${ms.toFixed(3)} ms GPU`;
case "cpu-wallclock": return `~${ms.toFixed(1)} ms wall (incl. queue)`;
case "disabled": return "profiling unavailable";
}
}
The rule that keeps the degradation honest is never presenting a CPU wall-clock figure with the same authority as a GPU timestamp: the wall-clock bracket includes submission latency and queue depth, so a 4 ms reading might reflect a 1 ms pass behind a 3 ms queue. Labelling the tier — as the timingLabel helper does — stops a coarse estimate from being mistaken for a precise per-pass duration when you compare against a frame budget. When the feature is present but its resolution is quantized to uselessness, downgrade to the wall-clock tier at runtime rather than trusting a zero delta, closing the loop between detection and the failure modes below.
Parameter reference
| Value | Setting | Guidance |
|---|---|---|
| Feature string | "timestamp-query" |
Exact GPUFeatureName. Check with adapter.features.has(...) before adding to requiredFeatures. |
| Probe target | adapter.features |
A read-only set on the adapter, available before requestDevice. Never probe device.features to decide what to request — the device already exists by then. |
GPUQuerySet.type |
"timestamp" |
Required type for timing queries. Only creatable when the feature was granted. |
GPUQuerySet.count |
2 | One begin, one end per pass. Raise to 2 * passCount to time several passes in one frame. |
| Resolve buffer usage | QUERY_RESOLVE | COPY_SRC |
resolveQuerySet writes here; COPY_SRC stages it for readback. |
| Read buffer usage | MAP_READ | COPY_DST |
Maps resolved timestamps to the CPU. Omit entirely if you consume timings GPU-side. |
| Timestamp unit | nanoseconds | Values are 64-bit u64 nanoseconds; subtract then divide by 1e6 for milliseconds. May be quantized by the browser for privacy. |
Failure modes
requestDevicerejects on an unsupported feature. Passing'timestamp-query'inrequiredFeatureson an adapter that does not advertise it rejects the promise, so the device is never created and the app dead-ends. Detection: an unhandled rejection at init on a subset of machines, often specific browsers or privacy modes. Fix: gate the push onadapter.features.has('timestamp-query')as shown, and only ever request features the adapter lists.- Quantized or zero timestamp deltas. Some browsers coarsen timestamp resolution — or clamp deltas to zero — as a timing side-channel mitigation, so a real pass reports 0 ms or a suspiciously rounded value. Detection:
ts[1] - ts[0]is zero or snaps to a fixed granularity across different workloads. Fix: treat sub-microsecond or zero deltas as “unavailable at useful resolution”, average over many frames, and fall back to the CPU wall-clock estimate for coarse guidance. - Reading the query set before resolve. Mapping the read buffer without
resolveQuerySetfirst leaves it holding stale or zero data, because timestamps are not readable until resolved into a buffer. Detection: timings are always zero even though the feature is supported. Fix: callresolveQuerySetthencopyBufferToBufferinto theMAP_READbuffer beforemapAsync, in that order, within the same encoder. - Assuming support from a prior session. Caching
timestampsSupported: trueacross reloads is unsafe because the user may switch GPUs, browsers, or toggle a flag between sessions. Detection: init throws after a hardware or browser change despite cached state. Fix: re-probeadapter.featureson every initialization and never persist the result past the current device.
Related
- Browser Support & Fallback Routing Strategies — the broader capability-routing model this probe fits into.
- Frame Profiling with Timestamp Queries — what the detected feature unlocks once it is confirmed present.
- Initializing WebGPU Devices for GIS Workloads — where feature negotiation happens before the device exists.
- Implementing WebGL2 Fallbacks When WebGPU Fails — the deeper fallback when no adapter is available at all.