perf: cut core-sim GC churn another 36% (61% cumulative) (#4496)

## Summary

Round 2 of GC-churn reduction, attacking the next tier of allocation
sources found by the profiling harness from #4494. All changes are
**behavior-preserving** — the simulation is bit-identical (final hash
unchanged on three seeded runs).

### Changes

| Site | Change | Churn target |
|---|---|---|
| `Player.units()` / `Game.units()` | Rest parameter → fixed-arity +
array overloads (`units()`, `units(types[])`, `units(t1, t2?, t3?)`).
The rest array was allocated on **every call** of one of the hottest
functions in the sim. Spread call sites (`units(...Structures.types)`)
now pass the array directly. `GameImpl.units()` builds one flat array
instead of `Array.from().flatMap()` per-player intermediates. | ~18 GB |
| `PlayerExecution` cluster flood fill | Results are plain `TileRef[]`
in mark order instead of `Set<TileRef>` — the generation-stamped visited
array already deduplicates, and consumers only iterate/measure. DFS
stack reused across fills. | ~3.7 GB |
| `SpatialQuery.bfsNearest` | Fused generation-stamped BFS with per-game
scratch buffers (`WeakMap`-keyed, same pattern as `PlayerExecution`)
instead of materializing a `Set` of the entire search area per query.
Identical traversal and tie-breaking. | ~2.2 GB |
| `NationWarshipBehavior` ship tracking | Single-pass loops instead of
`filter().forEach()`; dropped defensive `Array.from(set)` copies
(deleting the current entry while iterating a `Set` is well-defined). |
~1.4 GB |

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

| Metric | Before | After | vs. pre-#4494 |
|---|---|---|---|
| Sampled allocations (incl. collected) | 59.2 GB | **37.8 GB (−36%)** |
97.7 GB (**−61%**) |
| GC count / total pause | 1,076 / 1,830 ms | 772 / 1,442 ms | 1,682 /
3,313 ms |
| Ticks/sec | 73 | **82** | 66 (+24%) |
| Mean / p99 tick | 13.6 / 39.2 ms | 12.2 / 36.0 ms | 15.2 / 49.9 ms |

`units()` no longer appears in the top-30 allocator list at all. The
remaining leaders (possible round 3): the minimap pathfinding `Cell`
pipeline (~8.5 GB), `diffPlayerUpdate`/`toFullUpdate` per-tick
serialization (~4.6 GB), and iterator allocations (~3.3 GB).

## Determinism

Final game-state 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,905 tests), including updated `units()`
semantics tests (array overload, snapshot isolation, insertion order)
- [x] Hash equality on 3 seeded headless runs (2 maps)
- [x] Before/after 20-min GC benchmarks on the same commit base

🤖 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:02:36 -07:00
committed by GitHub
parent 3c196cb7e7
commit 9e9c608053
10 changed files with 189 additions and 80 deletions
+1 -1
View File
@@ -127,7 +127,7 @@ export function simpleHash(str: string): number {
export function calculateBoundingBox(
gm: GameMap,
borderTiles: ReadonlySet<TileRef>,
borderTiles: Iterable<TileRef>,
): { min: Cell; max: Cell } {
let minX = Infinity,
minY = Infinity,
+23 -16
View File
@@ -13,6 +13,8 @@ import { calculateBoundingBox, getMode, inscribed, simpleHash } from "../Util";
interface ClusterTraversalState {
visited: Uint32Array;
gen: number;
// Reusable DFS stack for flood fills; cleared at the start of each fill.
stack: TileRef[];
}
// Per-game traversal state used by calculateClusters() to avoid per-player buffers.
@@ -125,9 +127,9 @@ export class PlayerExecution implements Execution {
// Find the largest cluster with a single linear scan (O(n)).
let largestIndex = 0;
let largestSize = clusters[0].size;
let largestSize = clusters[0].length;
for (let i = 1; i < clusters.length; i++) {
const size = clusters[i].size;
const size = clusters[i].length;
if (size > largestSize) {
largestSize = size;
largestIndex = i;
@@ -158,7 +160,7 @@ export class PlayerExecution implements Execution {
}
private surroundedBySamePlayer(
cluster: Set<TileRef>,
cluster: readonly TileRef[],
clusterBox: { min: Cell; max: Cell },
): false | Player {
const enemies = new Set<number>();
@@ -211,7 +213,7 @@ export class PlayerExecution implements Execution {
return false;
}
private isSurrounded(cluster: Set<TileRef>): boolean {
private isSurrounded(cluster: readonly TileRef[]): boolean {
let hasEnemy = false;
let minX = Infinity,
minY = Infinity,
@@ -246,7 +248,7 @@ export class PlayerExecution implements Execution {
return inscribed(enemyBox, clusterBox);
}
private removeCluster(cluster: Set<TileRef>) {
private removeCluster(cluster: readonly TileRef[]) {
for (const t of cluster) {
if (this.mg?.ownerID(t) !== this.player?.smallID()) {
// Other removeCluster operations could change tile owners,
@@ -260,8 +262,8 @@ export class PlayerExecution implements Execution {
return;
}
const firstTile = cluster.values().next().value;
if (!firstTile) {
const firstTile = cluster[0];
if (firstTile === undefined) {
return;
}
@@ -273,7 +275,7 @@ export class PlayerExecution implements Execution {
(tile) => this.mg.ownerID(tile) === this.player.smallID(),
);
if (this.player.numTilesOwned() === tiles.size) {
if (this.player.numTilesOwned() === tiles.length) {
this.mg.conquerPlayer(capturing, this.player);
}
@@ -282,7 +284,7 @@ export class PlayerExecution implements Execution {
}
}
private getCapturingPlayer(cluster: Set<TileRef>): Player | null {
private getCapturingPlayer(cluster: readonly TileRef[]): Player | null {
const neighbors = new Map<Player, number>();
const map = this.map;
const mySmallID = this.player.smallID();
@@ -327,7 +329,7 @@ export class PlayerExecution implements Execution {
return getMode(neighbors);
}
private calculateClusters(): Set<TileRef>[] {
private calculateClusters(): TileRef[][] {
const borderTiles = this.player.borderTiles();
if (borderTiles.size === 0) return [];
@@ -335,7 +337,7 @@ export class PlayerExecution implements Execution {
const currentGen = this.bumpGeneration();
const visited = state.visited;
const clusters: Set<TileRef>[] = [];
const clusters: TileRef[][] = [];
for (const startTile of borderTiles) {
if (visited[startTile] === currentGen) continue;
@@ -370,6 +372,7 @@ export class PlayerExecution implements Execution {
state = {
visited: new Uint32Array(totalTiles),
gen: 0,
stack: [],
};
traversalStates.set(this.mg, state);
}
@@ -392,15 +395,19 @@ export class PlayerExecution implements Execution {
startTiles: TileRef[],
neighborFn: (tile: TileRef, callback: (neighbor: TileRef) => void) => void,
includeFn: (tile: TileRef) => boolean,
): Set<TileRef> {
const result = new Set<TileRef>();
const stack: TileRef[] = [];
): TileRef[] {
// The visited generation array already deduplicates, so the result can be
// a plain array (in mark order) — far cheaper than a Set of the same
// size. The DFS stack is reused across fills via the traversal state.
const result: TileRef[] = [];
const stack = this.traversalState().stack;
stack.length = 0;
for (const start of startTiles) {
if (visited[start] === currentGen) continue;
if (!includeFn(start)) continue;
visited[start] = currentGen;
result.add(start);
result.push(start);
stack.push(start);
}
@@ -412,7 +419,7 @@ export class PlayerExecution implements Execution {
return;
}
visited[neighbor] = currentGen;
result.add(neighbor);
result.push(neighbor);
stack.push(neighbor);
};
@@ -99,7 +99,7 @@ export class NationNukeBehavior {
}
const range = this.game.config().nukeMagnitudes(nukeType).outer;
const structures = nukeTarget.units(...Structures.types);
const structures = nukeTarget.units(Structures.types);
const structureTiles = structures.map((u) => u.tile());
const difficulty = this.game.config().gameConfig().difficulty;
// Use more random tiles on Impossible difficulty to improve chances of finding a perfect SAM outranging spot
@@ -284,7 +284,7 @@ export class NationNukeBehavior {
if (this.player.isFriendly(other)) continue;
const tilesOwned = other.numTilesOwned();
if (tilesOwned === 0) continue;
const structures = other.units(...Structures.types);
const structures = other.units(Structures.types);
let levelSum = 0;
for (const s of structures) levelSum += s.level();
// Skip players with too few structures regardless of density
@@ -724,7 +724,7 @@ export class NationStructureBehavior {
private getTotalStructureDensity(): number {
const tilesOwned = this.player.numTilesOwned();
return tilesOwned > 0
? this.player.units(...Structures.types).length / tilesOwned
? this.player.units(Structures.types).length / tilesOwned
: 0; //ignoring levels for structures
}
@@ -104,8 +104,9 @@ export class NationWarshipBehavior {
.units(UnitType.TransportShip)
.forEach((u) => this.trackedTransportShips.add(u));
// Iterate tracked transport ships; if it got destroyed by an enemy: retaliate
for (const ship of Array.from(this.trackedTransportShips)) {
// Iterate tracked transport ships; if it got destroyed by an enemy:
// retaliate. Deleting the current entry while iterating a Set is safe.
for (const ship of this.trackedTransportShips) {
if (!ship.isActive()) {
// Distinguish between arrival/retreat and enemy destruction
if (ship.wasDestroyedByEnemy() && ship.destroyer() !== undefined) {
@@ -127,8 +128,9 @@ export class NationWarshipBehavior {
.units(UnitType.TradeShip)
.forEach((u) => this.trackedTradeShips.add(u));
// Iterate tracked trade ships; if we no longer own it, it was captured: retaliate
for (const ship of Array.from(this.trackedTradeShips)) {
// Iterate tracked trade ships; if we no longer own it, it was captured:
// retaliate. Deleting the current entry while iterating a Set is safe.
for (const ship of this.trackedTradeShips) {
if (!ship.isActive()) {
this.trackedTradeShips.delete(ship);
continue;
@@ -143,21 +145,21 @@ export class NationWarshipBehavior {
private trackIncomingTransportsAndRetaliate(): void {
// Add any transports which are targeting us to our tracking map
this.game
.units(UnitType.TransportShip)
.filter((p) => {
const target = p.targetTile();
return (
target &&
p.isActive() &&
!p.transportShipState().isRetreating &&
this.game.ownerID(target) === this.player?.smallID() &&
p.owner().smallID() !== this.player?.smallID()
);
})
.forEach((p) => this.trackedIncomingTransportShips.add(p));
for (const p of this.game.units(UnitType.TransportShip)) {
const target = p.targetTile();
if (
target &&
p.isActive() &&
!p.transportShipState().isRetreating &&
this.game.ownerID(target) === this.player?.smallID() &&
p.owner().smallID() !== this.player?.smallID()
) {
this.trackedIncomingTransportShips.add(p);
}
}
for (const transport of Array.from(this.trackedIncomingTransportShips)) {
// Deleting the current entry while iterating a Set is safe.
for (const transport of this.trackedIncomingTransportShips) {
const target = transport.targetTile();
if (
!transport.isActive() ||
+11 -2
View File
@@ -584,7 +584,13 @@ export interface Player {
removeTroops(troops: number): number;
// Units
units(...types: UnitType[]): Unit[];
// Fixed-arity + array overloads instead of a rest parameter: the rest array
// would be allocated on every call, and this is one of the hottest calls in
// the simulation. With no arguments the player's live unit array is
// returned — do not mutate it; typed queries return a fresh snapshot array.
units(): Unit[];
units(types: readonly UnitType[]): Unit[];
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
unitCount(type: UnitType): number;
unitsConstructed(type: UnitType): number;
unitsOwned(type: UnitType): number;
@@ -758,7 +764,10 @@ export interface Game extends GameMap {
// Units
unit(id: number): Unit | undefined;
units(...types: UnitType[]): Unit[];
// See Player.units() for why this is not a rest parameter.
units(): Unit[];
units(types: readonly UnitType[]): Unit[];
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
unitCount(type: UnitType): number;
unitInfo(type: UnitType): UnitInfo;
hasUnitNearby(
+40 -2
View File
@@ -293,8 +293,46 @@ export class GameImpl implements Game {
return this._unitMap.get(id);
}
units(...types: UnitType[]): Unit[] {
return Array.from(this._players.values()).flatMap((p) => p.units(...types));
units(): Unit[];
units(types: readonly UnitType[]): Unit[];
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
units(
first?: UnitType | readonly UnitType[],
second?: UnitType,
third?: UnitType,
): Unit[] {
// Built as a single flat array per call; per-player intermediate arrays
// would churn the heap (player.units() with no args is allocation-free).
const out: Unit[] = [];
if (Array.isArray(first) && (first as readonly UnitType[]).length > 0) {
const ts = new Set(first as readonly UnitType[]);
for (const p of this._players.values()) {
for (const u of p.units()) {
if (ts.has(u.type())) out.push(u);
}
}
return out;
}
if (first === undefined || Array.isArray(first)) {
for (const p of this._players.values()) {
for (const u of p.units()) {
out.push(u);
}
}
return out;
}
for (const p of this._players.values()) {
for (const u of p.units()) {
const t = u.type();
if (
t === first ||
(second !== undefined && (t === second || t === third))
) {
out.push(u);
}
}
}
return out;
}
unitCount(type: UnitType): number {
+34 -26
View File
@@ -88,6 +88,7 @@ const EMPTY_EMBARGOES = new Set<string>();
// are fully consumed before any re-entrant call, so sharing is safe.
const NEIGHBOR_SCRATCH: TileRef[] = [0, 0, 0, 0];
const UNITS_SCRATCH: Unit[] = [];
const TYPE_SET_SCRATCH = new Set<UnitType>();
// N, S, W, E — the sampling directions used by shoreReachableNeighbors().
const SHORE_DIRECTIONS_DX = [0, 0, -1, 1];
const SHORE_DIRECTIONS_DY = [-1, 1, 0, 0];
@@ -362,45 +363,52 @@ export class PlayerImpl implements Player {
return this.playerInfo.playerType;
}
units(...types: UnitType[]): Unit[] {
const len = types.length;
if (len === 0) {
units(): Unit[];
units(types: readonly UnitType[]): Unit[];
units(type: UnitType, type2?: UnitType, type3?: UnitType): Unit[];
units(
first?: UnitType | readonly UnitType[],
second?: UnitType,
third?: UnitType,
): Unit[] {
if (first === undefined) {
return this._units;
}
// Hot path. Matches are gathered into a reusable scratch buffer and
// copied out with an exact-size slice, so each call allocates exactly
// one right-sized result array.
// one right-sized result array. Fixed-arity parameters (rather than a
// rest parameter) avoid allocating an argument array per call.
const scratch = UNITS_SCRATCH;
let n = 0;
// Fast paths for common small arity calls to avoid Set allocation.
if (len === 1) {
const t0 = types[0]!;
for (const u of this._units) {
if (u.type() === t0) scratch[n++] = u;
if (Array.isArray(first)) {
const types = first as readonly UnitType[];
if (types.length === 0) {
return this._units;
}
} else if (len === 2) {
const t0 = types[0]!;
const t1 = types[1]!;
for (const u of this._units) {
const t = u.type();
if (t === t0 || t === t1) scratch[n++] = u;
const ts = TYPE_SET_SCRATCH;
ts.clear();
for (const t of types) {
ts.add(t);
}
} else if (len === 3) {
const t0 = types[0]!;
const t1 = types[1]!;
const t2 = types[2]!;
// Keep semantics identical for duplicates in types by using direct comparisons.
for (const u of this._units) {
const t = u.type();
if (t === t0 || t === t1 || t === t2) scratch[n++] = u;
}
} else {
const ts = new Set(types);
for (const u of this._units) {
if (ts.has(u.type())) scratch[n++] = u;
}
} else if (second === undefined) {
for (const u of this._units) {
if (u.type() === first) scratch[n++] = u;
}
} else if (third === undefined) {
for (const u of this._units) {
const t = u.type();
if (t === first || t === second) scratch[n++] = u;
}
} else {
for (const u of this._units) {
const t = u.type();
if (t === first || t === second || t === third) scratch[n++] = u;
}
}
return scratch.slice(0, n);
}
+54 -9
View File
@@ -8,6 +8,16 @@ type Owner = Player | TerraNullius;
const REFINE_MAX_SEARCH_AREA = 100 * 100;
// Per-game BFS scratch (generation-stamped visited array + reusable stack) so
// bfsNearest allocates nothing per query. Keyed by game because SpatialQuery
// instances are created per call site.
interface BfsScratch {
visited: Uint32Array;
gen: number;
stack: TileRef[];
}
const bfsScratches = new WeakMap<Game, BfsScratch>();
export class SpatialQuery {
private boundedAStar: AStarWaterBounded | null = null;
@@ -26,28 +36,63 @@ export class SpatialQuery {
* Find nearest tile matching predicate using BFS traversal.
* Uses Manhattan distance filter, ignores terrain barriers.
*/
private bfsScratch(): BfsScratch {
const map = this.game.map();
const totalTiles = map.width() * map.height();
let s = bfsScratches.get(this.game);
if (!s || s.visited.length < totalTiles) {
s = { visited: new Uint32Array(totalTiles), gen: 0, stack: [] };
bfsScratches.set(this.game, s);
}
return s;
}
private bfsNearest(
from: TileRef,
maxDist: number,
predicate: (t: TileRef) => boolean,
): TileRef | null {
const map = this.game.map();
const scratch = this.bfsScratch();
scratch.gen++;
if (scratch.gen === 0xffffffff) {
scratch.visited.fill(0);
scratch.gen = 1;
}
const gen = scratch.gen;
const visited = scratch.visited;
const stack = scratch.stack;
stack.length = 0;
// Strict < keeps the first candidate on distance ties, so the winner
// depends only on the deterministic BFS visit order.
// Strict < keeps the first candidate at the minimum distance, so the
// winner depends only on the deterministic traversal order (LIFO with
// neighbors visited in the shared N, S, W, E order).
let best: TileRef | null = null;
let bestDist = Infinity;
for (const tile of map.bfs(
from,
(_, t) => map.manhattanDist(from, t) <= maxDist,
)) {
if (predicate(tile)) {
const dist = map.manhattanDist(from, tile);
const mark = (t: TileRef) => {
visited[t] = gen;
stack.push(t);
if (predicate(t)) {
const dist = map.manhattanDist(from, t);
if (dist < bestDist) {
best = tile;
best = t;
bestDist = dist;
}
}
};
if (maxDist >= 0) {
mark(from);
}
const visit = (n: TileRef) => {
if (visited[n] !== gen && map.manhattanDist(from, n) <= maxDist) {
mark(n);
}
};
while (stack.length > 0) {
const curr = stack.pop()!;
map.forEachNeighbor(curr, visit);
}
return best;
+3 -3
View File
@@ -120,14 +120,14 @@ describe("PlayerImpl", () => {
);
});
test("four or more types (Set path) and no match", () => {
test("array of types (Set path) and no match", () => {
expect(
player.units(
player.units([
UnitType.City,
UnitType.DefensePost,
UnitType.MissileSilo,
UnitType.Port,
),
]),
).toEqual(
expected(UnitType.City, UnitType.DefensePost, UnitType.MissileSilo),
);