From 7fa81c6bb92cc154476ed4384ee5833c5669c93c Mon Sep 17 00:00:00 2001 From: Evan Date: Sat, 4 Jul 2026 15:25:29 -0700 Subject: [PATCH] perf: reduce core live-memory footprint by 45% on large maps (#4507) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary Reduces the simulation's steady-state memory footprint. On Giant World Map at 20 game-minutes (12 000 ticks, 400 bots, seed `perf-default`), live memory after a full GC drops **293 MB → 161 MB (−45%)**; unforced peak heap drops **326 MB → 165 MB**. The simulation also runs ~10% faster (85 → 94 ticks/s). The final game-state hash is **bit-identical** (`57830793797434300`) — no behavior change. ## Measurement (first commit) The full-game perf harness gains a footprint mode: - `--footprint` — forces a full GC at every `--window` boundary and records the live heap / ArrayBuffer / RSS curve across the game (requires `NODE_OPTIONS=--expose-gc`). - `--snapshot-at 0,2000,12000` — writes V8 `.heapsnapshot` files at chosen ticks. - `HeapSnapshotRetainers.ts` — attributes every heap node to its nearest meaningfully-named retainer (e.g. `PlayerImpl._tiles`), plus prints retainer chains for all nodes ≥128 KB. `HeapSnapshotSummary.ts` is a streaming fallback for snapshots too large to `JSON.parse`. Baseline attribution at tick 12 000: player `_tiles`/`_borderTiles` Sets **83 MB**, GameMap `refToX`/`refToY` lookup tables **38 MB**, two duplicate 30.5 MB visited-scratch arrays, trade-ship stepper paths **15 MB**, a construction-only flood-fill queue **9.5 MB**. ## Optimizations **Map-sized buffers (second commit):** - `GameMap.x()/y()` compute `ref % width` / `(ref / width) | 0` instead of reading two per-tile Uint16 tables (−38 MB). The arithmetic is cheaper than the tables' random-access cache misses — this is where the speedup comes from. - `PlayerExecution` and `SpatialQuery` each kept their own per-game generation-stamped visited `Uint32Array`; both now share one via `TileTraversalScratch` (−30 MB). - `PathFinderStepper` stores numeric paths as `Uint32Array` (half the bytes; steppers hold their full path for a unit's whole journey). - `ConnectedComponents` frees its flood-fill queue after `initialize()`. **Player tile sets (third commit):** - New `TileSet`: insertion-ordered set of tile refs backed by a dense `Uint32Array` plus an open-addressing hash index — ~12 bytes/element vs ~34 for a native `Set`. Deletes tombstone; compaction is deferred while iteration is in progress so positions never shift under an iterator. - Iteration semantics match `Set` exactly (insertion order, entries added mid-iteration visited, deleted ones skipped, delete+re-add moves to end) — the simulation relies on this order for determinism, and the unchanged hash confirms it. - `Player.borderTiles()` now returns `ReadonlyTileSet` (a native `Set` still satisfies it structurally); `GameRunner.playerBorderTiles` copies into a real `Set` since that result crosses the worker boundary via structured clone. ## Footprint curve (giant world map, live MB after forced GC) | checkpoint | before | after | |---|---|---| | spawn end | 20 + 100 buf | 20 + 55 buf | | tick 6301 | 119 + 161 buf | 29 + 127 buf | | tick 12301 | 130 + 161 buf | 32 + 129 buf | ## Validation - Final hash `57830793797434300` identical across baseline / round 1 / round 2 runs (12 000 ticks). - Full suite passes (1798 + 126 tests), including new `TileSet` tests: order semantics, mutation-during-iteration parity with `Set`, tombstone compaction, and a 20 000-op randomized differential test against native `Set`. - Runs recorded in `tests/perf/output/footprint-{baseline,round1,round2}-giant.txt`. 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Fable 5 --- src/core/GameRunner.ts | 4 +- src/core/Util.ts | 5 +- src/core/execution/PlayerExecution.ts | 37 +-- src/core/game/Game.ts | 3 +- src/core/game/GameMap.ts | 45 ++-- src/core/game/PlayerImpl.ts | 7 +- src/core/game/TileSet.ts | 219 +++++++++++++++ src/core/game/TileTraversalScratch.ts | 44 +++ src/core/pathfinding/PathFinderStepper.ts | 20 +- .../algorithms/ConnectedComponents.ts | 16 +- src/core/pathfinding/spatial/SpatialQuery.ts | 34 +-- tests/TileSet.test.ts | 141 ++++++++++ tests/perf/fullgame/FullGamePerf.ts | 75 ++++++ tests/perf/fullgame/GcProfiler.ts | 38 +++ tests/perf/fullgame/HeapSnapshotRetainers.ts | 189 +++++++++++++ tests/perf/fullgame/HeapSnapshotSummary.ts | 250 ++++++++++++++++++ 16 files changed, 1022 insertions(+), 105 deletions(-) create mode 100644 src/core/game/TileSet.ts create mode 100644 src/core/game/TileTraversalScratch.ts create mode 100644 tests/TileSet.test.ts create mode 100644 tests/perf/fullgame/HeapSnapshotRetainers.ts create mode 100644 tests/perf/fullgame/HeapSnapshotSummary.ts diff --git a/src/core/GameRunner.ts b/src/core/GameRunner.ts index f64c5126e..1e8627455 100644 --- a/src/core/GameRunner.ts +++ b/src/core/GameRunner.ts @@ -275,7 +275,9 @@ export class GameRunner { throw new Error(`player with id ${playerID} not found`); } return { - borderTiles: player.borderTiles(), + // Copy into a plain Set: this result crosses the worker boundary via + // structured clone, which TileSet does not survive. + borderTiles: new Set(player.borderTiles()), } as PlayerBorderTiles; } diff --git a/src/core/Util.ts b/src/core/Util.ts index 107ca089e..c8a6bbc7a 100644 --- a/src/core/Util.ts +++ b/src/core/Util.ts @@ -2,6 +2,7 @@ import DOMPurify from "dompurify"; import { customAlphabet } from "nanoid"; import { Cell, PlayerType, Unit } from "./game/Game"; import { GameMap, TileRef } from "./game/GameMap"; +import { TileSet } from "./game/TileSet"; import { GameConfig, GameID, @@ -148,7 +149,7 @@ export function calculateBoundingBox( for (let i = 0; i < borderTiles.length; i++) { visit(borderTiles[i]); } - } else if (borderTiles instanceof Set) { + } else if (borderTiles instanceof Set || borderTiles instanceof TileSet) { borderTiles.forEach(visit); } else { for (const tile of borderTiles) { @@ -213,7 +214,7 @@ export function getMode(counts: Map): T | null { export function calculateBoundingBoxCenter( gm: GameMap, - borderTiles: ReadonlySet, + borderTiles: Iterable, ): Cell { const { min, max } = calculateBoundingBox(gm, borderTiles); return boundingBoxCenter({ min, max }); diff --git a/src/core/execution/PlayerExecution.ts b/src/core/execution/PlayerExecution.ts index c40cb80cf..550076a26 100644 --- a/src/core/execution/PlayerExecution.ts +++ b/src/core/execution/PlayerExecution.ts @@ -8,18 +8,13 @@ import { UnitType, } from "../game/Game"; import { GameMap, TileRef } from "../game/GameMap"; +import { + bumpTraversalGeneration, + tileTraversalScratch, + TileTraversalScratch, +} from "../game/TileTraversalScratch"; import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util"; -interface ClusterTraversalState { - visited: Uint32Array; - gen: number; - // Reusable DFS stack for flood fills; cleared at the start of each fill. - stack: TileRef[]; -} - -// Per-game traversal state used by calculateClusters() to avoid per-player buffers. -const traversalStates = new WeakMap(); - export class PlayerExecution implements Execution { private readonly ticksPerClusterCalc = 20; @@ -370,28 +365,12 @@ export class PlayerExecution implements Execution { return this.active; } - private traversalState(): ClusterTraversalState { - const totalTiles = this.mg.width() * this.mg.height(); - let state = traversalStates.get(this.mg); - if (!state || state.visited.length < totalTiles) { - state = { - visited: new Uint32Array(totalTiles), - gen: 0, - stack: [], - }; - traversalStates.set(this.mg, state); - } - return state; + private traversalState(): TileTraversalScratch { + return tileTraversalScratch(this.mg); } private bumpGeneration(): number { - const state = this.traversalState(); - state.gen++; - if (state.gen === 0xffffffff) { - state.visited.fill(0); - state.gen = 1; - } - return state.gen; + return bumpTraversalGeneration(this.traversalState()); } private floodFillWithGen( diff --git a/src/core/game/Game.ts b/src/core/game/Game.ts index 67a47d87e..d2cf39c76 100644 --- a/src/core/game/Game.ts +++ b/src/core/game/Game.ts @@ -13,6 +13,7 @@ import { import { MotionPlanRecord } from "./MotionPlans"; import { RailNetwork } from "./RailNetwork"; import { Stats } from "./Stats"; +import { ReadonlyTileSet } from "./TileSet"; import { UnitPredicate } from "./UnitGrid"; function isEnumValue>( @@ -569,7 +570,7 @@ export interface Player { // Territory tiles(): ReadonlySet; - borderTiles(): ReadonlySet; + borderTiles(): ReadonlyTileSet; numTilesOwned(): number; conquer(tile: TileRef): void; relinquish(tile: TileRef): void; diff --git a/src/core/game/GameMap.ts b/src/core/game/GameMap.ts index 292e3c47c..2d2548a3d 100644 --- a/src/core/game/GameMap.ts +++ b/src/core/game/GameMap.ts @@ -111,15 +111,11 @@ export class GameMapImpl implements GameMap { private readonly width_: number; private readonly height_: number; - // Lookup tables (LUTs) contain pre-computed values to avoid performing division at runtime. - // Typed arrays are used instead of plain JS Array to keep memory tight on large maps: - // Uint16Array uses 2 bytes/element vs ~8 bytes for a boxed number, saving ~53 MB on - // the Indian Subcontinent map (2000×2220 = 4.44 M tiles). - // Coordinates never exceed 65535 for any map in the game, so Uint16 is safe for x/y. - // yToRef stores tile refs (up to width*height-1) which can exceed 65535 for large maps, - // so it uses Int32Array. - private readonly refToX: Uint16Array; - private readonly refToY: Uint16Array; + // Row-start ref per y, so ref(x, y) avoids a multiply. x/y are derived from + // a ref arithmetically (ref % width, ref / width) rather than via per-tile + // lookup tables — two Uint16 tables cost 4 bytes per tile (~32 MB on the + // largest maps) and their random-access reads miss cache more often than + // the division costs. private readonly yToRef: Int32Array; // Terrain bits (Uint8Array) @@ -154,18 +150,9 @@ export class GameMapImpl implements GameMap { this.height_ = height; this.terrain = terrainData; this.state = new Uint16Array(width * height); - // Precompute the LUTs using typed arrays (see field declarations for rationale). - let ref = 0; - this.refToX = new Uint16Array(width * height); - this.refToY = new Uint16Array(width * height); this.yToRef = new Int32Array(height); for (let y = 0; y < height; y++) { - this.yToRef[y] = ref; - for (let x = 0; x < width; x++) { - this.refToX[ref] = x; - this.refToY[ref] = y; - ref++; - } + this.yToRef[y] = y * width; } } numTilesWithFallout(): number { @@ -180,15 +167,15 @@ export class GameMapImpl implements GameMap { } isValidRef(ref: TileRef): boolean { - return ref >= 0 && ref < this.refToX.length; + return ref >= 0 && ref < this.width_ * this.height_; } x(ref: TileRef): number { - return this.refToX[ref]; + return ref % this.width_; } y(ref: TileRef): number { - return this.refToY[ref]; + return (ref / this.width_) | 0; } cell(ref: TileRef): Cell { @@ -234,7 +221,7 @@ export class GameMapImpl implements GameMap { return false; } const w = this.width_; - const x = this.refToX[ref]; + const x = ref % w; if (x !== 0 && this.isOcean(ref - 1)) return true; if (x !== w - 1 && this.isOcean(ref + 1)) return true; if (ref >= w && this.isOcean(ref - w)) return true; @@ -330,7 +317,7 @@ export class GameMapImpl implements GameMap { isBorder(ref: TileRef): boolean { const w = this.width_; - const x = this.refToX[ref]; + const x = ref % w; const owner = this.ownerID(ref); if (x !== 0 && this.ownerID(ref - 1) !== owner) return true; if (x !== w - 1 && this.ownerID(ref + 1) !== owner) return true; @@ -383,7 +370,7 @@ export class GameMapImpl implements GameMap { neighbors(ref: TileRef): TileRef[] { const neighbors: TileRef[] = []; const w = this.width_; - const x = this.refToX[ref]; + const x = ref % w; if (ref >= w) neighbors.push(ref - w); if (ref < (this.height_ - 1) * w) neighbors.push(ref + w); @@ -395,7 +382,7 @@ export class GameMapImpl implements GameMap { forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void { const w = this.width_; - const x = this.refToX[ref]; + const x = ref % w; if (ref >= w) callback(ref - w); if (ref < (this.height_ - 1) * w) callback(ref + w); @@ -405,7 +392,7 @@ export class GameMapImpl implements GameMap { neighbors4(ref: TileRef, out: TileRef[]): number { const w = this.width_; - const x = this.refToX[ref]; + const x = ref % w; let n = 0; if (ref >= w) out[n++] = ref - w; @@ -420,7 +407,7 @@ export class GameMapImpl implements GameMap { callback: (neighbor: TileRef) => void, ): void { const w = this.width_; - const x = this.refToX[ref]; + const x = ref % w; const hasN = ref >= w; const hasS = ref < (this.height_ - 1) * w; @@ -501,7 +488,7 @@ export class GameMapImpl implements GameMap { while (q.length > 0) { const curr = q.pop(); if (curr === undefined) continue; - const x = this.refToX[curr]; + const x = curr % w; if (curr >= w) visit(curr - w); if (curr < southLimit) visit(curr + w); if (x !== 0) visit(curr - 1); diff --git a/src/core/game/PlayerImpl.ts b/src/core/game/PlayerImpl.ts index b71218cc1..2da1de3cb 100644 --- a/src/core/game/PlayerImpl.ts +++ b/src/core/game/PlayerImpl.ts @@ -53,6 +53,7 @@ import { GameUpdateType, PlayerUpdate, } from "./GameUpdates"; +import { ReadonlyTileSet, TileSet } from "./TileSet"; import { bestShoreDeploymentSource, canBuildTransportShip, @@ -111,10 +112,10 @@ export class PlayerImpl implements Player { private embargoes = new Map(); - public _borderTiles: Set = new Set(); + public _borderTiles = new TileSet(); public _units: Unit[] = []; - public _tiles: Set = new Set(); + public _tiles = new TileSet(); public pastOutgoingAllianceRequests: AllianceRequest[] = []; private _expiredAlliances: Alliance[] = []; @@ -479,7 +480,7 @@ export class PlayerImpl implements Player { return new Set(this._tiles.values()) as Set; } - borderTiles(): ReadonlySet { + borderTiles(): ReadonlyTileSet { return this._borderTiles; } diff --git a/src/core/game/TileSet.ts b/src/core/game/TileSet.ts new file mode 100644 index 000000000..1ae0e5bac --- /dev/null +++ b/src/core/game/TileSet.ts @@ -0,0 +1,219 @@ +import { TileRef } from "./GameMap"; + +// Deleted dense slots hold this sentinel. Tile refs are grid indices and map +// coordinates are capped at 65535, so the largest possible ref is +// 65535 * 65535 - 1, which is below 2^32 - 1 — the sentinel can never be a +// real tile. +const TOMBSTONE = 0xffffffff; +// Hash-table slot states (slots otherwise hold indices into `dense`). +const EMPTY = -1; +const DELETED = -2; + +/** + * The read surface of TileSet, mirroring the parts of ReadonlySet that + * simulation code uses. A native Set also satisfies this interface. + */ +export interface ReadonlyTileSet { + readonly size: number; + has(tile: TileRef): boolean; + forEach( + callback: (tile: TileRef, tile2: TileRef, set: ReadonlyTileSet) => void, + ): void; + values(): IterableIterator; + [Symbol.iterator](): IterableIterator; +} + +/** + * An insertion-ordered set of tile refs with compact storage: values live in + * a Uint32Array in insertion order, with an open-addressing hash table (also + * a typed array) for membership. Compared to Set at V8's ~30+ bytes + * per element this costs ~12 bytes, which matters because every owned tile of + * every player sits in one of these for the whole game — tens of MB on large + * maps. + * + * Iteration semantics match Set: insertion order, entries added during + * iteration are visited, entries deleted during iteration are skipped, and a + * delete + re-add moves the value to the end. Deleted slots are tombstoned + * and reclaimed by compaction, which is deferred while any iteration is in + * progress so positions never shift under an iterator. + */ +export class TileSet implements ReadonlyTileSet { + private dense: Uint32Array = new Uint32Array(16); + // Used dense slots, including tombstones; live entries = size_. + private denseLen = 0; + private size_ = 0; + private table: Int32Array = new Int32Array(32).fill(EMPTY); + // Occupied table slots, including DELETED markers (bounds probe lengths). + private tableUsed = 0; + private iterDepth = 0; + + constructor(values?: Iterable) { + if (values !== undefined) { + for (const v of values) { + this.add(v); + } + } + } + + get size(): number { + return this.size_; + } + + private static hash(value: number): number { + const h = Math.imul(value, 0x9e3779b1); + return (h ^ (h >>> 15)) >>> 0; + } + + has(value: TileRef): boolean { + const table = this.table; + const dense = this.dense; + const mask = table.length - 1; + let slot = TileSet.hash(value) & mask; + for (;;) { + const di = table[slot]; + if (di === EMPTY) return false; + if (di !== DELETED && dense[di] === value) return true; + slot = (slot + 1) & mask; + } + } + + add(value: TileRef): this { + if (this.has(value)) return this; + + if (this.denseLen === this.dense.length) { + // Prefer reclaiming tombstones over growing, unless an iterator is + // live (compaction shifts positions). + if (this.iterDepth === 0 && this.denseLen - this.size_ >= this.size_) { + this.compact(this.dense.length); + } else { + const grown = new Uint32Array(this.dense.length * 2); + grown.set(this.dense); + this.dense = grown; + } + } + // Keep the table under ~75% occupied so probe chains stay short and + // always hit an EMPTY slot. + if ((this.tableUsed + 1) * 4 > this.table.length * 3) { + this.rehash( + this.size_ * 4 > this.table.length + ? this.table.length * 2 + : this.table.length, // mostly DELETED markers — same size, cleaned + ); + } + + const di = this.denseLen++; + this.dense[di] = value; + this.size_++; + const table = this.table; + const mask = table.length - 1; + let slot = TileSet.hash(value) & mask; + while (table[slot] >= 0) { + slot = (slot + 1) & mask; + } + if (table[slot] === EMPTY) this.tableUsed++; + table[slot] = di; + return this; + } + + delete(value: TileRef): boolean { + const table = this.table; + const dense = this.dense; + const mask = table.length - 1; + let slot = TileSet.hash(value) & mask; + for (;;) { + const di = table[slot]; + if (di === EMPTY) return false; + if (di !== DELETED && dense[di] === value) { + table[slot] = DELETED; + dense[di] = TOMBSTONE; + this.size_--; + // Mostly tombstones? Compact so long-dead players don't pin memory. + if ( + this.iterDepth === 0 && + this.denseLen >= 64 && + this.denseLen - this.size_ > this.size_ * 2 + ) { + this.compact(nextCapacity(this.size_)); + } + return true; + } + slot = (slot + 1) & mask; + } + } + + clear(): void { + this.dense = new Uint32Array(16); + this.denseLen = 0; + this.size_ = 0; + this.table = new Int32Array(32).fill(EMPTY); + this.tableUsed = 0; + } + + forEach( + callback: (tile: TileRef, tile2: TileRef, set: ReadonlyTileSet) => void, + ): void { + this.iterDepth++; + try { + // denseLen and dense are re-read every step: entries appended during + // iteration must be visited, and an append can swap in a grown buffer. + for (let i = 0; i < this.denseLen; i++) { + const v = this.dense[i]; + if (v !== TOMBSTONE) callback(v, v, this); + } + } finally { + this.iterDepth--; + } + } + + *values(): IterableIterator { + this.iterDepth++; + try { + for (let i = 0; i < this.denseLen; i++) { + const v = this.dense[i]; + if (v !== TOMBSTONE) yield v; + } + } finally { + this.iterDepth--; + } + } + + [Symbol.iterator](): IterableIterator { + return this.values(); + } + + /** Rewrites dense storage without tombstones, preserving insertion order. */ + private compact(capacity: number): void { + const compacted = new Uint32Array(Math.max(capacity, 16)); + let n = 0; + for (let i = 0; i < this.denseLen; i++) { + const v = this.dense[i]; + if (v !== TOMBSTONE) compacted[n++] = v; + } + this.dense = compacted; + this.denseLen = n; + this.rehash(Math.max(nextCapacity(n * 2), 32)); + } + + private rehash(tableLength: number): void { + const table = new Int32Array(tableLength).fill(EMPTY); + const mask = tableLength - 1; + const dense = this.dense; + for (let di = 0; di < this.denseLen; di++) { + if (dense[di] === TOMBSTONE) continue; + let slot = TileSet.hash(dense[di]) & mask; + while (table[slot] !== EMPTY) { + slot = (slot + 1) & mask; + } + table[slot] = di; + } + this.table = table; + this.tableUsed = this.size_; + } +} + +/** Smallest power of two >= n (and >= 16). */ +function nextCapacity(n: number): number { + let cap = 16; + while (cap < n) cap *= 2; + return cap; +} diff --git a/src/core/game/TileTraversalScratch.ts b/src/core/game/TileTraversalScratch.ts new file mode 100644 index 000000000..6ad014013 --- /dev/null +++ b/src/core/game/TileTraversalScratch.ts @@ -0,0 +1,44 @@ +import { Game } from "./Game"; +import { TileRef } from "./GameMap"; + +/** + * Shared per-game traversal scratch: a generation-stamped visited array (one + * slot per tile) plus a reusable stack, so BFS/DFS passes over the map + * allocate nothing per query. A single scratch is shared by all traversal + * users of a game — the visited array alone is ~32 MB on the largest maps, + * so each user keeping its own would multiply that cost. + * + * Usage contract: call bumpTraversalGeneration() at the start of a traversal + * pass and treat visited[t] === gen as "seen this pass". A pass must run to + * completion synchronously — starting another pass (by any user) invalidates + * the previous generation's marks. The simulation is single-threaded and no + * current traversal triggers another mid-pass. + */ +export interface TileTraversalScratch { + visited: Uint32Array; + stack: TileRef[]; + /** Current generation — advance via bumpTraversalGeneration(), not directly. */ + gen: number; +} + +const scratches = new WeakMap(); + +export function tileTraversalScratch(game: Game): TileTraversalScratch { + const totalTiles = game.width() * game.height(); + let scratch = scratches.get(game); + if (!scratch || scratch.visited.length < totalTiles) { + scratch = { visited: new Uint32Array(totalTiles), stack: [], gen: 0 }; + scratches.set(game, scratch); + } + return scratch; +} + +/** Starts a new traversal pass and returns its generation stamp. */ +export function bumpTraversalGeneration(scratch: TileTraversalScratch): number { + scratch.gen++; + if (scratch.gen === 0xffffffff) { + scratch.visited.fill(0); + scratch.gen = 1; + } + return scratch.gen; +} diff --git a/src/core/pathfinding/PathFinderStepper.ts b/src/core/pathfinding/PathFinderStepper.ts index 4b8081fdc..52407678b 100644 --- a/src/core/pathfinding/PathFinderStepper.ts +++ b/src/core/pathfinding/PathFinderStepper.ts @@ -18,7 +18,10 @@ export interface StepperConfig { * Generic over any PathFinder implementation. */ export class PathFinderStepper implements SteppingPathFinder { - private path: T[] | null = null; + // Numeric paths (TileRefs) are stored as a Uint32Array: steppers hold their + // whole path for the unit's entire journey, and paths across large maps run + // to thousands of nodes, so halving the per-node size matters in aggregate. + private path: T[] | Uint32Array | null = null; private pathIndex = 0; private lastTo: T | null = null; @@ -58,24 +61,29 @@ export class PathFinderStepper implements SteppingPathFinder { // Compute path if not cached if (this.path === null) { + let path: T[] | null; try { - this.path = this.finder.findPath(from, to); + path = this.finder.findPath(from, to); } catch (err) { console.error("PathFinder threw an error during findPath", err); return { status: PathStatus.NOT_FOUND }; } - if (this.path === null) { + if (path === null) { return { status: PathStatus.NOT_FOUND }; } + this.path = + path.length > 0 && typeof path[0] === "number" + ? new Uint32Array(path as number[]) + : path; this.pathIndex = 0; - if (this.path.length > 0 && this.config.equals(this.path[0], from)) { + if (path.length > 0 && this.config.equals(path[0], from)) { this.pathIndex = 1; } } - const expectedPos = this.path[this.pathIndex - 1]; + const expectedPos = this.path[this.pathIndex - 1] as T; if (this.pathIndex > 0 && !this.config.equals(from, expectedPos)) { this.invalidate(); this.lastTo = to; @@ -88,7 +96,7 @@ export class PathFinderStepper implements SteppingPathFinder { } // Return next step - const nextNode = this.path[this.pathIndex]; + const nextNode = this.path[this.pathIndex] as T; this.pathIndex++; return { status: PathStatus.NEXT, node: nextNode }; diff --git a/src/core/pathfinding/algorithms/ConnectedComponents.ts b/src/core/pathfinding/algorithms/ConnectedComponents.ts index 62bcee874..944c755c0 100644 --- a/src/core/pathfinding/algorithms/ConnectedComponents.ts +++ b/src/core/pathfinding/algorithms/ConnectedComponents.ts @@ -15,7 +15,9 @@ export class ConnectedComponents { private readonly height: number; private readonly numTiles: number; private readonly lastRowStart: number; - private readonly queue: Int32Array; + // Flood-fill work queue; exists only while initialize() runs — a + // numTiles-sized Int32Array is ~8 MB per instance on large maps. + private queue: Int32Array | null = null; private componentIds: Uint8Array | Uint16Array | null = null; private _componentSizes: number[] = []; @@ -27,11 +29,11 @@ export class ConnectedComponents { this.height = map.height(); this.numTiles = this.width * this.height; this.lastRowStart = (this.height - 1) * this.width; - this.queue = new Int32Array(this.numTiles); } initialize(): void { DebugSpan.start("ConnectedComponents:initialize"); + this.queue = new Int32Array(this.numTiles); let ids: Uint8Array | Uint16Array = this.createPrefilledIds(); this._componentSizes = []; @@ -64,6 +66,7 @@ export class ConnectedComponents { } this.componentIds = ids; + this.queue = null; DebugSpan.end(); } @@ -148,12 +151,13 @@ export class ConnectedComponents { start: number, componentId: number, ): void { + const queue = this.queue!; let head = 0; let tail = 0; - this.queue[tail++] = start; + queue[tail++] = start; while (head < tail) { - const seed = this.queue[head++]!; + const seed = queue[head++]!; // Skip if already processed if (ids[seed] !== 0) continue; @@ -184,7 +188,7 @@ export class ConnectedComponents { if (x >= this.width) { const above = x - this.width; if (ids[above] === 0) { - this.queue[tail++] = above; + queue[tail++] = above; } } @@ -192,7 +196,7 @@ export class ConnectedComponents { if (x < this.lastRowStart) { const below = x + this.width; if (ids[below] === 0) { - this.queue[tail++] = below; + queue[tail++] = below; } } } diff --git a/src/core/pathfinding/spatial/SpatialQuery.ts b/src/core/pathfinding/spatial/SpatialQuery.ts index 9e25cda75..8eabff307 100644 --- a/src/core/pathfinding/spatial/SpatialQuery.ts +++ b/src/core/pathfinding/spatial/SpatialQuery.ts @@ -1,5 +1,9 @@ import { Game, Player, TerraNullius } from "../../game/Game"; import { TileRef } from "../../game/GameMap"; +import { + bumpTraversalGeneration, + tileTraversalScratch, +} from "../../game/TileTraversalScratch"; import { DebugSpan } from "../../utilities/DebugSpan"; import { PathFinding } from "../PathFinder"; import { AStarWaterBounded } from "../algorithms/AStar.WaterBounded"; @@ -8,16 +12,6 @@ type Owner = Player | TerraNullius; const REFINE_MAX_SEARCH_AREA = 100 * 100; -// Per-game BFS scratch (generation-stamped visited array + reusable stack) so -// bfsNearest allocates nothing per query. Keyed by game because SpatialQuery -// instances are created per call site. -interface BfsScratch { - visited: Uint32Array; - gen: number; - stack: TileRef[]; -} -const bfsScratches = new WeakMap(); - export class SpatialQuery { private boundedAStar: AStarWaterBounded | null = null; @@ -36,30 +30,14 @@ export class SpatialQuery { * Find nearest tile matching predicate using BFS traversal. * Uses Manhattan distance filter, ignores terrain barriers. */ - private bfsScratch(): BfsScratch { - const map = this.game.map(); - const totalTiles = map.width() * map.height(); - let s = bfsScratches.get(this.game); - if (!s || s.visited.length < totalTiles) { - s = { visited: new Uint32Array(totalTiles), gen: 0, stack: [] }; - bfsScratches.set(this.game, s); - } - return s; - } - private bfsNearest( from: TileRef, maxDist: number, predicate: (t: TileRef) => boolean, ): TileRef | null { const map = this.game.map(); - const scratch = this.bfsScratch(); - scratch.gen++; - if (scratch.gen === 0xffffffff) { - scratch.visited.fill(0); - scratch.gen = 1; - } - const gen = scratch.gen; + const scratch = tileTraversalScratch(this.game); + const gen = bumpTraversalGeneration(scratch); const visited = scratch.visited; const stack = scratch.stack; stack.length = 0; diff --git a/tests/TileSet.test.ts b/tests/TileSet.test.ts new file mode 100644 index 000000000..cb54ff4a5 --- /dev/null +++ b/tests/TileSet.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "vitest"; +import { PseudoRandom } from "../src/core/PseudoRandom"; +import { TileSet } from "../src/core/game/TileSet"; + +describe("TileSet", () => { + it("adds, reports membership and size", () => { + const s = new TileSet(); + expect(s.size).toBe(0); + expect(s.has(5)).toBe(false); + s.add(5); + s.add(9); + s.add(5); // duplicate + expect(s.size).toBe(2); + expect(s.has(5)).toBe(true); + expect(s.has(9)).toBe(true); + expect(s.has(6)).toBe(false); + }); + + it("deletes and reports whether the value was present", () => { + const s = new TileSet([1, 2, 3]); + expect(s.delete(2)).toBe(true); + expect(s.delete(2)).toBe(false); + expect(s.delete(99)).toBe(false); + expect(s.size).toBe(2); + expect(s.has(2)).toBe(false); + expect([...s]).toEqual([1, 3]); + }); + + it("iterates in insertion order across all iteration surfaces", () => { + const values = [42, 7, 100000, 0, 13]; + const s = new TileSet(values); + expect([...s]).toEqual(values); + expect(Array.from(s.values())).toEqual(values); + const seen: number[] = []; + s.forEach((t) => seen.push(t)); + expect(seen).toEqual(values); + }); + + it("moves a value to the end on delete + re-add, matching Set", () => { + const s = new TileSet([1, 2, 3]); + s.delete(1); + s.add(1); + expect([...s]).toEqual([2, 3, 1]); + }); + + it("re-adding an existing value does not change its position", () => { + const s = new TileSet([1, 2, 3]); + s.add(1); + expect([...s]).toEqual([1, 2, 3]); + }); + + it("visits entries added during forEach, matching Set", () => { + const s = new TileSet([1, 2]); + const seen: number[] = []; + s.forEach((t) => { + seen.push(t); + if (t === 1) s.add(3); + }); + expect(seen).toEqual([1, 2, 3]); + }); + + it("skips entries deleted during forEach, matching Set", () => { + const s = new TileSet([1, 2, 3]); + const seen: number[] = []; + s.forEach((t) => { + seen.push(t); + if (t === 1) s.delete(3); + }); + expect(seen).toEqual([1, 2]); + }); + + it("supports deleting the current entry during iteration", () => { + const s = new TileSet([1, 2, 3]); + const seen: number[] = []; + for (const t of s) { + seen.push(t); + s.delete(t); + } + expect(seen).toEqual([1, 2, 3]); + expect(s.size).toBe(0); + }); + + it("preserves order through tombstone compaction", () => { + const s = new TileSet(); + // Interleave adds and deletes well past the compaction thresholds. + for (let i = 0; i < 1000; i++) s.add(i); + for (let i = 0; i < 1000; i++) { + if (i % 3 !== 0) s.delete(i); + } + for (let i = 2000; i < 2100; i++) s.add(i); + const expected: number[] = []; + for (let i = 0; i < 1000; i++) { + if (i % 3 === 0) expected.push(i); + } + for (let i = 2000; i < 2100; i++) expected.push(i); + expect([...s]).toEqual(expected); + expect(s.size).toBe(expected.length); + for (const v of expected) expect(s.has(v)).toBe(true); + expect(s.has(1)).toBe(false); + }); + + it("clear empties the set", () => { + const s = new TileSet([1, 2, 3]); + s.clear(); + expect(s.size).toBe(0); + expect(s.has(1)).toBe(false); + expect([...s]).toEqual([]); + s.add(7); + expect([...s]).toEqual([7]); + }); + + it("handles large tile refs (up to the 65535x65535 map bound)", () => { + const big = 65535 * 65535 - 1; + const s = new TileSet([big, 0, big - 1]); + expect(s.has(big)).toBe(true); + expect([...s]).toEqual([big, 0, big - 1]); + }); + + it("matches native Set behavior on a randomized operation sequence", () => { + const random = new PseudoRandom(12345); + const tileSet = new TileSet(); + const reference = new Set(); + for (let op = 0; op < 20000; op++) { + const value = random.nextInt(0, 500); + if (random.chance(3)) { + expect(tileSet.delete(value)).toBe(reference.delete(value)); + } else { + tileSet.add(value); + reference.add(value); + } + if (op % 500 === 0) { + expect(tileSet.size).toBe(reference.size); + expect([...tileSet]).toEqual([...reference]); + } + } + expect([...tileSet]).toEqual([...reference]); + for (let v = 0; v < 500; v++) { + expect(tileSet.has(v)).toBe(reference.has(v)); + } + }); +}); diff --git a/tests/perf/fullgame/FullGamePerf.ts b/tests/perf/fullgame/FullGamePerf.ts index d097394c6..df6be56a4 100644 --- a/tests/perf/fullgame/FullGamePerf.ts +++ b/tests/perf/fullgame/FullGamePerf.ts @@ -24,8 +24,16 @@ * [--seed perf-default] [--top 30] [--window 1000] * [--no-cpu-profile] [--no-exec-profile] * [--no-gc-profile] [--no-alloc-profile] + * [--footprint] [--snapshot-at 0,2000,12000] + * + * --footprint records the live heap (used heap after a forced full GC) at + * every --window boundary; it requires NODE_OPTIONS=--expose-gc. + * --snapshot-at writes .heapsnapshot files at the given game-phase ticks + * (0 = right after the spawn phase) for offline attribution; summarize them + * with tests/perf/fullgame/HeapSnapshotSummary.ts. */ import fs from "fs"; +import v8 from "node:v8"; import path from "path"; import { fileURLToPath } from "url"; import { Config } from "../../../src/core/configuration/Config"; @@ -47,11 +55,13 @@ import { GameConfig, GameStartInfo } from "../../../src/core/Schemas"; import { simpleHash } from "../../../src/core/Util"; import { AllocationSampler, + FootprintCheckpoint, GcTracker, HeapSampler, HeapWindow, summarizeAllocationProfile, summarizeGcEvents, + takeFootprintCheckpoint, } from "./GcProfiler"; import { NodeGameMapLoader } from "./NodeGameMapLoader"; import { @@ -81,6 +91,9 @@ interface Options { execProfile: boolean; gcProfile: boolean; allocProfile: boolean; + footprint: boolean; + snapshotAt: number[]; + waterNukes: boolean; } function resolveMap(name: string): GameMapType { @@ -109,6 +122,9 @@ function parseArgs(argv: string[]): Options { execProfile: true, gcProfile: true, allocProfile: true, + footprint: false, + snapshotAt: [], + waterNukes: false, }; for (let i = 0; i < argv.length; i++) { const arg = argv[i]; @@ -154,6 +170,17 @@ function parseArgs(argv: string[]): Options { case "--no-alloc-profile": opts.allocProfile = false; break; + case "--footprint": + opts.footprint = true; + break; + case "--snapshot-at": + opts.snapshotAt = next() + .split(",") + .map((v) => parseInt(v, 10)); + break; + case "--water-nukes": + opts.waterNukes = true; + break; default: throw new Error(`unknown argument: ${arg}`); } @@ -203,6 +230,7 @@ async function main(): Promise { infiniteTroops: false, instantBuild: false, randomSpawn: false, + waterNukes: opts.waterNukes ? true : undefined, }; const gameStart: GameStartInfo = { gameID: opts.seed, @@ -272,6 +300,28 @@ async function main(): Promise { gcTracker?.start(); const heapSampler = opts.gcProfile ? new HeapSampler() : null; + const footprints: FootprintCheckpoint[] = []; + const recordFootprint = (label: string): void => { + if (!opts.footprint) return; + const cp = takeFootprintCheckpoint(label); + if (cp === null) { + throw new Error( + "--footprint requires the gc() global; run with NODE_OPTIONS=--expose-gc", + ); + } + footprints.push(cp); + }; + const snapshotDir = path.join(PROJECT_ROOT, "tests/perf/output"); + const writeSnapshot = (label: string): void => { + fs.mkdirSync(snapshotDir, { recursive: true }); + const file = path.join( + snapshotDir, + `fullgame-${opts.map.replace(/\W+/g, "_")}-${opts.seed}-${label}.heapsnapshot`, + ); + console.log(`Writing heap snapshot ${path.relative(PROJECT_ROOT, file)}…`); + v8.writeHeapSnapshot(file); + }; + let turnNumber = 0; const runTick = (stats: TickStats): boolean => { runner.addTurn({ turnNumber: turnNumber++, intents: [] }); @@ -302,6 +352,10 @@ async function main(): Promise { ); heapSampler?.closeWindow("spawn"); + recordFootprint(`spawn (tick ${game.ticks() - 1})`); + if (opts.snapshotAt.includes(0)) { + writeSnapshot("tick0"); + } // Main game phase, under the CPU profiler and allocation sampler. const cpuProfiler = opts.cpuProfile ? new CpuProfiler() : null; @@ -328,6 +382,10 @@ async function main(): Promise { if ((i + 1) % opts.window === 0 || i === opts.ticks - 1) { heapSampler?.closeWindow(`${windowStartTick}-${game.ticks() - 1}`); windowStartTick = game.ticks(); + recordFootprint(`tick ${game.ticks() - 1}`); + } + if (opts.snapshotAt.includes(i + 1)) { + writeSnapshot(`tick${i + 1}`); } } const gamePhaseMs = performance.now() - gameStart_; @@ -372,6 +430,23 @@ async function main(): Promise { summary.slowest.map((s) => `#${s.tick} (${fmtMs(s.ms)}ms)`).join(", "), ); + if (footprints.length > 0) { + console.log(`\n--- Live-heap footprint (after forced full GC) ---`); + console.log( + table( + ["checkpoint", "live MB", "total MB", "ext MB", "arrbuf MB", "rss MB"], + footprints.map((cp) => [ + cp.label, + fmtMB(cp.liveHeapBytes), + fmtMB(cp.totalHeapBytes), + fmtMB(cp.externalBytes), + fmtMB(cp.arrayBuffersBytes), + fmtMB(cp.rssBytes), + ]), + ), + ); + } + if (opts.execProfile) { console.log(`\n--- Time by Execution class ---`); const rows = execProfiler.report(); diff --git a/tests/perf/fullgame/GcProfiler.ts b/tests/perf/fullgame/GcProfiler.ts index 31fbf4420..d4c5b291e 100644 --- a/tests/perf/fullgame/GcProfiler.ts +++ b/tests/perf/fullgame/GcProfiler.ts @@ -274,3 +274,41 @@ export function summarizeAllocationProfile( sites.sort((a, b) => b.selfBytes - a.selfBytes); return { sites, totalBytes }; } + +// ── Live-heap footprint checkpoints ── + +export interface FootprintCheckpoint { + label: string; + /** used_heap_size after a forced full GC — the live set. */ + liveHeapBytes: number; + totalHeapBytes: number; + externalBytes: number; + arrayBuffersBytes: number; + rssBytes: number; +} + +/** + * Forces a full GC (twice, so objects freed by finalizers in the first pass + * are also collected) and returns the resulting heap statistics. Requires the + * process to run with --expose-gc; returns null otherwise. + */ +export function takeFootprintCheckpoint( + label: string, +): FootprintCheckpoint | null { + const gc = (globalThis as { gc?: () => void }).gc; + if (gc === undefined) { + return null; + } + gc(); + gc(); + const heap = v8.getHeapStatistics(); + const mem = process.memoryUsage(); + return { + label, + liveHeapBytes: heap.used_heap_size, + totalHeapBytes: heap.total_heap_size, + externalBytes: mem.external, + arrayBuffersBytes: mem.arrayBuffers, + rssBytes: mem.rss, + }; +} diff --git a/tests/perf/fullgame/HeapSnapshotRetainers.ts b/tests/perf/fullgame/HeapSnapshotRetainers.ts new file mode 100644 index 000000000..6eeff17a5 --- /dev/null +++ b/tests/perf/fullgame/HeapSnapshotRetainers.ts @@ -0,0 +1,189 @@ +/** + * Retainer attribution for a V8 .heapsnapshot: aggregates every node's self + * size under a label derived from its retainer chain — the nearest ancestor + * with a project-meaningful constructor name plus the property path from it + * (e.g. "PlayerImpl._tiles.table"). Also lists the largest individual nodes + * with their full retainer chains. + * + * Loads the whole snapshot with JSON.parse, so only suitable for snapshots + * under V8's max string length (~500 MB); use HeapSnapshotSummary.ts for a + * flat by-type summary of bigger files. + * + * Usage: + * npx tsx tests/perf/fullgame/HeapSnapshotRetainers.ts [top] + */ +import fs from "fs"; + +// Constructor names that identify a container, not an owner — the walk +// continues past these to find whose field the container is. +const GENERIC_NAMES = new Set([ + "", + "Object", + "Array", + "Set", + "Map", + "WeakMap", + "WeakSet", + "ArrayBuffer", + "SharedArrayBuffer", + "DataView", + "Int8Array", + "Uint8Array", + "Uint8ClampedArray", + "Int16Array", + "Uint16Array", + "Int32Array", + "Uint32Array", + "Float32Array", + "Float64Array", + "BigInt64Array", + "BigUint64Array", +]); + +function main(): void { + const file = process.argv[2]; + const top = parseInt(process.argv[3] ?? "40", 10); + const snap = JSON.parse(fs.readFileSync(file, "utf8")) as { + snapshot: { + meta: { + node_fields: string[]; + node_types: (string[] | string)[]; + edge_fields: string[]; + edge_types: (string[] | string)[]; + }; + node_count: number; + edge_count: number; + }; + nodes: number[]; + edges: number[]; + strings: string[]; + }; + + const { meta } = snap.snapshot; + const NF = meta.node_fields.length; + const N_TYPE = meta.node_fields.indexOf("type"); + const N_NAME = meta.node_fields.indexOf("name"); + const N_SIZE = meta.node_fields.indexOf("self_size"); + const N_EDGES = meta.node_fields.indexOf("edge_count"); + const nodeTypes = meta.node_types[N_TYPE] as string[]; + const EF = meta.edge_fields.length; + const E_TYPE = meta.edge_fields.indexOf("type"); + const E_NAME = meta.edge_fields.indexOf("name_or_index"); + const E_TO = meta.edge_fields.indexOf("to_node"); + const edgeTypes = meta.edge_types[E_TYPE] as string[]; + const WEAK_EDGE = edgeTypes.indexOf("weak"); + const ELEMENT_EDGE = edgeTypes.indexOf("element"); + const HIDDEN_EDGE = edgeTypes.indexOf("hidden"); + + const { nodes, edges, strings } = snap; + const nodeCount = snap.snapshot.node_count; + + // First retainer of each node (prefer non-weak edges), plus the edge name. + const retainer = new Int32Array(nodeCount).fill(-1); + const retainerWeak = new Uint8Array(nodeCount); + const retainerEdge = new Int32Array(nodeCount).fill(-1); // string idx or -1 + let edgeIdx = 0; + for (let src = 0; src < nodeCount; src++) { + const count = nodes[src * NF + N_EDGES]; + for (let e = 0; e < count; e++, edgeIdx += EF) { + const to = edges[edgeIdx + E_TO] / NF; + const type = edges[edgeIdx + E_TYPE]; + const weak = type === WEAK_EDGE ? 1 : 0; + if (retainer[to] === -1 || (retainerWeak[to] === 1 && weak === 0)) { + retainer[to] = src; + retainerWeak[to] = weak; + retainerEdge[to] = + type === ELEMENT_EDGE || type === HIDDEN_EDGE + ? -2 // numeric index — label as [] + : edges[edgeIdx + E_NAME]; + } + } + } + + const nodeName = (i: number): string => strings[nodes[i * NF + N_NAME]]; + const nodeType = (i: number): string => nodeTypes[nodes[i * NF + N_TYPE]]; + const edgeLabel = (i: number): string => + retainerEdge[i] === -2 ? "[]" : (strings[retainerEdge[i]] ?? "?"); + + // Label a node by its nearest non-generic named ancestor plus the property + // path from that ancestor (capped, deepest segments dropped first). + const labelOf = (i: number): string => { + const segments: string[] = []; + let cur = i; + for (let depth = 0; depth < 12; depth++) { + const parent = retainer[cur]; + if (parent === -1) return `(root) ${segments.join(".")}`; + const t = nodeType(parent); + const name = nodeName(parent); + if ( + (t === "object" || t === "closure" || t === "native") && + !GENERIC_NAMES.has(name) + ) { + return `${name}.${segments.slice(0, 3).join(".")}`; + } + if (t === "synthetic") { + return `(${name}) ${segments.slice(0, 3).join(".")}`; + } + segments.unshift(edgeLabel(cur)); + cur = parent; + } + return `(deep) ${segments.slice(0, 3).join(".")}`; + }; + + interface Bucket { + bytes: number; + count: number; + } + const buckets = new Map(); + let totalBytes = 0; + const big: { i: number; size: number }[] = []; + for (let i = 0; i < nodeCount; i++) { + const size = nodes[i * NF + N_SIZE]; + if (size === 0) continue; + totalBytes += size; + const t = nodeType(i); + // Group bulk data types under their retainers; everything else by type. + const key = + t === "code" || t === "string" || t === "concatenated string" + ? `(all ${t})` + : labelOf(i); + const b = buckets.get(key); + if (b) { + b.bytes += size; + b.count++; + } else { + buckets.set(key, { bytes: size, count: 1 }); + } + if (size >= 128 * 1024) { + big.push({ i, size }); + } + } + + const fmtMB = (bytes: number): string => (bytes / 1024 / 1024).toFixed(2); + + console.log(`${file}\nlive: ${fmtMB(totalBytes)} MB\n`); + console.log(`--- Top ${top} retainer groups by self size ---`); + const sorted = [...buckets.entries()].sort((a, b) => b[1].bytes - a[1].bytes); + for (const [label, b] of sorted.slice(0, top)) { + console.log( + `${fmtMB(b.bytes).padStart(9)} MB ${String(b.count).padStart(8)} ${label}`, + ); + } + + console.log(`\n--- Nodes ≥128KB with retainer chains ---`); + big.sort((a, b) => b.size - a.size); + for (const { i, size } of big.slice(0, top)) { + const chain: string[] = []; + let cur = i; + for (let depth = 0; depth < 8 && retainer[cur] !== -1; depth++) { + const parent = retainer[cur]; + chain.push(`${nodeName(parent) || nodeType(parent)}.${edgeLabel(cur)}`); + cur = parent; + } + console.log( + `${fmtMB(size).padStart(9)} MB ${nodeType(i)} ${nodeName(i)} ← ${chain.join(" ← ")}`, + ); + } +} + +main(); diff --git a/tests/perf/fullgame/HeapSnapshotSummary.ts b/tests/perf/fullgame/HeapSnapshotSummary.ts new file mode 100644 index 000000000..513825e52 --- /dev/null +++ b/tests/perf/fullgame/HeapSnapshotSummary.ts @@ -0,0 +1,250 @@ +/** + * Summarizes a V8 .heapsnapshot file: total live bytes and the top heap + * consumers grouped by (node type, constructor/name), by self size. + * + * Snapshot files from a large heap are multi-GB JSON — far beyond V8's max + * string length — so this streams the file and parses just the `nodes` array + * (flat integers) and the `strings` table with a byte-level scanner. + * + * Usage: + * npx tsx tests/perf/fullgame/HeapSnapshotSummary.ts [top] + */ +import fs from "fs"; + +interface Group { + typeIdx: number; + nameIdx: number; // -1 when the type's node names are per-instance content + count: number; + bytes: number; +} + +// Node types whose per-node name is instance content (string payloads, +// function source positions, ...) rather than a meaningful grouping key. +const CONTENT_NAMED_TYPES = new Set([ + "string", + "concatenated string", + "sliced string", + "number", + "bigint", + "symbol", + "regexp", + "code", +]); + +async function main(): Promise { + const file = process.argv[2]; + const top = parseInt(process.argv[3] ?? "40", 10); + if (!file) { + console.error( + "usage: npx tsx tests/perf/fullgame/HeapSnapshotSummary.ts [top]", + ); + process.exit(1); + } + + // ── Meta: parse the small "snapshot" header object from the file prefix ── + const fd = fs.openSync(file, "r"); + const prefixBuf = Buffer.alloc(1 << 20); + const prefixLen = fs.readSync(fd, prefixBuf, 0, prefixBuf.length, 0); + fs.closeSync(fd); + const prefix = prefixBuf.subarray(0, prefixLen).toString("utf8"); + const nodesKey = '"nodes":['; + const nodesIdx = prefix.indexOf(nodesKey); + if (nodesIdx < 0) { + throw new Error(`"nodes" array not found in the first 1MB of ${file}`); + } + const metaJson = prefix.slice(0, prefix.lastIndexOf(",", nodesIdx)) + "}"; + const meta = JSON.parse(metaJson).snapshot.meta as { + node_fields: string[]; + node_types: (string[] | string)[]; + }; + const fieldCount = meta.node_fields.length; + const typeField = meta.node_fields.indexOf("type"); + const nameField = meta.node_fields.indexOf("name"); + const sizeField = meta.node_fields.indexOf("self_size"); + const typeNames = meta.node_types[typeField] as string[]; + const contentNamedTypeIdx = new Set( + typeNames.flatMap((t, i) => (CONTENT_NAMED_TYPES.has(t) ? [i] : [])), + ); + + // ── Stream pass: aggregate the nodes array, then collect needed strings ── + const groups = new Map(); + const groupKey = (typeIdx: number, nameIdx: number) => + typeIdx * 0x100000000 + nameIdx + 1; // +1 so nameIdx -1 maps to 0 + + let totalBytes = 0; + let totalNodes = 0; + + // Scanner state. + const SEEK_STRINGS = 0; // between the nodes array and the strings table + const IN_NODES = 1; + const STRINGS_BETWEEN = 2; // inside strings array, between tokens + const IN_STRING = 3; + const DONE = 4; + let state = IN_NODES; + + // IN_NODES state: integer accumulator + current node's fields. + let cur = 0; + let hasCur = false; + const nodeVals = new Array(fieldCount).fill(0); + let fieldIdx = 0; + + const finishNumber = (): void => { + if (!hasCur) return; + nodeVals[fieldIdx] = cur; + cur = 0; + hasCur = false; + if (++fieldIdx === fieldCount) { + fieldIdx = 0; + totalNodes++; + const size = nodeVals[sizeField]; + totalBytes += size; + const typeIdx = nodeVals[typeField]; + const nameIdx = contentNamedTypeIdx.has(typeIdx) + ? -1 + : nodeVals[nameField]; + const key = groupKey(typeIdx, nameIdx); + const g = groups.get(key); + if (g) { + g.count++; + g.bytes += size; + } else { + groups.set(key, { typeIdx, nameIdx, count: 1, bytes: size }); + } + } + }; + + // SEEK_STRINGS state: match the `"strings":[` marker across chunk borders. + const stringsKey = Buffer.from('"strings":['); + let matchPos = 0; + + // IN_STRING state: raw token bytes (with quotes) for JSON.parse. + let stringIdx = 0; + let escape = false; + let tokenChunks: Buffer[] = []; + let tokenStart = -1; // start of current token in current chunk, if wanted + let wantToken = false; + const names = new Map(); + const neededNames = new Set(); + + const stream = fs.createReadStream(file, { + start: nodesIdx + nodesKey.length, + highWaterMark: 8 << 20, + }); + + for await (const chunk of stream as AsyncIterable) { + for (let i = 0; i < chunk.length; i++) { + const b = chunk[i]; + switch (state) { + case IN_NODES: + if (b >= 0x30 && b <= 0x39) { + cur = cur * 10 + (b - 0x30); + hasCur = true; + } else { + finishNumber(); + if (b === 0x5d) { + // "]" — end of nodes; now that groups are final, we know which + // string-table entries we need. + for (const g of groups.values()) { + if (g.nameIdx >= 0) neededNames.add(g.nameIdx); + } + state = SEEK_STRINGS; + } + } + break; + case SEEK_STRINGS: + if (b === stringsKey[matchPos]) { + if (++matchPos === stringsKey.length) { + state = STRINGS_BETWEEN; + } + } else { + matchPos = b === stringsKey[0] ? 1 : 0; + } + break; + case STRINGS_BETWEEN: + if (b === 0x22) { + state = IN_STRING; + escape = false; + wantToken = neededNames.has(stringIdx); + tokenChunks = []; + tokenStart = wantToken ? i : -1; + } else if (b === 0x5d) { + state = DONE; + } + break; + case IN_STRING: + if (escape) { + escape = false; + } else if (b === 0x5c) { + escape = true; + } else if (b === 0x22) { + if (wantToken) { + tokenChunks.push(chunk.subarray(tokenStart, i + 1)); + names.set( + stringIdx, + JSON.parse(Buffer.concat(tokenChunks).toString("utf8")), + ); + tokenChunks = []; + } + stringIdx++; + state = STRINGS_BETWEEN; + } + break; + case DONE: + break; + } + } + // Carry an in-progress wanted token across the chunk border. + if (state === IN_STRING && wantToken) { + tokenChunks.push(chunk.subarray(Math.max(tokenStart, 0))); + tokenStart = 0; + } + if (state === DONE) break; + } + + // ── Report ── + const fmtMB = (bytes: number): string => (bytes / 1024 / 1024).toFixed(2); + const all = [...groups.values()].sort((a, b) => b.bytes - a.bytes); + + console.log( + `${file}\nlive: ${fmtMB(totalBytes)} MB across ${totalNodes} nodes\n`, + ); + + const byType = new Map(); + for (const g of all) { + const t = byType.get(g.typeIdx) ?? { count: 0, bytes: 0 }; + t.count += g.count; + t.bytes += g.bytes; + byType.set(g.typeIdx, t); + } + console.log("--- By node type ---"); + for (const [typeIdx, t] of [...byType.entries()].sort( + (a, b) => b[1].bytes - a[1].bytes, + )) { + console.log( + `${fmtMB(t.bytes).padStart(10)} MB ${String(t.count).padStart(9)} ${typeNames[typeIdx]}`, + ); + } + + console.log(`\n--- Top ${top} by (type, name) self size ---`); + console.log( + `${"MB".padStart(10)} ${"%".padStart(5)} ${"count".padStart(9)} group`, + ); + for (const g of all.slice(0, top)) { + const name = + g.nameIdx < 0 + ? `(${typeNames[g.typeIdx]} data)` + : (names.get(g.nameIdx) ?? ``); + console.log( + `${fmtMB(g.bytes).padStart(10)} ${((g.bytes * 100) / totalBytes) + .toFixed(1) + .padStart( + 5, + )} ${String(g.count).padStart(9)} ${typeNames[g.typeIdx]} ${name}`, + ); + } +} + +main().catch((err) => { + console.error(err); + process.exit(1); +});