mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-18 23:42:48 +00:00
## 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>
251 lines
7.7 KiB
TypeScript
251 lines
7.7 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|