mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 10:32:04 +00:00
7fa81c6bb9
## 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>
190 lines
6.2 KiB
TypeScript
190 lines
6.2 KiB
TypeScript
/**
|
|
* 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();
|