Skip to content

Add a main-thread perf harness for the worker → client update pipeline#4243

Merged
evanpelle merged 1 commit into
mainfrom
client-update-perf-harness
Jun 12, 2026
Merged

Add a main-thread perf harness for the worker → client update pipeline#4243
evanpelle merged 1 commit into
mainfrom
client-update-perf-harness

Conversation

@evanpelle

@evanpelle evanpelle commented Jun 12, 2026

Copy link
Copy Markdown
Collaborator

What

npm run perf:client — a headless harness (companion to npm run perf:game from #4228) that measures the main-thread burst the client runs every simulation tick. The sim ticks at 10Hz in a worker; each tick the main thread synchronously runs deserialization → GameView.update()WebGLFrameBuilder.update() → HUD ticks. On low-end devices that burst exceeds the 16.7ms frame budget and shows up as a stutter every 100ms. Before optimizing that path, this gives us numbers.

Per tick it runs the real pipeline end to end and times three stages:

  • clonestructuredClone of the GameUpdateViewData with the same transfer list Worker.worker.ts uses (serialize+deserialize, an upper bound on the main-thread share of the real postMessage)
  • view — the real client GameView.update(), including all populateFrame() derivations
  • builder — the real WebGLFrameBuilder.update() against a no-op GL stub that counts payload sizes

It reports mean/p50/p95/p99/max per stage, slowest bursts with their tile counts, payload stats, a filtered V8 CPU profile table, and writes a .cpuprofile. Not covered (browser-only): CPU inside the WebGL view's update*() methods and HUD layer ticks.

Same flags as perf:game: --map --ticks --bots --nations --seed --top --no-cpu-profile.

Determinism

  • Prints the sim Final hash, which matches the perf:game references on all three standard configs (world/200t/100b → 5607618202213430, default → 29309648281599524, giantworldmap/600t → 39945089450032050) — the harness's worker side is faithful.
  • Prints a View hash (FNV over the tile-state buffer, FrameData deriveds, and per-player/unit view state) — verified stable across runs. Client-side optimizations should keep it identical, the same workflow as the sim hash.

Baseline (this machine; low-end devices are ~5–20× slower)

Default run (world, 400 bots, 1800 ticks):

stage mean p50 p95 p99 max
clone (serialize+deserialize) 1.02ms 0.96 1.53 2.11 9.15
GameView.update 0.62ms 0.58 0.93 1.25 5.09
WebGLFrameBuilder.update 0.04ms 0.04 0.05 0.07 0.17
TOTAL burst 1.67ms 1.60 2.46 3.47 10.3

giantworldmap/600t: TOTAL mean 2.54ms, p99 5.65ms, max 6.42ms.

Notable: the clone is the largest stage (~60%) — the packed tile/motion buffers transfer for free, so the cost is structured-cloning the updates object (~278 partial player updates/tick on world, ~508 on giantworldmap). Inside view, the recurring cost is populateFrame's derivations (computePlayerStatus, the O(players²) relation matrix, alliance clusters); tile apply dominates the land-grab spikes.

Code changes outside the harness

  • WebGLFrameBuilder: the ./render/gl import is now import type so the module loads under Node — a value import pulls GPURenderer and its .glsl?raw shader imports. No behavior change (the symbols were only used in type positions).
  • tests/perf/client/Shims.ts: an in-memory localStorage shim so UserSettings/theme code runs under Node (all settings resolve to defaults, which is also the deterministic choice).

Verification

  • Sim + view hashes identical on repeat runs.
  • npm test (1474 tests), eslint, prettier --check, tsc --noEmit all pass.

🤖 Generated with Claude Code

The sim runs at 10Hz in a worker; every tick the main thread synchronously
runs structuredClone deserialization, GameView.update() (tile apply +
FrameData derivations), and WebGLFrameBuilder.update(). On low-end devices
that burst exceeds the 16.7ms frame budget and causes a visible stutter
every 100ms. npm run perf:client measures the burst headlessly per stage
(clone / view / builder), reports payload sizes, writes a .cpuprofile, and
prints a deterministic View hash so client-side optimizations can be
verified to not change view behavior.

WebGLFrameBuilder's import of ./render/gl is made type-only so the module
loads under Node (a value import pulls GPURenderer and its .glsl?raw
shader imports); no behavior change.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 12, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Walkthrough

This pull request adds a comprehensive Node-based performance harness for measuring the client-side game update pipeline. It includes a localStorage shim for Node, a WebGL stub that tracks GPU-layer calls without rendering, precise timing measurement across clone/view/builder stages, optional CPU profiling, and deterministic view state hashing to validate optimizations.

Changes

Client Performance Harness

Layer / File(s) Summary
Environmental setup and script registration
tests/perf/client/Shims.ts, package.json, src/client/WebGLFrameBuilder.ts
In-memory localStorage shim installs globally in Node. New perf:client npm script entry point. WebGLFrameBuilder import converted to type-only to prevent bundler side effects in non-Vite consumers.
Harness entry point and CLI configuration
tests/perf/client/ClientUpdatePerf.ts
Module docstring explains measurement stages. Options type and CLI argument parsing support map, tick count, bot/nation counts, seed, profiling toggle, and top-function count. Map names resolved against GameMapType enum.
GL stub and view state validation
tests/perf/client/ClientUpdatePerf.ts
createGlStub() returns no-op mock with counters for dispatch volume and changed tiles. FNV-32 deterministic hasher and computeViewHash() hash renderer-facing state (tiles, relations, units, players) to validate client optimizations preserve view output.
Main tick measurement loop
tests/perf/client/ClientUpdatePerf.ts
Constructs game config, loads map terrain, creates GameView and stubbed WebGLFrameBuilder. Runs tick loop via runner.executeNextTick() with precise timing for structured cloning (with transferables), GameView.update (state application), and builder.update (CPU work). Accumulates TickStats per stage.
Spawn phase and CPU profiling integration
tests/perf/client/ClientUpdatePerf.ts
Separate spawn-phase loop measures full pipeline until runner exits spawn. Optional V8 sampling CPU profiler wraps main loop; profiler stops at end and object is captured for reporting.
Results reporting and output artifacts
tests/perf/client/ClientUpdatePerf.ts
Prints game-state metrics, view hash, and peak heap. Builds per-stage timing table (mean, p50, p95, p99, max, over-budget counts vs 16.7ms). Lists slowest main-thread bursts. Reports tile/unit/player update counts and GL dispatch counts. Optionally prints top CPU functions and writes .cpuprofile to tests/perf/output.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~50 minutes

Poem

🚀 A harness built to test the frame,
Where client workers play the game,
Each tick now timed, each burst recorded,
GPU stubs and hashes sorted,
Performance tuning, measured well! 📊✨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: adding a performance harness for measuring main-thread work in the worker-to-client update pipeline.
Description check ✅ Passed The description is directly related to the changeset, providing clear context about what the perf harness does, how it works, and baseline measurements.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

ESLint install failed due to a network error.


Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@tests/perf/client/ClientUpdatePerf.ts`:
- Around line 374-394: The test collects payload/GL metrics (stats,
tilePairsByTick, totalTilePairs, maxTilePairs, unitUpdatesTotal,
playerUpdatesTotal and the glStub/WebGLFrameBuilder usage) before the spawn loop
so the "Update payload (game phase)" report includes spawn-phase traffic; to
fix, introduce a second set of counters (e.g. gameStats, gameTilePairsByTick,
gameTotalTilePairs, gameMaxTilePairs, gameUnitUpdatesTotal,
gamePlayerUpdatesTotal) or snapshot/reset the existing counters immediately
after the spawn loop and use those snapshots for the game-phase report, keeping
the original counters if you still want overall totals—apply the same change
where similar reports are generated around the other ranges mentioned.
- Around line 118-135: The CLI parsing accepts invalid numeric input silently;
update the handlers for the "--ticks", "--bots", "--nations" (when not
"default"/"disabled"), and "--top" cases to validate parseInt(next(), 10)
results: after parsing, check for Number.isInteger(value) and that value >= 0
(or >0 if zero is invalid), and if validation fails throw an informative Error
or exit with a nonzero code so the process fails fast; refer to the case labels
"--ticks", "--bots", "--nations", and "--top" and the
opts.{ticks,bots,nations,top} assignments to locate where to add these checks.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 0900901b-1711-4862-8f5a-4056762a4f74

📥 Commits

Reviewing files that changed from the base of the PR and between d96c055 and 092f2aa.

📒 Files selected for processing (4)
  • package.json
  • src/client/WebGLFrameBuilder.ts
  • tests/perf/client/ClientUpdatePerf.ts
  • tests/perf/client/Shims.ts

Comment on lines +118 to +135
case "--ticks":
opts.ticks = parseInt(next(), 10);
break;
case "--bots":
opts.bots = parseInt(next(), 10);
break;
case "--nations": {
const v = next();
opts.nations =
v === "default" || v === "disabled" ? v : parseInt(v, 10);
break;
}
case "--seed":
opts.seed = next();
break;
case "--top":
opts.top = parseInt(next(), 10);
break;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Reject invalid numeric CLI values early.

parseInt() here accepts bad input too quietly. For example, --ticks nope becomes NaN and the main loop runs zero iterations, while negative values also slip through. That makes the harness print misleading results instead of failing fast.

Suggested fix
+function parsePositiveInt(flag: string, raw: string): number {
+  const value = Number(raw);
+  if (!Number.isInteger(value) || value < 0) {
+    throw new Error(`invalid value for ${flag}: ${raw}`);
+  }
+  return value;
+}
+
 function parseArgs(argv: string[]): Options {
   const opts: Options = {
     map: GameMapType.World,
@@
       case "--ticks":
-        opts.ticks = parseInt(next(), 10);
+        opts.ticks = parsePositiveInt("--ticks", next());
         break;
       case "--bots":
-        opts.bots = parseInt(next(), 10);
+        opts.bots = parsePositiveInt("--bots", next());
         break;
       case "--nations": {
         const v = next();
         opts.nations =
-          v === "default" || v === "disabled" ? v : parseInt(v, 10);
+          v === "default" || v === "disabled"
+            ? v
+            : parsePositiveInt("--nations", v);
         break;
       }
@@
       case "--top":
-        opts.top = parseInt(next(), 10);
+        opts.top = parsePositiveInt("--top", next());
         break;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
case "--ticks":
opts.ticks = parseInt(next(), 10);
break;
case "--bots":
opts.bots = parseInt(next(), 10);
break;
case "--nations": {
const v = next();
opts.nations =
v === "default" || v === "disabled" ? v : parseInt(v, 10);
break;
}
case "--seed":
opts.seed = next();
break;
case "--top":
opts.top = parseInt(next(), 10);
break;
function parsePositiveInt(flag: string, raw: string): number {
const value = Number(raw);
if (!Number.isInteger(value) || value < 0) {
throw new Error(`invalid value for ${flag}: ${raw}`);
}
return value;
}
case "--ticks":
opts.ticks = parsePositiveInt("--ticks", next());
break;
case "--bots":
opts.bots = parsePositiveInt("--bots", next());
break;
case "--nations": {
const v = next();
opts.nations =
v === "default" || v === "disabled"
? v
: parsePositiveInt("--nations", v);
break;
}
case "--seed":
opts.seed = next();
break;
case "--top":
opts.top = parsePositiveInt("--top", next());
break;
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/perf/client/ClientUpdatePerf.ts` around lines 118 - 135, The CLI
parsing accepts invalid numeric input silently; update the handlers for the
"--ticks", "--bots", "--nations" (when not "default"/"disabled"), and "--top"
cases to validate parseInt(next(), 10) results: after parsing, check for
Number.isInteger(value) and that value >= 0 (or >0 if zero is invalid), and if
validation fails throw an informative Error or exit with a nonzero code so the
process fails fast; refer to the case labels "--ticks", "--bots", "--nations",
and "--top" and the opts.{ticks,bots,nations,top} assignments to locate where to
add these checks.

Comment on lines +374 to +394
const glStub = createGlStub();
const builder = new WebGLFrameBuilder(
glStub.view as unknown as ConstructorParameters<
typeof WebGLFrameBuilder
>[0],
);

// Per-stage stats. "total" is clone+view+builder — the main-thread burst.
const stats = {
sim: new TickStats(),
clone: new TickStats(),
view: new TickStats(),
builder: new TickStats(),
total: new TickStats(),
};
const tilePairsByTick = new Map<number, number>();
let totalTilePairs = 0;
let maxTilePairs = 0;
let unitUpdatesTotal = 0;
let playerUpdatesTotal = 0;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor | ⚡ Quick win

Keep spawn metrics out of the “game phase” payload report.

The payload counters and GL stub start collecting before the spawn loop, but the final section says Update payload (game phase). Right now those numbers include spawn-phase traffic too, so the reported tile/unit/player/GPU totals are overstated for the phase this harness says it is measuring.

Suggested fix
   const spawnTurns = turnNumber;
   const spawnClientMs = spawnStats.total.summarize(FRAME_BUDGET_MS).totalMs;
   console.log(
     `Spawn phase done: ${spawnTurns} turns in ` +
       `${fmtMs(performance.now() - spawnStart)}ms wall ` +
       `(${fmtMs(spawnClientMs)}ms client-side), ` +
       `${runner.game.players().filter((p) => p.isAlive()).length} players spawned.`,
   );
 
   // Main game phase, under the CPU profiler.
+  tilePairsByTick.clear();
+  totalTilePairs = 0;
+  maxTilePairs = 0;
+  unitUpdatesTotal = 0;
+  playerUpdatesTotal = 0;
+  const glStub = createGlStub();
+  const builder = new WebGLFrameBuilder(
+    glStub.view as unknown as ConstructorParameters<
+      typeof WebGLFrameBuilder
+    >[0],
+  );
+
   const cpuProfiler = opts.cpuProfile ? new CpuProfiler() : null;

If you want spawn + game totals too, keep a second set of counters instead of reusing the game-phase ones.

Also applies to: 396-420, 447-471, 567-586

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@tests/perf/client/ClientUpdatePerf.ts` around lines 374 - 394, The test
collects payload/GL metrics (stats, tilePairsByTick, totalTilePairs,
maxTilePairs, unitUpdatesTotal, playerUpdatesTotal and the
glStub/WebGLFrameBuilder usage) before the spawn loop so the "Update payload
(game phase)" report includes spawn-phase traffic; to fix, introduce a second
set of counters (e.g. gameStats, gameTilePairsByTick, gameTotalTilePairs,
gameMaxTilePairs, gameUnitUpdatesTotal, gamePlayerUpdatesTotal) or
snapshot/reset the existing counters immediately after the spawn loop and use
those snapshots for the game-phase report, keeping the original counters if you
still want overall totals—apply the same change where similar reports are
generated around the other ranges mentioned.

@github-project-automation github-project-automation Bot moved this from Triage to Development in OpenFront Release Management Jun 12, 2026
@evanpelle evanpelle merged commit aa22339 into main Jun 12, 2026
14 of 18 checks passed
@github-project-automation github-project-automation Bot moved this from Development to Complete in OpenFront Release Management Jun 12, 2026
@evanpelle evanpelle deleted the client-update-perf-harness branch June 12, 2026 19:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Complete

Development

Successfully merging this pull request may close these issues.

1 participant