mirror of
https://github.com/penpot/penpot.git
synced 2026-10-29 22:10:34 +00:00
🎉 Add cross-engine GPU memory harness for render-wasm
This commit is contained in:
parent
f9b8f1ba75
commit
45077e6608
2
.gitignore
vendored
2
.gitignore
vendored
@ -112,3 +112,5 @@ opencode.json
|
||||
/.ci-logs
|
||||
/.codex/
|
||||
/tools/__pycache__
|
||||
/frontend/playwright/perf/results*/
|
||||
/frontend/playwright/perf/report.html
|
||||
|
||||
@ -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 */
|
||||
|
||||
57
frontend/playwright/perf/README.md
Normal file
57
frontend/playwright/perf/README.md
Normal 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.
|
||||
205
frontend/playwright/perf/gpu-memory.spec.js
Normal file
205
frontend/playwright/perf/gpu-memory.spec.js
Normal 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),
|
||||
);
|
||||
});
|
||||
}
|
||||
162
frontend/playwright/perf/gpu-memory/os-sampler.js
Normal file
162
frontend/playwright/perf/gpu-memory/os-sampler.js
Normal 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))),
|
||||
};
|
||||
}
|
||||
572
frontend/playwright/perf/gpu-memory/report.mjs
Normal file
572
frontend/playwright/perf/gpu-memory/report.mjs
Normal 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) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[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}`);
|
||||
423
frontend/playwright/perf/gpu-memory/webgl-tracker.js
Normal file
423
frontend/playwright/perf/gpu-memory/webgl-tracker.js
Normal 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,
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user