Repository navigation
perf(rendering): stop scanning every cursor event on each rendered frame - #2499
richiemcilroy merged 1 commit into
Conversation
|
@apoorvdarshan is attempting to deploy a commit to the Cap Software Inc Team on Vercel. A member of the Team first needs to authorize it. |
|
Thanks for this! It now conflicts with main after today's merges. Could you merge or rebase onto the latest main? Once it's up to date I'll run the full engine verify and real-capture suites on it and merge if they pass. |
The cursor layer and click ripples searched the whole recording's move and click lists on every playback and export frame. With "Hide when idle" on, each idle frame walked the moves from the end of the recording twice, so the cost grew with recording length. PrecomputedCursorTimeline now records whether clicks are ordered (moves already were), ProjectUniforms carries both flags, and ordered recordings use binary search for the click scale, the idle hide/fade, and the ripple window. Unordered or NaN-containing events keep the original scans.
b987b51 to
bdc0931
Compare
|
@richiemcilroy Rebased onto current main (one commit, bdc0931). The one conflict was with 0aa0152 ("skip cursor fade-in lookup when fully hidden"): I kept its early return when the fade-out is 0 and moved the PR's ordered-moves slice after it. Its new opacity tests now run through both the ordered and unordered lookup paths and assert identical results. cargo test -p cap-rendering --lib gives 312 passed, and cargo clippy -p cap-rendering --all-targets -D warnings is clean. |
|
@greptileai review |
richiemcilroy
left a comment
There was a problem hiding this comment.
Verified: exports are frame- and audio-identical to the base on 1080p/4K fixtures, the ordered fast paths fall back for unordered or NaN times, CI green, Greptile 5/5. Thanks!
The GPUI editor's playback and exports pay a per-frame cursor cost that grows with recording length. On every rendered frame the cursor layer and click ripples search the whole recording's move and click lists. With Hide when idle on (the editor's Cursor tab), every idle frame walks the moves backwards from the end of the recording twice. On a 60-minute capture that is about 0.5 ms per frame at p50 and 1.6 ms at p95, spent before any GPU work.
This PR makes those lookups O(log n) for ordered recordings. Rendered output is unchanged.
Problem
CursorLayer::prepareandProjectUniforms::new_innerrun once per playback or export frame. They are given the fullCursorEventsfor the segment.compute_cursor_idle_opacityalready binary-searches for the next move. It then finds the last move withrposition, a scan from the end of the recording.compute_cursor_fade_indoes another reversewindows(2)scan from the end. Near the start of a long recording, each idle frame touches almost every move twice.get_click_tscans clicks from the front for the next click and from the back for the previous one, so every frame touches all clicks. This runs for every user, whether or not any setting is enabled.collect_click_ripplesfilters every click on every frame when ripples are on.Solution
PrecomputedCursorTimelinealready checks once whether moves are ordered (perf(rendering): avoid linear cursor scans during raw playback #2489). It now also records whether clicks are ordered: non-decreasing and NaN-free. It exposes both flags asCursorEventOrder.ProjectUniformsstores that order.new_with_precomputed_cursorfills it in from the timeline, which is the path playback, the editor preview and export use.ProjectUniforms::newleaves it unknown, so those callers keep today's scans.partition_point - 1.get_click_ttakes the previous and next click from onepartition_point.Benchmark
benchmark_idle_cursor_frame_preparationmeasures the real per-frame path. It timesProjectUniforms::new_with_precomputed_cursor, then renders withFrameRenderer::render_immediate_with_timingsand recordscursor_prepare_duration, which covers the click-ripple and cursor layer prepare.opt-level=3, the GPUI app's release optimisation level.main(3d19c5cfa) for the baseline. 3 interleaved A/B runs; the tables show the median of each run's percentiles.Cursor stage per frame (
cursor_prepare_duration)mainp50mainp95The remaining ~30 µs is the layer's normal uniform and bind-group work, and it no longer depends on recording length. At the end of a recording, and in 1-minute captures, there was little to scan, so those rows are within noise.
Building the frame uniforms, which includes the ripple window, went from 13–21 µs to 7–10 µs p50 on the 60-minute capture. At 10 minutes and below, the difference is noise.
At the 60-minute p95,
mainspends about 1.5 ms of each 16.7 ms frame at 60 fps on this lookup alone.Reproduce from the repo root:
CARGO_PROFILE_RELEASE_OPT_LEVEL=3 cargo test -p cap-rendering --release --lib \ benchmark_idle_cursor_frame_preparation -- --ignored --nocapture --test-threads=1For the baseline, copy only
benchmark_idle_cursor_frame_preparationintomain'sproject_uniforms_testsmodule and run the same command. It only uses APIs that already exist there.Validation
ordered_cursor_lookups_match_linear_scansandordered_click_ripple_window_matches_linear_scancompare the ordered paths with the original scans, bit for bit. They use the production ordering flags and cover:ordered_cursor_event_lookups_preserve_rendered_pixelsrenders through the real renderer on Metal. It covers click shrink, idle fade-out, the fully hidden cursor, and fade-in after resuming, and requires byte-identical frames between the ordered and linear paths. It also asserts that the fixture produces many distinct frames.cargo test -p cap-rendering: 302 unit tests and the integration tests pass (8 ignored).cargo clippy -p cap-rendering --all-targets -- -D warningsis clean.cargo fmt --all --checkis clean.cargo check -p cap-editor -p cap-exportpasses.ProjectUniformsonly gains a private field, and the new items arepub(crate), so no public API changes. I did not rebuild the GPUI app crate itself. I tested only on macOS; Windows and Linux are not covered. The fade-in search on ordered input is bounded by the length of the current cursor movement burst rather than O(log n); a burst only lasts until the cursor pauses for longer than the idle delay.

Confidence Score: 5/5
The PR appears safe to merge; no blocking issue was found.Summary
Speeds up per-frame cursor lookups by using cached event-order flags.
No actionable issues were found. Tests were inspected but not run.
Reviews (2) · Last reviewed commit: "perf(rendering): stop scanning every cur..." · Reviewed by Greptile