perf: three-tier performance testing (PR-gated counts, nightly timings, local-only CPU/GPU) - #179
Open
simion wants to merge 6 commits into
Open
perf: three-tier performance testing (PR-gated counts, nightly timings, local-only CPU/GPU)#179simion wants to merge 6 commits into
simion wants to merge 6 commits into
Conversation
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
marked this pull request as ready for review
August 12, 2026 05:08
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
…fig) Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01F2JzhUsFa9YLjBBvUJQGYj
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
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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:
bench:idle-cpu,bench:startup,bench:main-thread-jank— invoked by zero workflowsSo 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 mounteduseTaskTabssubscribers, 1000setSidebarWidthwrites (a sidebar drag), assert zero snapshot invalidations. ModelsuseSyncExternalStoreexactly. Counts are machine-independent, so this holds on a 3-core VM.Verified it can fail, rather than trusting it green:
?? []instead ofEMPTY_TABSSelector bodies are exported from
app.tsso the test measures the real selectors, not a copy that drifts. No behaviour change: the hooks already passed a fresh arrow per render, anduseSyncExternalStorecompares snapshots, never selector identity.Tier 2 — nightly, ungated (
perf/,.github/workflows/perf.yml). Cron 03:30 UTC plusworkflow_dispatch,macos-14, never on PRs. Startup timing and RSS growth. Writes a$GITHUB_STEP_SUMMARYtable 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 onerm -rffrom 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.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 perfon the Mac Studio three times. Everything below was found by running it, not by review: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.memory.growthcame 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 theterminalGpuEnabledtoggle 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 inbench/README.mdand then walked straight into. Now polls until RSS is stable, and labels the row when it never settles.make perfbefore Section 2 ran, hiding half the report over an unrelated failure. Status is now captured and re-raised at the end.perf/was in no tsconfig, so none of it was typechecked. Added toe2e/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 onmacos-14for a while.No CHANGELOG entry, per CLAUDE.md.
🤖 Generated with Claude Code
https://claude.ai/code/session_01F2JzhUsFa9YLjBBvUJQGYj