mirror of
https://github.com/openfrontio/OpenFrontIO.git
synced 2026-07-20 19:20:27 +00:00
perf(client): tick-dispatch timing harness + main-thread tick optimizations (late-game p95 −65%) (#4512)
## 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>
This commit is contained in:
@@ -663,23 +663,21 @@ describe("GameView.frameData() — renderer contract", () => {
|
||||
game.update(gu2);
|
||||
const ct = game.frameData().changedTiles;
|
||||
expect(ct).not.toBeNull();
|
||||
expect(ct!.map((t) => t.ref).sort((a, b) => a - b)).toEqual([3, 5, 9]);
|
||||
expect([...ct!].sort((a, b) => a - b)).toEqual([3, 5, 9]);
|
||||
});
|
||||
|
||||
it("changedTiles scratch array is reused across ticks (no per-tick alloc)", () => {
|
||||
it("changedTiles holds only the current tick's refs", () => {
|
||||
const game = makeGameView({ width: 4, height: 4 });
|
||||
game.update(makeEmptyGu(1)); // first populate (changedTiles = null)
|
||||
const gu2 = makeEmptyGu(2);
|
||||
gu2.packedTileUpdates = new Uint32Array([1, 0]);
|
||||
game.update(gu2);
|
||||
const ct1 = game.frameData().changedTiles;
|
||||
expect(game.frameData().changedTiles).toEqual([1]);
|
||||
|
||||
const gu3 = makeEmptyGu(3);
|
||||
gu3.packedTileUpdates = new Uint32Array([2, 0]);
|
||||
game.update(gu3);
|
||||
const ct2 = game.frameData().changedTiles;
|
||||
|
||||
expect(ct2).toBe(ct1); // same array instance
|
||||
expect(game.frameData().changedTiles).toEqual([2]);
|
||||
});
|
||||
|
||||
it("frame.units is === gameView.unitStates() (same long-lived map)", () => {
|
||||
@@ -771,3 +769,88 @@ describe("GameView.frameData() — renderer contract", () => {
|
||||
expect(relationMatrix[3 * relationSize + 1]).toBe(RELATION_NEUTRAL);
|
||||
});
|
||||
});
|
||||
|
||||
describe("GameView.unitsOwnedBy — per-tick owner index", () => {
|
||||
function withUnits(
|
||||
tick: number,
|
||||
units: ReturnType<typeof makeUnitUpdate>[],
|
||||
players: ReturnType<typeof makePlayerUpdate>[] = [],
|
||||
) {
|
||||
const gu = makeEmptyGu(tick);
|
||||
gu.updates[GameUpdateType.Unit] = units;
|
||||
gu.updates[GameUpdateType.Player] = players;
|
||||
return gu;
|
||||
}
|
||||
|
||||
it("groups active units by owner smallID", () => {
|
||||
const game = makeGameView();
|
||||
game.update(
|
||||
withUnits(1, [
|
||||
makeUnitUpdate({ id: 1, ownerID: 1, unitType: UnitType.City }),
|
||||
makeUnitUpdate({ id: 2, ownerID: 1, unitType: UnitType.Port }),
|
||||
makeUnitUpdate({ id: 3, ownerID: 2, unitType: UnitType.City }),
|
||||
]),
|
||||
);
|
||||
expect(game.unitsOwnedBy(1).map((u) => u.id())).toEqual([1, 2]);
|
||||
expect(game.unitsOwnedBy(2).map((u) => u.id())).toEqual([3]);
|
||||
expect(game.unitsOwnedBy(99)).toEqual([]);
|
||||
});
|
||||
|
||||
it("excludes inactive units", () => {
|
||||
const game = makeGameView();
|
||||
game.update(
|
||||
withUnits(1, [
|
||||
makeUnitUpdate({ id: 1, ownerID: 1, isActive: true }),
|
||||
makeUnitUpdate({ id: 2, ownerID: 1, isActive: false }),
|
||||
]),
|
||||
);
|
||||
expect(game.unitsOwnedBy(1).map((u) => u.id())).toEqual([1]);
|
||||
});
|
||||
|
||||
it("reflects ownership changes on the next tick (capture)", () => {
|
||||
const game = makeGameView();
|
||||
game.update(withUnits(1, [makeUnitUpdate({ id: 1, ownerID: 1 })]));
|
||||
expect(game.unitsOwnedBy(1)).toHaveLength(1);
|
||||
expect(game.unitsOwnedBy(2)).toHaveLength(0);
|
||||
|
||||
game.update(withUnits(2, [makeUnitUpdate({ id: 1, ownerID: 2 })]));
|
||||
expect(game.unitsOwnedBy(1)).toHaveLength(0);
|
||||
expect(game.unitsOwnedBy(2)).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("reflects unit death on the tick it happens", () => {
|
||||
const game = makeGameView();
|
||||
game.update(withUnits(1, [makeUnitUpdate({ id: 1, ownerID: 1 })]));
|
||||
expect(game.unitsOwnedBy(1)).toHaveLength(1);
|
||||
|
||||
game.update(
|
||||
withUnits(2, [makeUnitUpdate({ id: 1, ownerID: 1, isActive: false })]),
|
||||
);
|
||||
expect(game.unitsOwnedBy(1)).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("PlayerView.units() filters the owner's units by type", () => {
|
||||
const game = makeGameView();
|
||||
const gu = withUnits(
|
||||
1,
|
||||
[
|
||||
makeUnitUpdate({ id: 1, ownerID: 1, unitType: UnitType.City }),
|
||||
makeUnitUpdate({ id: 2, ownerID: 1, unitType: UnitType.Port }),
|
||||
makeUnitUpdate({ id: 3, ownerID: 1, unitType: UnitType.City }),
|
||||
makeUnitUpdate({ id: 4, ownerID: 2, unitType: UnitType.City }),
|
||||
],
|
||||
[
|
||||
makePlayerUpdate({ id: "alice", smallID: 1 }),
|
||||
makePlayerUpdate({ id: "bob", smallID: 2 }),
|
||||
],
|
||||
);
|
||||
game.update(gu);
|
||||
|
||||
const alice = game.player("alice");
|
||||
expect(alice.units(UnitType.City).map((u) => u.id())).toEqual([1, 3]);
|
||||
expect(alice.units().map((u) => u.id())).toEqual([1, 2, 3]);
|
||||
// Returned arrays are copies — mutating them must not corrupt the index.
|
||||
alice.units().pop();
|
||||
expect(alice.units()).toHaveLength(3);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user