mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-05 12:32:03 +00:00
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:
+14
-1
@@ -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) };
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user