cmux NEXT perf: one harness times splits, the side pane, the sidebar, a new workspace at the top and switches (app-perf)

Merged
TE
14d ago
+726-18

Leo-ask: ask-995a5ef560

Leo (2026-10-21): "the fellas and i we were talking about systemically why are things slow how can they get faster / e.g. splitting, e.g. cmd shift d, e.g. adding stuff to sidebar, all that... / personally, i was thinking how some companies/apps have rendering systems, and about games -- well why can they do things faster? because they have animations and logic for recalculating bundled in / i think we're frequently just recalculating too often, too unnecessarily / there's more with virtualizing things... i imagine we're not really virtualizing too much of our sidebar. right now adding a workspace at the top ... it pushes all the stuff down"

This is step 1 of that program: one harness that measures those gestures, so each fix PR can show a before and after number from the same harness.

What it measures

scripts/cmux-next/app-perf.py launches a tagged cmux-next app (scratch config), holds 10 and then 50 terminal workspaces, and times each gesture from the press until the window rests:

scenariogesture
split-rightCmd-D (real key path, debug.key), then the close (untimed)
split-downCmd-Shift-D, then the close
right-sidebar-open / -closeCmd-Opt-B, the side pane (read from the main pane's width)
sidebar-hide / -showCmd-B
new-topNew Workspace at Top above the existing 10 or 50
switchgoToWorkspace between the first and the last workspace

Per scenario: frame interval p50/p95/p99/max, missed vsyncs, frames dropped at 120 Hz, main-thread busy ms per frame (p50/p95/max and the gesture's total), NSView.layout() calls and window layout passes per frame, run-loop gaps over 8.3 ms, hangs, the busiest layout classes, the sidebar and layout work counters (debug.sidebar_perf, debug.layout_counters), and the app's own press-to-commit ms for new-top. It prints a Markdown table and a REPORT-JSON line; the JSON keeps every frame.

A run passes only as a full set (RESULT PASS): every requested workspace count fills, and every round of every scenario does what it names (a split made a pane; each toggle round has both halves and each press changed the main pane's width; new-top's single new workspace is the sidebar's first workspace row, read from debug.sidebar_rows; a switch landed). Otherwise RESULT FAIL lists the reasons in the output and in failures. Slow numbers are data, not failures.

Dropped at 120 Hz: on a 120 Hz display it counts the vsyncs each interval lost; on a 60 Hz console it estimates per frame the larger of the 120 Hz frames the interval lost and the 120 Hz frames the main thread's work overran (work over 8.33 ms misses a 120 Hz vsync). The table header names the display rate it ran at.

App side (DEBUG builds only)

  • debug.frames keeps, per frame, the main run loop's awake time (two CFRunLoopObservers, wake and before-wait) and the deltas of two new LayoutPassGuard counters. raw: true returns the per-frame arrays. Stop condition and cleanup: the observers exist only between start and stop; stop, or the probe's existing 600 s expiry, invalidates and removes them. No timer or display link is added (the probe's existing FrameClient ticks).
  • LayoutPassGuard.viewLayoutCalls / windowLayoutPasses: plain integer adds inside the existing -[NSView layout] / -[NSWindow layoutIfNeeded] swizzle, which already updates a dictionary per call. Compiled under #if DEBUG; a release build always reads 0. A window pass counts only when it laid out a view.

Run it (fleet, alone on an owned mini's console)

cmux-ci run --class exclusive --measurement --label measure --script scripts/measure/split-spring.sh \
  --ref <full sha> --key app-perf-<sha12> --arg=--suite=app-perf --artifact artifacts/split-spring/report.json

scripts/measure/split-spring.sh is the allowlisted entry; split-spring-terminal.py --suite=app-perf hands the run to app-perf.py, the way --suite=pane-perf does. Options: --arg=--workspaces=10,50, --arg=--rounds=5, --arg=--scenarios=split-right,new-top, --arg=--input=action.

No capture: no UI change (a debug probe and a script).

Sample run at this head

Script at ccaa3a0fd340 on the fleet build of bd7d426075d1 (job 07e085411c1223a738507cf1, tag apperf; the Swift is identical at both heads, only app-perf.py changed). Run on capture mini cmux-mac-mini-cua-3 (capture-host lease, --open):

python3 -I app-perf.py --tag apperf --app 'app/cmux DEV apperf.app' --open --sha bd7d426075d10341c0bed6205156adc622c2ecd5 --workspaces 10,50 --rounds 3 --markdown table.md

It printed closing 52 workspaces an earlier run left, then 55 of 55 rows ok and RESULT PASS. The run's files and the mini's tags/apperf state were removed afterwards.

The sample I posted earlier (at c6fda1223b89) is superseded and its numbers are wrong. Its new-top closes were refused (closeWorkspace needs confirm from a script), so later rounds ran with extra workspaces. The restore check added since then caught that.

Output table

app-perf at bd7d426075d1 on cmux-austin-mini-1 (macOS 27.0.1), display 60 Hz, 3 rounds, input key. Frame and main-thread columns pool every round's frames; totals are medians per gesture.

wsscenariookframe p50p95p99max msmisseddrop@120main ms/frame p50p95maxmain ms totalview layouts/frame p95maxview layouts totalwindow passes/frame maxwindow passes totalgaps>8.3mshangsapp ms
10split-right3/316.716.733.333.3290.2511.6324.50167.3146427155440-
10split-down3/316.716.733.333.32100.3012.4621.65158.9146427055450-
10right-sidebar-open3/316.716.733.378.9140.173.3867.4662.71825581021-
10right-sidebar-close3/316.716.733.539.4260.165.0528.1080.41455481020-
10sidebar-hide3/316.716.733.333.32120.2114.6824.52145.3555633795570-
10sidebar-show3/316.716.733.333.31130.2214.3528.73169.9566246497590-
10new-top3/316.729.044.348.65170.1416.5970.38170.99801425147022.7
10switch6/616.716.733.351.0140.152.3240.9956.1018183320-
50split-right3/316.716.733.333.32110.2413.2830.41186.8147027165440-
50split-down3/316.716.733.333.32120.2512.6025.28189.1146427155450-
50right-sidebar-open3/316.716.733.939.4140.175.5732.3972.91465591120-
50right-sidebar-close3/316.716.733.341.1260.174.1629.7584.91465591120-
50sidebar-hide3/316.716.733.333.31100.2014.4224.44139.75657342106180-
50sidebar-show3/316.716.733.3502130.2913.9931.50159.45763472108390-
50new-top3/316.733.350.459.84230.1529.1661.55194.011851446158027.2
50switch6/616.716.733.748.3140.155.3946.11101.14154731720-

Busiest layout classes per gesture (median NSView.layout calls):

  • 10 ws split-right: _NSCoreHostingView 38, NSScrollPocket 35, NSGlassEffectContainerView 32, _NSCoreHostingView 32, NSThemeFrame 19, NSView 11
  • 10 ws split-down: _NSCoreHostingView 38, NSScrollPocket 35, NSGlassEffectContainerView 32, _NSCoreHostingView 32, NSThemeFrame 18, NSView 11
  • 10 ws right-sidebar-open: NSScrollPocket 8, NSView 3, NSThemeFrame 2, _NSCoreHostingView 2, NSGlassEffectContainerView 2, HomeSidebarSplitView 2
  • 10 ws right-sidebar-close: NSScrollPocket 8, NSView 3, NSThemeFrame 2, _NSCoreHostingView 2, NSGlassEffectContainerView 2, HomeSidebarSplitView 2
  • 10 ws sidebar-hide: NSScrollPocket 54, NSView 24, TitlebarBandButton 19, NSThemeFrame 13, _NSCoreHostingView 12, NSGlassEffectContainerView 12
  • 10 ws sidebar-show: NSScrollPocket 73, NSView 32, TitlebarBandButton 25, NSThemeFrame 17, _NSCoreHostingView 16, NSGlassEffectContainerView 16
  • 10 ws new-top: NSThemeFrame 10, NSView 8, _NSCoreHostingView 6, WorkspaceStatusView 6, HoverRevealProbe 6, TabStripView 6
  • 10 ws switch: NSThemeFrame 2, _NSCoreHostingView 2, NSGlassEffectContainerView 2, NSNextStepFrame 2, DividerMouseCatcherView 2, _NSCoreHostingView 2
  • 50 ws split-right: _NSCoreHostingView 38, NSScrollPocket 35, NSGlassEffectContainerView 32, _NSCoreHostingView 32, NSThemeFrame 19, NSView 11
  • 50 ws split-down: _NSCoreHostingView 38, NSScrollPocket 35, NSGlassEffectContainerView 32, _NSCoreHostingView 32, NSThemeFrame 19, NSView 11
  • 50 ws right-sidebar-open: NSScrollPocket 9, NSView 3, NSThemeFrame 2, _NSCoreHostingView 2, NSGlassEffectContainerView 2, _NSCoreHostingView 2
  • 50 ws right-sidebar-close: NSScrollPocket 9, NSView 3, NSThemeFrame 2, _NSCoreHostingView 2, NSGlassEffectContainerView 2, _NSCoreHostingView 2
  • 50 ws sidebar-hide: NSScrollPocket 60, NSView 24, TitlebarBandButton 19, NSThemeFrame 13, _NSCoreHostingView 12, NSGlassEffectContainerView 12
  • 50 ws sidebar-show: NSScrollPocket 81, NSView 32, TitlebarBandButton 25, NSThemeFrame 17, _NSCoreHostingView 16, NSGlassEffectContainerView 16
  • 50 ws new-top: NSThemeFrame 11, NSView 8, _NSCoreHostingView 6, WorkspaceStatusView 6, HoverRevealProbe 6, TabStripView 6
  • 50 ws switch: NSThemeFrame 16, WorkspaceRowView 10, UnreadBadgeView 2, _NSCoreHostingView 2, NSGlassEffectContainerView 2, DividerMouseCatcherView 2

Agent session: cosmic-gray

🤖 Generated with Claude Code