Skip to content

Cut per-frame UI cost and add frame-level profiling - #116

Merged
arizkami merged 5 commits into
mainfrom
perf/ui-frame-cost
Aug 17, 2026
Merged

Cut per-frame UI cost and add frame-level profiling#116
arizkami merged 5 commits into
mainfrom
perf/ui-frame-cost

Conversation

@arizkami

Copy link
Copy Markdown
Collaborator

Why

Playback stuttered on sessions with many plug-ins, and every attempt to fix it
was a guess: the profiler could time this crate's render functions, but those
turned out to be 3 ms of a 32 ms frame. Everything after them — layout, paint,
present — was invisible.

So this branch does two things: it fixes the per-frame costs that measurement
could prove, and it builds the instrumentation that found them.

What the measurements ruled out

Each of these was a plausible cause. All were eliminated with numbers rather
than reasoning:

Hypothesis Evidence against
Audio and UI threads not separated Callback is wait-free; plug-ins run out of process
MIDI clip drawing A nearly empty viewport still took 40 ms
GPU / renderer present is 0.30 ms
Channel count The slow session has 31 tracks
Font fallback / text shaping 0.08 ms, 7 cache misses
Accessibility tree 0.04 ms
Too many elements 3,034 layout nodes
Taffy re-measuring 1.2 measure calls per node

What was left: 13.5 ms across 3,637 measure calls — 3.7 us each.

Fixes

Truncated text re-measured every pass. TextLayout::layout refused its own
cached size whenever the style truncated, because a cached layout might have
been built without truncation. Taffy measures a node more than once per frame
and this UI truncates nearly every label it draws. Recording the truncation
width the layout was built with answers that question instead of assuming the
worst; a layout is reused only when it was built for exactly the width being
asked for, so output is unchanged.

Repaint scope. The studio root was notified on playhead motion, so GPUI
re-rendered, re-laid-out, and repainted every panel in the window ~31 times a
second for a bar.beat label that changes twice. It now follows what the chrome
actually displays; the playhead, meters, and status footer already reach their
own isolated entities.

Whole-project clones per row per frame. Lane, clip, automation, and ruler
event closures must own what they read to be 'static, and did so by cloning
the entire TimelineState — every track, clip, and MIDI note — once per visible
row per frame. They now carry only the viewport transform and snap grid.

MIDI clip previews. Resolved into paint-ready geometry during element build,
in one allocation-free pass bounded by visible pixels rather than note count,
and culled to the on-screen slice of the clip.

Meter path. Meters were resolved against the track list with a linear find
each — O(tracks x meters) at the display refresh — and allocated two owned key
strings per plug-in output channel per tick.

Measured in release at the reported scale (2,103 channels):

TimelineState::clone x12 rows   54.30 ms/frame -> 0.003 ms
meter tick (lookup + keys)       3.41 ms       -> 0.32 ms
meter path per second          491 ms/s        -> 47 ms/s

Instrumentation

The Profiler overlay now shows hot scopes, a frame breakdown, the draw split,
and the build stamp of the running executable. Collection follows the overlay
and clears when it closes, so it costs nothing when not in use.

GPUI is patched (recorded in crates/gpui/PATCHED.md) with frame_profile,
reporting draw/present duration, the prepaint/paint/a11y split, layout solve and
measure-callback time, layout node count, and text shaping that missed the
line-layout cache. Cost is a handful of Instant::now() calls per frame.

Also here

The WGPU timeline renderer's clear-only scaffold is replaced with a real
instanced quad pipeline drawing the same primitives as the GPUI paint fallback,
with cached target and buffers. Compositing into GPUI is still pending, so the
paint fallback remains the user-visible path — and the 0.30 ms present time says
this was never where the frame went.

Clip visual geometry moved to render/clip_geometry.rs so the element path and
a future snapshot painter cannot disagree.

Testing

cargo fmt --all -- --check clean; cargo test -p sphere_ui_components --lib
976 passing; cargo check -p futureboard_native clean. New tests cover the
gesture context against the state it replaces, clip preview bounds and pitch
mapping, meter key formatting and generation-based pruning, the repaint rule,
the WGPU instance builder, and a GPU readback of the offscreen pass (skipped
when no adapter is present).

Not verified: the effect of the text-measure fix on a real session. The numbers
above are from release micro-benchmarks at the reported scale and from the
in-app profiler; the last build has not been run yet.

🤖 Generated with Claude Code

arizkami and others added 5 commits August 17, 2026 11:18
Playback repainted far more than the visible state changed, and the
profiler could not show where the time went, so each fix was a guess.

Measured at the reported project scale (release, 2103 channels):

  TimelineState::clone x12 rows   54.30 ms/frame -> 0.003 ms
  meter tick (lookup + keys)       3.41 ms      -> 0.32 ms
  meter path per second          491 ms/s       -> 47 ms/s

Arrangement:
- Add TimelineGestureContext so lane, clip, automation, and ruler event
  closures own only the viewport transform and snap grid. They cloned
  the whole TimelineState — every track, clip, and MIDI note — once per
  visible row per frame to satisfy 'static.
- Resolve MIDI clip previews into paintable geometry during element
  build, in one allocation-free pass bounded by visible pixels rather
  than note count, and cull to the on-screen slice of the clip.
- Drop the pre-gesture ClipState from ClipResizeDrag; the timeline root
  captures it on the first drag-move instead, so the payload rebuilt for
  every clip on every repaint is identity-only.

Meters:
- Resolve published meters through one id -> index map instead of a
  linear find per meter (O(tracks x meters) at the display refresh).
- Stamp entries with a generation counter instead of rebuilding a set of
  owned key strings, and format keys into a reusable buffer.

Repaint scope:
- Notify the studio root only when the transport chrome's bar.beat
  readout actually changes. Notifying it on playhead motion made GPUI
  re-render, re-lay-out, and repaint every panel in the window ~31 times
  a second for a label that changes twice.

Renderer:
- Replace the clear-only WGPU scaffold with a real instanced quad
  pipeline drawing the same primitives as the GPUI paint fallback, with
  cached target and buffers. Compositing into GPUI is still pending, so
  the fallback remains the user-visible path.

Profiling:
- Show hot scopes, a frame breakdown, and the running build stamp in the
  Profiler overlay; collection follows the overlay and clears when it
  closes.
- Patch GPUI with frame_profile, reporting draw/present duration, text
  shaping that missed the line-layout cache, and scene primitive count.
  Without it a 40 ms frame containing 0.2 ms of app work is
  indistinguishable from a broken profiler.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The arrangement is moving from an element tree to a painted surface, so
the note preview and clip visuals need one implementation both paths can
read. Living inside the per-clip GPUI element made that impossible
without duplicating it — and two copies of this geometry would drift.

render/clip_geometry.rs is pure: clip data in, paint-ready geometry out,
no GPUI and no theme lookups. midi_clip.rs now renders from it, so the
element path and the coming snapshot painter cannot disagree.

No behavior change; the geometry tests move with the code.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The frame breakdown proved the cost is inside GPUI's draw (27 ms of a
31 ms frame) and ruled out text shaping (0.04 ms, 3 cache misses) and
the GPU (0.30 ms present). But 27 ms to emit 5,367 primitives is ~5 us
each, which no reasonable paint pass costs — so the remaining question
is whether the time is layout or paint, and that needs measuring rather
than guessing.

Report each phase separately, plus the layout node count. Containers lay
out without drawing, so the node count is normally far larger than the
primitive count and is what prepaint scales with — the number that says
whether replacing per-clip elements with a painted surface is the right
fix.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The draw split put 26 ms of a 31 ms frame in prepaint, and ruled out the
accessibility tree (0.04 ms) and text shaping (0.08 ms). But it also
ruled out the theory behind the planned painter rewrite: the frame lays
out only 3,006 nodes, so ~8.7 us per node is the anomaly, not the tree
size. Replacing per-clip elements with a painted surface would cut nodes
that are not what is costing the time.

Report the layout engine's solve separately from the rest of prepaint,
and count measure callbacks. Taffy runs several sizing passes, so a
measure count far above the node count would explain the per-node cost —
and points at element styles rather than element quantity.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The layout solve is 20.9 ms of a 32 ms frame, and 13.5 ms of that is
measure callbacks — 3,637 of them, against 3,034 layout nodes. About one
call per node, so taffy is not thrashing; each call simply costs 3.7 us,
which is the anomaly.

TextLayout::layout refused its own cached size whenever the style
truncated, on the grounds that the cached layout might have been built
without truncation. Taffy measures a node more than once per frame, and
this UI truncates nearly every label it draws, so those elements re-ran
line wrapping and truncation on every pass. Text shaping stayed cheap
(0.08 ms) precisely because the shaping cache was working — it was the
wrapping around it that was thrown away.

Record the truncation width the layout was produced with and compare it,
instead of disabling the cache. A layout is reused only when it was built
for exactly the width being asked for, so the output is unchanged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@arizkami
arizkami merged commit 45c4267 into main Aug 17, 2026
5 of 9 checks passed
@arizkami
arizkami deleted the perf/ui-frame-cost branch August 17, 2026 08:33
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