From 571f58440d63addb40f7a4ffbe394a21d6b7b58a Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 4 Jul 2026 20:18:52 -0700 Subject: [PATCH] perf(client): main-thread memory harness + drop three map-sized render buffers (-23%) (#4511) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Follow-up to #4507, moving the memory-footprint campaign to the **main thread** (client). Two parts: a headless browser measurement harness, and a first optimization round that cuts the main-thread live heap on Giant World Map by **23%** (166 → 128 MB at tick 2000). ## Part 1 — `npm run perf:client-mem`: headless main-thread memory harness Drives a real singleplayer game in headless Chromium and measures the **page's isolate only** (the core sim worker is a separate CDP target): - Starts its own vite dev server on a private port (default 9017) so it always measures the current checkout. - Double-forced-GC checkpoints every `--window` ticks: JS heap, ArrayBuffer backing-store bytes (`Runtime.getHeapUsage`), DOM nodes, listeners, ticks/s. - `--snapshot-at ` writes V8 heap snapshots, analyzable with the retainer/summary tools from #4507. - Spoofs the unmasked WebGL renderer string via an init script so the software-GL gate (#4324) admits SwiftShader — no game code touched; rendering still runs software (hence the rAF throttle). - End-of-run screenshot as a rendering sanity check. Baseline (Giant World Map, 400 bots, 12,000 ticks): ~176 MB live, of which ~116 MB is **static per-tile buffers** allocated up front for the 8M-tile map — flat during play, no leaks. ## Part 2 — drop three map-sized render-layer buffer copies | Buffer | Before | After | |---|---|---| | `TrailPass.cpuTrailState` | 15.3 MB copy | **deleted** — dead code; every upload entry point sets the live reference to TrailManager's array | | `RailroadPass.cpuRailroadState` | 15.3 MB across 2 arrays | references `RailroadCache.railroadState` (stable identity, mutated in place) | | `RailroadPass.cpuGhostRailState` | ↑ | sparse `Map`; preview diffs applied as per-texel `texSubImage2D` writes (path-sized work instead of a full 8 MB texture upload per build-preview mouse move) | | `TerrainPass` + `MapRenderer` terrain bytes | 7.6 MB (one buffer, two retainers) | `terrainSource()` provider — re-bakes (theme change, context restore) regenerate from the live game map, which already reflects water-nuke conversions | Tick-2000 snapshot comparison (giant world, 400 bots): **166.4 → 128.4 MB**. ## Verification - `tsc --noEmit`, eslint, full test suite (1924 tests) pass. - 2000-tick headless giant-world game after the change: no GL pageerrors, end-of-run screenshot renders terrain/territory/borders/names correctly, sim speed unchanged (~5 ticks/s headless). - Ghost-rail ops flush before the zoom-fade early-return, so the op queue can't grow while previewing at low zoom. - WebGL context restore recreates all passes fresh and the owner re-uploads state (existing `onContextRestored` path), consistent with the new reference-based buffers. Note: heap snapshots in `tests/perf/output/` are gitignored; the numbers above are from runs recorded in the PR discussion. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- .claude/skills/run-openfront/driver.mjs | 5 +- package.json | 1 + src/client/ClientGameRunner.ts | 20 +- src/client/render/gl/MapRenderer.ts | 7 +- src/client/render/gl/Renderer.ts | 28 +- src/client/render/gl/passes/RailroadPass.ts | 105 +++-- src/client/render/gl/passes/TerrainPass.ts | 21 +- src/client/render/gl/passes/TrailPass.ts | 18 +- tests/perf/client/ClientMemoryPerf.ts | 405 +++++++++++++++++++ tests/perf/fullgame/HeapSnapshotRetainers.ts | 8 +- 10 files changed, 551 insertions(+), 67 deletions(-) create mode 100644 tests/perf/client/ClientMemoryPerf.ts diff --git a/.claude/skills/run-openfront/driver.mjs b/.claude/skills/run-openfront/driver.mjs index afadbb80d..4abcd2f54 100644 --- a/.claude/skills/run-openfront/driver.mjs +++ b/.claude/skills/run-openfront/driver.mjs @@ -23,7 +23,8 @@ export const BASE_URL = process.env.OPENFRONT_URL ?? "http://localhost:9000"; // of CPU per frame, and an unthrottled rAF loop starves the // main thread (timers, the singleplayer turn loop, input). // ~1000 is a good value; frames still render for screenshots. -export async function launch({ viewport, rafIntervalMs } = {}) { +// args - extra Chromium flags appended to the defaults. +export async function launch({ viewport, rafIntervalMs, args } = {}) { const env = { ...process.env }; const libs = path.join(CACHE, "extracted", "usr", "lib", "x86_64-linux-gnu"); if (fs.existsSync(libs)) { @@ -33,7 +34,7 @@ export async function launch({ viewport, rafIntervalMs } = {}) { env.FONTCONFIG_FILE = path.join(CACHE, "fonts.conf"); } const browser = await chromium.launch({ - args: ["--no-sandbox", "--disable-gpu"], + args: ["--no-sandbox", "--disable-gpu", ...(args ?? [])], env, }); const context = await browser.newContext({ diff --git a/package.json b/package.json index ac56f6e85..2030ad6af 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "perf": "npx tsx tests/perf/run-all.ts", "perf:game": "npx tsx tests/perf/fullgame/FullGamePerf.ts", "perf:client": "npx tsx tests/perf/client/ClientUpdatePerf.ts", + "perf:client-mem": "npx tsx tests/perf/client/ClientMemoryPerf.ts", "test:coverage": "vitest run --coverage", "format": "prettier --ignore-unknown --write .", "format:map-generator": "cd map-generator && go fmt .", diff --git a/src/client/ClientGameRunner.ts b/src/client/ClientGameRunner.ts index ce00f2a27..474ddc811 100644 --- a/src/client/ClientGameRunner.ts +++ b/src/client/ClientGameRunner.ts @@ -275,12 +275,20 @@ function createWebGLView( const mapWidth = gameMap.width(); const mapHeight = gameMap.height(); - const terrainBytes = new Uint8Array(mapWidth * mapHeight); - for (let y = 0; y < mapHeight; y++) { - for (let x = 0; x < mapWidth; x++) { - terrainBytes[y * mapWidth + x] = gameMap.terrainByte(gameMap.ref(x, y)); + // Provider, not a buffer: per-tile terrain bytes are map-sized (8 MB on + // the giant map), so consumers regenerate them on demand (initial bake, + // context restore, theme change) instead of anyone retaining a copy. + // gameMap is updated live by water-nuke conversions, so a regenerated + // array always reflects them. + const terrainSource = (): Uint8Array => { + const terrainBytes = new Uint8Array(mapWidth * mapHeight); + for (let y = 0; y < mapHeight; y++) { + for (let x = 0; x < mapWidth; x++) { + terrainBytes[y * mapWidth + x] = gameMap.terrainByte(gameMap.ref(x, y)); + } } - } + return terrainBytes; + }; const glCanvas = createCanvas(); glCanvas.id = "webgl-debug-canvas"; @@ -324,7 +332,7 @@ function createWebGLView( // bound at construction time. maxPlayers: 1024, }, - terrainBytes, + terrainSource, palette, config, settings, diff --git a/src/client/render/gl/MapRenderer.ts b/src/client/render/gl/MapRenderer.ts index 31ce496bb..50495faaf 100644 --- a/src/client/render/gl/MapRenderer.ts +++ b/src/client/render/gl/MapRenderer.ts @@ -47,7 +47,10 @@ export class MapRenderer { constructor( private canvas: HTMLCanvasElement, private header: RendererConfig, - private terrainBytes: Uint8Array, + // Called (not stored) whenever terrain bytes are needed — initial bake + // and every context restore. Regenerating on demand avoids retaining a + // map-sized buffer for the rare restore path. + private terrainSource: () => Uint8Array, private paletteData: Float32Array, private config: Config, // Resolved render settings (defaults + overrides). Held so the same object @@ -79,7 +82,7 @@ export class MapRenderer { this.renderer = new GPURenderer( this.canvas, this.header, - this.terrainBytes, + this.terrainSource, this.paletteData, this.config, this.settings, diff --git a/src/client/render/gl/Renderer.ts b/src/client/render/gl/Renderer.ts index f7ffb1007..a2e94307c 100644 --- a/src/client/render/gl/Renderer.ts +++ b/src/client/render/gl/Renderer.ts @@ -197,7 +197,7 @@ export class GPURenderer { constructor( canvas: HTMLCanvasElement, header: RendererConfig, - terrainBytes: Uint8Array, + terrainSource: () => Uint8Array, paletteData: Float32Array, config: Config, settings: RenderSettings, @@ -246,13 +246,25 @@ export class GPURenderer { this.camera = new Camera(mapW, mapH); // --- Terrain (static) --- - this.terrainPass = new TerrainPass(gl, terrainBytes, mapW, mapH, { - oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined, - sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined, - plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined, - highlandColor: hexToRgb(this.settings.terrain.highlandColor) ?? undefined, - mountainColor: hexToRgb(this.settings.terrain.mountainColor) ?? undefined, - }); + // Bake once and let the array go — nothing below retains map-sized + // terrain bytes; re-bakes call terrainSource again. + const terrainBytes = terrainSource(); + this.terrainPass = new TerrainPass( + gl, + terrainSource, + terrainBytes, + mapW, + mapH, + { + oceanColor: hexToRgb(this.settings.terrain.oceanColor) ?? undefined, + sandColor: hexToRgb(this.settings.terrain.sandColor) ?? undefined, + plainsColor: hexToRgb(this.settings.terrain.plainsColor) ?? undefined, + highlandColor: + hexToRgb(this.settings.terrain.highlandColor) ?? undefined, + mountainColor: + hexToRgb(this.settings.terrain.mountainColor) ?? undefined, + }, + ); // --- Shared palette texture (RGBA32F, 4096×2) --- this.paletteData = paletteData; diff --git a/src/client/render/gl/passes/RailroadPass.ts b/src/client/render/gl/passes/RailroadPass.ts index b590684b3..a315c72fd 100644 --- a/src/client/render/gl/passes/RailroadPass.ts +++ b/src/client/render/gl/passes/RailroadPass.ts @@ -108,11 +108,23 @@ export class RailroadPass { private mapH: number; private settings: RenderSettings; - private cpuRailroadState: Uint8Array; + /** + * Reference to the caller-owned railroad state (RailroadCache's array; + * stable identity, mutated in place). The pass keeps no copy — the array + * must stay current until the flush. Null until the first upload. + */ + private liveRailroadRef: Uint8Array | null = null; private railroadDirty = false; - private cpuGhostRailState: Uint8Array; - private ghostRailDirty = false; + /** + * Current ghost overlay content, sparse: tile ref → texel value (1-6 = + * orientation, 7 = overlap highlight). Ghost paths cover at most a few + * thousand tiles, so tracking them beats a full-map array + full-map + * texture upload per preview change. + */ + private ghostTiles = new Map(); + /** Pending ghost texel writes, interleaved [ref, value, …]. */ + private ghostOps: number[] = []; private ghostOwnerID = 0; private localPlayerID = 0; @@ -132,8 +144,6 @@ export class RailroadPass { this.tileTex = tileTex; this.paletteTex = paletteTex; this.settings = settings; - this.cpuRailroadState = new Uint8Array(mapW * mapH); - this.cpuGhostRailState = new Uint8Array(mapW * mapH); this.program = createProgram( gl, @@ -187,14 +197,14 @@ export class RailroadPass { filter: gl.NEAREST, }); - // R8UI railroad texture + // R8UI railroad texture (null data = zero-initialized per the WebGL spec) this.railroadTex = createTexture2D(gl, { width: mapW, height: mapH, internalFormat: gl.R8UI, format: gl.RED_INTEGER, type: gl.UNSIGNED_BYTE, - data: this.cpuRailroadState, + data: null, filter: gl.NEAREST, }); @@ -205,7 +215,7 @@ export class RailroadPass { internalFormat: gl.R8UI, format: gl.RED_INTEGER, type: gl.UNSIGNED_BYTE, - data: this.cpuGhostRailState, + data: null, filter: gl.NEAREST, }); @@ -213,7 +223,7 @@ export class RailroadPass { } uploadRailroadState(railroadState: Uint8Array): void { - this.cpuRailroadState.set(railroadState); + this.liveRailroadRef = railroadState; this.railroadDirty = true; } @@ -257,7 +267,7 @@ export class RailroadPass { } updateGhostPreview(data: GhostPreviewData | null): void { - this.cpuGhostRailState.fill(0); + const next = new Map(); if (data) { const maxRef = this.mapW * this.mapH; @@ -268,7 +278,7 @@ export class RailroadPass { const tiles = this.computePathOrientations(path); for (const t of tiles) { if (t.ref >= 0 && t.ref < maxRef) { - this.cpuGhostRailState[t.ref] = t.type + 1; + next.set(t.ref, t.type + 1); } } } @@ -277,7 +287,7 @@ export class RailroadPass { // overlappingRailroads contains resolved tile refs (not rail IDs) for (const ref of data.overlappingRailroads) { if (ref >= 0 && ref < maxRef) { - this.cpuGhostRailState[ref] = 7; + next.set(ref, 7); } } @@ -286,7 +296,15 @@ export class RailroadPass { this.ghostOwnerID = 0; } - this.ghostRailDirty = true; + // Queue texel writes for the diff: clear tiles that left the ghost, + // (re)write tiles whose value is new or changed. + for (const ref of this.ghostTiles.keys()) { + if (!next.has(ref)) this.ghostOps.push(ref, 0); + } + for (const [ref, value] of next) { + if (this.ghostTiles.get(ref) !== value) this.ghostOps.push(ref, value); + } + this.ghostTiles = next; } /** Draw the railroad overlay. Must be called with alpha blending enabled. */ @@ -294,6 +312,10 @@ export class RailroadPass { const gl = this.gl; const rs = this.settings.railroad; + // Flush queued ghost texel writes even when faded out, so the op queue + // can't grow unboundedly while the player previews at low zoom. + this.flushGhostOps(); + // Fade out as zoom drops below railMinZoom; fully invisible at railMinZoom - railFadeRange const fadeRange = Math.max(rs.railFadeRange, 0); const fadeStart = rs.railMinZoom - fadeRange; @@ -305,8 +327,8 @@ export class RailroadPass { : Math.min(1, Math.max(0, (zoom - fadeStart) / fadeRange)); if (fade <= 0) return; - // Flush CPU railroad state → GPU - if (this.railroadDirty) { + // Flush railroad state → GPU + if (this.railroadDirty && this.liveRailroadRef !== null) { gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.railroadTex); gl.texSubImage2D( @@ -318,29 +340,11 @@ export class RailroadPass { this.mapH, gl.RED_INTEGER, gl.UNSIGNED_BYTE, - this.cpuRailroadState, + this.liveRailroadRef, ); this.railroadDirty = false; } - // Flush ghost railroad state → GPU - if (this.ghostRailDirty) { - gl.activeTexture(gl.TEXTURE4); - gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex); - gl.texSubImage2D( - gl.TEXTURE_2D, - 0, - 0, - 0, - this.mapW, - this.mapH, - gl.RED_INTEGER, - gl.UNSIGNED_BYTE, - this.cpuGhostRailState, - ); - this.ghostRailDirty = false; - } - gl.useProgram(this.program); gl.uniformMatrix3fv(this.uCamera, false, cameraMatrix); gl.uniform2f(this.uMapSize, this.mapW, this.mapH); @@ -378,6 +382,39 @@ export class RailroadPass { gl.drawArrays(gl.TRIANGLES, 0, 6); } + /** + * Apply queued ghost texel writes to the ghost texture, one texel per + * texSubImage2D. Ghost diffs are path-sized (at most a few thousand + * texels), far cheaper than the full-map upload a dense mirror needs. + */ + private flushGhostOps(): void { + const ops = this.ghostOps; + if (ops.length === 0) return; + const gl = this.gl; + gl.activeTexture(gl.TEXTURE4); + gl.bindTexture(gl.TEXTURE_2D, this.ghostRailTex); + gl.pixelStorei(gl.UNPACK_ALIGNMENT, 1); + const scratch = new Uint8Array(1); + for (let i = 0; i < ops.length; i += 2) { + const ref = ops[i]; + const x = ref % this.mapW; + const y = (ref - x) / this.mapW; + scratch[0] = ops[i + 1]; + gl.texSubImage2D( + gl.TEXTURE_2D, + 0, + x, + y, + 1, + 1, + gl.RED_INTEGER, + gl.UNSIGNED_BYTE, + scratch, + ); + } + ops.length = 0; + } + // ---- Rail orientation computation ---- private computePathOrientations( diff --git a/src/client/render/gl/passes/TerrainPass.ts b/src/client/render/gl/passes/TerrainPass.ts index 206a2431d..02d0394ce 100644 --- a/src/client/render/gl/passes/TerrainPass.ts +++ b/src/client/render/gl/passes/TerrainPass.ts @@ -40,7 +40,12 @@ export class TerrainPass { constructor( private gl: WebGL2RenderingContext, - private terrainBytes: Uint8Array, + // Regenerates current per-tile terrain bytes (reflecting water-nuke + // conversions) for the rare full re-bake in setTerrainColors. A provider + // instead of a retained buffer: terrain bytes are map-sized (8 MB on the + // giant map). + private terrainSource: () => Uint8Array, + terrainBytes: Uint8Array, mapW: number, mapH: number, terrainColors?: TerrainColorOverrides, @@ -86,7 +91,12 @@ export class TerrainPass { this.mapH, gl.RGBA, gl.UNSIGNED_BYTE, - buildTerrainRGBA(this.terrainBytes, this.mapW, this.mapH, terrainColors), + buildTerrainRGBA( + this.terrainSource(), + this.mapW, + this.mapH, + terrainColors, + ), ); } @@ -94,10 +104,8 @@ export class TerrainPass { * Update a subset of terrain tiles in-place (e.g. land→water from a water * nuke). `bytes[i]` is the new terrain byte for `refs[i]` (parallel arrays). * One 1×1 texSubImage2D per ref — fine for the small bursts a single nuke - * produces. - * - * Also writes back into `terrainBytes` so a later full re-upload (e.g. - * setTerrainColor) reflects these conversions instead of reverting them. + * produces. A later full re-upload (setTerrainColors) regenerates from + * terrainSource, whose backing game map already reflects these conversions. */ applyTerrainDelta(refs: readonly number[], bytes: Uint8Array): void { if (refs.length === 0) return; @@ -108,7 +116,6 @@ export class TerrainPass { const ref = refs[i]; const x = ref % this.mapW; const y = (ref - x) / this.mapW; - this.terrainBytes[ref] = bytes[i]; encodeTerrainTile(bytes[i], this.pixelScratch, 0, this.terrainColors); gl.texSubImage2D( gl.TEXTURE_2D, diff --git a/src/client/render/gl/passes/TrailPass.ts b/src/client/render/gl/passes/TrailPass.ts index b4d4729c7..5b4a07f07 100644 --- a/src/client/render/gl/passes/TrailPass.ts +++ b/src/client/render/gl/passes/TrailPass.ts @@ -1,9 +1,10 @@ /** * TrailPass — boat + nuke trail lines. * - * Owns the CPU-side trail state (R16UI: 0=none, bits 0-11=ownerID, bit 12=nuke - * trail), the dirty-row bookkeeping for partial GPU uploads, and the trail + * Owns the dirty-row bookkeeping for partial GPU uploads and the trail * fragment shader that draws the colored breadcrumb behind moving units. + * Trail state itself (R16UI: 0=none, bits 0-11=ownerID, bit 12=nuke trail) + * is referenced from the caller's array, not copied. */ import type { RenderSettings } from "../RenderSettings"; @@ -37,11 +38,14 @@ export class TrailPass { // so the value stays small and sin()/fract() don't quantize over long sessions. private readonly startTime = performance.now(); - /** CPU-side trail state (R16UI: 0=none, owner in bits 0-11, nuke bit 12). */ - private cpuTrailState: Uint16Array; private trailsDirty = false; - /** Live-game reference — bypasses memcpy. Null for replay path. */ + /** + * Reference to the caller-owned trail state (R16UI: 0=none, owner in bits + * 0-11, nuke bit 12). Every upload entry point provides it, so the pass + * keeps no copy of its own; the caller's array must stay current until the + * flush. Null until the first upload. + */ private liveTrailRef: Uint16Array | null = null; /** Dirty row range for partial trail upload. Infinity/-1 = full upload. */ @@ -64,7 +68,6 @@ export class TrailPass { this.trailTex = trailTex; this.paletteTex = paletteTex; this.effectTex = effectTex; - this.cpuTrailState = new Uint16Array(mapW * mapH); this.program = createProgram( gl, @@ -128,8 +131,9 @@ export class TrailPass { /** Flush trail texture to GPU. Called once per render frame in uploadTextures. */ flushTexture(): void { if (!this.trailsDirty) return; + const src = this.liveTrailRef; + if (src === null) return; // dirty is only ever set alongside the ref const gl = this.gl; - const src = this.liveTrailRef ?? this.cpuTrailState; gl.activeTexture(gl.TEXTURE0); gl.bindTexture(gl.TEXTURE_2D, this.trailTex); diff --git a/tests/perf/client/ClientMemoryPerf.ts b/tests/perf/client/ClientMemoryPerf.ts new file mode 100644 index 000000000..dd91f9b36 --- /dev/null +++ b/tests/perf/client/ClientMemoryPerf.ts @@ -0,0 +1,405 @@ +/** + * Main-thread memory harness: drives a real singleplayer game in headless + * Chromium and measures the page's JS heap with forced-GC checkpoints and + * full V8 heap snapshots taken over the Chrome DevTools Protocol. + * + * The core simulation runs in a Web Worker, so a page-session snapshot + * isolates the MAIN thread: GameView state, rendering layers, UI components. + * Snapshots are the standard V8 format — analyze them with + * npx tsx tests/perf/fullgame/HeapSnapshotRetainers.ts [top] + * (or HeapSnapshotSummary.ts for multi-GB files). + * + * The harness starts its own vite dev server on a private port (default + * 9017) so results always come from THIS checkout, even when another + * working copy is serving port 9000. + * + * One-time browser setup (installs playwright + chromium libs, no sudo): + * bash .claude/skills/run-openfront/setup.sh + * + * Usage: + * npm run perf:client-mem -- --map "Giant World Map" --ticks 3000 \ + * --window 500 --snapshot-at 0,3000 + * + * Flags: + * --map GameMapType value (default "Giant World Map") + * --bots bot count (default 400, the solo-modal default) + * --difficulty Easy|Medium|Hard|Impossible (default modal default) + * --ticks run until this game tick (default 3000) + * --window checkpoint every n ticks (default 500) + * --snapshot-at comma-separated ticks to snapshot; 0 = post-spawn + * --spawn fixed human spawn tile (default: auto-pick), + * for run-to-run repeatability on a given map + * --port vite dev-server port (default 9017) + * --raf-interval rAF throttle; SwiftShader frames cost seconds of + * CPU, so an unthrottled frame loop starves the sim + * (default 3000) + * --out-dir output dir (default tests/perf/output) + * + * Headless caveats: rendering uses SwiftShader, so GPU texture memory lives + * in the GPU process and is NOT in these numbers — this measures main-thread + * JS heap + ArrayBuffers (which is where GameView, layers, and pixel staging + * buffers live). Solo games are RNG-driven (bot spawns), so numbers vary a + * few percent run-to-run; compare trends, not bytes. + */ +import { ChildProcess, spawn as spawnProcess } from "child_process"; +import fs from "fs"; +import path from "path"; + +interface Options { + map: string; + bots: number; + difficulty: string | undefined; + ticks: number; + window: number; + snapshotAt: number[]; + spawn: { x: number; y: number } | null; + port: number; + rafIntervalMs: number; + outDir: string; +} + +interface Checkpoint { + label: string; + ticks: number; + wallMs: number; + jsHeapUsedBytes: number; + jsHeapTotalBytes: number; + backingStoreBytes: number; + embedderHeapBytes: number; + domNodes: number; + jsEventListeners: number; + documents: number; +} + +function parseArgs(): Options { + const opts: Options = { + map: "Giant World Map", + bots: 400, + difficulty: undefined, + ticks: 3000, + window: 500, + snapshotAt: [], + spawn: null, + port: 9017, + rafIntervalMs: 3000, + outDir: path.join("tests", "perf", "output"), + }; + const argv = process.argv.slice(2); + for (let i = 0; i < argv.length; i++) { + const next = () => argv[++i]; + switch (argv[i]) { + case "--map": + opts.map = next(); + break; + case "--bots": + opts.bots = parseInt(next(), 10); + break; + case "--difficulty": + opts.difficulty = next(); + break; + case "--ticks": + opts.ticks = parseInt(next(), 10); + break; + case "--window": + opts.window = parseInt(next(), 10); + break; + case "--snapshot-at": + opts.snapshotAt = next() + .split(",") + .map((s) => parseInt(s.trim(), 10)) + .filter((n) => Number.isFinite(n)); + break; + case "--spawn": { + const [x, y] = next().split(",").map(Number); + opts.spawn = { x, y }; + break; + } + case "--port": + opts.port = parseInt(next(), 10); + break; + case "--raf-interval": + opts.rafIntervalMs = parseInt(next(), 10); + break; + case "--out-dir": + opts.outDir = next(); + break; + default: + throw new Error(`unknown flag: ${argv[i]}`); + } + } + return opts; +} + +// ---------- dev server ---------- + +async function startViteServer(port: number): Promise { + // --strictPort makes vite exit instead of silently picking another port — + // that also guards against measuring a different checkout's server. + const child = spawnProcess( + "npx", + ["vite", "--port", String(port), "--strictPort"], + { + env: { ...process.env, SKIP_BROWSER_OPEN: "true" }, + stdio: ["ignore", "pipe", "pipe"], + detached: true, // own process group, so cleanup kills vite's children + }, + ); + let output = ""; + child.stdout?.on("data", (d: Buffer) => (output += d.toString())); + child.stderr?.on("data", (d: Buffer) => (output += d.toString())); + + const deadline = Date.now() + 60_000; + while (Date.now() < deadline) { + if (child.exitCode !== null) { + throw new Error( + `vite exited with code ${child.exitCode} (port ${port} busy?)\n${output}`, + ); + } + try { + const res = await fetch(`http://localhost:${port}/`); + if (res.ok) return child; + } catch { + // not up yet + } + await new Promise((r) => setTimeout(r, 500)); + } + throw new Error(`vite did not become ready on port ${port}\n${output}`); +} + +function stopViteServer(child: ChildProcess): void { + if (child.pid !== undefined && child.exitCode === null) { + try { + process.kill(-child.pid, "SIGTERM"); // whole process group + } catch { + // already gone + } + } +} + +// ---------- report ---------- + +const fmtMB = (bytes: number): string => (bytes / 1024 / 1024).toFixed(1); + +function printReport(checkpoints: Checkpoint[], opts: Options): void { + console.log( + `\n=== Main-thread memory (map=${opts.map}, bots=${opts.bots}) ===`, + ); + console.log( + `${"label".padEnd(12)} ${"ticks".padStart(6)} ${"heapUsed".padStart(9)} ${"heapTotal".padStart(10)} ${"buffers".padStart(9)} ${"domNodes".padStart(9)} ${"listeners".padStart(9)} ${"ticks/s".padStart(8)}`, + ); + let prev: Checkpoint | null = null; + for (const c of checkpoints) { + const rate = + prev !== null && c.wallMs > prev.wallMs + ? ((c.ticks - prev.ticks) / ((c.wallMs - prev.wallMs) / 1000)).toFixed( + 1, + ) + : "-"; + console.log( + `${c.label.padEnd(12)} ${String(c.ticks).padStart(6)} ${fmtMB(c.jsHeapUsedBytes).padStart(6)} MB ${fmtMB(c.jsHeapTotalBytes).padStart(7)} MB ${fmtMB(c.backingStoreBytes).padStart(6)} MB ${String(c.domNodes).padStart(9)} ${String(c.jsEventListeners).padStart(9)} ${rate.padStart(8)}`, + ); + prev = c; + } +} + +// ---------- main ---------- + +async function main(): Promise { + const opts = parseArgs(); + fs.mkdirSync(opts.outDir, { recursive: true }); + + console.log(`starting vite on port ${opts.port}…`); + const vite = await startViteServer(opts.port); + + // The skill driver reads OPENFRONT_URL at import time, so set it before + // the dynamic imports below. + process.env.OPENFRONT_URL = `http://localhost:${opts.port}`; + const { launch, gotoHome, openSoloModal } = + // @ts-expect-error untyped .mjs skill module + await import("../../../.claude/skills/run-openfront/driver.mjs"); + const { startSoloGame, spawn, waitForSpawnPhaseEnd, gameState } = + // @ts-expect-error untyped .mjs skill module + await import("../../../.claude/skills/run-openfront/game.mjs"); + + let browser: { close(): Promise } | null = null; + try { + console.log("launching headless chromium…"); + const launched = await launch({ + rafIntervalMs: opts.rafIntervalMs, + // Headless Chromium throttles backgrounded/occluded pages, which + // starves the singleplayer turn loop on top of SwiftShader's cost. + args: [ + "--disable-background-timer-throttling", + "--disable-backgrounding-occluded-windows", + "--disable-renderer-backgrounding", + ], + }); + browser = launched.browser; + const page = launched.page; + + // Headless Chromium only has SwiftShader, and the WebGL gate (#4324) + // refuses software renderers by matching the unmasked renderer string. + // Spoof the string so the gate passes; rendering still runs on + // SwiftShader (hence the rAF throttle). + await page.addInitScript(() => { + const orig = WebGL2RenderingContext.prototype.getParameter; + WebGL2RenderingContext.prototype.getParameter = function (p: number) { + const v = orig.call(this, p); + return typeof v === "string" + ? v.replace(/swiftshader|llvmpipe|software/gi, "PerfHarnessGPU") + : v; + }; + }); + + // Solo games are fully local: block external requests (ad scripts) and + // all websockets — vite's HMR socket times out under heavy throttling + // and force-reloads the page mid-game. + await page.route("**/*", (route: any) => { + const host = new URL(route.request().url()).hostname; + return host === "localhost" || host === "127.0.0.1" + ? route.continue() + : route.abort(); + }); + await page.routeWebSocket("**", () => {}); + + // CDP session against the page = the main thread's isolate only (the + // core sim worker is a separate target and not included). + const cdp = await page.context().newCDPSession(page); + await cdp.send("HeapProfiler.enable"); + await cdp.send("Performance.enable"); + + const startWall = Date.now(); + const checkpoints: Checkpoint[] = []; + + const checkpoint = async (label: string): Promise => { + // Two forced GCs: the first can leave finalizer garbage behind. + await cdp.send("HeapProfiler.collectGarbage"); + await cdp.send("HeapProfiler.collectGarbage"); + const { metrics } = await cdp.send("Performance.getMetrics"); + const m = new Map( + metrics.map((x: { name: string; value: number }) => [x.name, x.value]), + ); + // JSHeapUsedSize excludes ArrayBuffer backing stores — where most + // render-layer memory lives. Newer CDP reports them here. + const usage = (await cdp.send("Runtime.getHeapUsage")) as { + usedSize: number; + totalSize: number; + backingStorageSize?: number; + embedderHeapUsedSize?: number; + }; + const state = await gameState(page); + const c: Checkpoint = { + label, + ticks: state?.ticks ?? 0, + wallMs: Date.now() - startWall, + jsHeapUsedBytes: m.get("JSHeapUsedSize") ?? 0, + jsHeapTotalBytes: m.get("JSHeapTotalSize") ?? 0, + backingStoreBytes: usage.backingStorageSize ?? 0, + embedderHeapBytes: usage.embedderHeapUsedSize ?? 0, + domNodes: m.get("Nodes") ?? 0, + jsEventListeners: m.get("JSEventListeners") ?? 0, + documents: m.get("Documents") ?? 0, + }; + checkpoints.push(c); + console.log( + `[checkpoint] ${label}: tick ${c.ticks}, heap ${fmtMB(c.jsHeapUsedBytes)} MB used / ${fmtMB(c.jsHeapTotalBytes)} MB total, buffers ${fmtMB(c.backingStoreBytes)} MB, ${c.domNodes} DOM nodes`, + ); + return c; + }; + + const writeSnapshot = async (label: string): Promise => { + const file = path.join(opts.outDir, `client-${label}.heapsnapshot`); + const ws = fs.createWriteStream(file); + const onChunk = (p: { chunk: string }) => ws.write(p.chunk); + cdp.on("HeapProfiler.addHeapSnapshotChunk", onChunk); + // All chunks are delivered before takeHeapSnapshot resolves. + await cdp.send("HeapProfiler.takeHeapSnapshot", { + reportProgress: false, + }); + cdp.off("HeapProfiler.addHeapSnapshotChunk", onChunk); + await new Promise((r) => ws.end(r)); + const mb = (fs.statSync(file).size / 1024 / 1024).toFixed(0); + console.log(`[snapshot] ${file} (${mb} MB)`); + }; + + console.log("loading home page + solo modal…"); + await gotoHome(page); + await openSoloModal(page); + + console.log( + `starting solo game: map=${opts.map}, bots=${opts.bots}` + + (opts.difficulty !== undefined + ? `, difficulty=${opts.difficulty}` + : ""), + ); + await startSoloGame(page, { + map: opts.map, + bots: opts.bots, + ...(opts.difficulty !== undefined ? { difficulty: opts.difficulty } : {}), + }); + await checkpoint("loaded"); + + console.log("spawning…"); + const tile = await spawn(page, opts.spawn); + console.log(`spawned at (${tile.x},${tile.y})`); + await waitForSpawnPhaseEnd(page, 120_000); + await checkpoint("spawned"); + + const pendingSnapshots = [...new Set(opts.snapshotAt)].sort( + (a, b) => a - b, + ); + const takeDueSnapshots = async (currentTick: number): Promise => { + while ( + pendingSnapshots.length > 0 && + pendingSnapshots[0] <= currentTick + ) { + await writeSnapshot(`tick${pendingSnapshots.shift()}`); + } + }; + const spawnedTick = checkpoints[checkpoints.length - 1].ticks; + await takeDueSnapshots(spawnedTick); + + const targets: number[] = []; + for (let t = spawnedTick + opts.window; t < opts.ticks; t += opts.window) { + targets.push(t); + } + if (opts.ticks > spawnedTick) targets.push(opts.ticks); + for (const target of targets) { + // Generous timeout: headless sim speed varies wildly with map size + // and bot count (0.5–10 ticks/s). + const timeout = opts.window * 2000 + 120_000; + await page.waitForFunction( + (t: number) => { + const g = (document.querySelector("build-menu") as any)?.game; + return g !== undefined && g.ticks() >= t; + }, + target, + { timeout, polling: 1000 }, + ); + const c = await checkpoint(`tick ${target}`); + await takeDueSnapshots(c.ticks); + } + // Anything requested beyond the reached tick range. + await takeDueSnapshots(Number.MAX_SAFE_INTEGER); + + // End-of-run screenshot — a cheap rendering sanity check (a black map + // means the GL pipeline broke even if no pageerror surfaced). + const shotPath = path.join(opts.outDir, "client-final.png"); + await page.screenshot({ path: shotPath }); + console.log(`[screenshot] ${shotPath}`); + + printReport(checkpoints, opts); + const finalState = await gameState(page); + console.log( + `\nfinal state: ${JSON.stringify(finalState)}\ntotal wall time ${((Date.now() - startWall) / 1000 / 60).toFixed(1)} min`, + ); + } finally { + await browser?.close(); + stopViteServer(vite); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/tests/perf/fullgame/HeapSnapshotRetainers.ts b/tests/perf/fullgame/HeapSnapshotRetainers.ts index 6eeff17a5..be1ddba5e 100644 --- a/tests/perf/fullgame/HeapSnapshotRetainers.ts +++ b/tests/perf/fullgame/HeapSnapshotRetainers.ts @@ -100,7 +100,13 @@ function main(): void { } } - const nodeName = (i: number): string => strings[nodes[i * NF + N_NAME]]; + // Cap at one short line: string-type node names are the full string + // content (e.g. an entire script source for external strings). + const nodeName = (i: number): string => { + const raw = strings[nodes[i * NF + N_NAME]] ?? ""; + const firstLine = raw.split("\n", 1)[0]; + return firstLine.length > 80 ? `${firstLine.slice(0, 77)}…` : firstLine; + }; const nodeType = (i: number): string => nodeTypes[nodes[i * NF + N_TYPE]]; const edgeLabel = (i: number): string => retainerEdge[i] === -2 ? "[]" : (strings[retainerEdge[i]] ?? "?");