Studio bench (perf/bench)
A built studio measured in Chromium against an in-process mock of the Sanity API: no tokens, no
network, one clock. The interesting output is an A/B verdict per metric from interleaved
reference/experiment sessions with a cluster bootstrap — 🔴 regression, 🟢 improvement, ✅ neutral,
⚪ inconclusive (CI too wide within budget; never a coin flip). Absolute numbers are host-relative
and only comparable with the run's calibration score alongside. perf/bench/README.md is the
full reference; this skill is the operating card. Stored results are read through sanity-radar.
Local
pnpm build:bench # required first: packages + bench studio
pnpm bench help # commands; `pnpm bench run --help` for flags
pnpm bench scenarios # singleString, arrayI18n, article, recipe, synthetic, syntheticLarge
pnpm bench run --scenario singleString --sessions 4 # absolute interaction run
pnpm bench run --mode pageload --scenario singleString # load vitals + bundle size
pnpm bench run --mode inp --scenario singleString --sessions 3
pnpm bench run --mode soak --scenario singleString --minutes 5
pnpm --filter bench build:reference-config \
&& pnpm bench run --scenario singleString --reference-dist perf/bench/.reference/dist # self-test: must be all-neutral
pnpm bench prepare-backfill --sha <sha> # build a historical commit into perf/bench/dist (tarball recipe)
pnpm bench dev # mock + sanity dev, type into a seeded doc
pnpm bench:unit # mock contract + stats tests
Flags worth knowing: --headed, --throttle 1 (interaction runs default to 4× CPU throttle),
--seed N, --budget <s>, --json-out <file>, --fail-on-verdict. In a sandbox where the tsx
CLI cannot open its IPC pipe (listen EPERM … tsx-*/…pipe), run the CLI directly:
node --conditions=monorepo --import tsx perf/bench/cli/index.ts <command>.
CI: .github/workflows/bench.yml
| Trigger |
What runs |
Result lands in |
Label a PR trigger:perf-bench (maintainers) |
PR head vs merge-base A/B, one shard per scenario. The label persists: every later push re-runs the suite until the label is removed. |
Sticky PR comment bench-report; absolute side stored under the branch (?branches=main,<branch> in Radar) |
| Cron 05:00 UTC daily |
Absolute suite on main + soak + INP |
benchRun mode: "absolute" in Radar (Trends) |
| Cron Monday 06:00 UTC |
Self-test (build vs itself, --fail-on-verdict) |
Red job = harness drift |
-f run_suite=true |
Same as the daily cron, on demand |
Radar |
-f run_suite=true -f backfill_sha=<40-char sha> |
Replay a historical main commit, store under that sha and its commit date |
Radar (fills a hole in the series) |
--ref v6.x.y -f run_suite=true -f release_tag=v6.x.y |
Measure a release commit (release-latest.yml does this automatically) |
Radar, trigger: "release" → anchors the release marker |
-f ab_from=<sha> -f ab_to=<sha> |
A/B of two arbitrary commits (reference → experiment) |
Run summary page + benchRun mode: "ab" (Radar Comparisons tool) |
-f self_test=true |
Self-test on demand |
Job status |
gh workflow run bench.yml -R sanity-io/sanity -f ab_from=<sha> -f ab_to=<sha>
gh run list -R sanity-io/sanity --workflow bench.yml --event workflow_dispatch --limit 5
gh run watch -R sanity-io/sanity <run-id>; gh run view -R sanity-io/sanity <run-id> --web
Input rules (the workflow validates them): ab_from/ab_to go together and exclude
backfill_sha and self_test; backfill_sha requires run_suite and excludes self_test;
release_tag must point at the measured commit (dispatch at the tag, or pass the tag's sha as
backfill_sha from main). Shas are full 40 characters. Historical builds (backfill, A/B, PR
reference) install the old commit with --frozen-lockfile, pack sanity and its workspace deps
as tarballs, and build HEAD's perf/bench harness against them — harness and scenarios are
always HEAD's; only product code differs. Backfill and A/B fail loudly, the PR reference falls
back to absolute mode with a warning in the comment.
Radar's run popover copies the A/B command for "this point vs the previous run" (Copy A/B vs
previous run) so the shas never need typing.
Reading a result
- The PR comment / run summary is deliberately minimal: verdicts, then a link to Radar. A missing scenario is named (
missing-scenarios.json fails the run) — silence is never "fine".
- Gates: interaction median with
absMs 16ms floor (two Event Timing quantisation steps), pageload time-to-editable; INP, vitals, resources, bundle are report-only. Thresholds live in perf/bench/stats/gate.ts and are shared with Radar's drift feed.
- ⚪ on a heavy scenario usually means the budget ran out; re-dispatch or look at
stoppedBy and failures in the stored run.
- A
hermeticity violation or unexpected endpoint session failure means the studio made a request the mock does not know: extend perf/bench/mock-api in the same PR as the studio change.
Changing the suite
- New scenario: schema in
perf/bench/studio/schemas, defineScenario in perf/bench/scenarios (seeded PRNG, never Math.random), register in scenarios/index.ts, add it to the bench-interaction matrix and BENCH_EXPECTED_INTERACTION_SCENARIOS in bench.yml, verify one absolute session passes.
- Stored document shape:
perf/bench/report/types.ts → storeShape.ts, mirrored by dev/radar/schemaTypes/benchRun.ts; bump all three together.
- Flake rules are in the README (no fixed sleeps, noise widens sampling, seeded PRNG, failed sessions retried and counted, environment drift fails fast). A change that makes a verdict flip on identical builds is a harness bug — the weekly self-test exists to catch it.
1---2name: sanity-bench3description: Run, dispatch and read the hermetic Studio performance benchmark suite in perf/bench — local runs and modes, the trigger:perf-bench PR label, the Studio Bench workflow_dispatch inputs (run_suite, self_test, backfill_sha, release_tag, ab_from/ab_to) and which combine, and how to read 🔴🟢✅⚪ verdicts. Use when asked to benchmark a PR or commit, compare two commits, backfill or re-run the daily main series, measure a release, add a bench scenario, extend the mock API, or interpret a bench PR comment or a failed Studio Bench run.4---56# Studio bench (perf/bench)78A built studio measured in Chromium against an in-process mock of the Sanity API: no tokens, no9network, one clock. The interesting output is an **A/B verdict per metric** from interleaved10reference/experiment sessions with a cluster bootstrap — 🔴 regression, 🟢 improvement, ✅ neutral,11⚪ inconclusive (CI too wide within budget; never a coin flip). Absolute numbers are host-relative12and only comparable with the run's calibration score alongside. `perf/bench/README.md` is the13full reference; this skill is the operating card. Stored results are read through `sanity-radar`.1415## Local1617```bash18pnpm build:bench # required first: packages + bench studio19pnpm bench help # commands; `pnpm bench run --help` for flags20pnpm bench scenarios # singleString, arrayI18n, article, recipe, synthetic, syntheticLarge21pnpm bench run --scenario singleString --sessions 4 # absolute interaction run22pnpm bench run --mode pageload --scenario singleString # load vitals + bundle size23pnpm bench run --mode inp --scenario singleString --sessions 324pnpm bench run --mode soak --scenario singleString --minutes 525pnpm --filter bench build:reference-config \26 && pnpm bench run --scenario singleString --reference-dist perf/bench/.reference/dist # self-test: must be all-neutral27pnpm bench prepare-backfill --sha <sha> # build a historical commit into perf/bench/dist (tarball recipe)28pnpm bench dev # mock + sanity dev, type into a seeded doc29pnpm bench:unit # mock contract + stats tests30```3132Flags worth knowing: `--headed`, `--throttle 1` (interaction runs default to 4× CPU throttle),33`--seed N`, `--budget <s>`, `--json-out <file>`, `--fail-on-verdict`. In a sandbox where the `tsx`34CLI cannot open its IPC pipe (`listen EPERM … tsx-*/…pipe`), run the CLI directly:35`node --conditions=monorepo --import tsx perf/bench/cli/index.ts <command>`.3637## CI: `.github/workflows/bench.yml`3839| Trigger | What runs | Result lands in |40| ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------ |41| Label a PR `trigger:perf-bench` (maintainers) | PR head vs merge-base A/B, one shard per scenario. The label persists: every later push re-runs the suite until the label is removed. | Sticky PR comment `bench-report`; absolute side stored under the branch (`?branches=main,<branch>` in Radar) |42| Cron 05:00 UTC daily | Absolute suite on main + soak + INP | `benchRun` `mode: "absolute"` in Radar (Trends) |43| Cron Monday 06:00 UTC | Self-test (build vs itself, `--fail-on-verdict`) | Red job = harness drift |44| `-f run_suite=true` | Same as the daily cron, on demand | Radar |45| `-f run_suite=true -f backfill_sha=<40-char sha>` | Replay a historical main commit, store under that sha and its commit date | Radar (fills a hole in the series) |46| `--ref v6.x.y -f run_suite=true -f release_tag=v6.x.y` | Measure a release commit (`release-latest.yml` does this automatically) | Radar, `trigger: "release"` → anchors the release marker |47| `-f ab_from=<sha> -f ab_to=<sha>` | A/B of two arbitrary commits (reference → experiment) | Run summary page + `benchRun` `mode: "ab"` (Radar Comparisons tool) |48| `-f self_test=true` | Self-test on demand | Job status |4950```bash51gh workflow run bench.yml -R sanity-io/sanity -f ab_from=<sha> -f ab_to=<sha>52gh run list -R sanity-io/sanity --workflow bench.yml --event workflow_dispatch --limit 553gh run watch -R sanity-io/sanity <run-id>; gh run view -R sanity-io/sanity <run-id> --web54```5556Input rules (the workflow validates them): `ab_from`/`ab_to` go together and exclude57`backfill_sha` and `self_test`; `backfill_sha` requires `run_suite` and excludes `self_test`;58`release_tag` must point at the measured commit (dispatch at the tag, or pass the tag's sha as59`backfill_sha` from main). Shas are full 40 characters. Historical builds (backfill, A/B, PR60reference) install the old commit with `--frozen-lockfile`, pack `sanity` and its workspace deps61as tarballs, and build HEAD's `perf/bench` harness against them — harness and scenarios are62always HEAD's; only product code differs. Backfill and A/B fail loudly, the PR reference falls63back to absolute mode with a warning in the comment.6465Radar's run popover copies the A/B command for "this point vs the previous run" (**Copy A/B vs66previous run**) so the shas never need typing.6768## Reading a result6970- The PR comment / run summary is deliberately minimal: verdicts, then a link to Radar. A missing scenario is named (`missing-scenarios.json` fails the run) — silence is never "fine".71- Gates: interaction median with `absMs` 16ms floor (two Event Timing quantisation steps), pageload time-to-editable; INP, vitals, resources, bundle are report-only. Thresholds live in `perf/bench/stats/gate.ts` and are shared with Radar's drift feed.72- ⚪ on a heavy scenario usually means the budget ran out; re-dispatch or look at `stoppedBy` and `failures` in the stored run.73- A `hermeticity violation` or `unexpected endpoint` session failure means the studio made a request the mock does not know: extend `perf/bench/mock-api` in the same PR as the studio change.7475## Changing the suite7677- New scenario: schema in `perf/bench/studio/schemas`, `defineScenario` in `perf/bench/scenarios` (seeded PRNG, never `Math.random`), register in `scenarios/index.ts`, add it to the `bench-interaction` matrix **and** `BENCH_EXPECTED_INTERACTION_SCENARIOS` in `bench.yml`, verify one absolute session passes.78- Stored document shape: `perf/bench/report/types.ts` → `storeShape.ts`, mirrored by `dev/radar/schemaTypes/benchRun.ts`; bump all three together.79- Flake rules are in the README (no fixed sleeps, noise widens sampling, seeded PRNG, failed sessions retried and counted, environment drift fails fast). A change that makes a verdict flip on identical builds is a harness bug — the weekly self-test exists to catch it.