mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 12:52:05 +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>
142 lines
4.2 KiB
TypeScript
142 lines
4.2 KiB
TypeScript
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));
|
|
}
|
|
});
|
|
});
|