perf: cut core-sim GC churn another 36% (75% cumulative) (#4498)

## Summary

Round 3 of GC-churn reduction (follow-up to #4494 and #4496). All
changes are **behavior-preserving** — final game-state hash unchanged on
three seeded runs.

### Changes

| Site | Change | Churn target |
|---|---|---|
| `MiniMapTransformer` | Path upscaling works in pure numeric
coordinates and emits main-map `TileRef`s directly, replacing three
intermediate `Cell`-object arrays per path (cell path → scaled path →
smoothed path → final map). Identical arithmetic, so identical rounding
and identical tiles. | ~7.1 GB |
| `ShoreCoercingTransformer` | Reused neighbor buffers instead of
`neighbors()` arrays; no per-call `{water, original}` objects.
Tie-breaking preserved (helpers share the unified N,S,W,E order since
#4495). | ~1.5 GB |
| `diffPlayerUpdate` | Allocation-free all-equal fast path. Runs per
player per tick and usually returns `null` (gold/troops/tiles travel via
packed arrays), but previously allocated the diff object + a closure
first. Field list matches the diff exactly. | ~2.6 GB |
| Large-`Set` iteration | `for..of` over a `Set` allocates an
iterator-result object per element — significant on 100k-tile border
sets. `calculateClusters`, `calculateBoundingBox` (indexed fast path for
arrays too) and `getAttackFrontTiles` (also dropped its `neighbors()`
arrays) now use `Set.forEach`. | ~3.6 GB |

### Results (Giant World Map, 400 bots, 12,000 ticks, seed
`perf-default`)

| Metric | Before | After | vs. original (pre-#4494) |
|---|---|---|---|
| Sampled allocations (incl. collected) | 37.8 GB | **24.1 GB (−36%)** |
97.7 GB (**−75%**) |
| Ticks/sec | 82 | **88** | 66 (+33%) |
| Mean / p99 tick | 12.2 / 36.0 ms | 11.3 / 34.8 ms | 15.2 / 49.9 ms |
| Peak heap | 762 MB | 529 MB | 758 MB |

## Determinism

Final hash unchanged on all three reference runs:
- Giant World Map 12,000 ticks: `57830793797434300` ✓
- Giant World Map 2,000 ticks: `55125379638382860` ✓
- World 1,800 ticks: `32337437717390864` ✓

## Test plan

- [x] Full suite green (1,906 tests; the `getAttackFrontTiles` test stub
gained a `neighbors4` implementation to match the real interface)
- [x] Hash equality on 3 seeded headless runs (2 maps)
- [x] Before/after 20-min GC benchmarks

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Evan
2026-07-03 13:44:21 -07:00
committed by GitHub
parent 9e9c608053
commit 20c81ca5f6
7 changed files with 173 additions and 96 deletions
+14 -1
View File
@@ -134,13 +134,26 @@ export function calculateBoundingBox(
maxX = -Infinity,
maxY = -Infinity;
for (const tile of borderTiles) {
const visit = (tile: TileRef) => {
const x = gm.x(tile);
const y = gm.y(tile);
minX = Math.min(minX, x);
minY = Math.min(minY, y);
maxX = Math.max(maxX, x);
maxY = Math.max(maxY, y);
};
// Indexed/forEach paths: for..of over a large Set (player border sets)
// allocates an iterator-result object per element.
if (Array.isArray(borderTiles)) {
for (let i = 0; i < borderTiles.length; i++) {
visit(borderTiles[i]);
}
} else if (borderTiles instanceof Set) {
borderTiles.forEach(visit);
} else {
for (const tile of borderTiles) {
visit(tile);
}
}
return { min: new Cell(minX, minY), max: new Cell(maxX, maxY) };
+10 -5
View File
@@ -339,18 +339,23 @@ export class PlayerExecution implements Execution {
const clusters: TileRef[][] = [];
for (const startTile of borderTiles) {
if (visited[startTile] === currentGen) continue;
// Set.forEach instead of for..of: iterating a large Set allocates an
// iterator-result object per element, and border sets can be huge.
const neighborFn = (tile: TileRef, cb: (neighbor: TileRef) => void) =>
this.mg.forEachNeighborWithDiag(tile, cb);
const includeFn = (tile: TileRef) => borderTiles.has(tile);
borderTiles.forEach((startTile) => {
if (visited[startTile] === currentGen) return;
const cluster = this.floodFillWithGen(
currentGen,
visited,
[startTile],
(tile, cb) => this.mg.forEachNeighborWithDiag(tile, cb),
(tile) => borderTiles.has(tile),
neighborFn,
includeFn,
);
clusters.push(cluster);
}
});
return clusters;
}
@@ -129,6 +129,9 @@ const UNDER_ATTACK_THREAT_RATIO = 0.35;
*/
const DEFENSE_POST_RATIO_PER_POST = 0.4;
// Reusable neighbor buffer for hot loops; the simulation is single-threaded.
const NEIGHBOR_SCRATCH: TileRef[] = [0, 0, 0, 0];
export class NationStructureBehavior {
private reachableStationsCache: Array<{
tile: TileRef;
@@ -256,16 +259,21 @@ export class NationStructureBehavior {
const attackerSet = new Set(landAttacks.map((a) => a.attacker()));
if (attackerSet.size === 0) return [];
// Set.forEach + a reused neighbor buffer: border sets are huge, and
// for..of over a Set allocates an iterator-result object per element.
// "Any neighbor is an attacker" is order-insensitive.
const frontTiles: TileRef[] = [];
outer: for (const borderTile of player.borderTiles()) {
for (const neighbor of game.neighbors(borderTile)) {
const owner = game.owner(neighbor);
const nbuf = NEIGHBOR_SCRATCH;
player.borderTiles().forEach((borderTile) => {
const n = game.neighbors4(borderTile, nbuf);
for (let i = 0; i < n; i++) {
const owner = game.owner(nbuf[i]);
if (attackerSet.has(owner as Player)) {
frontTiles.push(borderTile);
continue outer;
return;
}
}
}
});
return frontTiles;
}
+37
View File
@@ -31,6 +31,43 @@ export function diffPlayerUpdate(
prev: PlayerUpdate,
next: PlayerUpdate,
): PlayerUpdate | null {
// Fast path: this runs for every player every tick and usually finds no
// changes (tilesOwned/gold/troops travel separately) — return without
// allocating anything. The comparisons repeat below only for the rare
// changed player.
if (
prev.clientID === next.clientID &&
prev.name === next.name &&
prev.displayName === next.displayName &&
prev.team === next.team &&
prev.smallID === next.smallID &&
prev.playerType === next.playerType &&
prev.isAlive === next.isAlive &&
prev.isDisconnected === next.isDisconnected &&
prev.isTraitor === next.isTraitor &&
prev.traitorRemainingTicks === next.traitorRemainingTicks &&
prev.inDoomsdayClock === next.inDoomsdayClock &&
prev.markedDoomsdayClockTick === next.markedDoomsdayClockTick &&
prev.hasSpawned === next.hasSpawned &&
prev.spawnTile === next.spawnTile &&
prev.betrayals === next.betrayals &&
prev.lastDeleteUnitTick === next.lastDeleteUnitTick &&
prev.isLobbyCreator === next.isLobbyCreator &&
numberArrayEqual(prev.allies, next.allies) &&
numberArrayEqual(prev.targets, next.targets) &&
stringArrayEqual(
prev.outgoingAllianceRequests,
next.outgoingAllianceRequests,
) &&
stringSetEqual(prev.embargoes, next.embargoes) &&
emojiArrayEqual(prev.outgoingEmojis, next.outgoingEmojis) &&
attackArrayMembershipEqual(prev.outgoingAttacks, next.outgoingAttacks) &&
attackArrayMembershipEqual(prev.incomingAttacks, next.incomingAttacks) &&
allianceArrayEqual(prev.alliances, next.alliances)
) {
return null;
}
const diff: PlayerUpdate = { type: GameUpdateType.Player, id: next.id };
let changed = false;
@@ -1,4 +1,3 @@
import { Cell } from "../../game/Game";
import { GameMap, TileRef } from "../../game/GameMap";
import { PathFinder } from "../types";
@@ -32,97 +31,94 @@ export class MiniMapTransformer implements PathFinder<number> {
return null;
}
// Convert minimap TileRefs → Cells
const cellPath = path.map(
(ref) => new Cell(this.miniMap.x(ref), this.miniMap.y(ref)),
);
// Upscale minimap path to main-map refs. All coordinate work stays
// numeric (paths can be thousands of points, so per-point wrapper
// objects are significant churn).
const upscaledPath = this.upscalePath(path);
// For multi-source, find closest source to path start
const upscaledPath = this.upscalePath(cellPath);
let cellFrom: Cell | undefined;
let srcRef: TileRef | undefined;
if (Array.isArray(from)) {
if (upscaledPath.length > 0) {
const pathStart = upscaledPath[0];
const startX = this.map.x(upscaledPath[0]);
const startY = this.map.y(upscaledPath[0]);
let minDist = Infinity;
for (const f of from) {
const fx = this.map.x(f);
const fy = this.map.y(f);
const dist = Math.abs(fx - pathStart.x) + Math.abs(fy - pathStart.y);
const dist =
Math.abs(this.map.x(f) - startX) + Math.abs(this.map.y(f) - startY);
if (dist < minDist) {
minDist = dist;
cellFrom = new Cell(fx, fy);
srcRef = f;
}
}
}
} else {
cellFrom = new Cell(this.map.x(from), this.map.y(from));
srcRef = from;
}
const cellTo = new Cell(this.map.x(to), this.map.y(to));
const upscaled = this.fixExtremes(upscaledPath, cellTo, cellFrom);
return upscaled.map((c) => this.map.ref(c.x, c.y));
return this.fixExtremes(upscaledPath, to, srcRef);
}
private upscalePath(path: Cell[], scaleFactor: number = 2): Cell[] {
const scaledPath = path.map(
(point) => new Cell(point.x * scaleFactor, point.y * scaleFactor),
);
/**
* Scale a minimap path up to main-map refs, inserting interpolated points
* so consecutive path tiles stay adjacent.
*/
private upscalePath(path: TileRef[], scaleFactor: number = 2): TileRef[] {
const mini = this.miniMap;
const main = this.map;
const smoothPath: TileRef[] = [];
const smoothPath: Cell[] = [];
for (let i = 0; i < path.length - 1; i++) {
const curX = mini.x(path[i]) * scaleFactor;
const curY = mini.y(path[i]) * scaleFactor;
const nextX = mini.x(path[i + 1]) * scaleFactor;
const nextY = mini.y(path[i + 1]) * scaleFactor;
for (let i = 0; i < scaledPath.length - 1; i++) {
const current = scaledPath[i];
const next = scaledPath[i + 1];
smoothPath.push(main.ref(curX, curY));
smoothPath.push(current);
const dx = next.x - current.x;
const dy = next.y - current.y;
const distance = Math.max(Math.abs(dx), Math.abs(dy));
const steps = distance;
const dx = nextX - curX;
const dy = nextY - curY;
const steps = Math.max(Math.abs(dx), Math.abs(dy));
for (let step = 1; step < steps; step++) {
smoothPath.push(
new Cell(
Math.round(current.x + (dx * step) / steps),
Math.round(current.y + (dy * step) / steps),
main.ref(
Math.round(curX + (dx * step) / steps),
Math.round(curY + (dy * step) / steps),
),
);
}
}
if (scaledPath.length > 0) {
smoothPath.push(scaledPath[scaledPath.length - 1]);
if (path.length > 0) {
const last = path[path.length - 1];
smoothPath.push(
main.ref(mini.x(last) * scaleFactor, mini.y(last) * scaleFactor),
);
}
return smoothPath;
}
private fixExtremes(upscaled: Cell[], cellDst: Cell, cellSrc?: Cell): Cell[] {
if (cellSrc !== undefined) {
const srcIndex = this.findCell(upscaled, cellSrc);
private fixExtremes(
upscaled: TileRef[],
dst: TileRef,
src?: TileRef,
): TileRef[] {
if (src !== undefined) {
const srcIndex = upscaled.indexOf(src);
if (srcIndex === -1) {
upscaled.unshift(cellSrc);
upscaled.unshift(src);
} else if (srcIndex !== 0) {
upscaled = upscaled.slice(srcIndex);
}
}
const dstIndex = this.findCell(upscaled, cellDst);
const dstIndex = upscaled.indexOf(dst);
if (dstIndex === -1) {
upscaled.push(cellDst);
upscaled.push(dst);
} else if (dstIndex !== upscaled.length - 1) {
upscaled = upscaled.slice(0, dstIndex + 1);
}
return upscaled;
}
private findCell(cells: Cell[], target: Cell): number {
for (let i = 0; i < cells.length; i++) {
if (cells[i].x === target.x && cells[i].y === target.y) {
return i;
}
}
return -1;
}
}
@@ -6,6 +6,11 @@ import { PathFinder } from "../types";
* Coerces shore tiles to nearby water tiles before pathfinding,
* then fixes the path extremes to include the original shore tiles.
*/
// Reusable neighbor buffers; the simulation is single-threaded and both are
// fully consumed before any re-entrant call.
const NEIGHBOR_SCRATCH: TileRef[] = [0, 0, 0, 0];
const NEIGHBOR_SCRATCH_INNER: TileRef[] = [0, 0, 0, 0];
export class ShoreCoercingTransformer implements PathFinder<number> {
constructor(
private inner: PathFinder<number>,
@@ -14,14 +19,22 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
findPath(from: TileRef | TileRef[], to: TileRef): TileRef[] | null {
const fromArray = Array.isArray(from) ? from : [from];
const waterToOriginal = new Map<TileRef, TileRef | null>();
const waterToOriginal = new Map<TileRef, TileRef>();
const waterFrom: TileRef[] = [];
for (const f of fromArray) {
const coerced = this.coerceToWater(f);
if (coerced.water !== null) {
waterFrom.push(coerced.water);
waterToOriginal.set(coerced.water, coerced.original);
if (this.map.isWater(f)) {
waterFrom.push(f);
// A raw water source needs no shore restoration — and overrides any
// earlier shore tile that coerced to this same water tile (last
// write wins, matching processing order).
waterToOriginal.delete(f);
} else {
const water = this.bestWaterNeighbor(f);
if (water !== -1) {
waterFrom.push(water);
waterToOriginal.set(water, f);
}
}
}
@@ -29,51 +42,50 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
return null;
}
const coercedTo = this.coerceToWater(to);
if (coercedTo.water === null) {
return null;
// Coerce the destination: shore tiles path to their best water neighbor,
// with the original appended back afterwards.
let waterTo = to;
let originalTo: TileRef = -1;
if (!this.map.isWater(to)) {
waterTo = this.bestWaterNeighbor(to);
if (waterTo === -1) {
return null;
}
originalTo = to;
}
const fromTiles = waterFrom.length === 1 ? waterFrom[0] : waterFrom;
const path = this.inner.findPath(fromTiles, coercedTo.water);
const path = this.inner.findPath(fromTiles, waterTo);
if (!path || path.length === 0) {
return null;
}
// Restore original start shore tile
const originalShore = waterToOriginal.get(path[0]);
if (originalShore !== undefined && originalShore !== null) {
if (originalShore !== undefined) {
path.unshift(originalShore);
}
// Append original to if different
if (
coercedTo.original !== null &&
path[path.length - 1] !== coercedTo.original
) {
path.push(coercedTo.original);
if (originalTo !== -1 && path[path.length - 1] !== originalTo) {
path.push(originalTo);
}
return path;
}
/**
* Coerce a tile to water for pathfinding.
* If tile is already water, returns it unchanged.
* If tile is shore, finds the best adjacent water neighbor.
* Best adjacent water neighbor of a shore tile (highest water-neighbor
* connectivity, first wins on ties), or -1 if it has none.
*/
private coerceToWater(tile: TileRef): {
water: TileRef | null;
original: TileRef | null;
} {
if (this.map.isWater(tile)) {
return { water: tile, original: null };
}
let best: TileRef | null = null;
private bestWaterNeighbor(tile: TileRef): TileRef {
let best: TileRef = -1;
let maxScore = -1;
for (const n of this.map.neighbors(tile)) {
const nbuf = NEIGHBOR_SCRATCH;
const numNeighbors = this.map.neighbors4(tile, nbuf);
for (let i = 0; i < numNeighbors; i++) {
const n = nbuf[i];
if (!this.map.isWater(n)) continue;
// Score by water neighbor count (connectivity)
@@ -86,16 +98,15 @@ export class ShoreCoercingTransformer implements PathFinder<number> {
}
}
if (best !== null) {
return { water: best, original: tile };
}
return { water: null, original: tile };
return best;
}
private countWaterNeighbors(tile: TileRef): number {
let count = 0;
for (const n of this.map.neighbors(tile)) {
if (this.map.isWater(n)) count++;
const nbuf = NEIGHBOR_SCRATCH_INNER;
const numNeighbors = this.map.neighbors4(tile, nbuf);
for (let i = 0; i < numNeighbors; i++) {
if (this.map.isWater(nbuf[i])) count++;
}
return count;
}
+7
View File
@@ -703,6 +703,13 @@ describe("NationStructureBehavior.getAttackFrontTiles", () => {
return {
config: () => ({ nukeMagnitudes: () => ({ outer: 50 }) }),
neighbors: neighborsFn,
neighbors4: (tile: number, out: number[]) => {
const ns = neighborsFn(tile);
for (let i = 0; i < ns.length; i++) {
out[i] = ns[i];
}
return ns.length;
},
owner: ownerFn,
};
}