Skip to content

Add Vitest performance benchmarks with CI regression checks - #1601

Merged
Azgaar merged 9 commits into
Azgaar:1.148.0from
SheepFromHeaven:test/vitest-performance-benchmarks
Aug 23, 2026
Merged

Add Vitest performance benchmarks with CI regression checks#1601
Azgaar merged 9 commits into
Azgaar:1.148.0from
SheepFromHeaven:test/vitest-performance-benchmarks

Conversation

@SheepFromHeaven

Copy link
Copy Markdown
Collaborator

Introduces bench suites for generators, renderers, and zoom, plus a baseline workflow and PR check script that compares runs against a committed baseline and comments the diff.

I was inspired by #1599

The idea would be: for future performance improvements, we can put these benchmark checks in place (done with vitest benchmarks). Then we can merge them and the PR check will show if there was an improvement in the performance or even a decrease.

I also added some baselines for performance heavy code.

There were also 2 new github actions added:
bench.yml => runs on PRs to master and checks the benchmark against the baseline
bench-baseline.yml => runs on merge to master and updates the benchmark baselines to prevent stale checks

Introduces bench suites for generators, renderers, and zoom, plus a
baseline workflow and PR check script that compares runs against a
committed baseline and comments the diff.

Copilot AI 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.

🟡 Changes recommended

CI stability/correctness issues are present in the benchmarks and workflows (node vs jsdom runtime error, nondeterministic benchmark inputs, and fork-PR commenting permissions), which can break or flake the regression checks.

Once you've addressed the issues Copilot identified, you can request another Copilot review.

Pull request overview

This PR introduces a Vitest benchmark suite and CI automation to track performance over time, aiming to catch regressions (and highlight improvements) in key generator and renderer hotspots.

Changes:

  • Adds multiple *.bench.ts benchmark suites covering generators, renderers, and zoom/viewport reconciliation behavior.
  • Adds a benchmark comparison script (scripts/bench-check.js) plus committed baseline data (bench/baseline.json) and local-run artifacts in .gitignore.
  • Adds GitHub Actions workflows to run benchmark checks on PRs and to regenerate/update baselines on master.
File summaries
File Description
src/renderers/viewport/viewport-renderer.bench.ts Benchmarks ViewportRenderer reconcile behavior across a gesture (pre/post #1599 shape)
src/renderers/draw-rivers.bench.ts Adds drawRivers benchmarks with synthetic river networks
src/renderers/draw-heightmap.bench.ts Adds drawHeightmap benchmarks with synthetic Voronoi grid + height bands
src/renderers/draw-borders.bench.ts Adds drawBorders benchmarks with synthetic state/province partitioning
src/generators/voronoi.bench.ts Adds Voronoi construction benchmarks at multiple point counts
src/generators/states-generator.bench.ts Adds States.expandStates benchmarks using a synthetic grid pack and heap impl
src/generators/routes-generator.bench.ts Adds Routes.generate benchmarks using a synthetic pack and heap impl
src/generators/heightmap-generator.bench.ts Adds HeightmapGenerator.fromTemplate benchmarks over increasing grid sizes
src/generators/cultures-generator.bench.ts Adds Cultures.expand benchmarks with synthetic pack inputs
src/generators/burgs-generator.bench.ts Adds Burgs.generate placement benchmarks with synthetic fully-populated packs
src/generators/biomes-generator.bench.ts Adds Biomes.define benchmarks scaling with cell count
src/components/zoom.bench.ts Benchmarks full zoom drag gesture path through applyZoomBehavior + ViewportRenderer
scripts/bench-check.js Compares current vs baseline benchmark JSON and emits a PR-comment markdown summary
package.json Adds bench, bench:baseline, bench:check npm scripts
bench/baseline.json Adds committed benchmark baseline used for CI comparisons
.gitignore Ignores generated bench/current.json and bench/comment.md artifacts
.github/workflows/deploy.yml Avoids triggering deploy on baseline-only updates
.github/workflows/bench.yml Runs benchmark checks on PRs and posts a sticky PR comment with results
.github/workflows/bench-baseline.yml Regenerates and commits updated benchmark baselines on pushes to master
Review details
  • Files reviewed: 18/19 changed files
  • Comments generated: 4
  • Review effort level: Lite

💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread src/generators/voronoi.bench.ts
Comment thread src/renderers/viewport/viewport-renderer.bench.ts
Comment thread .github/workflows/bench.yml Outdated
Comment thread bench/baseline.json Outdated
- Strip local absolute filepaths (and username) from bench/baseline.json
  by normalizing to repo-relative paths after each vitest --outputJson run.
- Seed voronoi.bench.ts point generation deterministically instead of
  Math.random(), so baseline vs PR comparisons aren't flaky.
- Run viewport-renderer.bench.ts under jsdom since ViewportRenderer touches
  `document`, which throws under the default node test environment.
- Skip the sticky PR comment step in bench.yml on fork PRs, where
  GITHUB_TOKEN lacks pull-requests: write permission.
- zoom.bench.ts: wrap vi.useFakeTimers/useRealTimers calls so the bench
  hook returns void; tsc rejected the VitestUtils return type otherwise,
  breaking `npm run build`.
- bench.yml: upload bench/current.json as an artifact so a CI-generated
  baseline can be pulled down. The committed baseline was measured on a
  laptop, which runs faster than the GitHub Actions runner, so every
  benchmark showed as a false regression on CI.
@netlify

netlify Bot commented Aug 22, 2026

Copy link
Copy Markdown

Deploy Preview for afmg ready!

Name Link
🔨 Latest commit 7de11de
🔍 Latest deploy log https://app.netlify.com/projects/afmg/deploys/6a8a909bbdbf1a0008005ad4
😎 Deploy Preview https://deploy-preview-1601--afmg.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

Gating the comment step on same-repo PRs meant fork PRs (the normal case
for an open-source project) never got benchmark feedback. Use the
standard secure pattern instead: bench.yml runs the untrusted PR code
with read-only permissions and uploads results as an artifact; the new
bench-comment.yml is triggered by workflow_run (runs in the base repo's
trusted context) and posts the PR comment for every PR, fork or not.
The committed baseline was measured on a laptop, which is meaningfully
faster than the ubuntu-latest CI runner used by bench.yml, so every
benchmark showed as a false regression on CI. Replace it with results
pulled from an actual CI run (job 32599117416) so future PR comparisons
run on comparable hardware.
Two consecutive CI runs with no code changes showed several benchmarks
swinging 40-48%, which is just noise from GitHub-hosted runners sharing
CPUs with other jobs, not a real regression. Run each benchmark longer
(1.5s instead of the 500ms default) to average out more of that noise,
and raise the failure threshold from 40% to 75% so the check still
catches real (multi-x) regressions without flaking on run-to-run noise.
@barrulus

Copy link
Copy Markdown
Collaborator

Had a look at the bench failure before reviewing. It wasn't a regression: all 33 benchmarks were slower in that run (+4% to +48%, median ~30%), nothing faster. That's the runner, not the code. And it was the run on fd9e7d0a, i.e. already comparing against the CI-generated baseline, so it's runner-to-runner variance rather than local-vs-CI.

The catch with going to 75% is that the gate can then only catch roughly 2x regressions. A real 30% regression is indistinguishable from a noisy neighbour.

I tried the alternative on my fork: benchmark base and head in the same job, alternating between them over N rounds, and compare median ratios. Both refs hit the same noisy neighbours, so the machine offset cancels instead of needing to be thresholded away.

Ran it 5 times on 5 different runners with base == head, so every reported number is pure error:

  • median 1.4%, p90 3.7%
  • worst single measurement 23.9% (drawBorders large map — everything else under 8%)
  • zero false alarms at a 25% threshold across 165 measurements

So 25-30% looks usable, which would make the check about 3x more sensitive than 75%. Cost is ~4.5-6 min vs ~1 min, and it drops bench/baseline.json and the baseline-update workflow entirely, since there's nothing stored to go stale.

Script and workflow: https://github.com/barrulus/Fantasy-Map-Generator/tree/bench-ab-experiment (scripts/bench-ab.mjs). Only swaps the comparison step, your suites are untouched. Take it or leave it.

Two smaller things: several benchmarks are sub-millisecond (drawHeightmap 0.067ms, expandStates small 0.077ms), which won't give a trustworthy signal at any threshold — might be worth keeping those informational. And the workflow split for fork safety (read-only bench job + workflow_run for the comment) is the right call, good to see it done properly.

@SheepFromHeaven

Copy link
Copy Markdown
Collaborator Author

That's a great idea!

Credit to @barrulus (Azgaar#1601 (comment))
for the idea and reference implementation.

A committed baseline compares two different CI runs, so a busy neighbour
on the shared runner shows up as a "regression" that has nothing to do
with the code (see the false-positive runs earlier in this PR). Instead,
benchmark base (master) and head (the PR) alternately in the same job
across several rounds and compare median per-benchmark ratios — both
refs hit the same noisy neighbours, so that noise cancels out instead of
needing a threshold wide enough to hide it.

Validated with 5 self-comparison runs (base == head) on different
runners: median error 1.4%, p90 3.7%, worst single measurement 23.9%,
zero false alarms at a 25% threshold across 165 measurements. That's
roughly 3x more sensitive than the 75% threshold the stored-baseline
approach needed to stay quiet.

This also removes bench/baseline.json and bench-baseline.yml entirely:
there's nothing to keep in sync anymore, so no more stale-baseline
updates on every merge to master.

Trade-off: each PR now benchmarks both refs across 3 rounds (~4.5-6 min)
instead of one comparison against a stored baseline (~1 min).
SheepFromHeaven added a commit to SheepFromHeaven/Fantasy-Map-Generator that referenced this pull request Aug 23, 2026
Credit to @barrulus (Azgaar#1601 (comment))
for the idea and reference implementation.

A committed baseline compares two different CI runs, so a busy neighbour
on the shared runner shows up as a "regression" that has nothing to do
with the code (see the false-positive runs earlier in this PR). Instead,
benchmark base (master) and head (the PR) alternately in the same job
across several rounds and compare median per-benchmark ratios — both
refs hit the same noisy neighbours, so that noise cancels out instead of
needing a threshold wide enough to hide it.

Validated with 5 self-comparison runs (base == head) on different
runners: median error 1.4%, p90 3.7%, worst single measurement 23.9%,
zero false alarms at a 25% threshold across 165 measurements. That's
roughly 3x more sensitive than the 75% threshold the stored-baseline
approach needed to stay quiet.

This also removes bench/baseline.json and bench-baseline.yml entirely:
there's nothing to keep in sync anymore, so no more stale-baseline
updates on every merge to master.

Trade-off: each PR now benchmarks both refs across 3 rounds (~4.5-6 min)
instead of one comparison against a stored baseline (~1 min).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@SheepFromHeaven
SheepFromHeaven force-pushed the test/vitest-performance-benchmarks branch from e03a95c to d38e95e Compare August 23, 2026 06:14
writeFileSync(markdownOut, ...) threw ENOENT because bench/ was removed
along with the old baseline file. This always triggers on this PR's own
CI check, since origin/master has no bench suite yet to compare against
and the script's "no comparable benchmarks" fallback tried to write its
message to that same missing directory. Create parent directories before
writing any output file.
@SheepFromHeaven

Copy link
Copy Markdown
Collaborator Author

Incoperated your idea @barrulus. thanks!

@Azgaar
Azgaar changed the base branch from master to 1.148.0 August 23, 2026 10:16
@Azgaar
Azgaar merged commit b856e3f into Azgaar:1.148.0 Aug 23, 2026
8 checks passed
@Azgaar

Azgaar commented Aug 23, 2026

Copy link
Copy Markdown
Owner

I like the idea of the A/B testing a lot, but we need to agree on what we are measuring and on what data. So I will revert it from 1.148 for now and we can push it later directly to master.

The suites run on hand-built unrealistic square lattices, where real pack.cells come from voronoi, with irregular degree and jagged coastlines, so the numbers don't tell us much about actual maps, and fixture builders would need updating on every data-model change.

I'd rather measure something closer to what matters:

  1. Generation as a whole: same seeds on both branches, compare total and per-stage time. Time log for each step is already there and we can rework it as well. We would need to test both master and PR on the same exact map. If same seed/options produce a different map, it will be a good test by itself and we can try to catch where and why the randomization changes (there is a plan to lock Math.random on each step so that we get more reproducible results).
  2. Interaction via a scripted zoom/pan on a precreated fixture .map file with the default layer preset, so both branches have same data.

We already have Playwright, cached builds, and tests/fixtures/*.map, so most of the infrastructure is there.

@Azgaar

Azgaar commented Aug 23, 2026

Copy link
Copy Markdown
Owner

I'd built just TWO Playwright specs and one script, all under tests/, no bench.ts files.

  • tests/perf/generation.spec.ts
  • tests/perf/interaction.spec.ts
  • tests/perf/ab.mjs - the script

@barrulus

Copy link
Copy Markdown
Collaborator

Prototyped the three files you described: https://github.com/barrulus/Fantasy-Map-Generator/tree/perf/playwright-ab (plus a small perf-only playwright config — the main one pins testDir to tests/e2e).

generation.spec.ts regenerates at fixed seed/cell counts (10K and 100K), reading the existing TIME log from the console — no app changes, and it keeps working under your Pipeline sketch since that keeps console.time(step.id). Also heap, .map load times for the committed fixtures, and a hash of the pack arrays for the same-seed check. interaction.spec.ts loads 1.139.4.map and runs real wheel/drag gestures over the burg-densest area (the map centre is usually ocean): p95 frame time, settle time after last input, DOM node counts, plus per-layer draw timings on the fixture. ab.mjs alternates rounds between two built refs as before; a base==head run came out within ±7% on the gated metrics.

The hash check caught something on its first run: regeneration is nondeterministic on master. First load with ?seed= reproduces 6/6, but New Map with the same seed gives one of two different maps (2:1 over 6 runs). Terrain, rivers and burg counts are identical between the variants; divergence starts at generateCultures. So something async left over from the previous map consumes seeded Math.random draws between the rivers and cultures stages — your per-step RNG locking would fix exactly this. User-visible as a seed found via New Map not always reproducing from a URL. Because of it the gate only fails when base and head are each self-consistent but differ; within-side nondeterminism is a warning. Can file it as a separate issue.

Happy to open this as a PR if you want it as the base. The interaction metrics need base==head calibration on CI runners before any of them gate.

@Azgaar

Azgaar commented Aug 23, 2026

Copy link
Copy Markdown
Owner

Yes, sure, let have a new PR

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

4 participants