Cut per-frame UI cost and add frame-level profiling - #116
Merged
Conversation
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>
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.
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
renderfunctions, but thoseturned 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:
presentis 0.30 msWhat was left: 13.5 ms across 3,637 measure calls — 3.7 us each.
Fixes
Truncated text re-measured every pass.
TextLayout::layoutrefused its owncached 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 cloningthe entire
TimelineState— every track, clip, and MIDI note — once per visiblerow 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):
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) withframe_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.rsso the element path anda future snapshot painter cannot disagree.
Testing
cargo fmt --all -- --checkclean;cargo test -p sphere_ui_components --lib976 passing;
cargo check -p futureboard_nativeclean. New tests cover thegesture 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