mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-21 21:37:46 +00:00
perf: reduce core live-memory footprint by 45% on large maps (#4507)
## 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<number>`. 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 <noreply@anthropic.com>
This commit is contained in:
@@ -275,7 +275,9 @@ export class GameRunner {
|
|||||||
throw new Error(`player with id ${playerID} not found`);
|
throw new Error(`player with id ${playerID} not found`);
|
||||||
}
|
}
|
||||||
return {
|
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;
|
} as PlayerBorderTiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+3
-2
@@ -2,6 +2,7 @@ import DOMPurify from "dompurify";
|
|||||||
import { customAlphabet } from "nanoid";
|
import { customAlphabet } from "nanoid";
|
||||||
import { Cell, PlayerType, Unit } from "./game/Game";
|
import { Cell, PlayerType, Unit } from "./game/Game";
|
||||||
import { GameMap, TileRef } from "./game/GameMap";
|
import { GameMap, TileRef } from "./game/GameMap";
|
||||||
|
import { TileSet } from "./game/TileSet";
|
||||||
import {
|
import {
|
||||||
GameConfig,
|
GameConfig,
|
||||||
GameID,
|
GameID,
|
||||||
@@ -148,7 +149,7 @@ export function calculateBoundingBox(
|
|||||||
for (let i = 0; i < borderTiles.length; i++) {
|
for (let i = 0; i < borderTiles.length; i++) {
|
||||||
visit(borderTiles[i]);
|
visit(borderTiles[i]);
|
||||||
}
|
}
|
||||||
} else if (borderTiles instanceof Set) {
|
} else if (borderTiles instanceof Set || borderTiles instanceof TileSet) {
|
||||||
borderTiles.forEach(visit);
|
borderTiles.forEach(visit);
|
||||||
} else {
|
} else {
|
||||||
for (const tile of borderTiles) {
|
for (const tile of borderTiles) {
|
||||||
@@ -213,7 +214,7 @@ export function getMode<T>(counts: Map<T, number>): T | null {
|
|||||||
|
|
||||||
export function calculateBoundingBoxCenter(
|
export function calculateBoundingBoxCenter(
|
||||||
gm: GameMap,
|
gm: GameMap,
|
||||||
borderTiles: ReadonlySet<TileRef>,
|
borderTiles: Iterable<TileRef>,
|
||||||
): Cell {
|
): Cell {
|
||||||
const { min, max } = calculateBoundingBox(gm, borderTiles);
|
const { min, max } = calculateBoundingBox(gm, borderTiles);
|
||||||
return boundingBoxCenter({ min, max });
|
return boundingBoxCenter({ min, max });
|
||||||
|
|||||||
@@ -8,18 +8,13 @@ import {
|
|||||||
UnitType,
|
UnitType,
|
||||||
} from "../game/Game";
|
} from "../game/Game";
|
||||||
import { GameMap, TileRef } from "../game/GameMap";
|
import { GameMap, TileRef } from "../game/GameMap";
|
||||||
|
import {
|
||||||
|
bumpTraversalGeneration,
|
||||||
|
tileTraversalScratch,
|
||||||
|
TileTraversalScratch,
|
||||||
|
} from "../game/TileTraversalScratch";
|
||||||
import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
|
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<Game, ClusterTraversalState>();
|
|
||||||
|
|
||||||
export class PlayerExecution implements Execution {
|
export class PlayerExecution implements Execution {
|
||||||
private readonly ticksPerClusterCalc = 20;
|
private readonly ticksPerClusterCalc = 20;
|
||||||
|
|
||||||
@@ -370,28 +365,12 @@ export class PlayerExecution implements Execution {
|
|||||||
return this.active;
|
return this.active;
|
||||||
}
|
}
|
||||||
|
|
||||||
private traversalState(): ClusterTraversalState {
|
private traversalState(): TileTraversalScratch {
|
||||||
const totalTiles = this.mg.width() * this.mg.height();
|
return tileTraversalScratch(this.mg);
|
||||||
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 bumpGeneration(): number {
|
private bumpGeneration(): number {
|
||||||
const state = this.traversalState();
|
return bumpTraversalGeneration(this.traversalState());
|
||||||
state.gen++;
|
|
||||||
if (state.gen === 0xffffffff) {
|
|
||||||
state.visited.fill(0);
|
|
||||||
state.gen = 1;
|
|
||||||
}
|
|
||||||
return state.gen;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private floodFillWithGen(
|
private floodFillWithGen(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ import {
|
|||||||
import { MotionPlanRecord } from "./MotionPlans";
|
import { MotionPlanRecord } from "./MotionPlans";
|
||||||
import { RailNetwork } from "./RailNetwork";
|
import { RailNetwork } from "./RailNetwork";
|
||||||
import { Stats } from "./Stats";
|
import { Stats } from "./Stats";
|
||||||
|
import { ReadonlyTileSet } from "./TileSet";
|
||||||
import { UnitPredicate } from "./UnitGrid";
|
import { UnitPredicate } from "./UnitGrid";
|
||||||
|
|
||||||
function isEnumValue<T extends Record<string, string | number>>(
|
function isEnumValue<T extends Record<string, string | number>>(
|
||||||
@@ -569,7 +570,7 @@ export interface Player {
|
|||||||
|
|
||||||
// Territory
|
// Territory
|
||||||
tiles(): ReadonlySet<TileRef>;
|
tiles(): ReadonlySet<TileRef>;
|
||||||
borderTiles(): ReadonlySet<TileRef>;
|
borderTiles(): ReadonlyTileSet;
|
||||||
numTilesOwned(): number;
|
numTilesOwned(): number;
|
||||||
conquer(tile: TileRef): void;
|
conquer(tile: TileRef): void;
|
||||||
relinquish(tile: TileRef): void;
|
relinquish(tile: TileRef): void;
|
||||||
|
|||||||
+16
-29
@@ -111,15 +111,11 @@ export class GameMapImpl implements GameMap {
|
|||||||
private readonly width_: number;
|
private readonly width_: number;
|
||||||
private readonly height_: number;
|
private readonly height_: number;
|
||||||
|
|
||||||
// Lookup tables (LUTs) contain pre-computed values to avoid performing division at runtime.
|
// Row-start ref per y, so ref(x, y) avoids a multiply. x/y are derived from
|
||||||
// Typed arrays are used instead of plain JS Array to keep memory tight on large maps:
|
// a ref arithmetically (ref % width, ref / width) rather than via per-tile
|
||||||
// Uint16Array uses 2 bytes/element vs ~8 bytes for a boxed number, saving ~53 MB on
|
// lookup tables — two Uint16 tables cost 4 bytes per tile (~32 MB on the
|
||||||
// the Indian Subcontinent map (2000×2220 = 4.44 M tiles).
|
// largest maps) and their random-access reads miss cache more often than
|
||||||
// Coordinates never exceed 65535 for any map in the game, so Uint16 is safe for x/y.
|
// the division costs.
|
||||||
// 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;
|
|
||||||
private readonly yToRef: Int32Array;
|
private readonly yToRef: Int32Array;
|
||||||
|
|
||||||
// Terrain bits (Uint8Array)
|
// Terrain bits (Uint8Array)
|
||||||
@@ -154,18 +150,9 @@ export class GameMapImpl implements GameMap {
|
|||||||
this.height_ = height;
|
this.height_ = height;
|
||||||
this.terrain = terrainData;
|
this.terrain = terrainData;
|
||||||
this.state = new Uint16Array(width * height);
|
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);
|
this.yToRef = new Int32Array(height);
|
||||||
for (let y = 0; y < height; y++) {
|
for (let y = 0; y < height; y++) {
|
||||||
this.yToRef[y] = ref;
|
this.yToRef[y] = y * width;
|
||||||
for (let x = 0; x < width; x++) {
|
|
||||||
this.refToX[ref] = x;
|
|
||||||
this.refToY[ref] = y;
|
|
||||||
ref++;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
numTilesWithFallout(): number {
|
numTilesWithFallout(): number {
|
||||||
@@ -180,15 +167,15 @@ export class GameMapImpl implements GameMap {
|
|||||||
}
|
}
|
||||||
|
|
||||||
isValidRef(ref: TileRef): boolean {
|
isValidRef(ref: TileRef): boolean {
|
||||||
return ref >= 0 && ref < this.refToX.length;
|
return ref >= 0 && ref < this.width_ * this.height_;
|
||||||
}
|
}
|
||||||
|
|
||||||
x(ref: TileRef): number {
|
x(ref: TileRef): number {
|
||||||
return this.refToX[ref];
|
return ref % this.width_;
|
||||||
}
|
}
|
||||||
|
|
||||||
y(ref: TileRef): number {
|
y(ref: TileRef): number {
|
||||||
return this.refToY[ref];
|
return (ref / this.width_) | 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
cell(ref: TileRef): Cell {
|
cell(ref: TileRef): Cell {
|
||||||
@@ -234,7 +221,7 @@ export class GameMapImpl implements GameMap {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
const w = this.width_;
|
const w = this.width_;
|
||||||
const x = this.refToX[ref];
|
const x = ref % w;
|
||||||
if (x !== 0 && this.isOcean(ref - 1)) return true;
|
if (x !== 0 && this.isOcean(ref - 1)) return true;
|
||||||
if (x !== w - 1 && 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;
|
if (ref >= w && this.isOcean(ref - w)) return true;
|
||||||
@@ -330,7 +317,7 @@ export class GameMapImpl implements GameMap {
|
|||||||
|
|
||||||
isBorder(ref: TileRef): boolean {
|
isBorder(ref: TileRef): boolean {
|
||||||
const w = this.width_;
|
const w = this.width_;
|
||||||
const x = this.refToX[ref];
|
const x = ref % w;
|
||||||
const owner = this.ownerID(ref);
|
const owner = this.ownerID(ref);
|
||||||
if (x !== 0 && this.ownerID(ref - 1) !== owner) return true;
|
if (x !== 0 && this.ownerID(ref - 1) !== owner) return true;
|
||||||
if (x !== w - 1 && 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[] {
|
neighbors(ref: TileRef): TileRef[] {
|
||||||
const neighbors: TileRef[] = [];
|
const neighbors: TileRef[] = [];
|
||||||
const w = this.width_;
|
const w = this.width_;
|
||||||
const x = this.refToX[ref];
|
const x = ref % w;
|
||||||
|
|
||||||
if (ref >= w) neighbors.push(ref - w);
|
if (ref >= w) neighbors.push(ref - w);
|
||||||
if (ref < (this.height_ - 1) * 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 {
|
forEachNeighbor(ref: TileRef, callback: (neighbor: TileRef) => void): void {
|
||||||
const w = this.width_;
|
const w = this.width_;
|
||||||
const x = this.refToX[ref];
|
const x = ref % w;
|
||||||
|
|
||||||
if (ref >= w) callback(ref - w);
|
if (ref >= w) callback(ref - w);
|
||||||
if (ref < (this.height_ - 1) * 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 {
|
neighbors4(ref: TileRef, out: TileRef[]): number {
|
||||||
const w = this.width_;
|
const w = this.width_;
|
||||||
const x = this.refToX[ref];
|
const x = ref % w;
|
||||||
let n = 0;
|
let n = 0;
|
||||||
|
|
||||||
if (ref >= w) out[n++] = ref - w;
|
if (ref >= w) out[n++] = ref - w;
|
||||||
@@ -420,7 +407,7 @@ export class GameMapImpl implements GameMap {
|
|||||||
callback: (neighbor: TileRef) => void,
|
callback: (neighbor: TileRef) => void,
|
||||||
): void {
|
): void {
|
||||||
const w = this.width_;
|
const w = this.width_;
|
||||||
const x = this.refToX[ref];
|
const x = ref % w;
|
||||||
const hasN = ref >= w;
|
const hasN = ref >= w;
|
||||||
const hasS = ref < (this.height_ - 1) * w;
|
const hasS = ref < (this.height_ - 1) * w;
|
||||||
|
|
||||||
@@ -501,7 +488,7 @@ export class GameMapImpl implements GameMap {
|
|||||||
while (q.length > 0) {
|
while (q.length > 0) {
|
||||||
const curr = q.pop();
|
const curr = q.pop();
|
||||||
if (curr === undefined) continue;
|
if (curr === undefined) continue;
|
||||||
const x = this.refToX[curr];
|
const x = curr % w;
|
||||||
if (curr >= w) visit(curr - w);
|
if (curr >= w) visit(curr - w);
|
||||||
if (curr < southLimit) visit(curr + w);
|
if (curr < southLimit) visit(curr + w);
|
||||||
if (x !== 0) visit(curr - 1);
|
if (x !== 0) visit(curr - 1);
|
||||||
|
|||||||
@@ -53,6 +53,7 @@ import {
|
|||||||
GameUpdateType,
|
GameUpdateType,
|
||||||
PlayerUpdate,
|
PlayerUpdate,
|
||||||
} from "./GameUpdates";
|
} from "./GameUpdates";
|
||||||
|
import { ReadonlyTileSet, TileSet } from "./TileSet";
|
||||||
import {
|
import {
|
||||||
bestShoreDeploymentSource,
|
bestShoreDeploymentSource,
|
||||||
canBuildTransportShip,
|
canBuildTransportShip,
|
||||||
@@ -111,10 +112,10 @@ export class PlayerImpl implements Player {
|
|||||||
|
|
||||||
private embargoes = new Map<PlayerID, Embargo>();
|
private embargoes = new Map<PlayerID, Embargo>();
|
||||||
|
|
||||||
public _borderTiles: Set<TileRef> = new Set();
|
public _borderTiles = new TileSet();
|
||||||
|
|
||||||
public _units: Unit[] = [];
|
public _units: Unit[] = [];
|
||||||
public _tiles: Set<TileRef> = new Set();
|
public _tiles = new TileSet();
|
||||||
|
|
||||||
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
|
public pastOutgoingAllianceRequests: AllianceRequest[] = [];
|
||||||
private _expiredAlliances: Alliance[] = [];
|
private _expiredAlliances: Alliance[] = [];
|
||||||
@@ -479,7 +480,7 @@ export class PlayerImpl implements Player {
|
|||||||
return new Set(this._tiles.values()) as Set<TileRef>;
|
return new Set(this._tiles.values()) as Set<TileRef>;
|
||||||
}
|
}
|
||||||
|
|
||||||
borderTiles(): ReadonlySet<TileRef> {
|
borderTiles(): ReadonlyTileSet {
|
||||||
return this._borderTiles;
|
return this._borderTiles;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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<TileRef> 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<TileRef>;
|
||||||
|
[Symbol.iterator](): IterableIterator<TileRef>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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<TileRef> 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<TileRef>) {
|
||||||
|
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<TileRef> {
|
||||||
|
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<TileRef> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -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<Game, TileTraversalScratch>();
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
@@ -18,7 +18,10 @@ export interface StepperConfig<T> {
|
|||||||
* Generic over any PathFinder<T> implementation.
|
* Generic over any PathFinder<T> implementation.
|
||||||
*/
|
*/
|
||||||
export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
||||||
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 pathIndex = 0;
|
||||||
private lastTo: T | null = null;
|
private lastTo: T | null = null;
|
||||||
|
|
||||||
@@ -58,24 +61,29 @@ export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
|||||||
|
|
||||||
// Compute path if not cached
|
// Compute path if not cached
|
||||||
if (this.path === null) {
|
if (this.path === null) {
|
||||||
|
let path: T[] | null;
|
||||||
try {
|
try {
|
||||||
this.path = this.finder.findPath(from, to);
|
path = this.finder.findPath(from, to);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("PathFinder threw an error during findPath", err);
|
console.error("PathFinder threw an error during findPath", err);
|
||||||
return { status: PathStatus.NOT_FOUND };
|
return { status: PathStatus.NOT_FOUND };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (this.path === null) {
|
if (path === null) {
|
||||||
return { status: PathStatus.NOT_FOUND };
|
return { status: PathStatus.NOT_FOUND };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.path =
|
||||||
|
path.length > 0 && typeof path[0] === "number"
|
||||||
|
? new Uint32Array(path as number[])
|
||||||
|
: path;
|
||||||
this.pathIndex = 0;
|
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;
|
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)) {
|
if (this.pathIndex > 0 && !this.config.equals(from, expectedPos)) {
|
||||||
this.invalidate();
|
this.invalidate();
|
||||||
this.lastTo = to;
|
this.lastTo = to;
|
||||||
@@ -88,7 +96,7 @@ export class PathFinderStepper<T> implements SteppingPathFinder<T> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Return next step
|
// Return next step
|
||||||
const nextNode = this.path[this.pathIndex];
|
const nextNode = this.path[this.pathIndex] as T;
|
||||||
this.pathIndex++;
|
this.pathIndex++;
|
||||||
|
|
||||||
return { status: PathStatus.NEXT, node: nextNode };
|
return { status: PathStatus.NEXT, node: nextNode };
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ export class ConnectedComponents {
|
|||||||
private readonly height: number;
|
private readonly height: number;
|
||||||
private readonly numTiles: number;
|
private readonly numTiles: number;
|
||||||
private readonly lastRowStart: 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 componentIds: Uint8Array | Uint16Array | null = null;
|
||||||
private _componentSizes: number[] = [];
|
private _componentSizes: number[] = [];
|
||||||
|
|
||||||
@@ -27,11 +29,11 @@ export class ConnectedComponents {
|
|||||||
this.height = map.height();
|
this.height = map.height();
|
||||||
this.numTiles = this.width * this.height;
|
this.numTiles = this.width * this.height;
|
||||||
this.lastRowStart = (this.height - 1) * this.width;
|
this.lastRowStart = (this.height - 1) * this.width;
|
||||||
this.queue = new Int32Array(this.numTiles);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
initialize(): void {
|
initialize(): void {
|
||||||
DebugSpan.start("ConnectedComponents:initialize");
|
DebugSpan.start("ConnectedComponents:initialize");
|
||||||
|
this.queue = new Int32Array(this.numTiles);
|
||||||
let ids: Uint8Array | Uint16Array = this.createPrefilledIds();
|
let ids: Uint8Array | Uint16Array = this.createPrefilledIds();
|
||||||
|
|
||||||
this._componentSizes = [];
|
this._componentSizes = [];
|
||||||
@@ -64,6 +66,7 @@ export class ConnectedComponents {
|
|||||||
}
|
}
|
||||||
|
|
||||||
this.componentIds = ids;
|
this.componentIds = ids;
|
||||||
|
this.queue = null;
|
||||||
DebugSpan.end();
|
DebugSpan.end();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -148,12 +151,13 @@ export class ConnectedComponents {
|
|||||||
start: number,
|
start: number,
|
||||||
componentId: number,
|
componentId: number,
|
||||||
): void {
|
): void {
|
||||||
|
const queue = this.queue!;
|
||||||
let head = 0;
|
let head = 0;
|
||||||
let tail = 0;
|
let tail = 0;
|
||||||
this.queue[tail++] = start;
|
queue[tail++] = start;
|
||||||
|
|
||||||
while (head < tail) {
|
while (head < tail) {
|
||||||
const seed = this.queue[head++]!;
|
const seed = queue[head++]!;
|
||||||
|
|
||||||
// Skip if already processed
|
// Skip if already processed
|
||||||
if (ids[seed] !== 0) continue;
|
if (ids[seed] !== 0) continue;
|
||||||
@@ -184,7 +188,7 @@ export class ConnectedComponents {
|
|||||||
if (x >= this.width) {
|
if (x >= this.width) {
|
||||||
const above = x - this.width;
|
const above = x - this.width;
|
||||||
if (ids[above] === 0) {
|
if (ids[above] === 0) {
|
||||||
this.queue[tail++] = above;
|
queue[tail++] = above;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,7 +196,7 @@ export class ConnectedComponents {
|
|||||||
if (x < this.lastRowStart) {
|
if (x < this.lastRowStart) {
|
||||||
const below = x + this.width;
|
const below = x + this.width;
|
||||||
if (ids[below] === 0) {
|
if (ids[below] === 0) {
|
||||||
this.queue[tail++] = below;
|
queue[tail++] = below;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import { Game, Player, TerraNullius } from "../../game/Game";
|
import { Game, Player, TerraNullius } from "../../game/Game";
|
||||||
import { TileRef } from "../../game/GameMap";
|
import { TileRef } from "../../game/GameMap";
|
||||||
|
import {
|
||||||
|
bumpTraversalGeneration,
|
||||||
|
tileTraversalScratch,
|
||||||
|
} from "../../game/TileTraversalScratch";
|
||||||
import { DebugSpan } from "../../utilities/DebugSpan";
|
import { DebugSpan } from "../../utilities/DebugSpan";
|
||||||
import { PathFinding } from "../PathFinder";
|
import { PathFinding } from "../PathFinder";
|
||||||
import { AStarWaterBounded } from "../algorithms/AStar.WaterBounded";
|
import { AStarWaterBounded } from "../algorithms/AStar.WaterBounded";
|
||||||
@@ -8,16 +12,6 @@ type Owner = Player | TerraNullius;
|
|||||||
|
|
||||||
const REFINE_MAX_SEARCH_AREA = 100 * 100;
|
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<Game, BfsScratch>();
|
|
||||||
|
|
||||||
export class SpatialQuery {
|
export class SpatialQuery {
|
||||||
private boundedAStar: AStarWaterBounded | null = null;
|
private boundedAStar: AStarWaterBounded | null = null;
|
||||||
|
|
||||||
@@ -36,30 +30,14 @@ export class SpatialQuery {
|
|||||||
* Find nearest tile matching predicate using BFS traversal.
|
* Find nearest tile matching predicate using BFS traversal.
|
||||||
* Uses Manhattan distance filter, ignores terrain barriers.
|
* 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(
|
private bfsNearest(
|
||||||
from: TileRef,
|
from: TileRef,
|
||||||
maxDist: number,
|
maxDist: number,
|
||||||
predicate: (t: TileRef) => boolean,
|
predicate: (t: TileRef) => boolean,
|
||||||
): TileRef | null {
|
): TileRef | null {
|
||||||
const map = this.game.map();
|
const map = this.game.map();
|
||||||
const scratch = this.bfsScratch();
|
const scratch = tileTraversalScratch(this.game);
|
||||||
scratch.gen++;
|
const gen = bumpTraversalGeneration(scratch);
|
||||||
if (scratch.gen === 0xffffffff) {
|
|
||||||
scratch.visited.fill(0);
|
|
||||||
scratch.gen = 1;
|
|
||||||
}
|
|
||||||
const gen = scratch.gen;
|
|
||||||
const visited = scratch.visited;
|
const visited = scratch.visited;
|
||||||
const stack = scratch.stack;
|
const stack = scratch.stack;
|
||||||
stack.length = 0;
|
stack.length = 0;
|
||||||
|
|||||||
@@ -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<number>();
|
||||||
|
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));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -24,8 +24,16 @@
|
|||||||
* [--seed perf-default] [--top 30] [--window 1000]
|
* [--seed perf-default] [--top 30] [--window 1000]
|
||||||
* [--no-cpu-profile] [--no-exec-profile]
|
* [--no-cpu-profile] [--no-exec-profile]
|
||||||
* [--no-gc-profile] [--no-alloc-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 fs from "fs";
|
||||||
|
import v8 from "node:v8";
|
||||||
import path from "path";
|
import path from "path";
|
||||||
import { fileURLToPath } from "url";
|
import { fileURLToPath } from "url";
|
||||||
import { Config } from "../../../src/core/configuration/Config";
|
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 { simpleHash } from "../../../src/core/Util";
|
||||||
import {
|
import {
|
||||||
AllocationSampler,
|
AllocationSampler,
|
||||||
|
FootprintCheckpoint,
|
||||||
GcTracker,
|
GcTracker,
|
||||||
HeapSampler,
|
HeapSampler,
|
||||||
HeapWindow,
|
HeapWindow,
|
||||||
summarizeAllocationProfile,
|
summarizeAllocationProfile,
|
||||||
summarizeGcEvents,
|
summarizeGcEvents,
|
||||||
|
takeFootprintCheckpoint,
|
||||||
} from "./GcProfiler";
|
} from "./GcProfiler";
|
||||||
import { NodeGameMapLoader } from "./NodeGameMapLoader";
|
import { NodeGameMapLoader } from "./NodeGameMapLoader";
|
||||||
import {
|
import {
|
||||||
@@ -81,6 +91,9 @@ interface Options {
|
|||||||
execProfile: boolean;
|
execProfile: boolean;
|
||||||
gcProfile: boolean;
|
gcProfile: boolean;
|
||||||
allocProfile: boolean;
|
allocProfile: boolean;
|
||||||
|
footprint: boolean;
|
||||||
|
snapshotAt: number[];
|
||||||
|
waterNukes: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveMap(name: string): GameMapType {
|
function resolveMap(name: string): GameMapType {
|
||||||
@@ -109,6 +122,9 @@ function parseArgs(argv: string[]): Options {
|
|||||||
execProfile: true,
|
execProfile: true,
|
||||||
gcProfile: true,
|
gcProfile: true,
|
||||||
allocProfile: true,
|
allocProfile: true,
|
||||||
|
footprint: false,
|
||||||
|
snapshotAt: [],
|
||||||
|
waterNukes: false,
|
||||||
};
|
};
|
||||||
for (let i = 0; i < argv.length; i++) {
|
for (let i = 0; i < argv.length; i++) {
|
||||||
const arg = argv[i];
|
const arg = argv[i];
|
||||||
@@ -154,6 +170,17 @@ function parseArgs(argv: string[]): Options {
|
|||||||
case "--no-alloc-profile":
|
case "--no-alloc-profile":
|
||||||
opts.allocProfile = false;
|
opts.allocProfile = false;
|
||||||
break;
|
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:
|
default:
|
||||||
throw new Error(`unknown argument: ${arg}`);
|
throw new Error(`unknown argument: ${arg}`);
|
||||||
}
|
}
|
||||||
@@ -203,6 +230,7 @@ async function main(): Promise<void> {
|
|||||||
infiniteTroops: false,
|
infiniteTroops: false,
|
||||||
instantBuild: false,
|
instantBuild: false,
|
||||||
randomSpawn: false,
|
randomSpawn: false,
|
||||||
|
waterNukes: opts.waterNukes ? true : undefined,
|
||||||
};
|
};
|
||||||
const gameStart: GameStartInfo = {
|
const gameStart: GameStartInfo = {
|
||||||
gameID: opts.seed,
|
gameID: opts.seed,
|
||||||
@@ -272,6 +300,28 @@ async function main(): Promise<void> {
|
|||||||
gcTracker?.start();
|
gcTracker?.start();
|
||||||
const heapSampler = opts.gcProfile ? new HeapSampler() : null;
|
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;
|
let turnNumber = 0;
|
||||||
const runTick = (stats: TickStats): boolean => {
|
const runTick = (stats: TickStats): boolean => {
|
||||||
runner.addTurn({ turnNumber: turnNumber++, intents: [] });
|
runner.addTurn({ turnNumber: turnNumber++, intents: [] });
|
||||||
@@ -302,6 +352,10 @@ async function main(): Promise<void> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
heapSampler?.closeWindow("spawn");
|
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.
|
// Main game phase, under the CPU profiler and allocation sampler.
|
||||||
const cpuProfiler = opts.cpuProfile ? new CpuProfiler() : null;
|
const cpuProfiler = opts.cpuProfile ? new CpuProfiler() : null;
|
||||||
@@ -328,6 +382,10 @@ async function main(): Promise<void> {
|
|||||||
if ((i + 1) % opts.window === 0 || i === opts.ticks - 1) {
|
if ((i + 1) % opts.window === 0 || i === opts.ticks - 1) {
|
||||||
heapSampler?.closeWindow(`${windowStartTick}-${game.ticks() - 1}`);
|
heapSampler?.closeWindow(`${windowStartTick}-${game.ticks() - 1}`);
|
||||||
windowStartTick = game.ticks();
|
windowStartTick = game.ticks();
|
||||||
|
recordFootprint(`tick ${game.ticks() - 1}`);
|
||||||
|
}
|
||||||
|
if (opts.snapshotAt.includes(i + 1)) {
|
||||||
|
writeSnapshot(`tick${i + 1}`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const gamePhaseMs = performance.now() - gameStart_;
|
const gamePhaseMs = performance.now() - gameStart_;
|
||||||
@@ -372,6 +430,23 @@ async function main(): Promise<void> {
|
|||||||
summary.slowest.map((s) => `#${s.tick} (${fmtMs(s.ms)}ms)`).join(", "),
|
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) {
|
if (opts.execProfile) {
|
||||||
console.log(`\n--- Time by Execution class ---`);
|
console.log(`\n--- Time by Execution class ---`);
|
||||||
const rows = execProfiler.report();
|
const rows = execProfiler.report();
|
||||||
|
|||||||
@@ -274,3 +274,41 @@ export function summarizeAllocationProfile(
|
|||||||
sites.sort((a, b) => b.selfBytes - a.selfBytes);
|
sites.sort((a, b) => b.selfBytes - a.selfBytes);
|
||||||
return { sites, totalBytes };
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 <file.heapsnapshot> [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<string, Bucket>();
|
||||||
|
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();
|
||||||
@@ -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 <file.heapsnapshot> [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<void> {
|
||||||
|
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 <file.heapsnapshot> [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<number, Group>();
|
||||||
|
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<number>(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<number, string>();
|
||||||
|
const neededNames = new Set<number>();
|
||||||
|
|
||||||
|
const stream = fs.createReadStream(file, {
|
||||||
|
start: nodesIdx + nodesKey.length,
|
||||||
|
highWaterMark: 8 << 20,
|
||||||
|
});
|
||||||
|
|
||||||
|
for await (const chunk of stream as AsyncIterable<Buffer>) {
|
||||||
|
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<number, { count: number; bytes: number }>();
|
||||||
|
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) ?? `<string #${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);
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user