mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-23 19:45:22 +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:
+16
-29
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user