Skip to content

refactor: run every benchmark with strobe - #60880

Merged
Hona merged 18 commits into
devfrom
strobe-bench
Nov 4, 2026
Merged

Hona merged 18 commits into
devfrom
strobe-bench

Conversation

@Hona

@Hona Hona commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Every benchmark in the repo now runs with strobe. The app's Playwright harness, core's six benchmark-* scripts, the UI motion script, the desktop startup script and the dev-startup suite are gone: −13,712 / +3,396 lines. 57 cases in 14 *.bench.ts files cover their workloads. Each case compares two builds in pairs at normal speed, decides with an interval, refuses setups it cannot defend, and explains a move from one profiled run per side.

report

Before → after

flowchart LR
  subgraph before["before: one harness per package"]
    A[app: Playwright fixture, BENCHMARK JSON,<br/>11 rAF probes, 12 percentile copies,<br/>7 CDP memory probes, 3 Vite fixture pages]
    C[core: 6 benchmark-* scripts,<br/>own timers, medians and JSON]
    U[ui: motion-bench.ts]
    D[desktop: bench-startup.ts log scraper,<br/>e2e/devex dev-start timer]
  end
  subgraph after["after: *.bench.ts beside the code"]
    AB[app/perf: 9 files] --> S
    CB[core/perf: location, snapshot, session] --> S
    UB[ui/perf: motion] --> S
    DB[desktop/perf: startup] --> S
    S[strobe: isolate, pair A/B, trace,<br/>decide, attribute, report]
  end
  before -. workloads ported .-> after
Loading
File Cases Replaces
app/perf/timeline.bench.ts scroll history · type in composer · find in session · expand an edit search benchmark, patch-groups page
app/perf/stream.bench.ts stream a reply · into a tool-heavy history · with review open streaming benchmark (3 variants), text projection
app/perf/tabs.bench.ts switch tab (warm, review open, after a resize, 1,200 models) · open an unvisited tab (± review) tab switch (cold/warm/review/resized), provider memory, laggy session
app/perf/navigation.bench.ts Home → session · close the last tab · new session ×2 · new session page in/out · child session · restore 8 tabs · activate a restored tab · palette ×2 first navigation, Home tab navigation, session entry, inactive-tab prefetch, command palette
app/perf/home.bench.ts load home · update a session in the background, at 500 / 5,000 / 10,000 sessions Home session index
app/perf/review.bench.ts open review · filter files · next file, at 100 / 10,000 files review pane scaling, review pane
app/perf/diff.bench.ts open a long diff · a huge diff · return to a long diff session-ui highlighting page
app/perf/composer.bench.ts type in a large draft · move the cursor · recall from history composer write batch, composer-history page
app/perf/desktop.bench.ts idle —
desktop/perf/startup.bench.ts launch to Home · to a restored tab · with the service stopped · first launch · dev start to Home script/bench-startup.ts, e2e/devex
core/perf/location.bench.ts load a location, cached and dropped · keep a location graph · keep a models.dev plugin instance benchmark-location.ts, benchmark-location-memory.ts
core/perf/snapshot.bench.ts capture ×4 · 8 captures at once · list and diff 100 changed files · restore 100 files · agent step loop · pack the store benchmark-snapshot.ts, -pack.ts, -worker.ts
core/perf/session.bench.ts an agent session of 15 steps, snapshots on and off benchmark-snapshot-session.ts
ui/perf/motion.bench.ts 1,000 springs for 60 frames script/motion-bench.ts

Three kinds of case

// A page: the production build on the e2e mock server. A cached transcript is not fetched again, and the
// switch moves nothing that was already on screen.
const warm: Case<{}> = {
  primary: "main.work",
  metrics: ["run.duration", "interaction.p50", "net.requests{/api/session/*/message}", "layout.shift"],
  run: ({ page, prepared }) => page.click(tab(prepared.target), { until: prepared.targetReady }),
  async reset({ page, prepared }) {
    await page.click(tab(prepared.source), { until: prepared.sourceReady })
    await page.idle()
  },
}

// A launch: timed from process spawn to a selector, with the main process's own epoch marks as milestones.
"launch to a restored tab": {
  primary: "launch.ready",
  launch: { until: composer, milestones: packaged, profile: "warm", before: warmService },
}

// A script: a fresh Bun process per block loads the package's harness and calls its exports.
// In a git ref's worktree strobe places this checkout's harness, so its imports reach that build's code.
"capture after adding a file": { ...timed, run: (ctx) => git(ctx, "capture"), reset: (ctx) => ctx.call("add") }
  • until ends on what a person sees: strobe ignores visibility: hidden, and >>> reaches into the diff viewer's shadow root.
  • Page mocks go through strobe's mock (fixtures/mock.ts), so the browser's HTTP cache stays on and a case that reloads does not refetch every script. The e2e mock server runs unchanged.
  • Streaming cases get events from a real SSE endpoint (fixtures/events.ts) that never waits for the renderer.
  • Nothing is CPU- or network-throttled. Cases that should pay a first visit reload in reset.

Counted, compared exactly

Count Case Expected
net.requests{/api/session/*/message} switch tab (warm) 0: a cached transcript stays
net.requests{/api/session/*/message} restore 8 tabs 1: only the active tab's
count.draft.encode type in a large draft about 25 for 51 keystrokes: batched
count.draft.encode move the cursor in a large draft 0: a caret move saves no text
layout.shift switch tab, open an unvisited tab 0: nothing on screen jumps
count.git every snapshot case the processes the operation spawned

Checked against merged PRs

PR Case Base (dev before) Head (PR) Change [interval] Verdict
#60513 scroll history, main.busy 15.3% 14.2% −7.3% [−9.9 … −4.2] less, 8 pairs
#57016 stream a reply, main.work 489 ms 462 ms −6.0% [−11.7 … −0.1] less (small), 8 pairs; main.script −15%
#60755 scroll history, main.busy 13.9% 13.5% −3.5% [−5.6 … −1.1] less (small), 6 pairs
#60755 type in composer, interaction.p95 12.8 ms 12.8 ms −0.4% [−2.0 … +1.0] same (±2%)
A/A switch tab, main.work 27.9 ms 27.6 ms −1.0% [−1.9 … +0.7] same (±2%), 19 pairs

#59419 ("coalesce snapshot captures and skip repeat ignore work"), on core

strobe bench packages/core/perf/snapshot.bench.ts --base cdaba26fa5~1 --head cdaba26fa5, on the generated 20,000-file repo:

Case Base (dev before) Head (PR) Change [interval] Verdict count.git (exact)
8 captures at once, nothing changed 649 ms 103 ms −84% [−84 … −84] faster, 8 pairs 24 → 4
capture, nothing changed 84.3 ms 53.1 ms −36% [−39 … −35] faster, 8 pairs 3 → 2
agent step loop (20 steps) 4,187 ms 2,938 ms −28% [−45 … −26] faster, 8 pairs 136 → 100
capture after editing a file / adding one / editing 100 184 / 178 / 722 ms 184 / 179 / 728 ms within ±2% same same
list / diff / restore 100 files 73.7 / 97.6 / 130 ms 73.9 / 97.5 / 129 ms within ±2% same same
pack the store 383 ms 387 ms +0.8% [−0.9 … +2.4] underpowered (~43 pairs) same
  • The PR's own A/B promised that N concurrent callers cost at most 2 captures: 8 captures start 4 git processes after it, 24 before.
  • Primary intervals are 98.4% at the planned looks (8, 12, 18, 26, 40 pairs), 95% overall.

Why, for #60513 ("compute highlight styles once per subtree in Chromium")

Without reading the diff, strobe pins the win on style recalculation. Timed pairs, re-run with 8 pairs:

Per scroll run Base Head Change [95% interval]
main.busy (primary) 16.9% 15.6% −8.3% [−14 … −2.0], less
main.style 110 ms 66.7 ms −38% [−45 … −29]
main.script 86.2 ms 89.7 ms −2.1% [−8.1 … +6.4]
main.layout 14.1 ms 14.4 ms −1.3% [−9.5 … +9.0]

The profiled launches say where: the same ~500 forced style recalcs per run, each cheaper.

strobe query <rec> layout          base                       head
  forced style per run             495 (115 ms)               498 (84.1 ms)
  measureScrollState               425 ms                     294 ms
    packages/app/src/session/timeline/interaction.ts:122:25
    ← (anonymous) interaction.ts:140:26 ← tick packages/ui/src/motion/loop.ts:72:20
  handleListScroll                 140 ms                     132 ms
    packages/app/src/session/timeline/virtualizer.tsx:900:22 ← onScroll scroll-view.tsx:488:61

Removed

  • App: e2e/performance, session-ui/performance and their fixture pages; the dev debug bar's HUD (FPS, jank, long tasks, INP, CLS, heap, nav time; bar.tsx −480, 63 locales −1,350), which strobe record app:opencode measures from presented frames instead of rAF gaps. The bar keeps provider stats, the dev toggles, memory diagnostics and the Knobs.
  • Core: script/benchmark-{location,location-memory,snapshot,snapshot-pack,snapshot-session,snapshot-worker}.ts.
  • UI: script/motion-bench.ts. Desktop: script/bench-startup.ts. App: e2e/devex.
  • Their bench:* package scripts, a search event only the benchmark dispatched (search-controller.ts), messageDelay in the mock server, a profile script that imported from another machine's worktree, and a stale test-speed log.

Kept elsewhere

Old check Owner now
Layout continuity under CPU stress e2e/stability (bun run test:stability), a correctness suite
Theme preload before first paint (devex) test-browser/theme-preload.test.ts, electron.vite.config.test.ts
Snapshot robustness probes core/test/snapshot.test.ts
Pack correctness core/test/git.test.ts

Setup

  • @hona/strobe (catalog, 0.3.0) is a devDependency of the root, app, core, ui and desktop, for the bench files' types: typecheck and typecheck:e2e check every *.bench.ts. strobe runs its own embedded copy at run time. The package is also strobe's CLI launcher: bunx strobe … from the repository root runs the pinned build, one optional dependency per platform: Windows x64, macOS arm64 and x64, Linux x64 and arm64.
  • bunfig.toml exempts @hona/strobe and its five platform packages from the 3-day minimum release age, as it does other first-party packages.
  • Script cases (core, ui) run on every OS; on Linux and macOS they were checked from the npm package (bunx strobe bench … --check: snapshot 10/10, location 4/4, session 2/2, motion 1/1 on Linux; snapshot 10/10, motion 1/1 on macOS). Page and desktop cases measure the most on Windows; packages/app/perf/README.md lists what each OS gives.
  • The snapshot fixtures are generated once: bun packages/core/perf/snapshot.ts medium opencode.

Strobe gaps found here, all built

Issues Feature
#1, #2 Launch cases: spawn to a selector with milestones, through a command
#9 Script cases for Bun and Node code
#10, #11 Exact counters (requests, app counters), layout.shift
#3, #4 --check, published types
#5–#8 Default parameter subsets, hover and focus-aware waits, mocks that keep the HTTP cache, teardown
#12–#15 Case name in prepare, CSS visibility, shadow DOM, main.work across a page load

@Hona Hona changed the title feat(app): strobe benchmarks for the timeline, streaming, tab switching and the desktop app refactor(app): run every benchmark with strobe and remove the Playwright harness Oct 31, 2026
@Hona Hona changed the title refactor(app): run every benchmark with strobe and remove the Playwright harness refactor: run every benchmark with strobe Nov 1, 2026
@Hona
Hona marked this pull request as ready for review November 4, 2026 00:06
@Hona
Hona requested a review from Brendonovich as a code owner November 4, 2026 00:06
Copilot AI balanced review requested due to automatic review settings November 4, 2026 00:06

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.

Copilot was unable to review this pull request because the user who requested the review has insufficient remaining quota. Try requesting a review at a lower effort level.

An organization owner can choose the repository organization as the billing source for reviews requested by members with a paid Copilot license. Learn about Copilot code review billing.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The port looks clean: type checks pass for the bench files in app (e2e tsconfig), core, ui, desktop and gui-plugins; the stability and playwright-config/theme-preload tests pass; and the core snapshot harness runs under plain Bun with the git process counts the description reports (2 for a no-change capture, 4 for 8 at once). I could not run any *.bench.ts end to end, because strobe 0.2.0 only ships a Windows x64 binary (strobe on Linux: "no build for linux-x64 is installed").

Public API: unchanged. Only dev-only package scripts (bench:*, test:bench*, root bench:devex), internal e2e helpers and the dev-only debug bar performance readouts are removed.

  1. macOS and Linux lose every benchmark. The removed tools (Playwright perf suite, core benchmark-*, motion-bench.ts, bench-startup.ts) ran anywhere Bun or Playwright ran. Now none of the 57 cases runs off Windows, including the pure-Bun core and ui cases. Neither the perf README nor the desktop README says so, and strobe.json lists a macOS exe path that can't be used yet. Please either keep a cross-platform way to run the script cases until strobe ships those builds, or state clearly that benchmarks are Windows-only for now (see inline).
  2. Stale skill line. .opencode/skills/app-e2e-tests/SKILL.md:57 still says messageDelay exists for benchmarks (mock-server.ts:82), but this PR removes it. Agents following the skill will look for an option that no longer exists.

per block and calls its exports. Both builds run the same harness: in a git ref's worktree strobe places this
checkout's copy, so its imports reach that build's code.

## Run

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

strobe 0.2.0 only ships @hona/strobe-win32-x64, so every command below fails on macOS and Linux (no build for linux-x64 is installed), including the pure-Bun script cases in core and ui that used to run anywhere as bun script/benchmark-*.ts. Please say here, and in packages/desktop/README.md, that benchmarks need Windows x64 for now, or keep a way to run the script harnesses (snapshot.ts, location.ts, motion.ts) without strobe until it ships other platforms.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

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

Resolved by strobe 0.3.0, which this PR now pins. 0.3.0 ships macOS (arm64, x64) and Linux (x64, arm64) builds beside Windows x64 (@hona/strobe-darwin-arm64, -darwin-x64, -linux-x64, -linux-arm64). The root devDependencies list @hona/strobe too, so bunx strobe from the repository root runs the pinned build on every OS.

The script cases ran from the npm package on Linux and macOS (bun install --frozen-lockfile, then bunx strobe, no local strobe build):

Linux x64 (Debian 13 VM, 2 vCPU), e67bc51370:

$ bun packages/core/perf/snapshot.ts medium
$ bunx strobe bench packages/core/perf/snapshot.bench.ts --head . --check
10 of 10 cases passed
$ bunx strobe bench packages/core/perf/location.bench.ts --head . --check
4 of 4 cases passed
$ bunx strobe bench packages/core/perf/session.bench.ts --head . --check --params fixture=medium
2 of 2 cases passed
$ bunx strobe bench packages/ui/perf/motion.bench.ts --head . --check
1 of 1 cases passed

macOS arm64 (M4 MacBook Air, macOS 27), e67bc51370, fixture in the default temp folder:

$ bun packages/core/perf/snapshot.ts small
small: /private/var/folders/…/T/opencode-snapshot-bench/fixtures/small (2 s)
$ bunx strobe bench packages/core/perf/snapshot.bench.ts --head . --check --params fixture=small
10 of 10 cases passed
$ bunx strobe bench packages/ui/perf/motion.bench.ts --head . --check
1 of 1 cases passed

The macOS run found a bug in this PR, now fixed in a3719bc480. macOS's temp folder is a symlink (/var/folders is /private/var/folders). Snapshot compares a location with its worktree's real path, so a fixture generated under that symlink read as outside its own project, and every capture returned undefined. The harness now resolves the fixture root's real path.

Review point 2 (the stale messageDelay line in the e2e skill) was fixed in e825d74cc5.

What still differs by OS is now in packages/app/perf/README.md and packages/desktop/README.md. Script cases run the same everywhere. Page and desktop cases measure the most on Windows:

  • On macOS and Linux, frames come from Chromium's trace only. OS present events (--os-frames) are Windows only.
  • A software GPU is refused, so a VM or a server without a GPU cannot run page cases.
  • macOS runs are not pinned to CPUs (macOS has no affinity API).
  • On macOS, window pixels need the Screen Recording permission.
  • On macOS, a desktop case launches only a build whose bundle id this account has never run. An isolated launch cannot move a Cocoa app's ~/Library, so strobe refuses rather than write into the user's own.
  • Linux finds windows through X11.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Agreed, this is resolved at pr/head (e67bc5137).

  • 0.3.0 is pinned: the root catalog in package.json pins @hona/strobe to 0.3.0. In bun.lock, 0.3.0 lists optional builds for darwin-arm64, darwin-x64, linux-arm64, linux-x64 and win32-x64.

  • It runs on Linux x64: I ran it here in a separate worktree of pr/head (a Cloud Hypervisor VM). bun install --frozen-lockfile installed @hona/strobe-linux-x64@0.3.0. Then from the repository root:

    • bunx strobe bench packages/ui/perf/motion.bench.ts --head . --check passed 1 of 1.
    • bunx strobe bench packages/core/perf/location.bench.ts --head . --check passed 4 of 4.
    • strobe only warned that it was running in a VM, and the check passed anyway.

    I didn't run snapshot or session here, and I didn't test on macOS.

  • The macOS fix looks right: packages/core/perf/snapshot.ts:28 now builds the fixture root with realpathSync(base). location.ts:30 already used fs.realpath, so both harnesses get the real path for /var/folders → /private/var/folders.

  • The docs cover the OS limits: packages/app/perf/README.md:77-86 lists them: Windows-only --os-frames, a software GPU refused, no CPU pinning on macOS, Screen Recording permission, X11 on Linux. packages/desktop/README.md:73 covers the macOS bundle-id limit.

I didn't change anything in repo/. The shared checkout is a detached HEAD at e825d74cc, which is behind pr/head, and it has a bun.lock edit that isn't mine. I left both alone. The temporary worktree I used is removed.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The new commit fixes the stale messageDelay reference in the e2e skill I raised last time; its new timeline.ts:326 pointer is correct. The Windows-only point (thread on packages/app/perf/README.md) is still open. Public API unchanged. Nothing new to raise.

Hona added 12 commits November 4, 2026 11:07
core/perf (location, snapshot, session), ui/perf/motion and desktop/perf/startup replace core's benchmark-* scripts, ui's motion-bench, desktop's bench-startup and the e2e/devex dev-start suite. @hona/strobe-bench types the bench files, which every package typecheck now covers.
Page mocks keep the HTTP cache (fixtures/mock.ts); cases count history fetches, draft encodes and layout shift exactly; hover parks the pointer; Home and review sweep their larger sizes on request.
The snapshot cases then load at git refs whose session and model APIs differ.
Sessions in one project capture together; #59419 coalesced those captures (649 to 103 ms, 24 to 4 git processes on the medium fixture).
…became

@hona/strobe is the SDK and the CLI launcher (bunx strobe runs the pinned version); @hona/strobe-bench is a deprecated alias. The perf README adds report --format pr for pull request tables.
0.3.0 ships builds for macOS (arm64, x64) and Linux (x64, arm64) beside Windows x64; bunfig.toml exempts every platform package from the release-age rule. The root devDependencies list it too, so bunx strobe runs the pinned version from the repository root, where every bench command runs.
… on macOS

macOS's temp folder is a symlink (/var/folders is /private/var/folders). Snapshot compares a location with its worktree's real path, so a fixture generated there read as outside its own project and every capture returned undefined. The harness now resolves the fixture root's real path.
strobe 0.3.0 runs on Windows, macOS and Linux; script cases run the same everywhere, page and desktop cases measure the most on Windows.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Benchmarks now run off Windows: on Linux, with strobe 0.3.0, the ui motion bench (1/1) and core snapshot bench (10/10) pass with --check, and the READMEs say what each OS supports. That covers my earlier point. The snapshot harness's real-path fix is correct. Public API unchanged. Nothing new to raise.

@opencodejr opencodejr Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

This push only merges dev, and none of the PR's files change. The core snapshot bench still passes on Linux after #61546 (--check, 10/10). Thread 1 (Windows-only benchmarks) was already addressed by the strobe 0.3.0 bump and the per-OS notes. Public API unchanged. Nothing new to raise.

@Hona
Hona merged commit fe04656 into dev Nov 4, 2026
20 checks passed
@Hona
Hona deleted the strobe-bench branch November 4, 2026 02:05
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.

2 participants