Files
OpenFrontIO/tests/perf/fullgame/GcProfiler.ts
T
Evan 5e4b2791aa perf: reduce core-sim GC churn 42% and add GC-churn profiling to the perf harness (#4494)
## Summary

Reduces core-simulation GC churn by **42%** on a 20-game-minute Giant
World Map run, and extends the headless full-game perf harness so churn
is measurable and regressions are visible.

### 1. GC-churn measurement (`tests/perf/fullgame/GcProfiler.ts`)

`npm run perf:game` now reports:

- **GC pauses** by kind (minor/major/incremental) via a
`PerformanceObserver` on `'gc'` entries, bucketed into tick windows by
timestamp (V8 only delivers these entries on a timer task, so they're
flushed after the run)
- **Allocation rate** per `--window N` ticks (default 1000) from
used-heap deltas sampled every tick, so churn can be tracked across game
phases
- **Top allocating functions** from the V8 sampling heap profiler with
`includeObjectsCollectedBy{Major,Minor}GC` — i.e. actual churn including
short-lived garbage, not live memory — plus a `.heapprofile` loadable in
Chrome DevTools (Memory → Allocation sampling)

New flags: `--window N`, `--no-gc-profile`, `--no-alloc-profile`.

### 2. Allocation reductions in the hot paths it found

| Site | Change |
|---|---|
| `GameMap.bfs` | inline neighbor enumeration instead of an array per
visited tile |
| `GameMap`/`Game` | new `forEachNeighborNSWE` — allocation-free
iterator matching `neighbors()` N,S,W,E order for order-sensitive
callers (`forEachNeighbor` visits W,E,N,S, so substituting it would
change sim behavior) |
| `PlayerImpl.nearby` / `sharesBorderWith` / `shoreReachableNeighbors` |
no per-call neighbor arrays; no materialized shore-tile array |
| `PlayerImpl.units(types)` | gather into a reusable scratch buffer,
return one exact-size slice (still a fresh snapshot array per call) |
| `AiAttackBehavior.maybeAttack` | single pass over border neighbors
replacing the `flatMap`/`filter`/`map` chain over every border tile |
| `AiAttackBehavior.isBorderingNukedTerritory` | reusable `neighbors4`
buffer with early exit |
| `SharedWaterCache.build` | allocation-free neighbor iteration |
| `SpatialQuery.bfsNearest` | first-minimum scan instead of
collect-then-stable-sort (identical result incl. tie-breaking) |

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

| Metric | Before | After |
|---|---|---|
| Sampled allocations (incl. collected) | 97.7 GB | **56.9 GB (−42%)** |
| GC count / total pause | 1,682 / 3,313 ms (1.8% of wall) | 1,058 /
2,087 ms (1.2%) |
| Ticks/sec | 66 | 70 |
| p99 / max tick | 49.9 ms / 988 ms | 43.5 ms / 689 ms |
| Ticks over 100 ms budget | 31 | 19 |

## Determinism

Every rewrite preserves exact iteration order (the new NSWE iterator
exists precisely for the order-sensitive sites). Verified by identical
final game-state hashes on three runs: Giant World Map 12,000 ticks
(`67286276735690560`), Giant World Map 2,000 ticks, and World 1,800
ticks.

## Test plan

- [x] Full suite green (1,896 tests)
- [x] New tests: `forEachNeighborNSWE` order contract vs `neighbors()`
over every tile; `units()` filtering semantics (insertion order,
fresh-array guarantee, duplicate types, Set path)
- [x] Final-hash equality on 3 seeded headless runs (2 maps)

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

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-07-03 12:30:28 -07:00

277 lines
7.8 KiB
TypeScript

import { Session } from "node:inspector";
import { PerformanceObserver } from "node:perf_hooks";
import v8 from "node:v8";
// ── GC pause tracking (PerformanceObserver on 'gc' entries) ──
export type GcKind = "minor" | "major" | "incremental" | "weakcb";
const KIND_NAMES: Record<number, GcKind> = {
1: "minor", // NODE_PERFORMANCE_GC_MINOR (scavenge)
4: "major", // NODE_PERFORMANCE_GC_MAJOR (mark-sweep-compact)
8: "incremental", // NODE_PERFORMANCE_GC_INCREMENTAL (marking steps)
16: "weakcb", // NODE_PERFORMANCE_GC_WEAKCB (weak callbacks)
};
export interface GcEvent {
kind: GcKind;
/** performance.now() timeline of when the GC started. */
startTime: number;
durationMs: number;
}
export interface GcKindSummary {
count: number;
totalMs: number;
maxMs: number;
}
export type GcSummary = Record<GcKind, GcKindSummary> & {
all: GcKindSummary;
};
export function summarizeGcEvents(events: GcEvent[]): GcSummary {
const empty = (): GcKindSummary => ({ count: 0, totalMs: 0, maxMs: 0 });
const summary: GcSummary = {
minor: empty(),
major: empty(),
incremental: empty(),
weakcb: empty(),
all: empty(),
};
for (const e of events) {
for (const bucket of [summary[e.kind], summary.all]) {
bucket.count++;
bucket.totalMs += e.durationMs;
bucket.maxMs = Math.max(bucket.maxMs, e.durationMs);
}
}
return summary;
}
/**
* Records every GC the process performs, with timestamps, so pauses can be
* attributed to time windows after the fact. The tick loop is synchronous and
* V8 only dispatches buffered GC entries to observers on a later timer task
* (setImmediate and takeRecords() both see nothing), so stop() awaits timer
* ticks until no new entries arrive.
*/
export class GcTracker {
private observer: PerformanceObserver | null = null;
readonly events: GcEvent[] = [];
start(): void {
this.observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
// Node's PerformanceEntry has .detail; the bundled DOM type does not.
const detail = (entry as { detail?: { kind?: number } }).detail;
const kind = KIND_NAMES[detail?.kind ?? 0];
if (kind === undefined) continue;
this.events.push({
kind,
startTime: entry.startTime,
durationMs: entry.duration,
});
}
});
this.observer.observe({ entryTypes: ["gc"] });
}
async stop(): Promise<GcEvent[]> {
let idleRounds = 0;
let lastCount = this.events.length;
while (idleRounds < 3) {
await new Promise((resolve) => setTimeout(resolve, 0));
if (this.events.length === lastCount) {
idleRounds++;
} else {
idleRounds = 0;
lastCount = this.events.length;
}
}
this.observer?.disconnect();
this.observer = null;
return this.events;
}
/** Events whose start falls in [fromTime, toTime) on the performance.now() timeline. */
eventsBetween(fromTime: number, toTime: number): GcEvent[] {
return this.events.filter(
(e) => e.startTime >= fromTime && e.startTime < toTime,
);
}
}
// ── Per-window heap sampling (allocation-rate proxy) ──
export interface HeapWindow {
label: string;
ticks: number;
wallMs: number;
/**
* Sum of positive used-heap deltas between consecutive ticks. This is a
* lower bound on bytes allocated (allocation and collection inside a single
* tick cancel out), but tracks churn trends well at ~10ms ticks.
*/
allocatedBytes: number;
heapUsedEnd: number;
/** Filled in after the run from GcTracker events. */
startTime: number;
endTime: number;
}
/**
* Call tick() after every simulation tick and closeWindow() at reporting
* boundaries. Uses v8.getHeapStatistics() (no /proc reads, unlike
* process.memoryUsage()).
*/
export class HeapSampler {
private windows: HeapWindow[] = [];
private lastHeapUsed: number;
private windowStartTime: number;
private windowAllocated = 0;
private windowTicks = 0;
constructor() {
this.lastHeapUsed = v8.getHeapStatistics().used_heap_size;
this.windowStartTime = performance.now();
}
tick(): void {
const used = v8.getHeapStatistics().used_heap_size;
const delta = used - this.lastHeapUsed;
if (delta > 0) {
this.windowAllocated += delta;
}
this.lastHeapUsed = used;
this.windowTicks++;
}
closeWindow(label: string): HeapWindow {
const now = performance.now();
const window: HeapWindow = {
label,
ticks: this.windowTicks,
wallMs: now - this.windowStartTime,
allocatedBytes: this.windowAllocated,
heapUsedEnd: v8.getHeapStatistics().used_heap_size,
startTime: this.windowStartTime,
endTime: now,
};
this.windows.push(window);
this.windowStartTime = now;
this.windowAllocated = 0;
this.windowTicks = 0;
return window;
}
all(): HeapWindow[] {
return this.windows;
}
}
// ── V8 sampling heap profiler (allocation sites, includes collected objects) ──
interface SamplingHeapProfileNode {
callFrame: {
functionName: string;
url: string;
lineNumber: number;
};
selfSize: number;
children?: SamplingHeapProfileNode[];
}
export interface SamplingHeapProfile {
head: SamplingHeapProfileNode;
samples: unknown[];
}
export interface AllocationSite {
functionName: string;
location: string;
selfBytes: number;
selfPct: number;
}
/**
* Samples allocations (including objects already collected, i.e. churn) and
* attributes bytes to the allocating function. Sampled — low overhead, sizes
* are statistical estimates.
*/
export class AllocationSampler {
private session = new Session();
private post(method: string, params?: object): Promise<unknown> {
return new Promise((resolve, reject) => {
this.session.post(method, params, (err, result) =>
err ? reject(err) : resolve(result),
);
});
}
async start(samplingIntervalBytes = 65536): Promise<void> {
this.session.connect();
await this.post("HeapProfiler.enable");
await this.post("HeapProfiler.startSampling", {
samplingInterval: samplingIntervalBytes,
includeObjectsCollectedByMajorGC: true,
includeObjectsCollectedByMinorGC: true,
});
}
async stop(): Promise<SamplingHeapProfile> {
const { profile } = (await this.post("HeapProfiler.stopSampling")) as {
profile: SamplingHeapProfile;
};
this.session.disconnect();
return profile;
}
}
/** Aggregates self-allocated bytes per function from a sampling heap profile. */
export function summarizeAllocationProfile(
profile: SamplingHeapProfile,
projectRoot: string,
): { sites: AllocationSite[]; totalBytes: number } {
const bySite = new Map<string, AllocationSite>();
let totalBytes = 0;
const visit = (node: SamplingHeapProfileNode): void => {
if (node.selfSize > 0) {
totalBytes += node.selfSize;
const { functionName, url, lineNumber } = node.callFrame;
const name = functionName || "(anonymous)";
let location = url.replace(/^file:\/\//, "");
if (location.startsWith(projectRoot)) {
location = location.slice(projectRoot.length + 1);
}
if (location !== "" && lineNumber > 0) {
location += `:${lineNumber + 1}`;
}
const key = `${name}@${location}`;
const site = bySite.get(key);
if (site) {
site.selfBytes += node.selfSize;
} else {
bySite.set(key, {
functionName: name,
location,
selfBytes: node.selfSize,
} as AllocationSite);
}
}
for (const child of node.children ?? []) {
visit(child);
}
};
visit(profile.head);
const sites = [...bySite.values()];
for (const site of sites) {
site.selfPct = totalBytes > 0 ? (site.selfBytes * 100) / totalBytes : 0;
}
sites.sort((a, b) => b.selfBytes - a.selfBytes);
return { sites, totalBytes };
}