mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-08 17:33:10 +00:00
22c873cf55
## Problem
Every 100 ms the main thread's worker `onmessage` callback processes a
full game tick (`gameView.update` → `webglBuilder.update` →
`renderer.tick`). At 60 fps this competes with the 16.7 ms frame budget,
and on the Giant World Map it takes several ms — frame drops on low-end
hardware.
## Harness (`npm run perf:client-tick`)
Headless-Chromium harness that times every worker→main `game_update`
dispatch on the main thread, with structured-clone deserialization
measured separately from the handler body (via a
`Worker.prototype.addEventListener` wrapper installed as a page init
script — no product-code changes). It reports windowed distributions,
captures `.cpuprofile` files at chosen ticks, writes raw samples and an
end-of-run screenshot. `AnalyzeCpuProfile.ts` breaks a profile down by
inclusive time under the dispatch subtree.
Init scripts are passed as **strings**: tsx compiles function-form init
scripts with esbuild `keepNames`, whose injected `__name` helper doesn't
exist in-page and silently kills the game worker setup.
## Baseline (Giant World Map, 400 bots, headless)
Dispatch handler ms — cost **grows with game progression**:
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.22 | 2.20 | 3.40 | 5.00 |
| tick 1506 | 2.60 | 2.00 | 7.00 | 10.40 |
| tick 2000 | 2.67 | 1.90 | **8.70** | **12.70** |
Deserialization is negligible (0.12 ms mean). CPU profiles attributed
the growing tail to the leaderboard's once-per-second refresh: its
Max-troops column calls `config().maxTroops(p)` for **all ~508
players**, and `PlayerView.units()` scanned **every unit in the game**
per call — O(players × units), growing as units accumulate.
## Round 1 — algorithmic fixes
- **GameView**: new `unitsOwnedBy(smallID)` — an active-units-by-owner
index built lazily at most once per tick. `PlayerView.units()` reads its
own units from it: O(own units) instead of O(all units). Also speeds up
unit display, player panel, and buildables queries.
- **NamePass.updateNames**: reads player state directly from the
caller's map by smallID instead of rebuilding three lookup maps per
tick; skips the slot-assignment sweep once every player has a slot.
After (same map, same spawn tile):
| window | mean | p50 | p95 | max |
|---|---|---|---|---|
| tick 506 | 2.12 | 2.00 | 3.10 | 5.20 |
| tick 1506 | 1.86 | 1.80 | 2.90 | 4.30 |
| tick 2000 | 1.74 | 1.60 | **2.40** | **4.70** |
Late-game p95 −65% (8.7 → 2.4 ms), worst dispatch −63% (12.7 → 4.7 ms),
and per-dispatch cost no longer grows with game progression. The
leaderboard disappeared from the dispatch profile entirely.
## Round 2 — allocation churn + time slicing
Aimed at GC pauses and low-end CPUs; measures flat vs round 1 on a fast
machine, as expected:
- **`FrameData.changedTiles`** is now the plain tile-ref array GameView
already builds instead of a per-tile `{ref, state}` object copy — heavy
battle ticks allocated tens of thousands of objects per tick for a
`state` field that was always 0. `TilePair` removed; `TerritoryPass`
buckets refs synchronously, so the live reference is safe.
- **`UnitView.lastPos`** is only re-sliced when a move actually appended
a position — the unconditional `slice(-1)` allocated an identical
1-element array per unit per tick, including for structures that never
move.
- **`NamePass.updateNames`** refreshes slots round-robin, a quarter per
tick — the full per-player diff pass spreads over ~400 ms, under the
existing 500 ms troop-text cadence; positions lerp continuously. Unnamed
slots and snap passes (seeks) are always processed so nothing pops in
late. Dispatch share: 17% → 13%.
Not sliced on purpose: tile ingest and frame upload need a consistent
per-tick snapshot (stale `GameMap` reads would leak into hover queries,
minimap, attack targeting) — a correctness risk not worth ~1 ms while
the worst dispatch already fits in a quarter of the frame budget.
## Verification
- `npx tsc --noEmit`, eslint clean; full suite green (1929 tests)
- 6 new GameView tests cover the owner index (grouping, inactive
exclusion, ownership capture, death, type filtering, copy semantics);
changedTiles tests updated to the ref-array contract
- Headless end-of-run screenshots verified after each round: leaderboard
Max-troops values, map names + troop counts + flags all render correctly
(including with name slicing active)
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
91 lines
2.8 KiB
TypeScript
91 lines
2.8 KiB
TypeScript
// Inclusive-time breakdown of the tick-dispatch subtree in a .cpuprofile
|
|
// captured by ClientTickPerf. Attributes every sampled stack under the
|
|
// harness's "wrapped" listener to each enclosing function, so the phase
|
|
// split (gameView.update vs uploadFrameData vs renderer.tick) is visible.
|
|
// Usage: npx tsx tests/perf/client/AnalyzeCpuProfile.ts <file> [rootName]
|
|
import fs from "fs";
|
|
|
|
interface ProfileNode {
|
|
id: number;
|
|
callFrame: { functionName: string; url: string; lineNumber: number };
|
|
hitCount?: number;
|
|
children?: number[];
|
|
}
|
|
|
|
interface Profile {
|
|
nodes: ProfileNode[];
|
|
startTime: number;
|
|
endTime: number;
|
|
samples?: number[];
|
|
timeDeltas?: number[];
|
|
}
|
|
|
|
const file = process.argv[2];
|
|
const rootName = process.argv[3] ?? "wrapped";
|
|
const p: Profile = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
|
|
const byId = new Map(p.nodes.map((n) => [n.id, n]));
|
|
const parent = new Map<number, number>();
|
|
for (const n of p.nodes) {
|
|
for (const c of n.children ?? []) parent.set(c, n.id);
|
|
}
|
|
|
|
// Self micros per node id from samples/timeDeltas.
|
|
const selfMicros = new Map<number, number>();
|
|
const samples = p.samples ?? [];
|
|
const deltas = p.timeDeltas ?? [];
|
|
for (let i = 0; i < samples.length; i++) {
|
|
selfMicros.set(
|
|
samples[i],
|
|
(selfMicros.get(samples[i]) ?? 0) + (deltas[i] ?? 0),
|
|
);
|
|
}
|
|
|
|
// True when a node's ancestry (inclusive) contains a rootName frame.
|
|
const underRoot = (id: number): boolean => {
|
|
for (
|
|
let cur: number | undefined = id;
|
|
cur !== undefined;
|
|
cur = parent.get(cur)
|
|
) {
|
|
if (byId.get(cur)?.callFrame.functionName === rootName) return true;
|
|
}
|
|
return false;
|
|
};
|
|
|
|
// Attribute each in-subtree sample to every enclosing function (inclusive
|
|
// time), stopping at the root frame.
|
|
let rootTotal = 0;
|
|
const inclusiveByFn = new Map<string, number>();
|
|
for (const [id, micros] of selfMicros) {
|
|
if (!underRoot(id)) continue;
|
|
rootTotal += micros;
|
|
const seen = new Set<string>();
|
|
for (
|
|
let cur: number | undefined = id;
|
|
cur !== undefined;
|
|
cur = parent.get(cur)
|
|
) {
|
|
const cf = byId.get(cur)?.callFrame;
|
|
if (!cf) break;
|
|
const url = cf.url.replace(/^.*\/(src|node_modules)\//, "$1/");
|
|
const key = `${cf.functionName || "(anonymous)"} ${url}:${cf.lineNumber + 1}`;
|
|
if (!seen.has(key)) {
|
|
seen.add(key);
|
|
inclusiveByFn.set(key, (inclusiveByFn.get(key) ?? 0) + micros);
|
|
}
|
|
if (cf.functionName === rootName) break;
|
|
}
|
|
}
|
|
|
|
console.log(
|
|
`total under "${rootName}": ${(rootTotal / 1000).toFixed(1)} ms of ${((p.endTime - p.startTime) / 1000).toFixed(0)} ms profile`,
|
|
);
|
|
const rows = [...inclusiveByFn.entries()].sort((a, b) => b[1] - a[1]);
|
|
for (const [key, micros] of rows.slice(0, 40)) {
|
|
const pct = ((micros / rootTotal) * 100).toFixed(1);
|
|
console.log(
|
|
`${(micros / 1000).toFixed(1).padStart(9)} ms ${pct.padStart(5)}% ${key}`,
|
|
);
|
|
}
|