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,438 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useEffect,
|
|
3
|
+
useRef,
|
|
4
|
+
useState,
|
|
5
|
+
useSyncExternalStore,
|
|
6
|
+
type CSSProperties,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
} from "react";
|
|
9
|
+
import {
|
|
10
|
+
profileMemory,
|
|
11
|
+
reportToText,
|
|
12
|
+
type MemoryReport,
|
|
13
|
+
type TextureEntry,
|
|
14
|
+
type BufferEntry,
|
|
15
|
+
} from "../profiler.js";
|
|
16
|
+
import {
|
|
17
|
+
getActiveInstrumentation,
|
|
18
|
+
subscribeActiveInstrumentation,
|
|
19
|
+
type DeviceInstrumentation,
|
|
20
|
+
} from "../instrument.js";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Viewport-pinned overlay that polls {@link profileMemory} on a timer and
|
|
24
|
+
* renders a copyable breakdown of all GPU memory the page has allocated
|
|
25
|
+
* through `device.createBuffer` / `device.createTexture`.
|
|
26
|
+
*
|
|
27
|
+
* Self-contained (no external CSS, no design-token dependency). Default
|
|
28
|
+
* styling is a dark transparent panel. Re-skin via `style` / `className`.
|
|
29
|
+
*
|
|
30
|
+
* Each section (Textures / Render targets / Buffers) is independently
|
|
31
|
+
* scrollable, so the HUD never overflows the viewport.
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
export interface MemoryHUDProps {
|
|
35
|
+
/**
|
|
36
|
+
* Optional. When omitted, the HUD reads the active instrumentation set
|
|
37
|
+
* by `instrumentDevice()` or `autoInstrument()` via subscription. Pass
|
|
38
|
+
* explicitly if you're managing multiple devices.
|
|
39
|
+
*/
|
|
40
|
+
instrumentation?: DeviceInstrumentation | null;
|
|
41
|
+
refreshMs?: number;
|
|
42
|
+
corner?: "top-right" | "top-left" | "bottom-right" | "bottom-left";
|
|
43
|
+
className?: string;
|
|
44
|
+
style?: CSSProperties;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* React hook that subscribes to the active instrumentation. Re-renders
|
|
49
|
+
* the calling component when `instrumentDevice` or `autoInstrument` sets
|
|
50
|
+
* (or clears) the global handle.
|
|
51
|
+
*/
|
|
52
|
+
function useActiveInstrumentation(): DeviceInstrumentation | null {
|
|
53
|
+
return useSyncExternalStore(
|
|
54
|
+
subscribeActiveInstrumentation,
|
|
55
|
+
getActiveInstrumentation,
|
|
56
|
+
() => null,
|
|
57
|
+
);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_REFRESH_MS = 1000;
|
|
61
|
+
const SECTION_MAX_HEIGHT = 240;
|
|
62
|
+
const MIN_WIDTH = 360;
|
|
63
|
+
const NAME_MAX_CHARS = 26;
|
|
64
|
+
|
|
65
|
+
const CORNERS: Record<NonNullable<MemoryHUDProps["corner"]>, CSSProperties> = {
|
|
66
|
+
"top-right": { top: 8, right: 8 },
|
|
67
|
+
"top-left": { top: 8, left: 8 },
|
|
68
|
+
"bottom-right": { bottom: 8, right: 8 },
|
|
69
|
+
"bottom-left": { bottom: 8, left: 8 },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function mb(bytes: number): string {
|
|
73
|
+
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Pre-truncate names that would otherwise blow the layout; the CSS
|
|
77
|
+
* ellipsis is a safety net for edges this helper misses. */
|
|
78
|
+
function shortName(name: string, maxLen = NAME_MAX_CHARS): string {
|
|
79
|
+
if (name.length <= maxLen) return name;
|
|
80
|
+
return name.slice(0, maxLen - 1) + "…";
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// Default theme. Override via `style` prop or className.
|
|
84
|
+
const C = {
|
|
85
|
+
bg: "rgba(8, 12, 16, 0.92)",
|
|
86
|
+
border: "rgba(255, 255, 255, 0.1)",
|
|
87
|
+
hover: "rgba(255, 255, 255, 0.06)",
|
|
88
|
+
textPrimary: "rgba(255, 255, 255, 0.9)",
|
|
89
|
+
textSecondary: "rgba(255, 255, 255, 0.65)",
|
|
90
|
+
textMuted: "rgba(255, 255, 255, 0.4)",
|
|
91
|
+
} as const;
|
|
92
|
+
|
|
93
|
+
const monoFont =
|
|
94
|
+
'ui-monospace, "SF Mono", Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace';
|
|
95
|
+
|
|
96
|
+
// One-time global style injection for focus-visible. The HUD otherwise
|
|
97
|
+
// uses inline styles only, so consumers can override via `style` / `className`
|
|
98
|
+
// without specificity wars. SSR-safe via the `document` guard.
|
|
99
|
+
const FOCUS_STYLE_ID = "webgpu-profiler-focus";
|
|
100
|
+
function ensureFocusStyle(): void {
|
|
101
|
+
if (typeof document === "undefined") return;
|
|
102
|
+
if (document.getElementById(FOCUS_STYLE_ID)) return;
|
|
103
|
+
const el = document.createElement("style");
|
|
104
|
+
el.id = FOCUS_STYLE_ID;
|
|
105
|
+
el.textContent = `
|
|
106
|
+
[data-webgpu-profiler-btn]:focus-visible {
|
|
107
|
+
outline: 2px solid rgba(255, 255, 255, 0.5);
|
|
108
|
+
outline-offset: -2px;
|
|
109
|
+
border-radius: 4px;
|
|
110
|
+
}
|
|
111
|
+
`;
|
|
112
|
+
document.head.appendChild(el);
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export function MemoryHUD({
|
|
116
|
+
instrumentation: explicit,
|
|
117
|
+
refreshMs = DEFAULT_REFRESH_MS,
|
|
118
|
+
corner = "top-right",
|
|
119
|
+
className,
|
|
120
|
+
style,
|
|
121
|
+
}: MemoryHUDProps = {}) {
|
|
122
|
+
const active = useActiveInstrumentation();
|
|
123
|
+
const instrumentation = explicit !== undefined ? explicit : active;
|
|
124
|
+
|
|
125
|
+
const [report, setReport] = useState<MemoryReport | null>(null);
|
|
126
|
+
const [open, setOpen] = useState(true);
|
|
127
|
+
const [copied, setCopied] = useState(false);
|
|
128
|
+
const [headerHover, setHeaderHover] = useState(false);
|
|
129
|
+
const [copyHover, setCopyHover] = useState(false);
|
|
130
|
+
const timerRef = useRef<number | null>(null);
|
|
131
|
+
|
|
132
|
+
useEffect(() => {
|
|
133
|
+
ensureFocusStyle();
|
|
134
|
+
}, []);
|
|
135
|
+
|
|
136
|
+
useEffect(() => {
|
|
137
|
+
if (!instrumentation) return;
|
|
138
|
+
const tick = () => {
|
|
139
|
+
try {
|
|
140
|
+
setReport(profileMemory(instrumentation));
|
|
141
|
+
} catch {
|
|
142
|
+
// Device may not yet be ready; ignore until next tick.
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
tick();
|
|
146
|
+
timerRef.current = window.setInterval(tick, refreshMs);
|
|
147
|
+
return () => {
|
|
148
|
+
if (timerRef.current !== null) window.clearInterval(timerRef.current);
|
|
149
|
+
};
|
|
150
|
+
}, [instrumentation, refreshMs]);
|
|
151
|
+
|
|
152
|
+
if (!report) return null;
|
|
153
|
+
|
|
154
|
+
const [copyFailed, setCopyFailed] = useState(false);
|
|
155
|
+
const handleCopy = async () => {
|
|
156
|
+
try {
|
|
157
|
+
await navigator.clipboard.writeText(reportToText(report));
|
|
158
|
+
setCopied(true);
|
|
159
|
+
window.setTimeout(() => setCopied(false), 1200);
|
|
160
|
+
} catch (err) {
|
|
161
|
+
// Insecure context, sandboxed iframe, permission denied, etc.
|
|
162
|
+
// Surface the failure so the user knows to fall back to manual select.
|
|
163
|
+
console.warn("[webgpu-profiler] clipboard write failed:", err);
|
|
164
|
+
setCopyFailed(true);
|
|
165
|
+
window.setTimeout(() => setCopyFailed(false), 1500);
|
|
166
|
+
}
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const containerStyle: CSSProperties = {
|
|
170
|
+
position: "fixed",
|
|
171
|
+
zIndex: 9999,
|
|
172
|
+
pointerEvents: "auto",
|
|
173
|
+
minWidth: open ? MIN_WIDTH : undefined,
|
|
174
|
+
maxHeight: "calc(100vh - 16px)",
|
|
175
|
+
display: "flex",
|
|
176
|
+
flexDirection: "column",
|
|
177
|
+
background: C.bg,
|
|
178
|
+
border: `1px solid ${C.border}`,
|
|
179
|
+
borderRadius: 6,
|
|
180
|
+
fontFamily: monoFont,
|
|
181
|
+
fontSize: 11,
|
|
182
|
+
color: C.textSecondary,
|
|
183
|
+
backdropFilter: "blur(8px)",
|
|
184
|
+
WebkitBackdropFilter: "blur(8px)",
|
|
185
|
+
...CORNERS[corner],
|
|
186
|
+
...style,
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
return (
|
|
190
|
+
<div className={className} style={containerStyle}>
|
|
191
|
+
<button
|
|
192
|
+
type="button"
|
|
193
|
+
data-webgpu-profiler-btn=""
|
|
194
|
+
onClick={() => setOpen((v) => !v)}
|
|
195
|
+
onMouseEnter={() => setHeaderHover(true)}
|
|
196
|
+
onMouseLeave={() => setHeaderHover(false)}
|
|
197
|
+
style={{
|
|
198
|
+
all: "unset",
|
|
199
|
+
cursor: "pointer",
|
|
200
|
+
display: "flex",
|
|
201
|
+
alignItems: "center",
|
|
202
|
+
justifyContent: "space-between",
|
|
203
|
+
gap: 12,
|
|
204
|
+
padding: "6px 10px",
|
|
205
|
+
color: C.textPrimary,
|
|
206
|
+
borderBottom: open ? `1px solid ${C.border}` : undefined,
|
|
207
|
+
background: headerHover ? C.hover : "transparent",
|
|
208
|
+
flexShrink: 0,
|
|
209
|
+
transition: "background 100ms ease",
|
|
210
|
+
}}
|
|
211
|
+
>
|
|
212
|
+
<span>VRAM</span>
|
|
213
|
+
<span style={{ fontVariantNumeric: "tabular-nums" }}>
|
|
214
|
+
{mb(report.totals.all)}
|
|
215
|
+
</span>
|
|
216
|
+
<span style={{ color: C.textMuted }}>{open ? "▾" : "▸"}</span>
|
|
217
|
+
</button>
|
|
218
|
+
|
|
219
|
+
{open && (
|
|
220
|
+
<div
|
|
221
|
+
style={{
|
|
222
|
+
padding: "8px 10px 0",
|
|
223
|
+
display: "flex",
|
|
224
|
+
flexDirection: "column",
|
|
225
|
+
gap: 6,
|
|
226
|
+
minHeight: 0,
|
|
227
|
+
flex: "1 1 auto",
|
|
228
|
+
}}
|
|
229
|
+
>
|
|
230
|
+
{report.sampledTextures.length > 0 && (
|
|
231
|
+
<Section
|
|
232
|
+
label="Sampled textures"
|
|
233
|
+
total={report.totals.sampledTextures}
|
|
234
|
+
>
|
|
235
|
+
{report.sampledTextures.map((t, i) => (
|
|
236
|
+
<TextureRow key={i} entry={t} />
|
|
237
|
+
))}
|
|
238
|
+
</Section>
|
|
239
|
+
)}
|
|
240
|
+
|
|
241
|
+
{report.renderTargets.length > 0 && (
|
|
242
|
+
<Section
|
|
243
|
+
label="Render targets"
|
|
244
|
+
total={report.totals.renderTargets}
|
|
245
|
+
topBorder
|
|
246
|
+
>
|
|
247
|
+
{report.renderTargets.map((t, i) => (
|
|
248
|
+
<TextureRow key={i} entry={t} />
|
|
249
|
+
))}
|
|
250
|
+
</Section>
|
|
251
|
+
)}
|
|
252
|
+
|
|
253
|
+
{report.buffers.length > 0 && (
|
|
254
|
+
<Section label="Buffers" total={report.totals.buffers} topBorder>
|
|
255
|
+
{report.buffers.map((b, i) => (
|
|
256
|
+
<BufferRow key={i} entry={b} />
|
|
257
|
+
))}
|
|
258
|
+
</Section>
|
|
259
|
+
)}
|
|
260
|
+
</div>
|
|
261
|
+
)}
|
|
262
|
+
|
|
263
|
+
{open && (
|
|
264
|
+
<div
|
|
265
|
+
style={{
|
|
266
|
+
display: "flex",
|
|
267
|
+
justifyContent: "flex-end",
|
|
268
|
+
borderTop: `1px solid ${C.border}`,
|
|
269
|
+
padding: "6px 10px",
|
|
270
|
+
flexShrink: 0,
|
|
271
|
+
}}
|
|
272
|
+
>
|
|
273
|
+
<button
|
|
274
|
+
type="button"
|
|
275
|
+
data-webgpu-profiler-btn=""
|
|
276
|
+
onClick={handleCopy}
|
|
277
|
+
onMouseEnter={() => setCopyHover(true)}
|
|
278
|
+
onMouseLeave={() => setCopyHover(false)}
|
|
279
|
+
title="Copy report to clipboard"
|
|
280
|
+
style={{
|
|
281
|
+
all: "unset",
|
|
282
|
+
cursor: "pointer",
|
|
283
|
+
fontSize: 10,
|
|
284
|
+
color: copyHover ? C.textPrimary : C.textMuted,
|
|
285
|
+
transition: "color 100ms ease",
|
|
286
|
+
}}
|
|
287
|
+
>
|
|
288
|
+
{copyFailed ? "copy failed" : copied ? "copied" : "copy report"}
|
|
289
|
+
</button>
|
|
290
|
+
</div>
|
|
291
|
+
)}
|
|
292
|
+
</div>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
// ── Rows ────────────────────────────────────────────────────────────────────
|
|
297
|
+
//
|
|
298
|
+
// Table rows with `width: 100%` and `table-layout: auto`. Names are
|
|
299
|
+
// pre-truncated to NAME_MAX_CHARS in JS so column widths stay reasonable;
|
|
300
|
+
// CSS overflow/ellipsis is the safety net.
|
|
301
|
+
|
|
302
|
+
function TextureRow({ entry }: { entry: TextureEntry }) {
|
|
303
|
+
return (
|
|
304
|
+
<tr style={{ lineHeight: 1.4 }}>
|
|
305
|
+
<td
|
|
306
|
+
style={{
|
|
307
|
+
paddingRight: 8,
|
|
308
|
+
color: C.textPrimary,
|
|
309
|
+
overflow: "hidden",
|
|
310
|
+
textOverflow: "ellipsis",
|
|
311
|
+
whiteSpace: "nowrap",
|
|
312
|
+
}}
|
|
313
|
+
title={entry.name}
|
|
314
|
+
>
|
|
315
|
+
{shortName(entry.name)}
|
|
316
|
+
</td>
|
|
317
|
+
<td style={{ paddingRight: 8, color: C.textMuted, whiteSpace: "nowrap" }}>
|
|
318
|
+
{entry.width}x{entry.height}
|
|
319
|
+
{entry.depth > 1 ? `x${entry.depth}` : ""}
|
|
320
|
+
</td>
|
|
321
|
+
<td style={{ paddingRight: 8, color: C.textMuted, whiteSpace: "nowrap" }}>
|
|
322
|
+
{entry.format}
|
|
323
|
+
</td>
|
|
324
|
+
<td
|
|
325
|
+
style={{
|
|
326
|
+
textAlign: "right",
|
|
327
|
+
fontVariantNumeric: "tabular-nums",
|
|
328
|
+
color: C.textSecondary,
|
|
329
|
+
whiteSpace: "nowrap",
|
|
330
|
+
}}
|
|
331
|
+
>
|
|
332
|
+
{mb(entry.bytes)}
|
|
333
|
+
</td>
|
|
334
|
+
</tr>
|
|
335
|
+
);
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
function BufferRow({ entry }: { entry: BufferEntry }) {
|
|
339
|
+
return (
|
|
340
|
+
<tr style={{ lineHeight: 1.4 }}>
|
|
341
|
+
<td
|
|
342
|
+
style={{
|
|
343
|
+
paddingRight: 8,
|
|
344
|
+
color: C.textPrimary,
|
|
345
|
+
overflow: "hidden",
|
|
346
|
+
textOverflow: "ellipsis",
|
|
347
|
+
whiteSpace: "nowrap",
|
|
348
|
+
}}
|
|
349
|
+
title={entry.name}
|
|
350
|
+
>
|
|
351
|
+
{shortName(entry.name)}
|
|
352
|
+
</td>
|
|
353
|
+
<td
|
|
354
|
+
style={{
|
|
355
|
+
paddingRight: 8,
|
|
356
|
+
color: C.textMuted,
|
|
357
|
+
fontSize: 10,
|
|
358
|
+
whiteSpace: "nowrap",
|
|
359
|
+
}}
|
|
360
|
+
title={entry.usage}
|
|
361
|
+
>
|
|
362
|
+
{shortName(entry.usage, 18)}
|
|
363
|
+
</td>
|
|
364
|
+
<td
|
|
365
|
+
style={{
|
|
366
|
+
textAlign: "right",
|
|
367
|
+
fontVariantNumeric: "tabular-nums",
|
|
368
|
+
color: C.textSecondary,
|
|
369
|
+
whiteSpace: "nowrap",
|
|
370
|
+
}}
|
|
371
|
+
>
|
|
372
|
+
{mb(entry.bytes)}
|
|
373
|
+
</td>
|
|
374
|
+
</tr>
|
|
375
|
+
);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function Section({
|
|
379
|
+
label,
|
|
380
|
+
total,
|
|
381
|
+
topBorder,
|
|
382
|
+
children,
|
|
383
|
+
}: {
|
|
384
|
+
label: string;
|
|
385
|
+
total: number;
|
|
386
|
+
topBorder?: boolean;
|
|
387
|
+
children: ReactNode;
|
|
388
|
+
}) {
|
|
389
|
+
return (
|
|
390
|
+
<div
|
|
391
|
+
style={{
|
|
392
|
+
display: "flex",
|
|
393
|
+
flexDirection: "column",
|
|
394
|
+
minHeight: 0,
|
|
395
|
+
flex: "0 1 auto",
|
|
396
|
+
marginTop: topBorder ? 6 : 0,
|
|
397
|
+
paddingTop: topBorder ? 6 : 0,
|
|
398
|
+
borderTop: topBorder ? `1px solid ${C.border}` : undefined,
|
|
399
|
+
}}
|
|
400
|
+
>
|
|
401
|
+
<div
|
|
402
|
+
style={{
|
|
403
|
+
display: "flex",
|
|
404
|
+
justifyContent: "space-between",
|
|
405
|
+
color: C.textMuted,
|
|
406
|
+
marginBottom: 4,
|
|
407
|
+
flexShrink: 0,
|
|
408
|
+
}}
|
|
409
|
+
>
|
|
410
|
+
<span>{label}</span>
|
|
411
|
+
<span style={{ fontVariantNumeric: "tabular-nums" }}>{mb(total)}</span>
|
|
412
|
+
</div>
|
|
413
|
+
<div
|
|
414
|
+
style={{
|
|
415
|
+
overflowY: "auto",
|
|
416
|
+
overflowX: "hidden",
|
|
417
|
+
maxHeight: SECTION_MAX_HEIGHT,
|
|
418
|
+
minHeight: 0,
|
|
419
|
+
// `thin` shrinks the scrollbar; `stable` reserves space for it
|
|
420
|
+
// even when it's not visible, so the rightmost column never gets
|
|
421
|
+
// overlapped by an overlay scrollbar (macOS, mobile).
|
|
422
|
+
scrollbarWidth: "thin",
|
|
423
|
+
scrollbarGutter: "stable",
|
|
424
|
+
}}
|
|
425
|
+
>
|
|
426
|
+
<table
|
|
427
|
+
style={{
|
|
428
|
+
width: "100%",
|
|
429
|
+
tableLayout: "auto",
|
|
430
|
+
borderCollapse: "collapse",
|
|
431
|
+
}}
|
|
432
|
+
>
|
|
433
|
+
<tbody>{children}</tbody>
|
|
434
|
+
</table>
|
|
435
|
+
</div>
|
|
436
|
+
</div>
|
|
437
|
+
);
|
|
438
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export { MemoryHUD, type MemoryHUDProps } from "./MemoryHUD.js";
|