✨ Add fixture server and alloc traces to GPU harness

This commit is contained in:
Elena Torro 2026-09-29 11:10:13 +02:00
parent f726f5943d
commit 71cae946ab
4 changed files with 79 additions and 9 deletions

2
.gitignore vendored
View File

@ -113,4 +113,4 @@ opencode.json
/.codex/
/tools/__pycache__
/frontend/playwright/perf/results*/
/frontend/playwright/perf/report.html
/frontend/playwright/perf/report*.html

View File

@ -27,12 +27,15 @@ 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_FIXTURE` | `render-wasm/get-file-shadows.json` | `get-file` fixture under `playwright/data/`; served over local HTTP, so size is no limit |
| `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_EDIT_TARGET` | select all | `x,y` in CSS px: the edit phase selects the element there instead of everything |
| `PERF_SKIA_CACHE_MB` | build default | Skia resource cache budget, set after the first render (needs `set_resource_cache_limit_mb` in the wasm build) |
| `PERF_PURGE_PROBE` | off | `1` calls `free_gpu_resources` at the end and samples again |
| `PERF_TIMEOUT_MIN` | `15` | Time limit per run; heavy fixtures in Firefox need more |
| `PERF_TRACE_SIZE` | off | `WxH`: record the call stacks of allocations of that size (in each sample's `web.traces`) |
| `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

View File

@ -1,6 +1,8 @@
import { test } from "@playwright/test";
import { execFileSync } from "node:child_process";
import { createHash } from "node:crypto";
import { createReadStream } from "node:fs";
import { createServer } from "node:http";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { WasmWorkspacePage } from "../ui/pages/WasmWorkspacePage";
import { installWebGLMemoryTracker } from "./gpu-memory/webgl-tracker.js";
@ -23,7 +25,11 @@ const config = {
iterations: Number(env.PERF_ITERATIONS ?? 12),
settleMs: Number(env.PERF_SETTLE_MS ?? 300),
purgeProbe: env.PERF_PURGE_PROBE === "1",
// "x,y" in CSS px: select the element there instead of everything. On a
// big page, moving every shape at once takes minutes per step.
editTarget: env.PERF_EDIT_TARGET?.split(",").map(Number) ?? null,
skiaCacheMb: env.PERF_SKIA_CACHE_MB ? Number(env.PERF_SKIA_CACHE_MB) : null,
timeoutMin: Number(env.PERF_TIMEOUT_MIN ?? 15),
outDir: env.PERF_OUT_DIR ?? "playwright/perf/results",
};
@ -52,6 +58,36 @@ async function setPageSize(page, size) {
}, size);
}
// Serves the fixture over plain HTTP. `route.fulfill` sends the body through
// the DevTools protocol, and a large fixture (tens of MB) drops the browser
// session.
let fixtureServer;
async function fixtureUrl() {
if (!fixtureServer) {
fixtureServer = createServer((_req, res) => {
res.writeHead(200, {
"content-type": "application/transit+json",
"access-control-allow-origin": "*",
});
createReadStream(`playwright/data/${config.fixture}`).pipe(res);
});
await new Promise((resolve) =>
fixtureServer.listen(0, "127.0.0.1", resolve),
);
}
return `http://127.0.0.1:${fixtureServer.address().port}/get-file`;
}
test.afterAll(() => fixtureServer?.close());
// goToWorkspace waits for the page name, which differs per fixture.
async function fixturePageName(fixture) {
const data = JSON.parse(await readFile(`playwright/data/${fixture}`, "utf8"));
const pages = data["~:data"]?.["~:pages-index"] ?? {};
return Object.values(pages)[0]?.["~:name"] ?? "Page 1";
}
async function wasmHash() {
const bytes = await readFile("resources/public/js/render-wasm.wasm").catch(
() => null,
@ -83,10 +119,12 @@ async function settle(page, quietMs = config.settleMs, maxMs = 10000) {
);
}
test.describe.configure({ mode: "serial" });
test.beforeEach(async ({ page }) => {
await page.addInitScript(installWebGLMemoryTracker);
page.on("crash", () => console.error("[gpu-memory] page crashed"));
page.on("pageerror", (e) => console.error(`[gpu-memory] ${e.message}`));
await page.addInitScript(installWebGLMemoryTracker, {
traceSize: env.PERF_TRACE_SIZE ?? null,
});
await WasmWorkspacePage.init(page);
await WasmWorkspacePage.mockRPC(
page,
@ -101,7 +139,7 @@ for (let repeat = 0; repeat < config.repeats; repeat++) {
browser,
browserName,
}, testInfo) => {
test.setTimeout(15 * 60 * 1000);
test.setTimeout(config.timeoutMin * 60 * 1000);
const workspace = new WasmWorkspacePage(page);
const dir = `${config.outDir}/${config.label}`;
@ -120,7 +158,12 @@ for (let repeat = 0; repeat < config.repeats; repeat++) {
await workspace.setupEmptyFile();
await workspace.mockGetFile(config.fixture);
await workspace.goToWorkspace();
const url = await fixtureUrl();
// Registered last, so it wins over the fulfill route mockGetFile added.
await page.route(/get-file\?/, (route) => route.continue({ url }));
await workspace.goToWorkspace({
pageName: await fixturePageName(config.fixture),
});
await workspace.waitForFirstRenderWithoutUI();
if (config.skiaCacheMb) {
// Set after the first render, so the load phase still runs on the
@ -168,7 +211,12 @@ for (let repeat = 0; repeat < config.repeats; repeat++) {
await sample("pan", i);
}
await page.keyboard.press("ControlOrMeta+a");
if (config.editTarget) {
const [ex, ey] = config.editTarget;
await page.mouse.click(ex, ey);
} else {
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);
@ -201,6 +249,7 @@ for (let repeat = 0; repeat < config.repeats; repeat++) {
fixture: config.fixture,
iterations: config.iterations,
skiaCacheMb: config.skiaCacheMb,
editTarget: config.editTarget,
viewport: base,
dpr: testInfo.project.use.deviceScaleFactor ?? 1,
renderer,

View File

@ -5,8 +5,12 @@
// 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() {
export function installWebGLMemoryTracker(options = {}) {
if (globalThis.__gpuMem) return;
// Allocation size ("WxH") whose call stacks are recorded, for finding
// which render path creates it.
const traceSize = options.traceSize ?? null;
const traces = new Map();
const protos = [
globalThis.WebGL2RenderingContext?.prototype,
@ -105,6 +109,14 @@ export function installWebGLMemoryTracker() {
const allocsBySize = new Map();
function logAlloc(kind, w, h, bytes) {
if (traceSize === `${w}x${h}`) {
const stack = (new Error().stack ?? "")
.split("\n")
.slice(3, 30)
.map((l) => l.trim())
.join("\n");
traces.set(stack, (traces.get(stack) ?? 0) + 1);
}
const key = `${kind} ${w}x${h}`;
const entry = allocsBySize.get(key) ?? { count: 0, bytes: 0 };
entry.count++;
@ -443,6 +455,12 @@ export function installWebGLMemoryTracker() {
},
allocatedBytesTotal,
allocsBySize: bySize,
traces: traceSize
? [...traces.entries()]
.sort((a, b) => b[1] - a[1])
.slice(0, 8)
.map(([stack, count]) => ({ count, stack }))
: undefined,
contexts: contexts.length,
contextLosses,
wasmHeapBytes: wasmHeapBytes(),