🎉 Add cross-engine GPU memory harness for render-wasm

This commit is contained in:
Elena Torro 2026-09-28 13:20:55 +02:00
parent f9b8f1ba75
commit 45077e6608
7 changed files with 1449 additions and 0 deletions

2
.gitignore vendored
View File

@ -112,3 +112,5 @@ opencode.json
/.ci-logs
/.codex/
/tools/__pycache__
/frontend/playwright/perf/results*/
/frontend/playwright/perf/report.html

View File

@ -11,6 +11,33 @@ const userAgent = platform === 'darwin' ?
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36" :
undefined;
// GPU memory harness (playwright/perf). Never run in CI: select it with
// `--project gpu-memory-<engine>`. Headed, since headless Chromium falls
// back to SwiftShader and measures nothing real.
function gpuMemoryProjects() {
const engines = {
chromium: {
...devices["Desktop Chrome"],
launchOptions: { args: ["--enable-gpu", "--ignore-gpu-blocklist"] },
},
firefox: devices["Desktop Firefox"],
webkit: devices["Desktop Safari"],
};
return Object.entries(engines).map(([engine, use]) => ({
name: `gpu-memory-${engine}`,
testDir: "./playwright/perf",
timeout: 15 * 60 * 1000,
use: {
...use,
// The global clipboard permissions only exist in Chromium.
permissions: [],
viewport: { width: 1920, height: 1080 },
deviceScaleFactor: Number(process.env.PERF_DPR ?? 2),
headless: false,
},
}));
}
/**
* @see https://playwright.dev/docs/test-configuration
*/
@ -93,6 +120,7 @@ export default defineConfig({
},
},
},
...gpuMemoryProjects(),
],
/* Run your local dev server before starting the tests */

View File

@ -0,0 +1,57 @@
# GPU memory harness
Measures how much GPU memory the render-wasm workspace uses during a long
edit session, in Chromium, Firefox and WebKit, and compares builds.
Not part of CI. Needs the compiled frontend and wasm bundle that
`scripts/e2e-server.js` serves, and a display: the projects run headed,
since headless Chromium falls back to SwiftShader.
## Run
From `frontend/`:
```sh
# one engine, labelled run
PERF_LABEL=develop npx playwright test --project gpu-memory-chromium
# after rebuilding with a change
PERF_LABEL=cache-limit npx playwright test --project gpu-memory-chromium
# compare and write playwright/perf/report.html
node playwright/perf/gpu-memory/report.mjs
```
Projects: `gpu-memory-chromium`, `gpu-memory-firefox`, `gpu-memory-webkit`.
| Variable | Default | Meaning |
|---|---|---|
| `PERF_LABEL` | `git describe --dirty` | Name of the build under test; results go to `results/<label>/` |
| `PERF_FIXTURE` | `render-wasm/get-file-shadows.json` | `get-file` fixture under `playwright/data/` |
| `PERF_REPEATS` | `3` | Runs per engine; repeat 0 is dropped as warm-up |
| `PERF_ITERATIONS` | `12` | Steps per phase |
| `PERF_DPR` | `2` | Device pixel ratio |
| `PERF_PURGE_PROBE` | off | `1` calls `free_gpu_resources` at the end and samples again |
| `PERF_PROC_MATCH` | `ms-playwright` | Substring of the browser binary path used to find its processes |
Report flags: `--baseline <label>` (default `develop`, else the oldest
label), `--out <file>`, `--keep-warmup`, `--json`.
## What a run does
Loads the fixture, then runs these phases, sampling after the renderer goes
quiet: `load`, `resize` (cycles four page sizes through CSS, so every engine
takes the same path), `zoom`, `pan`, `edit` (select all, nudge back and
forth), `idle`, and optionally `purge`.
## What is measured
- **WebGL** (`gpu-memory/webgl-tracker.js`, every engine): bytes WebGL was
asked to store in textures, renderbuffers and buffers, and a count of every
storage allocation. Drivers add their own copies on top.
- **OS** (`gpu-memory/os-sampler.js`, Linux only): RSS of the processes that
hold a GPU device open, DRM memory from `/proc/<pid>/fdinfo`, dma-bufs
shared by the browser, and `nvidia-smi` when present.
A difference counts only when medians differ by 5% or more and the run
ranges don't overlap. Anything else is reported as noise.

View File

@ -0,0 +1,205 @@
import { test } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { WasmWorkspacePage } from "../ui/pages/WasmWorkspacePage";
import { installWebGLMemoryTracker } from "./gpu-memory/webgl-tracker.js";
import { sampleOs } from "./gpu-memory/os-sampler.js";
const env = process.env;
function git(...args) {
try {
return execFileSync("git", args, { encoding: "utf8" }).trim();
} catch (_) {
return null;
}
}
const config = {
label: env.PERF_LABEL ?? git("describe", "--always", "--dirty"),
fixture: env.PERF_FIXTURE ?? "render-wasm/get-file-shadows.json",
repeats: Number(env.PERF_REPEATS ?? 3),
iterations: Number(env.PERF_ITERATIONS ?? 12),
settleMs: Number(env.PERF_SETTLE_MS ?? 300),
purgeProbe: env.PERF_PURGE_PROBE === "1",
outDir: env.PERF_OUT_DIR ?? "playwright/perf/results",
};
// Page sizes cycled in the resize phase. Each change reaches the workspace
// ResizeObserver and reallocates the canvas drawing buffer and the render
// surfaces sized from it. Applied from CSS, not `setViewportSize`: headed
// WebKit on Linux can't resize its window.
const PAGE_SIZES = [
{ width: 1920, height: 1080 },
{ width: 1280, height: 720 },
{ width: 1600, height: 900 },
{ width: 1440, height: 810 },
];
async function setPageSize(page, size) {
await page.evaluate((size) => {
// The workspace root is sized in vw/vh, so body size alone does nothing.
const root = document.querySelector(
'[class*="workspace-content"]',
)?.parentElement;
if (!root) throw new Error("workspace root not found");
for (const prop of ["inline-size", "block-size", "max-block-size"]) {
const value = prop === "inline-size" ? size?.width : size?.height;
root.style.setProperty(prop, size ? `${value}px` : "");
}
}, size);
}
async function wasmHash() {
const bytes = await readFile("resources/public/js/render-wasm.wasm").catch(
() => null,
);
return bytes && createHash("sha256").update(bytes).digest("hex").slice(0, 12);
}
// Waits until the renderer has not emitted a frame for `quietMs`.
async function settle(page, quietMs = config.settleMs, maxMs = 10000) {
await page.evaluate(
({ quietMs, maxMs }) =>
new Promise((resolve) => {
const start = performance.now();
let last = window.wasmRenderCount || 0;
let lastChange = performance.now();
const tick = () => {
const now = performance.now();
const count = window.wasmRenderCount || 0;
if (count !== last) {
last = count;
lastChange = now;
}
if (now - lastChange >= quietMs || now - start >= maxMs) resolve();
else requestAnimationFrame(tick);
};
requestAnimationFrame(tick);
}),
{ quietMs, maxMs },
);
}
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.addInitScript(installWebGLMemoryTracker);
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockRPC(
page,
"update-file?id=*",
"text-editor/update-file.json",
);
});
for (let repeat = 0; repeat < config.repeats; repeat++) {
test(`GPU memory over a long edit session (repeat ${repeat})`, async ({
page,
browser,
browserName,
}, testInfo) => {
test.setTimeout(15 * 60 * 1000);
const workspace = new WasmWorkspacePage(page);
const dir = `${config.outDir}/${config.label}`;
await mkdir(dir, { recursive: true });
const samples = [];
const t0 = Date.now();
const sample = async (phase, iteration = 0) => {
await settle(page);
const [web, os] = await Promise.all([
page.evaluate(() => globalThis.__gpuMem?.snapshot() ?? null),
sampleOs(),
]);
samples.push({ phase, iteration, t: Date.now() - t0, web, os });
};
await workspace.setupEmptyFile();
await workspace.mockGetFile(config.fixture);
await workspace.goToWorkspace();
await workspace.waitForFirstRenderWithoutUI();
await sample("load");
// Canvas captures from the first repeat show whether a change broke
// rendering, not only how much memory it saved.
const capture = async (name) => {
if (repeat !== 0) return;
await workspace.canvas.screenshot({
path: `${dir}/${browserName}-${name}.png`,
});
};
await capture("load");
const base = page.viewportSize();
for (let i = 0; i < config.iterations; i++) {
await setPageSize(page, PAGE_SIZES[i % PAGE_SIZES.length]);
await sample("resize", i);
}
await setPageSize(page, null);
await page.mouse.move(base.width / 2, base.height / 2);
for (let i = 0; i < config.iterations; i++) {
await page.keyboard.down("ControlOrMeta");
await page.mouse.wheel(0, i % 4 < 2 ? -400 : 400);
await page.keyboard.up("ControlOrMeta");
await sample("zoom", i);
}
for (let i = 0; i < config.iterations; i++) {
const step = i % 4 < 2 ? 600 : -600;
await page.mouse.wheel(i % 2 ? step : 0, i % 2 ? 0 : step);
await sample("pan", i);
}
await page.keyboard.press("ControlOrMeta+a");
for (let i = 0; i < config.iterations; i++) {
await page.keyboard.press(i % 2 ? "Shift+ArrowLeft" : "Shift+ArrowRight");
await sample("edit", i);
}
await page.keyboard.press("Escape");
for (let i = 0; i < 2; i++) {
await page.waitForTimeout(3000);
await sample("idle", i);
}
await capture("idle");
if (config.purgeProbe) {
await page.evaluate(() =>
globalThis.app?.render_wasm?.api?.free_gpu_resources?.(),
);
await sample("purge");
}
const renderer = await page.evaluate(
() => globalThis.__gpuMem?.rendererInfo() ?? null,
);
const result = {
meta: {
label: config.label,
engine: browserName,
engineVersion: browser.version(),
project: testInfo.project.name,
repeat,
fixture: config.fixture,
iterations: config.iterations,
viewport: base,
dpr: testInfo.project.use.deviceScaleFactor ?? 1,
renderer,
commit: git("rev-parse", "--short", "HEAD"),
wasm: await wasmHash(),
platform: process.platform,
date: new Date().toISOString(),
},
samples,
};
await writeFile(
`${dir}/${browserName}-r${repeat}.json`,
JSON.stringify(result, null, 2),
);
});
}

View File

@ -0,0 +1,162 @@
// OS-level memory of the browser under test. Linux only: reads /proc, so it
// works the same for Chromium, Firefox and WebKit. The process that owns the
// GL context is found by who holds a GPU device open, not by engine names.
import { execFile } from "node:child_process";
import { readdir, readFile, readlink } from "node:fs/promises";
import { basename } from "node:path";
import { promisify } from "node:util";
const run = promisify(execFile);
const GPU_DEVICE =
/^\/dev\/(dri\/(renderD|card)\d+|nvidia(\d+|ctl|-uvm|-modeset))$/;
const DMABUF = /^(\/dmabuf:|anon_inode:dmabuf)/;
const DRM_KEY =
/^drm-(total|resident|shared|active|purgeable|memory)-([\w-]+):\s+(\d+)(?:\s*(KiB|MiB|GiB))?/;
const UNIT = { KiB: 1024, MiB: 1024 ** 2, GiB: 1024 ** 3 };
// Only processes whose binary path contains this are sampled.
const PROC_MATCH = process.env.PERF_PROC_MATCH ?? "ms-playwright";
async function safe(promise, fallback = null) {
try {
return await promise;
} catch (_) {
return fallback;
}
}
async function browserPids() {
const pids = [];
for (const entry of await readdir("/proc")) {
if (!/^\d+$/.test(entry)) continue;
const exe = await safe(readlink(`/proc/${entry}/exe`));
if (exe?.includes(PROC_MATCH)) pids.push({ pid: Number(entry), exe });
}
return pids;
}
function roleOf(exe, cmdline) {
const typeArg = cmdline.find((a) => a.startsWith("--type="));
if (typeArg) return typeArg.slice("--type=".length);
if (cmdline.includes("-contentproc")) return `content:${cmdline.at(-1)}`;
return basename(exe);
}
async function rollup(pid) {
const text = await safe(readFile(`/proc/${pid}/smaps_rollup`, "utf8"), "");
const kib = (key) => {
const m = text.match(new RegExp(`^${key}:\\s+(\\d+) kB`, "m"));
return m ? Number(m[1]) * 1024 : 0;
};
return { rss: kib("Rss"), pss: kib("Pss") };
}
async function fdScan(pid) {
const fdDir = `/proc/${pid}/fd`;
const fds = await safe(readdir(fdDir), []);
const devices = new Set();
const drm = {};
const drmClients = new Set();
const dmabufs = new Map();
for (const fd of fds) {
const link = await safe(readlink(`${fdDir}/${fd}`));
if (!link) continue;
const isGpu = GPU_DEVICE.test(link);
const isDmabuf = DMABUF.test(link);
if (!isGpu && !isDmabuf) continue;
if (isGpu) devices.add(link);
const info = await safe(readFile(`/proc/${pid}/fdinfo/${fd}`, "utf8"), "");
if (isDmabuf) {
const ino = info.match(/^ino:\s+(\d+)/m)?.[1] ?? `${pid}:${fd}`;
const size = Number(info.match(/^size:\s+(\d+)/m)?.[1] ?? 0);
dmabufs.set(ino, size);
continue;
}
// One DRM client can sit behind several fds; count it once.
const client = info.match(/^drm-client-id:\s+(\d+)/m)?.[1];
if (!client || drmClients.has(client)) continue;
drmClients.add(client);
for (const line of info.split("\n")) {
const m = line.match(DRM_KEY);
if (!m) continue;
const key = `${m[1]}-${m[2]}`;
drm[key] = (drm[key] ?? 0) + Number(m[3]) * (UNIT[m[4]] ?? 1);
}
}
return { devices: [...devices], drm, dmabufs };
}
let nvidiaAvailable = true;
async function nvidia(pids) {
if (!nvidiaAvailable) return null;
const xml = await safe(run("nvidia-smi", ["-q", "-x"], { timeout: 3000 }));
if (!xml) {
nvidiaAvailable = false;
return null;
}
const out = xml.stdout;
const used = out.match(/<fb_memory_usage>[\s\S]*?<used>(\d+) MiB<\/used>/);
const perProcess = {};
for (const block of out.matchAll(
/<process_info>([\s\S]*?)<\/process_info>/g,
)) {
const pid = Number(block[1].match(/<pid>(\d+)<\/pid>/)?.[1]);
const mem = block[1].match(/<used_memory>(\d+) MiB<\/used_memory>/)?.[1];
if (pids.has(pid) && mem) perProcess[pid] = Number(mem) * UNIT.MiB;
}
return {
usedBytes: used ? Number(used[1]) * UNIT.MiB : null,
perProcess,
};
}
export async function sampleOs() {
if (process.platform !== "linux") return { unsupported: process.platform };
const procs = [];
for (const { pid, exe } of await browserPids()) {
let cmdline = (await safe(readFile(`/proc/${pid}/cmdline`, "utf8"), ""))
.split("\0")
.filter(Boolean);
// Chromium rewrites its argv into one space-joined string.
if (cmdline.length === 1) cmdline = cmdline[0].split(" ");
const [mem, fds] = await Promise.all([rollup(pid), fdScan(pid)]);
procs.push({ pid, role: roleOf(exe, cmdline), ...mem, ...fds });
}
const gpuProcs = procs.filter((p) => p.devices.length > 0);
const sum = (list, f) => list.reduce((acc, p) => acc + f(p), 0);
// The same dma-buf is often open in several processes; count it once.
const dmabufs = new Map();
for (const p of procs)
for (const [ino, size] of p.dmabufs) dmabufs.set(ino, size);
const drm = {};
for (const p of gpuProcs) {
for (const [k, v] of Object.entries(p.drm)) drm[k] = (drm[k] ?? 0) + v;
}
return {
browserRss: sum(procs, (p) => p.rss),
browserPss: sum(procs, (p) => p.pss),
gpuProcessRss: sum(gpuProcs, (p) => p.rss),
gpuProcessPss: sum(gpuProcs, (p) => p.pss),
gpuProcesses: gpuProcs.map((p) => ({
pid: p.pid,
role: p.role,
rss: p.rss,
devices: p.devices,
})),
drm,
dmabuf: {
count: dmabufs.size,
bytes: [...dmabufs.values()].reduce((a, b) => a + b, 0),
},
nvidia: await nvidia(new Set(gpuProcs.map((p) => p.pid))),
};
}

View File

@ -0,0 +1,572 @@
#!/usr/bin/env node
// Compares GPU memory runs written by gpu-memory.spec.js.
//
// node playwright/perf/gpu-memory/report.mjs [results-dir]
// [--baseline <label>] [--out <file.html>] [--keep-warmup] [--json]
//
// Prints one table per engine to stdout and writes a self-contained HTML
// report with the time series. Every metric is "lower is better".
import { readdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
const argv = process.argv.slice(2);
const flag = (name) => argv.includes(name);
const option = (name) => {
const i = argv.indexOf(name);
return i >= 0 ? argv[i + 1] : undefined;
};
const positional = argv.filter(
(a, i) =>
!a.startsWith("--") && !["--baseline", "--out"].includes(argv[i - 1]),
);
const RESULTS_DIR = positional[0] ?? "playwright/perf/results";
const OUT = option("--out") ?? "playwright/perf/report.html";
const KEEP_WARMUP = flag("--keep-warmup");
const NOISE_PCT = 5;
// Differences below these are noise whatever the percentage, so metrics that
// sit near zero (growth, idle minus load) can't flag a few MiB as a change.
const NOISE_ABS = { bytes: 8 * 1024 ** 2, count: 5 };
const MiB = 1024 ** 2;
// ---------------------------------------------------------------- metrics
const max = (xs) => (xs.length ? Math.max(...xs) : null);
const nums = (xs) =>
xs.filter((x) => typeof x === "number" && Number.isFinite(x));
function drmResident(os) {
const drm = os?.drm ?? {};
const pick = (prefix) =>
Object.entries(drm)
.filter(([k]) => k.startsWith(prefix))
.reduce((acc, [, v]) => acc + v, 0);
const keys = Object.keys(drm);
if (keys.some((k) => k.startsWith("resident-"))) return pick("resident-");
if (keys.some((k) => k.startsWith("memory-"))) return pick("memory-");
return null;
}
function nvidiaBytes(os) {
const nv = os?.nvidia;
if (!nv) return null;
const perProcess = Object.values(nv.perProcess ?? {});
return perProcess.length
? perProcess.reduce((a, b) => a + b, 0)
: nv.usedBytes;
}
// Series plotted in the HTML report, per sample.
const SERIES = [
{
key: "webgl",
title: "WebGL memory (textures + renderbuffers + buffers)",
unit: "bytes",
get: (s) => s.web?.totalBytes,
},
{
key: "allocs",
title: "WebGL storage allocations (cumulative)",
unit: "count",
get: (s) => s.web?.allocs?.total,
},
{
key: "gpuRss",
title: "RSS of processes holding the GPU",
unit: "bytes",
get: (s) => s.os?.gpuProcessRss,
},
{
key: "drm",
title: "DRM resident memory (kernel fdinfo)",
unit: "bytes",
get: (s) => drmResident(s.os),
},
{
key: "dmabuf",
title: "dma-buf bytes shared by the browser",
unit: "bytes",
get: (s) => s.os?.dmabuf?.bytes,
},
{
key: "nvidia",
title: "NVIDIA memory (nvidia-smi)",
unit: "bytes",
get: (s) => nvidiaBytes(s.os),
},
];
function phaseSamples(run, phase) {
return run.samples.filter((s) => s.phase === phase);
}
function lastOf(run, phase, get) {
const xs = phaseSamples(run, phase);
return xs.length ? (get(xs.at(-1)) ?? null) : null;
}
function peak(run, get) {
return max(nums(run.samples.filter((s) => s.phase !== "purge").map(get)));
}
// Growth over one full cycle of page sizes: the same size is revisited
// every PAGE_SIZES.length samples, so any difference is memory not given back.
function perCycleGrowth(run, get, cycle = 4) {
const xs = phaseSamples(run, "resize").map(get);
const diffs = [];
for (let i = cycle; i < xs.length; i++) {
if (typeof xs[i] === "number" && typeof xs[i - cycle] === "number") {
diffs.push(xs[i] - xs[i - cycle]);
}
}
return diffs.length ? diffs.reduce((a, b) => a + b, 0) / diffs.length : null;
}
function perResize(run, get) {
const load = lastOf(run, "load", get);
const end = lastOf(run, "resize", get);
const n = phaseSamples(run, "resize").length;
return load == null || end == null || n === 0 ? null : (end - load) / n;
}
const webgl = (s) => s.web?.totalBytes;
const allocs = (s) => s.web?.allocs?.total;
const allocBytes = (s) => s.web?.allocatedBytesTotal;
const METRICS = [
{
key: "webglPeak",
title: "WebGL memory, peak",
unit: "bytes",
get: (r) => peak(r, webgl),
},
{
key: "webglIdle",
title: "WebGL memory, idle",
unit: "bytes",
get: (r) => lastOf(r, "idle", webgl),
},
{
key: "texturePeak",
title: "Texture memory, peak",
unit: "bytes",
get: (r) => peak(r, (s) => s.web?.textures?.bytes),
},
{
key: "drawingBufferPeak",
title: "Drawing buffer (estimate), peak",
unit: "bytes",
get: (r) => peak(r, (s) => s.web?.drawingBuffer?.bytesEstimate),
},
{
key: "allocsPerResize",
title: "Allocations per page resize",
unit: "count",
get: (r) => perResize(r, allocs),
},
{
key: "allocBytesPerResize",
title: "Bytes allocated per page resize",
unit: "bytes",
get: (r) => perResize(r, allocBytes),
},
{
key: "allocsTotal",
title: "Allocations, whole session",
unit: "count",
get: (r) => lastOf(r, "idle", allocs),
},
{
key: "allocBytesTotal",
title: "Bytes allocated, whole session",
unit: "bytes",
get: (r) => lastOf(r, "idle", allocBytes),
},
{
key: "webglCycleGrowth",
title: "WebGL growth per resize cycle",
unit: "bytes",
get: (r) => perCycleGrowth(r, webgl),
},
{
key: "wasmHeapPeak",
title: "WASM heap, peak",
unit: "bytes",
get: (r) => peak(r, (s) => s.web?.wasmHeapBytes),
},
{
key: "gpuRssPeak",
title: "GPU-process RSS, peak",
unit: "bytes",
get: (r) => peak(r, (s) => s.os?.gpuProcessRss),
},
{
key: "gpuRssGrowth",
title: "GPU-process RSS, idle minus load",
unit: "bytes",
get: (r) => {
const a = lastOf(r, "load", (s) => s.os?.gpuProcessRss);
const b = lastOf(r, "idle", (s) => s.os?.gpuProcessRss);
return a == null || b == null ? null : b - a;
},
},
{
key: "browserRssPeak",
title: "Browser RSS (all processes), peak",
unit: "bytes",
get: (r) => peak(r, (s) => s.os?.browserRss),
},
{
key: "drmPeak",
title: "DRM resident, peak",
unit: "bytes",
get: (r) => peak(r, (s) => drmResident(s.os)),
},
{
key: "drmIdle",
title: "DRM resident, idle",
unit: "bytes",
get: (r) => lastOf(r, "idle", (s) => drmResident(s.os)),
},
{
key: "dmabufPeak",
title: "dma-buf bytes, peak",
unit: "bytes",
get: (r) => peak(r, (s) => s.os?.dmabuf?.bytes),
},
{
key: "nvidiaPeak",
title: "NVIDIA memory, peak",
unit: "bytes",
get: (r) => peak(r, (s) => nvidiaBytes(s.os)),
},
{
key: "purgeFreed",
title: "Freed by free_gpu_resources (DRM)",
unit: "bytes",
informative: true,
get: (r) => {
const idle = lastOf(r, "idle", (s) => drmResident(s.os));
const purge = lastOf(r, "purge", (s) => drmResident(s.os));
return idle == null || purge == null ? null : idle - purge;
},
},
];
// ------------------------------------------------------------- aggregation
function median(xs) {
const s = [...xs].sort((a, b) => a - b);
const m = s.length >> 1;
return s.length % 2 ? s[m] : (s[m - 1] + s[m]) / 2;
}
function stats(values) {
const xs = nums(values);
if (xs.length === 0) return null;
return {
median: median(xs),
min: Math.min(...xs),
max: Math.max(...xs),
n: xs.length,
};
}
function verdict(base, other, informative, unit) {
if (!base || !other) return { delta: null, verdict: "—" };
const delta =
base.median === 0
? other.median === 0
? 0
: Infinity
: ((other.median - base.median) / Math.abs(base.median)) * 100;
if (informative) return { delta, verdict: "info" };
const overlap = other.min <= base.max && base.min <= other.max;
const small = Math.abs(other.median - base.median) < (NOISE_ABS[unit] ?? 0);
if (Math.abs(delta) < NOISE_PCT || small || overlap)
return { delta, verdict: "noise" };
return { delta, verdict: delta < 0 ? "lower" : "higher" };
}
async function loadRuns(dir) {
const runs = [];
for (const label of await readdir(dir, { withFileTypes: true })) {
if (!label.isDirectory()) continue;
for (const file of await readdir(join(dir, label.name))) {
if (!file.endsWith(".json")) continue;
const run = JSON.parse(
await readFile(join(dir, label.name, file), "utf8"),
);
runs.push(run);
}
}
return runs;
}
function group(runs) {
const groups = new Map();
for (const run of runs) {
const key = `${run.meta.engine}\u0000${run.meta.label}`;
if (!groups.has(key)) groups.set(key, []);
groups.get(key).push(run);
}
for (const list of groups.values())
list.sort((a, b) => a.meta.repeat - b.meta.repeat);
return groups;
}
function measured(list) {
// Repeat 0 runs on a cold GPU/shader cache; drop it when there are enough.
return !KEEP_WARMUP && list.length >= 3
? list.filter((r) => r.meta.repeat !== 0)
: list;
}
// ---------------------------------------------------------------- format
function fmt(value, unit) {
if (value == null) return "—";
if (unit === "bytes") {
const mib = value / MiB;
return `${Math.abs(mib) >= 100 ? mib.toFixed(0) : mib.toFixed(1)} MiB`;
}
return Math.abs(value) >= 100 ? value.toFixed(0) : value.toFixed(1);
}
function fmtDelta(delta) {
if (delta == null) return "";
if (!Number.isFinite(delta)) return "new";
return `${delta > 0 ? "+" : ""}${delta.toFixed(1)}%`;
}
function pad(s, n) {
return String(s).padEnd(n);
}
// ------------------------------------------------------------------- main
const runs = await loadRuns(RESULTS_DIR);
if (runs.length === 0) {
console.error(`No runs found under ${RESULTS_DIR}`);
process.exit(1);
}
const groups = group(runs);
const engines = [...new Set(runs.map((r) => r.meta.engine))].sort();
const labelsByDate = [
...new Set(
[...runs]
.sort((a, b) => a.meta.date.localeCompare(b.meta.date))
.map((r) => r.meta.label),
),
];
const baseline =
option("--baseline") ??
(labelsByDate.includes("develop") ? "develop" : labelsByDate[0]);
const labels = [baseline, ...labelsByDate.filter((l) => l !== baseline)];
const summary = {};
for (const engine of engines) {
summary[engine] = {};
for (const label of labels) {
const list = groups.get(`${engine}\u0000${label}`);
if (!list) continue;
const used = measured(list);
summary[engine][label] = {
runs: list.length,
measured: used.length,
meta: list[0].meta,
metrics: Object.fromEntries(
METRICS.map((m) => [m.key, stats(used.map(m.get))]),
),
};
}
}
for (const engine of engines) {
const byLabel = summary[engine];
const present = labels.filter((l) => byLabel[l]);
console.log(`\n## ${engine}`);
for (const label of present) {
const { meta, runs: n, measured: used } = byLabel[label];
console.log(
` ${label}: ${used}/${n} runs, commit ${meta.commit}, wasm ${meta.wasm}, ${meta.renderer?.renderer ?? "unknown renderer"}`,
);
}
const header = [pad("metric", 38), ...present.map((l) => pad(l, 22))].join(
"",
);
console.log(`\n${header}`);
for (const m of METRICS) {
const base = byLabel[baseline]?.metrics[m.key];
if (present.every((l) => !byLabel[l].metrics[m.key])) continue;
const cells = present.map((label) => {
const st = byLabel[label].metrics[m.key];
const value = fmt(st?.median, m.unit);
if (label === baseline) return pad(value, 22);
const v = verdict(base, st, m.informative, m.unit);
return pad(`${value} ${fmtDelta(v.delta)} ${v.verdict}`, 22);
});
console.log([pad(m.title, 38), ...cells].join(""));
}
}
if (flag("--json")) {
await writeFile(
OUT.replace(/\.html$/, ".json"),
JSON.stringify(summary, null, 2),
);
}
// ------------------------------------------------------------------- html
const COLORS = ["--c1", "--c2", "--c3", "--c4", "--c5", "--c6"];
const colorOf = (label) =>
`var(${COLORS[labels.indexOf(label) % COLORS.length]})`;
const esc = (s) =>
String(s).replace(
/[&<>"]/g,
(c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" })[c],
);
function chart(engine, series) {
const lines = [];
let maxX = 0;
let maxY = 0;
let phases = null;
for (const label of labels) {
for (const run of measured(groups.get(`${engine}\u0000${label}`) ?? [])) {
const ys = run.samples.map(series.get);
if (nums(ys).length === 0) continue;
maxX = Math.max(maxX, ys.length - 1);
maxY = Math.max(maxY, ...nums(ys));
lines.push({ label, ys });
phases ??= run.samples.map((s) => s.phase);
}
}
if (lines.length === 0) return "";
const W = 560,
H = 200,
L = 64,
R = 12,
T = 12,
B = 28;
const x = (i) => L + (maxX ? (i / maxX) * (W - L - R) : 0);
const y = (v) => T + (H - T - B) * (1 - (maxY ? v / maxY : 0));
const unitFmt = (v) =>
series.unit === "bytes" ? `${(v / MiB).toFixed(0)} MiB` : v.toFixed(0);
const bands = [];
let start = 0;
for (let i = 1; i <= phases.length; i++) {
if (i === phases.length || phases[i] !== phases[start]) {
const x0 = x(Math.max(0, start - 0.5));
const x1 = x(Math.min(maxX, i - 0.5));
const name = esc(phases[start]);
bands.push(
`<rect class="band${bands.length % 2 ? " alt" : ""}" x="${x0}" y="${T}" width="${Math.max(1, x1 - x0)}" height="${H - T - B}"><title>${name}</title></rect>` +
// Narrow bands (single samples) keep only the tooltip.
(x1 - x0 >= 36
? `<text class="phase" x="${(x0 + x1) / 2}" y="${H - 10}">${name}</text>`
: ""),
);
start = i;
}
}
const ticks = [0, 0.5, 1].map((f) => {
const v = maxY * f;
return (
`<line class="grid" x1="${L}" x2="${W - R}" y1="${y(v)}" y2="${y(v)}"/>` +
`<text class="tick" x="${L - 6}" y="${y(v) + 4}">${unitFmt(v)}</text>`
);
});
const paths = lines.map(({ label, ys }) => {
const d = ys
.map((v, i) =>
typeof v === "number" ? `${x(i).toFixed(1)},${y(v).toFixed(1)}` : null,
)
.filter(Boolean)
.join(" L");
return `<path d="M${d}" stroke="${colorOf(label)}"><title>${esc(label)}</title></path>`;
});
return `<figure><figcaption>${esc(series.title)}</figcaption>
<svg viewBox="0 0 ${W} ${H}" role="img" aria-label="${esc(series.title)}">${bands.join("")}${ticks.join("")}<g class="lines">${paths.join("")}</g></svg></figure>`;
}
function table(engine) {
const byLabel = summary[engine];
const present = labels.filter((l) => byLabel[l]);
const head = present
.map(
(l) =>
`<th><span class="swatch" style="background:${colorOf(l)}"></span>${esc(l)}</th>`,
)
.join("");
const rows = METRICS.filter((m) =>
present.some((l) => byLabel[l].metrics[m.key]),
).map((m) => {
const base = byLabel[baseline]?.metrics[m.key];
const cells = present.map((label) => {
const st = byLabel[label].metrics[m.key];
const range =
st && st.n > 1
? `<small>${fmt(st.min, m.unit)} – ${fmt(st.max, m.unit)}</small>`
: "";
if (label === baseline)
return `<td>${fmt(st?.median, m.unit)}${range}</td>`;
const v = verdict(base, st, m.informative, m.unit);
return `<td>${fmt(st?.median, m.unit)} <span class="chip ${v.verdict}">${fmtDelta(v.delta)} ${v.verdict}</span>${range}</td>`;
});
return `<tr><th scope="row">${esc(m.title)}</th>${cells.join("")}</tr>`;
});
return `<table><thead><tr><th>metric (median)</th>${head}</tr></thead><tbody>${rows.join("")}</tbody></table>`;
}
function metaList(engine) {
const byLabel = summary[engine];
return labels
.filter((l) => byLabel[l])
.map((l) => {
const { meta, runs: n, measured: used } = byLabel[l];
return `<li><b>${esc(l)}</b>: ${used}/${n} runs · commit ${esc(meta.commit)} · wasm ${esc(meta.wasm)} · ${esc(meta.engineVersion)} · ${esc(meta.renderer?.renderer ?? "unknown renderer")} · ${meta.viewport.width}×${meta.viewport.height}@${meta.dpr} · ${esc(meta.fixture)}</li>`;
})
.join("");
}
const html = `<!doctype html>
<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
<title>GPU memory report</title>
<style>
:root{--bg:#fbfbfa;--fg:#1d1d1b;--muted:#6b6b66;--line:#e2e2de;--band:#f1f1ee;
--c1:#2a6fdb;--c2:#d9480f;--c3:#2b8a3e;--c4:#9c36b5;--c5:#c2255c;--c6:#1098ad;
--lower:#2b8a3e;--higher:#c92a2a;--noise:#868e96;color-scheme:light}
@media (prefers-color-scheme:dark){:root:not([data-theme="light"]){--bg:#161615;--fg:#ecece8;--muted:#9a9a94;--line:#33332f;--band:#1f1f1d;
--c1:#6ea8fe;--c2:#ff922b;--c3:#69db7c;--c4:#da77f2;--c5:#f783ac;--c6:#3bc9db;--lower:#69db7c;--higher:#ff6b6b;--noise:#adb5bd;color-scheme:dark}}
:root[data-theme="dark"]{--bg:#161615;--fg:#ecece8;--muted:#9a9a94;--line:#33332f;--band:#1f1f1d;
--c1:#6ea8fe;--c2:#ff922b;--c3:#69db7c;--c4:#da77f2;--c5:#f783ac;--c6:#3bc9db;--lower:#69db7c;--higher:#ff6b6b;--noise:#adb5bd;color-scheme:dark}
*{box-sizing:border-box}body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.45 system-ui,sans-serif}
main{max-width:1200px;margin:0 auto;padding:24px 16px}h1{font-size:22px;margin:0 0 4px}h2{font-size:18px;margin:32px 0 8px;text-transform:capitalize}
p.lead,ul.meta{color:var(--muted)}ul.meta{padding-left:18px;font-size:13px}
.scroll{overflow-x:auto}table{border-collapse:collapse;width:100%;font-variant-numeric:tabular-nums}
th,td{padding:6px 10px;border-bottom:1px solid var(--line);text-align:right;white-space:nowrap}th[scope=row],thead th:first-child{text-align:left;font-weight:500}
td small{display:block;color:var(--muted);font-size:11px}
.chip{font-size:12px;padding:1px 6px;border-radius:10px;border:1px solid currentColor}.chip.lower{color:var(--lower)}.chip.higher{color:var(--higher)}.chip.noise,.chip.info,.chip.—{color:var(--noise)}
.swatch{display:inline-block;width:10px;height:10px;border-radius:2px;margin-right:6px;vertical-align:baseline}
.charts{display:grid;grid-template-columns:repeat(auto-fill,minmax(min(100%,520px),1fr));gap:16px;margin-top:16px}
figure{margin:0}figcaption{font-size:13px;margin-bottom:4px}svg{width:100%;height:auto;display:block}
.band{fill:var(--band)}.band.alt{fill:transparent}.grid{stroke:var(--line)}.tick{fill:var(--muted);font-size:10px;text-anchor:end}
.phase{fill:var(--muted);font-size:10px;text-anchor:middle}.lines path{fill:none;stroke-width:1.5;opacity:.85}
</style></head><body><main>
<h1>GPU memory report</h1>
<p class="lead">Baseline <b>${esc(baseline)}</b>. Lower is better for every metric. A change counts only when medians differ by ≥${NOISE_PCT}% and the run ranges don't overlap; otherwise it's noise.${KEEP_WARMUP ? "" : " Repeat 0 is dropped as warm-up when there are 3+ runs."}</p>
${engines.map((e) => `<section><h2>${esc(e)}</h2><ul class="meta">${metaList(e)}</ul><div class="scroll">${table(e)}</div><div class="charts">${SERIES.map((s) => chart(e, s)).join("")}</div></section>`).join("")}
</main></body></html>`;
await writeFile(OUT, html);
console.log(`\nReport: ${OUT}`);

View File

@ -0,0 +1,423 @@
// Engine-agnostic WebGL memory accounting. Passed to `page.addInitScript`,
// so it must stay self-contained (no imports, no outer references).
//
// Patches the WebGL prototypes before the app creates its context and keeps
// running totals of the bytes backing textures, renderbuffers and buffers,
// plus counters for every storage (re)allocation. Sizes are what WebGL was
// asked for; drivers add padding, alignment and their own copies on top.
export function installWebGLMemoryTracker() {
if (globalThis.__gpuMem) return;
const protos = [
globalThis.WebGL2RenderingContext?.prototype,
globalThis.WebGLRenderingContext?.prototype,
].filter(Boolean);
if (protos.length === 0) return;
const TEXTURE_2D = 0x0de1;
const TEXTURE_CUBE_MAP = 0x8513;
const CUBE_FACE_FIRST = 0x8515;
const CUBE_FACE_LAST = 0x851a;
const ELEMENT_ARRAY_BUFFER = 0x8893;
const SIZED_BPP = {
0x8229: 1, // R8
0x822b: 2, // RG8
0x8051: 3, // RGB8
0x8058: 4, // RGBA8
0x8c43: 4, // SRGB8_ALPHA8
0x8d62: 2, // RGB565
0x8056: 2, // RGBA4
0x8057: 2, // RGB5_A1
0x8059: 4, // RGB10_A2
0x822d: 2, // R16F
0x822f: 4, // RG16F
0x881a: 8, // RGBA16F
0x822e: 4, // R32F
0x8230: 8, // RG32F
0x8814: 16, // RGBA32F
0x8c3a: 4, // R11F_G11F_B10F
0x81a5: 2, // DEPTH_COMPONENT16
0x81a6: 4, // DEPTH_COMPONENT24
0x8cac: 4, // DEPTH_COMPONENT32F
0x88f0: 4, // DEPTH24_STENCIL8
0x8cad: 8, // DEPTH32F_STENCIL8
0x8d48: 1, // STENCIL_INDEX8
0x8232: 1, // R8UI
0x8d7c: 4, // RGBA8UI
};
const UNSIZED_CHANNELS = {
0x1906: 1, // ALPHA
0x1909: 1, // LUMINANCE
0x190a: 2, // LUMINANCE_ALPHA
0x1907: 3, // RGB
0x1908: 4, // RGBA
0x1903: 1, // RED
0x8227: 2, // RG
};
const TYPE_BYTES = {
0x1401: 1, // UNSIGNED_BYTE
0x1406: 4, // FLOAT
0x140b: 2, // HALF_FLOAT
0x8d61: 2, // HALF_FLOAT_OES
};
const PACKED_TYPES = new Set([0x8363, 0x8033, 0x8034]); // 565, 4444, 5551
function bpp(internalformat, format, type) {
if (SIZED_BPP[internalformat]) return SIZED_BPP[internalformat];
if (PACKED_TYPES.has(type)) return 2;
const channels =
UNSIZED_CHANNELS[internalformat] ?? UNSIZED_CHANNELS[format];
if (channels) return channels * (TYPE_BYTES[type] ?? 1);
return 4;
}
function sourceSize(src) {
if (!src) return [0, 0];
const w =
src.naturalWidth || src.videoWidth || src.displayWidth || src.width || 0;
const h =
src.naturalHeight ||
src.videoHeight ||
src.displayHeight ||
src.height ||
0;
return [w, h];
}
const counters = {
textureAllocs: 0,
renderbufferAllocs: 0,
bufferAllocs: 0,
texturesCreated: 0,
texturesDeleted: 0,
renderbuffersCreated: 0,
renderbuffersDeleted: 0,
buffersCreated: 0,
buffersDeleted: 0,
framebuffersCreated: 0,
framebuffersDeleted: 0,
};
const totals = { texture: 0, renderbuffer: 0, buffer: 0 };
let allocatedBytesTotal = 0;
let contextLosses = 0;
// texture -> Map("face:level" -> bytes); renderbuffer/buffer -> bytes
const textureLevels = new Map();
const objectBytes = { renderbuffer: new Map(), buffer: new Map() };
const contexts = [];
const stateByContext = new WeakMap();
const DEFAULT_VAO = {};
function stateOf(gl) {
let s = stateByContext.get(gl);
if (!s) {
s = {
unit: 0,
textures: new Map(),
buffers: new Map(),
renderbuffer: null,
vao: DEFAULT_VAO,
vaoElementBuffers: new Map(),
};
stateByContext.set(gl, s);
}
return s;
}
function boundTexture(gl, target) {
const s = stateOf(gl);
const bindTarget =
target >= CUBE_FACE_FIRST && target <= CUBE_FACE_LAST
? TEXTURE_CUBE_MAP
: target;
return s.textures.get(s.unit)?.get(bindTarget) ?? null;
}
function setTextureLevel(gl, target, level, bytes) {
const tex = boundTexture(gl, target);
if (!tex) return;
let levels = textureLevels.get(tex);
if (!levels) {
levels = new Map();
textureLevels.set(tex, levels);
}
const face =
target >= CUBE_FACE_FIRST && target <= CUBE_FACE_LAST
? target - CUBE_FACE_FIRST
: 0;
const key = `${face}:${level}`;
totals.texture += bytes - (levels.get(key) ?? 0);
levels.set(key, bytes);
allocatedBytesTotal += bytes;
counters.textureAllocs++;
}
function setTextureStorage(gl, target, levelsCount, w, h, d, bytesPerPixel) {
const faces = target === TEXTURE_CUBE_MAP ? 6 : 1;
for (let face = 0; face < faces; face++) {
const faceTarget = faces === 6 ? CUBE_FACE_FIRST + face : target;
for (let l = 0; l < levelsCount; l++) {
const lw = Math.max(1, w >> l);
const lh = Math.max(1, h >> l);
setTextureLevel(gl, faceTarget, l, lw * lh * d * bytesPerPixel);
}
}
// One storage call is one allocation, however many levels it fills.
counters.textureAllocs -= faces * levelsCount - 1;
}
function setObjectBytes(kind, obj, bytes) {
if (!obj) return;
const map = objectBytes[kind];
totals[kind] += bytes - (map.get(obj) ?? 0);
map.set(obj, bytes);
allocatedBytesTotal += bytes;
counters[`${kind}Allocs`]++;
}
function dropObject(kind, obj) {
const map = objectBytes[kind];
if (!obj || !map.has(obj)) return;
totals[kind] -= map.get(obj);
map.delete(obj);
}
function wrap(proto, name, after) {
const original = proto[name];
if (typeof original !== "function") return;
proto[name] = function (...args) {
const result = original.apply(this, args);
try {
after(this, args, result);
} catch (_) {
// Accounting must never break rendering.
}
return result;
};
}
for (const proto of protos) {
wrap(proto, "activeTexture", (gl, [unit]) => {
stateOf(gl).unit = unit;
});
wrap(proto, "bindTexture", (gl, [target, tex]) => {
const s = stateOf(gl);
if (!s.textures.has(s.unit)) s.textures.set(s.unit, new Map());
s.textures.get(s.unit).set(target, tex);
});
wrap(proto, "texImage2D", (gl, a) => {
if (a.length >= 8) {
setTextureLevel(gl, a[0], a[1], a[3] * a[4] * bpp(a[2], a[6], a[7]));
} else {
const [w, h] = sourceSize(a[5]);
setTextureLevel(gl, a[0], a[1], w * h * bpp(a[2], a[3], a[4]));
}
});
wrap(proto, "texImage3D", (gl, a) => {
setTextureLevel(
gl,
a[0],
a[1],
a[3] * a[4] * a[5] * bpp(a[2], a[7], a[8]),
);
});
wrap(proto, "copyTexImage2D", (gl, a) => {
setTextureLevel(gl, a[0], a[1], a[5] * a[6] * bpp(a[2]));
});
wrap(proto, "compressedTexImage2D", (gl, a) => {
const size = typeof a[6] === "number" ? a[6] : (a[6]?.byteLength ?? 0);
setTextureLevel(gl, a[0], a[1], size);
});
wrap(proto, "texStorage2D", (gl, a) => {
setTextureStorage(gl, a[0], a[1], a[3], a[4], 1, bpp(a[2]));
});
wrap(proto, "texStorage3D", (gl, a) => {
setTextureStorage(gl, a[0], a[1], a[3], a[4], a[5], bpp(a[2]));
});
wrap(proto, "createTexture", () => counters.texturesCreated++);
wrap(proto, "deleteTexture", (_gl, [tex]) => {
if (!tex) return;
counters.texturesDeleted++;
const levels = textureLevels.get(tex);
if (!levels) return;
for (const bytes of levels.values()) totals.texture -= bytes;
textureLevels.delete(tex);
});
wrap(proto, "bindRenderbuffer", (gl, [, rb]) => {
stateOf(gl).renderbuffer = rb;
});
wrap(proto, "renderbufferStorage", (gl, [, fmt, w, h]) => {
setObjectBytes(
"renderbuffer",
stateOf(gl).renderbuffer,
w * h * bpp(fmt),
);
});
wrap(
proto,
"renderbufferStorageMultisample",
(gl, [, samples, fmt, w, h]) => {
const bytes = w * h * bpp(fmt) * Math.max(1, samples);
setObjectBytes("renderbuffer", stateOf(gl).renderbuffer, bytes);
},
);
wrap(proto, "createRenderbuffer", () => counters.renderbuffersCreated++);
wrap(proto, "deleteRenderbuffer", (_gl, [rb]) => {
if (!rb) return;
counters.renderbuffersDeleted++;
dropObject("renderbuffer", rb);
});
const bindBuffer = (gl, target, buf) => {
const s = stateOf(gl);
s.buffers.set(target, buf);
if (target === ELEMENT_ARRAY_BUFFER) s.vaoElementBuffers.set(s.vao, buf);
};
wrap(proto, "bindBuffer", (gl, [target, buf]) =>
bindBuffer(gl, target, buf),
);
wrap(proto, "bindBufferBase", (gl, [target, , buf]) =>
bindBuffer(gl, target, buf),
);
wrap(proto, "bindBufferRange", (gl, [target, , buf]) =>
bindBuffer(gl, target, buf),
);
const bindVertexArray = (gl, [vao]) => {
const s = stateOf(gl);
s.vao = vao ?? DEFAULT_VAO;
s.buffers.set(
ELEMENT_ARRAY_BUFFER,
s.vaoElementBuffers.get(s.vao) ?? null,
);
};
wrap(proto, "bindVertexArray", bindVertexArray);
wrap(proto, "bindVertexArrayOES", bindVertexArray);
wrap(proto, "bufferData", (gl, [target, data, , srcOffset, length]) => {
let bytes;
if (typeof data === "number") {
bytes = data;
} else if (data && ArrayBuffer.isView(data)) {
const bpe = data.BYTES_PER_ELEMENT ?? 1;
const elements = data.byteLength / bpe;
bytes = length ? length * bpe : (elements - (srcOffset ?? 0)) * bpe;
} else {
bytes = data?.byteLength ?? 0;
}
setObjectBytes("buffer", stateOf(gl).buffers.get(target), bytes);
});
wrap(proto, "createBuffer", () => counters.buffersCreated++);
wrap(proto, "deleteBuffer", (_gl, [buf]) => {
if (!buf) return;
counters.buffersDeleted++;
dropObject("buffer", buf);
});
wrap(proto, "createFramebuffer", () => counters.framebuffersCreated++);
wrap(proto, "deleteFramebuffer", (_gl, [fb]) => {
if (fb) counters.framebuffersDeleted++;
});
}
function trackContext(canvas, ctx) {
const isGL = protos.some((p) => Object.getPrototypeOf(ctx) === p);
if (!isGL || contexts.some((c) => c.ctx === ctx)) return;
contexts.push({ canvas, ctx });
canvas.addEventListener?.("webglcontextlost", () => contextLosses++);
}
for (const Canvas of [
globalThis.HTMLCanvasElement,
globalThis.OffscreenCanvas,
]) {
if (!Canvas) continue;
const original = Canvas.prototype.getContext;
Canvas.prototype.getContext = function (...args) {
const ctx = original.apply(this, args);
if (ctx) trackContext(this, ctx);
return ctx;
};
}
function drawingBuffers() {
let pixels = 0;
let bytes = 0;
let preserve = false;
for (const { ctx } of contexts) {
if (ctx.isContextLost()) continue;
const attrs = ctx.getContextAttributes() ?? {};
const px = ctx.drawingBufferWidth * ctx.drawingBufferHeight;
// Estimate: front + back colour buffers, optional depth/stencil.
const perPixel = 8 + (attrs.depth || attrs.stencil ? 4 : 0);
pixels += px;
bytes += px * perPixel;
preserve ||= !!attrs.preserveDrawingBuffer;
}
return { pixels, bytesEstimate: bytes, preserveDrawingBuffer: preserve };
}
// Keep a reference to each instance's linear memory; exports stay untouched.
const wasmMemories = [];
for (const name of ["instantiate", "instantiateStreaming"]) {
const original = WebAssembly[name];
if (typeof original !== "function") continue;
WebAssembly[name] = function (...args) {
return original.apply(this, args).then((result) => {
const memory = (result.instance ?? result)?.exports?.memory;
if (memory instanceof WebAssembly.Memory) wasmMemories.push(memory);
return result;
});
};
}
function wasmHeapBytes() {
if (wasmMemories.length === 0) return null;
return wasmMemories.reduce((acc, m) => acc + m.buffer.byteLength, 0);
}
function rendererInfo() {
for (const { ctx } of contexts) {
if (ctx.isContextLost()) continue;
const ext = ctx.getExtension("WEBGL_debug_renderer_info");
return {
vendor: ctx.getParameter(ext ? ext.UNMASKED_VENDOR_WEBGL : ctx.VENDOR),
renderer: ctx.getParameter(
ext ? ext.UNMASKED_RENDERER_WEBGL : ctx.RENDERER,
),
version: ctx.getParameter(ctx.VERSION),
};
}
return null;
}
globalThis.__gpuMem = {
rendererInfo,
snapshot() {
const drawingBuffer = drawingBuffers();
return {
textures: { count: textureLevels.size, bytes: totals.texture },
renderbuffers: {
count: objectBytes.renderbuffer.size,
bytes: totals.renderbuffer,
},
buffers: { count: objectBytes.buffer.size, bytes: totals.buffer },
totalBytes: totals.texture + totals.renderbuffer + totals.buffer,
drawingBuffer,
allocs: {
...counters,
total:
counters.textureAllocs +
counters.renderbufferAllocs +
counters.bufferAllocs,
},
allocatedBytesTotal,
contexts: contexts.length,
contextLosses,
wasmHeapBytes: wasmHeapBytes(),
jsHeapBytes: performance.memory?.usedJSHeapSize ?? null,
};
},
};
}