WebGPU Timing Manager
The WebGpuTimingManager class provides accurate per-pass GPU timing measurement using WebGPU timestamp queries. It implements triple-buffered async readback to avoid GPU stalls.
Overview
import { WebGpuTimingManager } from '@3lens/core';
const timingManager = new WebGpuTimingManager({
maxHistorySize: 120,
maxPassesPerFrame: 16,
});
// Initialize with WebGPU device
const device = renderer.backend.device;
const success = await timingManager.initialize(device);
if (success) {
console.log('GPU timing ready');
}2
3
4
5
6
7
8
9
10
11
12
13
14
API Reference
Constructor
new WebGpuTimingManager(config?: Partial<GpuTimingConfig>)GpuTimingConfig:
| Property | Type | Default | Description |
|---|---|---|---|
maxHistorySize | number | 120 | Frames to keep in history |
autoReadback | boolean | true | Auto-read results each frame |
maxPassesPerFrame | number | 16 | Maximum passes to time per frame |
Methods
initialize
Initialize the timing manager with a WebGPU device.
async initialize(device: GPUDevice): Promise<boolean>Returns false if timestamp-query feature is not supported.
isEnabled
Check if GPU timing is available and active.
isEnabled(): booleanbeginFrame
Start timing a new frame.
beginFrame(frameNumber: number): voidgetPassStartQueryIndex
Get query index for pass start timestamp.
getPassStartQueryIndex(
passName: string,
passType?: GpuPassType
): number2
3
4
GpuPassType:
type GpuPassType =
| 'render' // Main render pass
| 'compute' // Compute shader pass
| 'shadow' // Shadow map generation
| 'post-process' // Post-processing effects
| 'copy' // Buffer/texture copies
| 'unknown'; // Default2
3
4
5
6
7
getPassEndQueryIndex
Get query index for pass end timestamp.
getPassEndQueryIndex(): numberwriteTimestamp
Write a timestamp to the query set.
writeTimestamp(encoder: GPUCommandEncoder, queryIndex: number): voidendFrame
End frame timing and resolve queries.
endFrame(encoder: GPUCommandEncoder): voidcopyForReadback
Copy resolved queries to mappable buffer.
copyForReadback(encoder: GPUCommandEncoder, bufferIndex?: number): voidreadResults
Asynchronously read timing results.
async readResults(): Promise<GpuFrameTiming | null>getLatestTiming
Get most recent timing (non-blocking).
getLatestTiming(): GpuFrameTiming | nullgetHistory
Get timing history array.
getHistory(): GpuFrameTiming[]getAverageTiming
Get averaged timing over recent frames.
getAverageTiming(frameCount?: number): GpuFrameTiming | nulldispose
Clean up GPU resources.
dispose(): voidInterfaces
GpuFrameTiming
Complete timing result for a frame.
interface GpuFrameTiming {
/** Total GPU time in milliseconds */
totalMs: number;
/** Individual pass timings */
passes: GpuPassTiming[];
/** Timing breakdown by pass type and name */
breakdown: Record<string, number>;
/** Frame number when timing was captured */
frameNumber: number;
/** CPU timestamp when result was read */
timestamp: number;
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
GpuPassTiming
Timing for an individual render/compute pass.
interface GpuPassTiming {
/** Pass name (e.g., "shadow", "main-render", "bloom") */
name: string;
/** Start timestamp in nanoseconds (BigInt) */
startNs: bigint;
/** End timestamp in nanoseconds (BigInt) */
endNs: bigint;
/** Duration in milliseconds */
durationMs: number;
}2
3
4
5
6
7
8
9
10
11
12
13
Architecture
Triple-Buffered Async Readback
The timing manager uses triple buffering to avoid GPU stalls:
┌────────────────────────────────────────────────────────────────┐
│ Triple Buffer Architecture │
├────────────────────────────────────────────────────────────────┤
│ │
│ Frame N: [Buffer 0] ← Currently writing timestamps │
│ Frame N-1: [Buffer 2] ← GPU resolving queries │
│ Frame N-2: [Buffer 1] ← CPU reading results (async map) │
│ │
│ Each buffer contains: │
│ - Query Set region (maxPasses × 2 timestamps) │
│ - Frame metadata (pass names, types, count) │
│ - Readback state tracking │
│ │
└────────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
13
14
Query Layout
┌─────────────────────────────────────────────────────────────┐
│ Query Set Memory Layout │
├─────────────────────────────────────────────────────────────┤
│ │
│ Buffer 0: [Start₀|End₀|Start₁|End₁|...|StartN|EndN] │
│ Buffer 1: [Start₀|End₀|Start₁|End₁|...|StartN|EndN] │
│ Buffer 2: [Start₀|End₀|Start₁|End₁|...|StartN|EndN] │
│ │
│ Each timestamp: 8 bytes (BigInt64) │
│ Total queries: maxPassesPerFrame × 2 × 3 buffers │
│ │
└─────────────────────────────────────────────────────────────┘2
3
4
5
6
7
8
9
10
11
12
Usage Examples
Basic Integration
const timingManager = new WebGpuTimingManager();
async function initGpuTiming() {
const device = renderer.backend.device;
return await timingManager.initialize(device);
}
function renderFrame(frameNumber: number) {
if (!timingManager.isEnabled()) return;
const encoder = device.createCommandEncoder();
// Begin timing for this frame
timingManager.beginFrame(frameNumber);
// Shadow pass
const shadowStartIdx = timingManager.getPassStartQueryIndex('shadow', 'shadow');
timingManager.writeTimestamp(encoder, shadowStartIdx);
renderShadowPass(encoder);
const shadowEndIdx = timingManager.getPassEndQueryIndex();
timingManager.writeTimestamp(encoder, shadowEndIdx);
// Main render pass
const mainStartIdx = timingManager.getPassStartQueryIndex('main', 'render');
timingManager.writeTimestamp(encoder, mainStartIdx);
renderMainPass(encoder);
const mainEndIdx = timingManager.getPassEndQueryIndex();
timingManager.writeTimestamp(encoder, mainEndIdx);
// End frame and resolve
timingManager.endFrame(encoder);
timingManager.copyForReadback(encoder);
device.queue.submit([encoder.finish()]);
}
async function getTimingResults() {
const timing = await timingManager.readResults();
if (timing) {
console.log(`Total GPU: ${timing.totalMs.toFixed(2)}ms`);
for (const pass of timing.passes) {
console.log(` ${pass.name}: ${pass.durationMs.toFixed(2)}ms`);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
Render Pass Descriptor Integration
Using timestampWrites in render pass descriptors:
// Create render pass with timing
function createTimedRenderPass(
encoder: GPUCommandEncoder,
passName: string
) {
const startIdx = timingManager.getPassStartQueryIndex(passName, 'render');
const endIdx = timingManager.getPassEndQueryIndex();
const passDescriptor: GPURenderPassDescriptor = {
colorAttachments: [/* ... */],
depthStencilAttachment: {/* ... */},
timestampWrites: {
querySet: timingManager.querySet,
beginningOfPassWriteIndex: startIdx,
endOfPassWriteIndex: endIdx,
},
};
return encoder.beginRenderPass(passDescriptor);
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
Performance Analysis
// Analyze GPU performance over time
function analyzePerformance() {
const history = timingManager.getHistory();
if (history.length === 0) return;
// Find slowest passes
const passStats = new Map<string, { total: number; count: number; max: number }>();
for (const frame of history) {
for (const pass of frame.passes) {
const stats = passStats.get(pass.name) ?? { total: 0, count: 0, max: 0 };
stats.total += pass.durationMs;
stats.count++;
stats.max = Math.max(stats.max, pass.durationMs);
passStats.set(pass.name, stats);
}
}
console.log('GPU Pass Analysis:');
for (const [name, stats] of passStats) {
const avg = stats.total / stats.count;
console.log(` ${name}: avg=${avg.toFixed(2)}ms, max=${stats.max.toFixed(2)}ms`);
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
Averaging for Stability
// Use averaged timing for stable UI display
function updateUI() {
const avgTiming = timingManager.getAverageTiming(30); // Average over 30 frames
if (avgTiming) {
gpuMeter.setValue(avgTiming.totalMs);
// Update breakdown chart
for (const pass of avgTiming.passes) {
chart.updateBar(pass.name, pass.durationMs);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
Conditional Timing
// Only time when explicitly requested
class OptionalTiming {
private shouldTime = false;
enableTiming() {
this.shouldTime = true;
}
disableTiming() {
this.shouldTime = false;
}
render(encoder: GPUCommandEncoder) {
if (this.shouldTime && timingManager.isEnabled()) {
// Full timing
timingManager.beginFrame(this.frameNumber);
this.renderWithTiming(encoder);
timingManager.endFrame(encoder);
} else {
// Skip timing overhead
this.renderWithoutTiming(encoder);
}
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
Browser Requirements
Feature Detection
async function checkTimestampSupport() {
if (!navigator.gpu) {
return { supported: false, reason: 'WebGPU not available' };
}
const adapter = await navigator.gpu.requestAdapter();
if (!adapter) {
return { supported: false, reason: 'No GPU adapter' };
}
// Request device with timestamp-query feature
const device = await adapter.requestDevice({
requiredFeatures: ['timestamp-query']
}).catch(() => null);
if (!device) {
return { supported: false, reason: 'timestamp-query not supported' };
}
return { supported: true, device };
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
Browser Support Matrix
| Browser | WebGPU | timestamp-query |
|---|---|---|
| Chrome 113+ | ✅ | ✅ |
| Edge 113+ | ✅ | ✅ |
| Firefox Nightly | ⚠️ | ⚠️ |
| Safari 17+ | ✅ | ⚠️ |
Performance Considerations
Overhead
| Operation | Typical Cost |
|---|---|
writeTimestamp | ~0.002ms |
resolveQuerySet | ~0.01ms |
copyBufferToBuffer | ~0.005ms |
mapAsync | 0.1-0.5ms (async) |
| Memory per buffer | 256 bytes (16 passes × 2 × 8 bytes) |
Best Practices
- Limit passes: Set
maxPassesPerFrameto actual needs - Batch reads: Read results once per frame, not per pass
- Handle async: Don't block on
readResults() - Check enabled: Skip timing calls when disabled
// Good: Check once at frame start
if (timingManager.isEnabled()) {
timingManager.beginFrame(frameNum);
// ... timing calls
}
// Bad: Check every call
timingManager.writeTimestamp(encoder, idx); // Already checks internally2
3
4
5
6
7
8
Error Handling
const timingManager = new WebGpuTimingManager();
async function safeInitialize(device: GPUDevice) {
try {
const success = await timingManager.initialize(device);
if (!success) {
console.warn('GPU timing not available, using CPU-only metrics');
return false;
}
return true;
} catch (e) {
console.error('GPU timing initialization failed:', e);
return false;
}
}
async function safeReadResults() {
try {
return await timingManager.readResults();
} catch (e) {
// Buffer mapping can fail if device is lost
console.warn('Timing readback failed:', e);
return timingManager.getLatestTiming(); // Return cached result
}
}2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Related
- GPU Timing System - Overview of GPU timing mechanisms
- WebGPU Adapter - WebGPU renderer adapter
- Frame Stats - Complete frame statistics reference