GpuProfiler.ts

src/gpu/GpuProfiler.ts · raw · all examples · site home

Exports: GpuProfileGpuProfiler

1
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
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
// GPU and CPU time per named phase. GPU times come from three's timestamp
// queries (WebGPURenderer with trackTimestamp: WebGPU `timestamp-query`, or
// EXT_disjoint_timer_query_webgl2 on the WebGL2 backend) through its
// Inspector hooks, which name the pass being recorded; CPU times wrap the
// phase's code. createRenderer({ profile: true }) installs one;
// `profiler.active` turns the GPU timestamps on and off.
//
//   profiler.beginFrame();
//   profiler.phase("update", () => update(camera));
//   profiler.label = "draw"; renderer.render(scene, camera);
//   const report = await profiler.collect(); // ms per phase, averaged
//
// Timestamps resolve a frame or two late; Chrome quantizes them to 100 us
// unless WebGPU developer features are on.
import { InspectorBase, TimestampQuery, type ComputeNode, type WebGPURenderer } from "three/webgpu";

export interface GpuProfile {
  // Average ms per frame of each phase over `frames` resolved frames
  gpu: Record<string, number>;
  cpu: Record<string, number>;
  frames: number;
}

interface PoolLike {
  timestamps: Map<string, number>;
}

export class GpuProfiler extends InspectorBase {
  // Phase of the passes recorded from now on
  label = "other";
  private frame = 0;
  private passes = new Map<string, { phase: string; frame: number }>();
  private cpu: Record<string, number> = {};
  private cpuFrames = new Set<number>();

  beginFrame() {
    this.frame += 1;
  }

  // Run fn with its passes labeled `name`; its CPU time is recorded too
  phase<T>(name: string, fn: () => T): T {
    const previous = this.label;
    this.label = name;
    const start = performance.now();
    try {
      return fn();
    } finally {
      this.mark(name, performance.now() - start);
      this.label = previous;
    }
  }

  // Record CPU milliseconds for a phase (e.g. an async worker round trip)
  mark(name: string, ms: number) {
    this.cpu[name] = (this.cpu[name] ?? 0) + ms;
    this.cpuFrames.add(this.frame);
  }

  beginCompute(uid: string, computeNode: ComputeNode) {
    if (!this.active) {
      return;
    }
    const nodes = Array.isArray(computeNode) ? computeNode : [computeNode];
    const name = nodes.map((node) => node.name).filter(Boolean).join(",");
    this.passes.set(uid, { phase: this.label !== "other" ? this.label : (name || "compute"), frame: this.frame });
  }

  beginRender(uid: string) {
    if (this.active) {
      this.passes.set(uid, { phase: this.label, frame: this.frame });
    }
  }

  // Whether the renderer tracks timestamps. Settable between frames (not
  // inside a render: WebGL2 timer queries must end as they began); stays
  // off without the feature. CPU phases are timed either way.
  get active() {
    return this.backend()?.trackTimestamp === true;
  }

  set active(on: boolean) {
    const backend = this.backend();
    if (backend) {
      backend.trackTimestamp = on && this.supported;
    }
  }

  // WebGPU `timestamp-query` (three requests every adapter feature) or
  // EXT_disjoint_timer_query_webgl2
  get supported() {
    const renderer = this.getRenderer() as WebGPURenderer | null;
    const gl = this.backend()?.gl;
    return renderer !== null && (gl ? gl.getExtension("EXT_disjoint_timer_query_webgl2") !== null : renderer.hasFeature("timestamp-query"));
  }

  private backend() {
    return (this.getRenderer() as WebGPURenderer | null)?.backend as { trackTimestamp?: boolean; gl?: WebGL2RenderingContext } | undefined;
  }

  // Resolve pending queries; undefined when nothing new resolved
  async collect(): Promise<GpuProfile | undefined> {
    const gpu: Record<string, number> = {};
    const frames = new Set<number>();
    const renderer = this.getRenderer() as WebGPURenderer | null;

    if (renderer && this.active) {
      await renderer.resolveTimestampsAsync(TimestampQuery.RENDER);
      await renderer.resolveTimestampsAsync(TimestampQuery.COMPUTE);
      const pools = (renderer.backend as unknown as { timestampQueryPool: Record<string, PoolLike | undefined> }).timestampQueryPool;
      for (const type of [TimestampQuery.RENDER, TimestampQuery.COMPUTE]) {
        for (const [uid, ms] of pools[type]?.timestamps ?? []) {
          const pass = this.passes.get(uid);
          if (pass) {
            this.passes.delete(uid);
            gpu[pass.phase] = (gpu[pass.phase] ?? 0) + ms;
            frames.add(pass.frame);
          }
        }
      }
      // Labels of passes whose queries never resolved (pool overflow)
      for (const [uid, pass] of this.passes) {
        if (pass.frame < this.frame - 8) {
          this.passes.delete(uid);
        }
      }
    }

    const cpuFrames = Math.max(1, this.cpuFrames.size);
    const cpu = Object.fromEntries(Object.entries(this.cpu).map(([name, ms]) => [name, ms / cpuFrames]));
    const resolved = Math.max(1, frames.size);
    this.cpu = {};
    this.cpuFrames.clear();

    if (frames.size === 0 && Object.keys(cpu).length === 0) {
      return undefined;
    }
    return { gpu: Object.fromEntries(Object.entries(gpu).map(([name, ms]) => [name, ms / resolved])), cpu, frames: frames.size };
  }

  static format(profile: GpuProfile) {
    const list = (times: Record<string, number>) => Object.entries(times).sort((a, b) => b[1] - a[1]).map(([name, ms]) => `${name} ${ms.toFixed(2)}`).join(", ");
    return `GPU ${list(profile.gpu) || "-"} | CPU ${list(profile.cpu) || "-"} ms`;
  }
}