webgpu-profiler 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +97 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/instrument.d.ts +115 -0
- package/dist/instrument.d.ts.map +1 -0
- package/dist/instrument.js +315 -0
- package/dist/instrument.js.map +1 -0
- package/dist/profiler.d.ts +48 -0
- package/dist/profiler.d.ts.map +1 -0
- package/dist/profiler.js +105 -0
- package/dist/profiler.js.map +1 -0
- package/dist/react/MemoryHUD.d.ts +27 -0
- package/dist/react/MemoryHUD.d.ts.map +1 -0
- package/dist/react/MemoryHUD.js +232 -0
- package/dist/react/MemoryHUD.js.map +1 -0
- package/dist/react/index.d.ts +2 -0
- package/dist/react/index.d.ts.map +1 -0
- package/dist/react/index.js +2 -0
- package/dist/react/index.js.map +1 -0
- package/package.json +73 -0
- package/src/index.ts +20 -0
- package/src/instrument.ts +377 -0
- package/src/profiler.ts +171 -0
- package/src/react/MemoryHUD.tsx +438 -0
- package/src/react/index.ts +1 -0
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Patches `GPUDevice.createBuffer` and `GPUDevice.createTexture` to track
|
|
3
|
+
* every GPU resource the page allocates. Combined with a `.destroy()` patch
|
|
4
|
+
* on each returned object, this gives a live view of all WebGPU memory
|
|
5
|
+
* the JS API can see.
|
|
6
|
+
*
|
|
7
|
+
* Why monkey-patch the device:
|
|
8
|
+
* - WebGPU has *exactly two* JS-visible allocation entry points
|
|
9
|
+
* (`createBuffer`, `createTexture`). Patching both captures everything
|
|
10
|
+
* a renderer, post-processing nodes, and compute services allocate.
|
|
11
|
+
* - No need to instrument production modules. This is the same pattern
|
|
12
|
+
* used by React DevTools, Vue DevTools, and Chrome's GPU inspector.
|
|
13
|
+
*
|
|
14
|
+
* What is NOT captured (driver-internal, not exposed to JS):
|
|
15
|
+
* - Canvas swapchain backbuffer (created by `context.configure()`).
|
|
16
|
+
* - Driver-side staging buffers, residency systems, MSAA backing,
|
|
17
|
+
* pipeline layouts, bind group memory.
|
|
18
|
+
*
|
|
19
|
+
* ## Timing
|
|
20
|
+
*
|
|
21
|
+
* Call `instrumentDevice(device)` *after* the renderer has obtained a
|
|
22
|
+
* `GPUDevice`, but *before* any other code allocates resources. In
|
|
23
|
+
* practice: right after the renderer's `init()` promise resolves. A few
|
|
24
|
+
* allocations (the swapchain, a couple of renderer internals) happen
|
|
25
|
+
* during init itself and will not be captured. Typical overhead: <5 MB.
|
|
26
|
+
*
|
|
27
|
+
* ## Usage
|
|
28
|
+
*
|
|
29
|
+
* import { instrumentDevice } from "webgpu-profiler";
|
|
30
|
+
*
|
|
31
|
+
* const inst = instrumentDevice(device);
|
|
32
|
+
* // ... time passes ...
|
|
33
|
+
* console.log(`GPU bytes: ${inst.bytes()}`);
|
|
34
|
+
* // When done:
|
|
35
|
+
* inst.uninstrument();
|
|
36
|
+
*
|
|
37
|
+
* The returned maps are live views — iterate at query time, do not cache.
|
|
38
|
+
* Entries are removed automatically when `buffer.destroy()` or
|
|
39
|
+
* `texture.destroy()` is called.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
export interface TrackedBuffer {
|
|
43
|
+
buffer: GPUBuffer;
|
|
44
|
+
descriptor: GPUBufferDescriptor;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export interface TrackedTexture {
|
|
48
|
+
texture: GPUTexture;
|
|
49
|
+
descriptor: GPUTextureDescriptor;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface DeviceInstrumentation {
|
|
53
|
+
readonly device: GPUDevice;
|
|
54
|
+
readonly buffers: ReadonlyMap<GPUBuffer, TrackedBuffer>;
|
|
55
|
+
readonly textures: ReadonlyMap<GPUTexture, TrackedTexture>;
|
|
56
|
+
/** Sum of all tracked buffer sizes, in bytes. */
|
|
57
|
+
bufferBytes(): number;
|
|
58
|
+
/** Sum of all tracked texture sizes (across formats, mip chains, layers). */
|
|
59
|
+
textureBytes(): number;
|
|
60
|
+
/** Combined total in bytes. */
|
|
61
|
+
bytes(): number;
|
|
62
|
+
/** Remove patches and restore the original device methods. */
|
|
63
|
+
uninstrument(): void;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// ── GPUTextureFormat → bytes per texel ──────────────────────────────────────
|
|
67
|
+
//
|
|
68
|
+
// Comprehensive table per WebGPU spec. For compressed block formats, bpp is
|
|
69
|
+
// the effective rate (BC1 = 0.5, BC7 = 1.0, etc.). A few approximate cases
|
|
70
|
+
// (depth24plus, ASTC variants) are flagged inline.
|
|
71
|
+
|
|
72
|
+
const FORMAT_BPP: Record<string, number> = {
|
|
73
|
+
// 8-bit single channel
|
|
74
|
+
r8unorm: 1, r8snorm: 1, r8uint: 1, r8sint: 1,
|
|
75
|
+
// 8-bit two channel
|
|
76
|
+
rg8unorm: 2, rg8snorm: 2, rg8uint: 2, rg8sint: 2,
|
|
77
|
+
// 8-bit four channel
|
|
78
|
+
rgba8unorm: 4, "rgba8unorm-srgb": 4,
|
|
79
|
+
rgba8snorm: 4, rgba8uint: 4, rgba8sint: 4,
|
|
80
|
+
bgra8unorm: 4, "bgra8unorm-srgb": 4,
|
|
81
|
+
// 16-bit single channel
|
|
82
|
+
r16float: 2, r16uint: 2, r16sint: 2, r16unorm: 2, r16snorm: 2,
|
|
83
|
+
// 16-bit two channel
|
|
84
|
+
rg16float: 4, rg16uint: 4, rg16sint: 4, rg16unorm: 4, rg16snorm: 4,
|
|
85
|
+
// 16-bit four channel
|
|
86
|
+
rgba16float: 8, rgba16uint: 8, rgba16sint: 8,
|
|
87
|
+
rgba16unorm: 8, rgba16snorm: 8,
|
|
88
|
+
// 32-bit single channel
|
|
89
|
+
r32float: 4, r32uint: 4, r32sint: 4,
|
|
90
|
+
// 32-bit two channel
|
|
91
|
+
rg32float: 8, rg32uint: 8, rg32sint: 8,
|
|
92
|
+
// 32-bit four channel
|
|
93
|
+
rgba32float: 16, rgba32uint: 16, rgba32sint: 16,
|
|
94
|
+
// Packed
|
|
95
|
+
rgb9e5ufloat: 4,
|
|
96
|
+
rgb10a2unorm: 4, rgb10a2uint: 4,
|
|
97
|
+
rg11b10ufloat: 4,
|
|
98
|
+
// Depth / stencil
|
|
99
|
+
stencil8: 1,
|
|
100
|
+
depth16unorm: 2,
|
|
101
|
+
depth24plus: 4, // implementation-defined, conservative
|
|
102
|
+
"depth24plus-stencil8": 4,
|
|
103
|
+
depth32float: 4,
|
|
104
|
+
"depth32float-stencil8": 5,
|
|
105
|
+
// Block-compressed (effective bytes per pixel)
|
|
106
|
+
"bc1-rgba-unorm": 0.5, "bc1-rgba-unorm-srgb": 0.5,
|
|
107
|
+
"bc2-rgba-unorm": 1, "bc2-rgba-unorm-srgb": 1,
|
|
108
|
+
"bc3-rgba-unorm": 1, "bc3-rgba-unorm-srgb": 1,
|
|
109
|
+
"bc4-r-unorm": 0.5, "bc4-r-snorm": 0.5,
|
|
110
|
+
"bc5-rg-unorm": 1, "bc5-rg-snorm": 1,
|
|
111
|
+
"bc6h-rgb-ufloat": 1, "bc6h-rgb-float": 1,
|
|
112
|
+
"bc7-rgba-unorm": 1, "bc7-rgba-unorm-srgb": 1,
|
|
113
|
+
"etc2-rgb8unorm": 0.5, "etc2-rgb8unorm-srgb": 0.5,
|
|
114
|
+
"etc2-rgb8a1unorm": 0.5, "etc2-rgb8a1unorm-srgb": 0.5,
|
|
115
|
+
"etc2-rgba8unorm": 1, "etc2-rgba8unorm-srgb": 1,
|
|
116
|
+
"eac-r11unorm": 0.5, "eac-r11snorm": 0.5,
|
|
117
|
+
"eac-rg11unorm": 1, "eac-rg11snorm": 1,
|
|
118
|
+
// ASTC variants are derived dynamically from the format string — see
|
|
119
|
+
// bytesPerTexel(). All ASTC blocks are 16 bytes; bpp is 16 / (W * H).
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
// ASTC block-compressed formats name their block dimensions inline, e.g.
|
|
123
|
+
// `astc-4x4-unorm` or `astc-12x12-unorm-srgb`. Every block is 16 bytes,
|
|
124
|
+
// covering W * H texels, so bpp = 16 / (W * H). Covers all 28 variants.
|
|
125
|
+
const ASTC_RE = /^astc-(\d+)x(\d+)-/;
|
|
126
|
+
|
|
127
|
+
function bytesPerTexel(format: string): number {
|
|
128
|
+
const known = FORMAT_BPP[format];
|
|
129
|
+
if (known !== undefined) return known;
|
|
130
|
+
const astc = ASTC_RE.exec(format);
|
|
131
|
+
if (astc) {
|
|
132
|
+
const blockW = Number(astc[1]);
|
|
133
|
+
const blockH = Number(astc[2]);
|
|
134
|
+
return 16 / (blockW * blockH);
|
|
135
|
+
}
|
|
136
|
+
return 4;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* Resolve a GPUExtent3D to (w, h, d). Per WebGPU spec, `size` is either a
|
|
141
|
+
* `GPUExtent3DDict` (object with `.width`) or any iterable of numbers
|
|
142
|
+
* (Array, Uint32Array, etc). `Array.isArray` returns false for typed
|
|
143
|
+
* arrays, so we discriminate on the dict's `width` property instead.
|
|
144
|
+
*/
|
|
145
|
+
export function resolveExtent(
|
|
146
|
+
size: GPUExtent3D,
|
|
147
|
+
): { width: number; height: number; depth: number } {
|
|
148
|
+
if (size != null && typeof (size as GPUExtent3DDict).width === "number") {
|
|
149
|
+
const dict = size as GPUExtent3DDict;
|
|
150
|
+
return {
|
|
151
|
+
width: dict.width,
|
|
152
|
+
height: dict.height ?? 1,
|
|
153
|
+
depth: dict.depthOrArrayLayers ?? 1,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
const [width = 0, height = 1, depth = 1] = Array.from(size as Iterable<number>);
|
|
157
|
+
return { width, height, depth };
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* Bytes occupied on the GPU by a texture with the given descriptor.
|
|
162
|
+
* Accounts for mip chain, array layers / depth, MSAA samples, and the
|
|
163
|
+
* format-specific bits per pixel.
|
|
164
|
+
*
|
|
165
|
+
* For 3D textures, each mip level halves all three dimensions, so the
|
|
166
|
+
* per-level pixel count series is `8^-i`. For 2D and 2D-array textures
|
|
167
|
+
* only x/y halve, giving `4^-i`.
|
|
168
|
+
*/
|
|
169
|
+
export function textureDescriptorBytes(desc: GPUTextureDescriptor): number {
|
|
170
|
+
const bpp = bytesPerTexel(desc.format);
|
|
171
|
+
const { width, height, depth } = resolveExtent(desc.size);
|
|
172
|
+
|
|
173
|
+
const is3D = desc.dimension === "3d";
|
|
174
|
+
const mipLevels = desc.mipLevelCount ?? 1;
|
|
175
|
+
const mipBase = is3D ? 8 : 4;
|
|
176
|
+
let mipFactor = 0;
|
|
177
|
+
for (let i = 0; i < mipLevels; i++) mipFactor += Math.pow(mipBase, -i);
|
|
178
|
+
|
|
179
|
+
const sampleCount = desc.sampleCount ?? 1;
|
|
180
|
+
|
|
181
|
+
return Math.ceil(width * height * depth * bpp * mipFactor * sampleCount);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// ── Module-level active instrumentation ─────────────────────────────────────
|
|
185
|
+
//
|
|
186
|
+
// For ease of use, `instrumentDevice` sets the returned handle as the
|
|
187
|
+
// "active" instrumentation. The React `MemoryHUD` reads from this state by
|
|
188
|
+
// default, so callers don't need to thread the handle through props or
|
|
189
|
+
// React context. Power users who want to manage multiple instrumentations
|
|
190
|
+
// can still ignore the global and pass handles explicitly.
|
|
191
|
+
|
|
192
|
+
let active: DeviceInstrumentation | null = null;
|
|
193
|
+
const subscribers = new Set<() => void>();
|
|
194
|
+
|
|
195
|
+
const INSTRUMENTED = Symbol.for("webgpu-profiler.instrumented");
|
|
196
|
+
|
|
197
|
+
function notify(): void {
|
|
198
|
+
for (const cb of subscribers) cb();
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Returns the most recently registered active instrumentation (or `null`
|
|
203
|
+
* if none). For React, prefer using `MemoryHUD` without a prop — it reads
|
|
204
|
+
* this via `useSyncExternalStore` and re-renders when it changes.
|
|
205
|
+
*/
|
|
206
|
+
export function getActiveInstrumentation(): DeviceInstrumentation | null {
|
|
207
|
+
return active;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Subscribe to changes in the active instrumentation. Returns an
|
|
212
|
+
* unsubscribe function. Used internally by `MemoryHUD` via React's
|
|
213
|
+
* `useSyncExternalStore`. Exposed for non-React consumers who want to
|
|
214
|
+
* react to active-instrumentation changes.
|
|
215
|
+
*/
|
|
216
|
+
export function subscribeActiveInstrumentation(cb: () => void): () => void {
|
|
217
|
+
subscribers.add(cb);
|
|
218
|
+
return () => {
|
|
219
|
+
subscribers.delete(cb);
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// ── Patcher ─────────────────────────────────────────────────────────────────
|
|
224
|
+
|
|
225
|
+
export function instrumentDevice(device: GPUDevice): DeviceInstrumentation {
|
|
226
|
+
// Idempotent: calling twice on the same device returns the existing
|
|
227
|
+
// handle rather than double-patching (and double-counting).
|
|
228
|
+
const existing = (device as unknown as { [INSTRUMENTED]?: DeviceInstrumentation })[
|
|
229
|
+
INSTRUMENTED
|
|
230
|
+
];
|
|
231
|
+
if (existing) {
|
|
232
|
+
active = existing;
|
|
233
|
+
notify();
|
|
234
|
+
return existing;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
const buffers = new Map<GPUBuffer, TrackedBuffer>();
|
|
238
|
+
const textures = new Map<GPUTexture, TrackedTexture>();
|
|
239
|
+
// Set to false by `uninstrument()`. Guards against a tracking write
|
|
240
|
+
// landing after teardown (rare, but possible if a `createBuffer` call
|
|
241
|
+
// is in-flight while uninstrument runs).
|
|
242
|
+
let alive = true;
|
|
243
|
+
|
|
244
|
+
const origCreateBuffer = device.createBuffer.bind(device);
|
|
245
|
+
const origCreateTexture = device.createTexture.bind(device);
|
|
246
|
+
|
|
247
|
+
device.createBuffer = (descriptor: GPUBufferDescriptor): GPUBuffer => {
|
|
248
|
+
const buffer = origCreateBuffer(descriptor);
|
|
249
|
+
if (alive) buffers.set(buffer, { buffer, descriptor });
|
|
250
|
+
const origDestroy = buffer.destroy.bind(buffer);
|
|
251
|
+
// GPUBuffer.destroy may be called multiple times; the patch handles
|
|
252
|
+
// that idempotently via the no-op Map.delete.
|
|
253
|
+
buffer.destroy = () => {
|
|
254
|
+
buffers.delete(buffer);
|
|
255
|
+
origDestroy();
|
|
256
|
+
};
|
|
257
|
+
return buffer;
|
|
258
|
+
};
|
|
259
|
+
|
|
260
|
+
device.createTexture = (descriptor: GPUTextureDescriptor): GPUTexture => {
|
|
261
|
+
const texture = origCreateTexture(descriptor);
|
|
262
|
+
if (alive) textures.set(texture, { texture, descriptor });
|
|
263
|
+
const origDestroy = texture.destroy.bind(texture);
|
|
264
|
+
texture.destroy = () => {
|
|
265
|
+
textures.delete(texture);
|
|
266
|
+
origDestroy();
|
|
267
|
+
};
|
|
268
|
+
return texture;
|
|
269
|
+
};
|
|
270
|
+
|
|
271
|
+
const inst: DeviceInstrumentation = {
|
|
272
|
+
device,
|
|
273
|
+
buffers,
|
|
274
|
+
textures,
|
|
275
|
+
bufferBytes() {
|
|
276
|
+
let total = 0;
|
|
277
|
+
for (const { descriptor } of buffers.values()) total += descriptor.size;
|
|
278
|
+
return total;
|
|
279
|
+
},
|
|
280
|
+
textureBytes() {
|
|
281
|
+
let total = 0;
|
|
282
|
+
for (const { descriptor } of textures.values()) {
|
|
283
|
+
total += textureDescriptorBytes(descriptor);
|
|
284
|
+
}
|
|
285
|
+
return total;
|
|
286
|
+
},
|
|
287
|
+
bytes() {
|
|
288
|
+
return this.bufferBytes() + this.textureBytes();
|
|
289
|
+
},
|
|
290
|
+
uninstrument() {
|
|
291
|
+
alive = false;
|
|
292
|
+
device.createBuffer = origCreateBuffer;
|
|
293
|
+
device.createTexture = origCreateTexture;
|
|
294
|
+
buffers.clear();
|
|
295
|
+
textures.clear();
|
|
296
|
+
delete (device as unknown as { [INSTRUMENTED]?: DeviceInstrumentation })[
|
|
297
|
+
INSTRUMENTED
|
|
298
|
+
];
|
|
299
|
+
if (active === inst) {
|
|
300
|
+
active = null;
|
|
301
|
+
notify();
|
|
302
|
+
}
|
|
303
|
+
},
|
|
304
|
+
};
|
|
305
|
+
|
|
306
|
+
(device as unknown as { [INSTRUMENTED]?: DeviceInstrumentation })[INSTRUMENTED] =
|
|
307
|
+
inst;
|
|
308
|
+
active = inst;
|
|
309
|
+
notify();
|
|
310
|
+
return inst;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ── Auto-instrument ─────────────────────────────────────────────────────────
|
|
314
|
+
//
|
|
315
|
+
// Patches `GPUAdapter.prototype.requestDevice` so every device any code
|
|
316
|
+
// requests is automatically instrumented as soon as it's created. Call
|
|
317
|
+
// this once at boot — before any renderer or framework's `init()` runs —
|
|
318
|
+
// and you're done. The React `MemoryHUD` picks up the active
|
|
319
|
+
// instrumentation through `useSyncExternalStore`.
|
|
320
|
+
//
|
|
321
|
+
// Idempotent and safe to call multiple times. No-op if WebGPU is not
|
|
322
|
+
// available in the current environment (e.g. SSR, older browsers).
|
|
323
|
+
|
|
324
|
+
const ADAPTER_PATCHED = Symbol.for("webgpu-profiler.adapter-patched");
|
|
325
|
+
|
|
326
|
+
export interface AutoInstrumentOptions {
|
|
327
|
+
/** Called for each device the patch instruments. Useful for logging. */
|
|
328
|
+
onInstrument?: (instrumentation: DeviceInstrumentation) => void;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Patches `GPUAdapter.prototype.requestDevice` to auto-instrument every
|
|
333
|
+
* `GPUDevice` it returns. Returns a disposer that restores the original
|
|
334
|
+
* method (existing instrumentations stay live until their own
|
|
335
|
+
* `uninstrument()` is called).
|
|
336
|
+
*
|
|
337
|
+
* Typical usage at the top of your app boot file:
|
|
338
|
+
*
|
|
339
|
+
* import { autoInstrument } from "webgpu-profiler";
|
|
340
|
+
* if (import.meta.env.DEV) autoInstrument();
|
|
341
|
+
*
|
|
342
|
+
* No further wiring needed — render `<MemoryHUD />` anywhere.
|
|
343
|
+
*/
|
|
344
|
+
export function autoInstrument(options: AutoInstrumentOptions = {}): () => void {
|
|
345
|
+
if (typeof GPUAdapter === "undefined") {
|
|
346
|
+
// WebGPU not available in this environment; nothing to patch.
|
|
347
|
+
return () => undefined;
|
|
348
|
+
}
|
|
349
|
+
const proto = GPUAdapter.prototype as unknown as {
|
|
350
|
+
[ADAPTER_PATCHED]?: boolean;
|
|
351
|
+
requestDevice: GPUAdapter["requestDevice"];
|
|
352
|
+
};
|
|
353
|
+
if (proto[ADAPTER_PATCHED]) {
|
|
354
|
+
return () => undefined;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const orig = proto.requestDevice;
|
|
358
|
+
proto.requestDevice = async function (
|
|
359
|
+
this: GPUAdapter,
|
|
360
|
+
descriptor?: GPUDeviceDescriptor,
|
|
361
|
+
): Promise<GPUDevice> {
|
|
362
|
+
const device = await orig.call(this, descriptor);
|
|
363
|
+
if (device) {
|
|
364
|
+
const inst = instrumentDevice(device);
|
|
365
|
+
options.onInstrument?.(inst);
|
|
366
|
+
}
|
|
367
|
+
return device;
|
|
368
|
+
} as GPUAdapter["requestDevice"];
|
|
369
|
+
proto[ADAPTER_PATCHED] = true;
|
|
370
|
+
|
|
371
|
+
return () => {
|
|
372
|
+
if (proto[ADAPTER_PATCHED]) {
|
|
373
|
+
proto.requestDevice = orig;
|
|
374
|
+
delete proto[ADAPTER_PATCHED];
|
|
375
|
+
}
|
|
376
|
+
};
|
|
377
|
+
}
|
package/src/profiler.ts
ADDED
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import {
|
|
2
|
+
resolveExtent,
|
|
3
|
+
textureDescriptorBytes,
|
|
4
|
+
type DeviceInstrumentation,
|
|
5
|
+
} from "./instrument.js";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Builds a snapshot of GPU memory currently allocated by the page, sourced
|
|
9
|
+
* from a {@link DeviceInstrumentation} handle (see `instrument.ts`).
|
|
10
|
+
*
|
|
11
|
+
* The numbers below are exact for every resource that flows through
|
|
12
|
+
* `GPUDevice.createBuffer` / `createTexture` — those are the only two
|
|
13
|
+
* WebGPU JS-side allocation entry points, so this is exhaustive by
|
|
14
|
+
* construction.
|
|
15
|
+
*
|
|
16
|
+
* Not counted (driver-internal, opaque to JS):
|
|
17
|
+
* - Canvas swapchain backbuffer (created by `context.configure()`).
|
|
18
|
+
* - Residency caches, MSAA backing, pipeline objects, bind group
|
|
19
|
+
* layouts, staging buffers that bypass `createBuffer`.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
export interface TextureEntry {
|
|
23
|
+
name: string;
|
|
24
|
+
width: number;
|
|
25
|
+
height: number;
|
|
26
|
+
depth: number;
|
|
27
|
+
format: string;
|
|
28
|
+
mipLevels: number;
|
|
29
|
+
sampleCount: number;
|
|
30
|
+
/** True if this texture is a render attachment (post-fx, MRT, depth, etc.). */
|
|
31
|
+
isRenderTarget: boolean;
|
|
32
|
+
bytes: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface BufferEntry {
|
|
36
|
+
name: string;
|
|
37
|
+
bytes: number;
|
|
38
|
+
/** Pipe-separated decoded usage flags (e.g. "STORAGE|COPY_DST"). */
|
|
39
|
+
usage: string;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface MemoryReport {
|
|
43
|
+
sampledTextures: TextureEntry[];
|
|
44
|
+
renderTargets: TextureEntry[];
|
|
45
|
+
buffers: BufferEntry[];
|
|
46
|
+
totals: {
|
|
47
|
+
sampledTextures: number;
|
|
48
|
+
renderTargets: number;
|
|
49
|
+
buffers: number;
|
|
50
|
+
all: number;
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── Helpers ─────────────────────────────────────────────────────────────────
|
|
55
|
+
|
|
56
|
+
// Spec-defined WebGPU usage flag values, hardcoded so this module can be
|
|
57
|
+
// imported in Node / SSR environments where `GPUBufferUsage` /
|
|
58
|
+
// `GPUTextureUsage` global objects are not defined.
|
|
59
|
+
// https://www.w3.org/TR/webgpu/#typedefdef-gpubufferusageflags
|
|
60
|
+
const BUFFER_USAGE_NAMES: ReadonlyArray<[number, string]> = [
|
|
61
|
+
[0x0001, "MAP_READ"],
|
|
62
|
+
[0x0002, "MAP_WRITE"],
|
|
63
|
+
[0x0004, "COPY_SRC"],
|
|
64
|
+
[0x0008, "COPY_DST"],
|
|
65
|
+
[0x0010, "INDEX"],
|
|
66
|
+
[0x0020, "VERTEX"],
|
|
67
|
+
[0x0040, "UNIFORM"],
|
|
68
|
+
[0x0080, "STORAGE"],
|
|
69
|
+
[0x0100, "INDIRECT"],
|
|
70
|
+
[0x0200, "QUERY_RESOLVE"],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
const TEXTURE_USAGE_RENDER_ATTACHMENT = 0x10;
|
|
74
|
+
|
|
75
|
+
function decodeBufferUsage(flags: number): string {
|
|
76
|
+
const names: string[] = [];
|
|
77
|
+
for (const [flag, name] of BUFFER_USAGE_NAMES) {
|
|
78
|
+
if (flags & flag) names.push(name);
|
|
79
|
+
}
|
|
80
|
+
return names.join("|") || "0";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function isRenderAttachment(usage: number): boolean {
|
|
84
|
+
return Boolean(usage & TEXTURE_USAGE_RENDER_ATTACHMENT);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
// ── Public API ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export function profileMemory(
|
|
91
|
+
instrumentation: DeviceInstrumentation,
|
|
92
|
+
): MemoryReport {
|
|
93
|
+
const sampledTextures: TextureEntry[] = [];
|
|
94
|
+
const renderTargets: TextureEntry[] = [];
|
|
95
|
+
|
|
96
|
+
for (const { descriptor } of instrumentation.textures.values()) {
|
|
97
|
+
const dims = resolveExtent(descriptor.size);
|
|
98
|
+
const bytes = textureDescriptorBytes(descriptor);
|
|
99
|
+
const usage = descriptor.usage ?? 0;
|
|
100
|
+
const entry: TextureEntry = {
|
|
101
|
+
name: descriptor.label || "(unnamed)",
|
|
102
|
+
width: dims.width,
|
|
103
|
+
height: dims.height,
|
|
104
|
+
depth: dims.depth,
|
|
105
|
+
format: descriptor.format,
|
|
106
|
+
mipLevels: descriptor.mipLevelCount ?? 1,
|
|
107
|
+
sampleCount: descriptor.sampleCount ?? 1,
|
|
108
|
+
isRenderTarget: isRenderAttachment(usage),
|
|
109
|
+
bytes,
|
|
110
|
+
};
|
|
111
|
+
if (entry.isRenderTarget) renderTargets.push(entry);
|
|
112
|
+
else sampledTextures.push(entry);
|
|
113
|
+
}
|
|
114
|
+
sampledTextures.sort((a, b) => b.bytes - a.bytes);
|
|
115
|
+
renderTargets.sort((a, b) => b.bytes - a.bytes);
|
|
116
|
+
|
|
117
|
+
const buffers: BufferEntry[] = [];
|
|
118
|
+
for (const { descriptor } of instrumentation.buffers.values()) {
|
|
119
|
+
buffers.push({
|
|
120
|
+
name: descriptor.label || "(unnamed)",
|
|
121
|
+
bytes: descriptor.size,
|
|
122
|
+
usage: decodeBufferUsage(descriptor.usage),
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
buffers.sort((a, b) => b.bytes - a.bytes);
|
|
126
|
+
|
|
127
|
+
const totalSampled = sampledTextures.reduce((s, t) => s + t.bytes, 0);
|
|
128
|
+
const totalRT = renderTargets.reduce((s, t) => s + t.bytes, 0);
|
|
129
|
+
const totalBuf = buffers.reduce((s, b) => s + b.bytes, 0);
|
|
130
|
+
|
|
131
|
+
return {
|
|
132
|
+
sampledTextures,
|
|
133
|
+
renderTargets,
|
|
134
|
+
buffers,
|
|
135
|
+
totals: {
|
|
136
|
+
sampledTextures: totalSampled,
|
|
137
|
+
renderTargets: totalRT,
|
|
138
|
+
buffers: totalBuf,
|
|
139
|
+
all: totalSampled + totalRT + totalBuf,
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/** Build a multi-line plain-text report. Feed to a copy button or bug report. */
|
|
145
|
+
export function reportToText(report: MemoryReport): string {
|
|
146
|
+
const mb = (b: number) => `${(b / 1024 / 1024).toFixed(2)} MB`;
|
|
147
|
+
const lines: string[] = [];
|
|
148
|
+
lines.push(`VRAM snapshot (live, from device.createBuffer/createTexture)`);
|
|
149
|
+
lines.push(``);
|
|
150
|
+
lines.push(`Sampled textures: ${mb(report.totals.sampledTextures)}`);
|
|
151
|
+
for (const t of report.sampledTextures) {
|
|
152
|
+
lines.push(
|
|
153
|
+
` ${t.name.padEnd(32)} ${t.width}x${t.height}${t.depth > 1 ? `x${t.depth}` : ""} ${t.format} mips=${t.mipLevels} ${mb(t.bytes)}`,
|
|
154
|
+
);
|
|
155
|
+
}
|
|
156
|
+
lines.push(``);
|
|
157
|
+
lines.push(`Render targets: ${mb(report.totals.renderTargets)}`);
|
|
158
|
+
for (const t of report.renderTargets) {
|
|
159
|
+
lines.push(
|
|
160
|
+
` ${t.name.padEnd(32)} ${t.width}x${t.height} ${t.format} ${mb(t.bytes)}`,
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
lines.push(``);
|
|
164
|
+
lines.push(`Buffers: ${mb(report.totals.buffers)}`);
|
|
165
|
+
for (const b of report.buffers) {
|
|
166
|
+
lines.push(` ${b.name.padEnd(32)} ${b.usage} ${mb(b.bytes)}`);
|
|
167
|
+
}
|
|
168
|
+
lines.push(``);
|
|
169
|
+
lines.push(`Total: ${mb(report.totals.all)}`);
|
|
170
|
+
return lines.join("\n");
|
|
171
|
+
}
|