Skip to content

perf: three-tier performance testing (PR-gated counts, nightly timings, local-only CPU/GPU) - #179

Open
simion wants to merge 6 commits into
mainfrom
perf-ci-tier0
Open

perf: three-tier performance testing (PR-gated counts, nightly timings, local-only CPU/GPU)#179
simion wants to merge 6 commits into
mainfrom
perf-ci-tier0

Conversation

@simion

@simion simion commented Aug 12, 2026

Copy link
Copy Markdown
Owner

Implements README roadmap item 12 (performance benchmarks in CI), scoped by what a CI runner can actually measure honestly.

Research doc: docs/research/perf-ci.md.

The finding that shaped this

The roadmap item names four targets: idle CPU, cold start, main-thread jank, RSS growth. Three durations and a percentage. None gate cleanly on macos-14 (3 M1 cores, virtualised, no Metal Performance Shaders in the guest) when termic's real deltas look like performance.md's "hidden 0.23% CPU vs visible 0.33%".

I got Orca wrong twice before getting it right, both times by inferring instead of reading. Corrected picture, verified across all 28 of their workflows:

Where What
Every PR (Linux) counts and static facts: selector fan-out, max-lines ratchet, asset budget
Nightly (Linux, xvfb) app-level latency budgets, enforced (the gate script chains the budget check and propagates its exit code)
Never in CI bench:idle-cpu, bench:startup, bench:main-thread-jank — invoked by zero workflows

So the axis is what the metric is, not gated vs ungated. Their one runtime PR gate, check:zustand-selector-fanout, guards exactly termic's bear trap 5. The metrics they never run in CI are exactly the four our roadmap names.

Three tiers

Tier 0 — PR-gated (src/store/selectorFanout.test.ts). 500 mounted useTaskTabs subscribers, 1000 setSidebarWidth writes (a sidebar drag), assert zero snapshot invalidations. Models useSyncExternalStore exactly. Counts are machine-independent, so this holds on a 3-core VM.

Verified it can fail, rather than trusting it green:

Injected regression Result
?? [] instead of EMPTY_TABS identity test fails
selector derives a fresh array 500,000 invalidations vs 0

Selector bodies are exported from app.ts so the test measures the real selectors, not a copy that drifts. No behaviour change: the hooks already passed a fresh arrow per render, and useSyncExternalStore compares snapshots, never selector identity.

Tier 2 — nightly, ungated (perf/, .github/workflows/perf.yml). Cron 03:30 UTC plus workflow_dispatch, macos-14, never on PRs. Startup timing and RSS growth. Writes a $GITHUB_STEP_SUMMARY table so the numbers are readable without downloading anything, plus a 90-day JSON artifact so a metric can earn a threshold from data later.

Tier 3 — local only (bench/). Idle CPU, GPU, compositor. Promoted out of gitignored scratchpad, where it was one rm -rf from gone. It encodes seven measurement traps, each of which produces a plausible wrong number rather than an error, and it already refuted a claim that shipped into the 0.26.0 changelog.

make perf       # both sections, reported separately
make perf-ci    # nightly suite only

Idle CPU is deliberately excluded from the nightly, matching Orca's own practice.

Running it locally found five real bugs

The suite is only worth anything if it was actually executed, so I ran make perf on the Mac Studio three times. Everything below was found by running it, not by review:

  1. Startup spec failed outright. WKWebView freezes rAF for an occluded window (documented in docs/automation.md, which I'd read and then ignored), and a WebdriverIO-driven window is always occluded. Fixed with a timeout fallback, and then properly by switching the primary metric to the Paint Timing API, which is engine-recorded and rAF-independent.
  2. RSS summed 10 WebKit helpers across three unrelated apps, adding ~570 MiB of someone else's memory. Helpers are parented to launchd so ppid cannot attribute them; now takes the lowest pid above the app's, per helper type. 10 helpers → 3.
  3. memory.growth came out at -355 MiB. A fixed 5s baseline caught the startup peak and then RSS decayed, so it measured settling rather than leaking. This is GH macOS: expose the terminalGpuEnabled toggle on Mac — WebGL renderer costs ~33pp of WindowServer CPU and ~44pp of GPU on macOS 26 #140 trap 6 ("poll until quiet instead") which I had documented in bench/README.md and then walked straight into. Now polls until RSS is stable, and labels the row when it never settles.
  4. A Section 1 failure aborted make perf before Section 2 ran, hiding half the report over an unrelated failure. Status is now captured and re-raised at the end.
  5. perf/ was in no tsconfig, so none of it was typechecked. Added to e2e/tsconfig.json.

One thing I want to flag as not a bug, because I initially wrote a code comment claiming it was: the ~1.6 GiB app RSS is real and reproducible for the debug build, not a misattribution. The anchored pgrep is defensive hardening; measured on a real run the unanchored pattern matched exactly one process too. Comment corrected.

Answered: WebGL on the runner

webglRenderer: Apple GPU / hardwareWebgl: looks hardware-backed, locally. That was the open question gating any future frame-timing work. The nightly records it every run, so we get the CI answer on the first scheduled execution and notice if it ever changes.

Deliberately not done

Tier 1 e2e invariants (hidden-pane canvas count, PTY event coalescing, sidebar render count) are still proposals in the research doc. No thresholds anywhere: a metric earns one after its spread is known, and there is no spread data yet.

Docs: perf/README.md, bench/README.md, docs/performance.md (new Measuring section), CLAUDE.md. Also fixes CLAUDE.md's stale "laptop-only, no CI" claim about the e2e suite, which has run on macos-14 for a while.

No CHANGELOG entry, per CLAUDE.md.

🤖 Generated with Claude Code

https://claude.ai/code/session_01F2JzhUsFa9YLjBBvUJQGYj

Roadmap item 12 asks for perf benchmarks wired into CI so a regression
fails a PR. Researching it (docs/research/perf-ci.md) found that the
four targets it names (idle CPU, cold start, jank, RSS) are three
durations and a percentage, and none of them survive a virtualised
3-core macos-14 runner. Orca, the stated inspiration, does not gate on
them either: its idle-cpu/startup/jank benches enforce no thresholds at
all, and its terminal-perf workflow is nightly and report-only. What
Orca DOES gate on PRs is counts and static facts, on Linux.

So: gate counts, track times.

- selectorFanout.test.ts asserts bear trap 5 as a count. 500 mounted
  useTaskTabs subscribers, 1000 setSidebarWidth writes (a sidebar drag),
  zero snapshot invalidations. Machine-independent, so it holds on a CI
  VM. Verified by injecting the regression both ways: a derived array
  turns 0 into 500,000, a `?? []` breaks EMPTY_TABS identity.
- Selector bodies are exported from app.ts so the test measures the real
  selectors, not a copy that drifts. No behaviour change: the hooks
  already passed a fresh arrow per render, and useSyncExternalStore
  compares snapshots, never selector identity.
- bench/ promotes the GH #140 harness out of gitignored scratchpad. It
  refuted a claim that had shipped into the 0.26.0 changelog, and it
  encodes seven measurement traps that each produce a plausible wrong
  number rather than an error. App name and window size are now env
  vars instead of one machine's hardcoded values.
- CLAUDE.md said the e2e suite is "laptop-only, no CI". It has run on
  macos-14 in test.yml for a while, deliberately non-required.

Deferred, with reasoning in the research doc: the Tier 1 e2e invariants
(hidden-pane canvas count, PTY event coalescing, render counts), the
RSS-delta report-only step, and the Tier 2 nightly job.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2JzhUsFa9YLjBBvUJQGYj
@simion
simion marked this pull request as ready for review August 12, 2026 05:08
simion and others added 4 commits August 12, 2026 10:05
Second correction in the same direction: I understated Orca again.

The claim "terminal-perf.yml never invokes the budget checker" was wrong.
The workflow runs test:e2e:terminal-perf:scale:report under
set -euo pipefail, and run-terminal-scale-perf-report-gate.mjs chains
run -> summarize -> check-terminal-perf-report-budgets.mjs -> html,
returning the budget check's exit code immediately if nonzero. A budget
violation fails the nightly job.

So the axis is nightly vs per-PR, not gated vs ungated. Orca does
enforce app-level latency budgets in CI; it keeps them off the PR path
because the run is expensive and needs medians over many samples.

Also verified by grepping all 28 workflows (not the 7 sampled before):
bench:idle-cpu, bench:startup, bench:main-thread-jank,
bench:daemon-coldstart and bench:hang-watchdog-memory are invoked by no
workflow at all. Those are purely local tools, and they are exactly the
metrics our roadmap item 12 names.

Tier 2 rewritten accordingly: start ungated, but record why Orca's gate
works (loose absolute thresholds, medians over many samples, Linux with
no GPU variance, workflow_dispatch parameterisation) so a later gate is
a deliberate copy rather than an accident.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2JzhUsFa9YLjBBvUJQGYj
@simion simion changed the title perf: gate Zustand selector fan-out in CI, track the local GPU harness perf: three-tier performance testing (PR-gated counts, nightly timings, local-only CPU/GPU) Aug 12, 2026
Tier 2 and Tier 3 of docs/research/perf-ci.md.

  perf/                 startup + memory specs, wdio.perf.conf.ts, NDJSON
                        collector, step-summary renderer
  .github/workflows/    perf.yml, cron 03:30 UTC + workflow_dispatch,
                        macos-14, never on PRs, 90-day JSON artifact
  bench/local-report.sh section 2 of `make perf`, skips cleanly when the
                        app is not running
  make perf             both sections, reported separately
  make perf-ci          nightly suite only

Ungated by design. A metric earns a threshold once its spread is known,
and there is no spread data yet. Idle CPU is deliberately absent from CI,
matching Orca, which invokes bench:idle-cpu from none of its 28 workflows.

FIVE BUGS FOUND BY ACTUALLY RUNNING IT, three times, not by review:

1. Startup spec failed outright. WKWebView freezes rAF for an occluded
   window (docs/automation.md), and a WebdriverIO-driven window always is.
   Fixed with a timeout fallback, then properly by making the Paint Timing
   API the primary source: engine-recorded, so it survives the freeze.
   Now yields FCP 261ms / spawn-to-FCP 554ms where before it got nothing.
2. RSS summed 10 WebKit helpers across three unrelated apps, ~570 MiB of
   someone else's memory. Helpers are parented to launchd so ppid cannot
   attribute them; take the lowest pid above the app's, per type. Now 3.
3. memory.growth read -355 MiB. A fixed 5s baseline caught the startup
   peak and RSS then decayed, so it measured settling, not leaking. This
   is GH #140 trap 6, which I documented in bench/README.md and then
   walked into. Now polls until stable and labels the row if it never is.
   Growth reads +17.7 MiB over 12 cycles with both ends settled.
4. A section 1 failure aborted `make perf` before section 2, hiding half
   the report over an unrelated failure. Status captured and re-raised.
5. perf/ was in no tsconfig, so none of it was typechecked.

NOT a bug, though an earlier comment here claimed it was: ~1.6 GiB app
RSS is real for the debug build. The anchored pgrep is defensive; the
unanchored pattern matched exactly one process on a real run.

Answers an open question from the research doc: webglRenderer is
"Apple GPU" locally, hardware-backed. The nightly records it every run so
the CI answer arrives on the first scheduled execution.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01F2JzhUsFa9YLjBBvUJQGYj
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.

1 participant