Skip to content

perf(rendering): stop scanning every cursor event on each rendered frame - #2499

Merged
richiemcilroy merged 1 commit into
CapSoftware:mainfrom
apoorvdarshan:perf/cursor-layer-event-lookups
Oct 19, 2026
Merged

richiemcilroy merged 1 commit into
CapSoftware:mainfrom
apoorvdarshan:perf/cursor-layer-event-lookups

Conversation

@apoorvdarshan

@apoorvdarshan apoorvdarshan commented Oct 13, 2026 •

Copy link
Copy Markdown
Contributor

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::prepare and ProjectUniforms::new_inner run once per playback or export frame. They are given the full CursorEvents for the segment.

  • compute_cursor_idle_opacity already binary-searches for the next move. It then finds the last move with rposition, a scan from the end of the recording. compute_cursor_fade_in does another reverse windows(2) scan from the end. Near the start of a long recording, each idle frame touches almost every move twice.
  • get_click_t scans 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_ripples filters every click on every frame when ripples are on.

Solution

  • PrecomputedCursorTimeline already 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 as CursorEventOrder.
  • ProjectUniforms stores that order. new_with_precomputed_cursor fills it in from the timeline, which is the path playback, the editor preview and export use. ProjectUniforms::new leaves it unknown, so those callers keep today's scans.
  • For ordered events:
    • The idle last move becomes partition_point - 1.
    • The fade-in search only looks at moves before the playhead. It stops at the start of the current movement burst instead of walking from the end of the recording.
    • get_click_t takes the previous and next click from one partition_point.
    • Ripples narrow the click list to the window that can pass the existing age filter, then run that filter unchanged.
  • Unordered or NaN-containing events take the original code paths unchanged.

Benchmark

benchmark_idle_cursor_frame_preparation measures the real per-frame path. It times ProjectUniforms::new_with_precomputed_cursor, then renders with FrameRenderer::render_immediate_with_timings and records cursor_prepare_duration, which covers the click-ripple and cursor layer prepare.

  • Capture: synthetic 60 Hz move bursts lasting 4 s, then 2 s idle, with two clicks per burst. Default smoothing, circle cursor, Hide when idle and ripples on.
  • Frames: taken in the idle gaps, where the idle path runs in full. 512 measured frames per workload, after 32 warmup frames.
  • Machine: Apple M4, macOS, Rust 1.88.0, release profile (thin LTO, one codegen unit) at opt-level=3, the GPUI app's release optimisation level.
  • Method: the identical benchmark was added to unmodified 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)

Capture Playhead main p50 This PR p50 main p95 This PR p95
10 min (24,000 moves, 400 clicks) start 151.3 µs 30.5 µs 247.7 µs 55.7 µs
10 min middle 107.4 µs 31.3 µs 176.2 µs 57.1 µs
10 min random seek 89.7 µs 29.6 µs 210.6 µs 54.0 µs
60 min (144,000 moves, 2,400 clicks) start 497.3 µs 27.4 µs 1,558.8 µs 58.2 µs
60 min middle 596.9 µs 31.4 µs 900.3 µs 57.8 µs
60 min random seek 332.7 µs 39.7 µs 1,558.5 µs 63.0 µs
60 min end 39.8 µs 27.5 µs 58.2 µs 52.8 µs
1 min (2,400 moves, 40 clicks) random seek 35.4 µs 33.7 µs 62.3 µs 54.2 µs

The 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, main spends 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=1

For the baseline, copy only benchmark_idle_cursor_frame_preparation into main's project_uniforms_tests module and run the same command. It only uses APIs that already exist there.

Validation

  • Equivalence tests: ordered_cursor_lookups_match_linear_scans and ordered_click_ripple_window_matches_linear_scan compare the ordered paths with the original scans, bit for bit. They use the production ordering flags and cover:
    • empty and single-event inputs, duplicates, signed zero, ±∞, NaN events, unordered events;
    • queries at, and one ULP either side of, every event time and threshold (click duration, resume lookahead, fade, idle delays);
    • NaN and infinite playheads.
  • GPU pixel test: ordered_cursor_event_lookups_preserve_rendered_pixels renders 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.
  • Mutation check: I made off-by-one mutations to the idle, click and ripple lookups. Each made these tests fail; the mutations were not committed.
  • Crate checks:
    • cargo test -p cap-rendering: 302 unit tests and the integration tests pass (8 ignored).
    • cargo clippy -p cap-rendering --all-targets -- -D warnings is clean.
    • cargo fmt --all --check is clean.
    • cargo check -p cap-editor -p cap-export passes.

ProjectUniforms only gains a private field, and the new items are pub(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.

RetriggerView in GreptileConfidence 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.

  • Idle cursor fading finds nearby moves without scanning the recording.
  • Cursor clicks and ripples find active events without scanning old history.

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

@vercel

vercel Bot commented Oct 13, 2026

Copy link
Copy Markdown
Contributor

@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.

@richiemcilroy

Copy link
Copy Markdown
Member

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.
@apoorvdarshan
apoorvdarshan force-pushed the perf/cursor-layer-event-lookups branch from b987b51 to bdc0931 Compare October 18, 2026 18:04
@apoorvdarshan

Copy link
Copy Markdown
Contributor Author

@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.

@richiemcilroy

Copy link
Copy Markdown
Member

@greptileai review

@richiemcilroy richiemcilroy left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

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

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!

@richiemcilroy
richiemcilroy merged commit 1f74a06 into CapSoftware:main Oct 19, 2026
46 of 48 checks passed
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