Skip to content

perf(desktop-gpui): presize flattened RGB buffer - #4

Closed
kyzer1023 wants to merge 163 commits into
mainfrom
cursor/presize-flatten-rgb-66ec
Closed

kyzer1023 wants to merge 163 commits into
mainfrom
cursor/presize-flatten-rgb-66ec

Conversation

@kyzer1023

@kyzer1023 kyzer1023 commented Oct 19, 2026 •

Copy link
Copy Markdown
Owner

Summary

flatten_rgba_to_rgb_over_white (the opaque share JPEG path in encode_jpeg, quality 90) reserved capacity and then extend_from_slice'd a 3-byte array for every pixel. It now allocates vec![0u8; rgba.len() / 4 * 3] and writes each pixel into dst[i..i + 3] in the same three branches, with the same integer blend ((channel * a + 255 * inv + 127) / 255).

One loop is kept. rgba_to_rgb and encode_jpeg_rgba_as_rgb are unchanged (CapSoftware#2672).

Output is byte-identical to the previous formula:

  • opaque (alpha == 255) copies RGB
  • fully transparent (alpha == 0) writes white
  • partial alpha uses the same rounded blend
  • trailing bytes that are not a full pixel are still ignored

This pull request is on kyzer1023/Cap. A pull request against CapSoftware/Cap:main was not opened: this workspace's source of truth is the fork. Fork main was fast-forwarded to CapSoftware/Cap main (85219015) so the diff is only this commit.

Profile

apps/desktop-gpui is its own Cargo workspace, so it does not inherit the root workspace [profile.release] opt-level = "s". The profile the GPUI app ships with is:

[profile.release]
codegen-units = 1
lto = "thin"
strip = "symbols"

opt-level is unset, so Cargo's release default applies: opt-level = 3. Confirmed on the bench binary: -C opt-level=3 -C lto=thin -C codegen-units=1 -C strip=symbols.

Benchmarks

kernels/benches/frame_export.rs inlines the previous extend_from_slice function as the before case (flatten-rgb-*-4k). Equality is asserted on the 4K buffers before timing.

rustc 1.99.1 (stable). Each run: 5 warmup iterations, median of 25 samples, order alternated. The table is the median of 5 such runs. 4K is 3840×2160.

Workload Before After Speedup
Opaque 4K 7.888 ms 6.349 ms 1.24×
Fully transparent 4K 8.593 ms 5.091 ms 1.69×
Mixed alpha 4K 7.937 ms 8.105 ms 0.98× (+2.1%)

Opaque and fully transparent frames are faster. Mixed alpha is about 2% slower, inside the 3% band, so the write loop was left as the presized dst[i..i + 3] stores. An earlier indicative figure of ~2.2× on stable for 4K opaque was measured at opt-level = 3 as well and did not reproduce on this VM.

Tests

cap-gpui-kernels lib tests: 42 passed, 0 failed, 1 ignored (benchmark_waveform_peaks) on both rustc 1.88.0 and 1.99.1. flatten_rgba_to_rgb_over_white_matches_extend_formula checks the three alpha classes against the previous formula, plus hand-checked opaque, transparent, and alpha == 128 pixels. The existing two-step comparison (flatten_onto_white then rgba_to_rgb) still passes.

The full GPUI app was not built. [patch."https://github.com/wingleeio/zed"] points at zed-cap, which is not in this VM, so apps/desktop-gpui/kernels was extracted into a one-crate workspace with stub cap-audio / cap-project deps for the test and bench runs.

Open in Web Open in Cursor 

richiemcilroy and others added 30 commits September 21, 2026 11:46
`CAP_GPUI_AUTO_TIMELINE_BENCH=1` times a synchronous `Window::draw` of the
editor the way `camera_bench` does for the camera bubble, after the
`CAP_GPUI_AUTO_EDITOR` project is open and its waveforms have landed. It
reports mean / p50 / p95 / max draw time for five scenarios: playback at
the default zoom, playback zoomed out to the whole clip, a paused hover
sweep, a zoomed-in following playback, and a clean draw with nothing
notified. The playing scenarios dirty the timeline through the production
`invalidate_playback_chrome`.

The draw-and-report helpers move out of `camera_bench` into `draw_bench`
so both benches share them.
…ints

The timeline repaints on every playhead tick during playback, and each
repaint rebuilt the mic and system waveform fills with lyon even when
their inputs had not changed. Zoomed out to a whole 180 s clip the two
fills tessellate to about 34,000 vertices, which cost the editor around
16 ms per draw on `main`, a full 60 Hz frame budget.

`waveform_path` now takes one `WaveformGeometry` value, which doubles as
the key of a `WaveformPaths` cache on `TimelineModel`: one slot per clip
segment and track, compared on every builder input with the peak table
matched by `Arc` identity, swept each render so only last frame's visible
paths are kept, and carried across `rebuild_timeline` like the waveforms.
The paint closure clones the stored path on a hit; colour, opacity, mask
and DPI scale are still applied by `paint_path` per paint. Once the view
follow-scrolls, the range changes every tick and the cache misses, so
scrolling playback costs what it did before.

Timeline draw benchmark, 180 s clip, 5 interleaved runs, median p50:
playing at the default zoom before the view follows 1.12 ms -> 0.70 ms,
playing zoomed out 15.7 ms -> 0.80 ms, paused hover zoomed out
16.1 ms -> 0.96 ms, scrolling playback (every draw misses)
1.39 ms -> 1.36 ms.
… in place

The Linux Instant camera compositor converted every screen frame to RGBA and back, and rebuilt the camera overlay and its mask, just to blend a small camera rectangle. Blend directly into packed RGB screen frames, rebuild the overlay only when a new camera frame arrives, and compute the mask once. The old conversion path remains the fallback for other formats.
…rame

ReadSample hands back a null sample for stream ticks (gaps in the stream),
and read_sample reported those as Ok(None), the same as the end of the
stream. The decoder then stopped a batch early and answered with an older
frame. Read again until a sample, the end-of-stream flag or an error.
…est state machine

The decoder thread mixed batching, a mid-batch readahead slide, deferred
requests and four fallback chains in one loop. The policy now lives in
FrameRequests, which never touches Media Foundation: the thread takes in
requests, asks it for the next step (seek, decode one sample, or wait) and
does it. Tests drive it with a fake sample stream.

Once the decoder moves past a requested frame, the request gets the newest
sample at or below that frame. Requests are served lowest first, each with
its own seek when decoding forward cannot reach it, so the "first sample
after" fallback only applies to a frame before the stream's first sample.
The cache keeps every sample since the last seek and evicts the oldest, so
the newest sample at or below a frame is always cached.

Behaviour changes:
- Several requests in flight (export transitions between two segments of
  one recording decode both through the same decoder) each get their own
  frame. The highest used to be the target, and a lower one far from it got
  the target's frame.
- A request behind the decoder with nothing cached at or below it seeks back.
  Within half the cache size it used to get the next decoded, later frame.
- Every request slides the readahead, cache hits included, so a sequential
  reader no longer drains the cache while the decoder idles.
- Readahead stops at its frame count, or before the next sample would evict
  the one answering the latest request (frames too big for the cache budget
  used to get a later frame); the per-batch time budget is gone.
- Samples outside the old cache window are cached too, so a frame after a
  VFR gap past the window gets its own sample, not the one before the gap.
- A failed seek answers what waits from the cache instead of reading on from
  the old position as if it had landed.

Export output is byte-identical to the previous decoder on the reference
recording.
The decoder thread copied each sample to a staging texture and mapped it
straight away, so it sat blocked in Map while the GPU decoded and copied, and
the GPU sat idle while the CPU copied the frame out. read_nv12 now queues the
copy of the next sample before mapping the current one, through a ring of two
staging textures created with the decoder.

Samples are copied from Media Foundation's output surface directly into
staging, skipping the pool texture that only the disabled zero-copy path
needs. The rendering decoder caches the CPU NV12 data itself and drops the
zero-copy fields its frames could never fill, along with the two zero-copy
constructors that were their only producers.
… job

An export that ffprobe cannot read, that has the wrong frame count, or that
falls outside the requested size is now an invalid run instead of a timing
sample. Benchmark processes start suspended and resume only once they are in
the job object, so nothing they spawn escapes its CPU accounting, and a failed
assignment kills the process. A note says why playback forces FFmpeg.
…ccluded-wake

fix(gpui): don't draw a hidden window when it wakes from idle
…t-waits-for-work

fix(desktop-gpui): hold the automatic update prompt until recording or export finishes
…window-frame

fix(gpui): end a frame quietly when its window has already closed
…ized-studio-backup

fix(recording): drop the original media backup once a plain Studio stop is published
…-install-shared-crate

fix(desktop-gpui): install the CLI from Settings with the shared cli-install crate
…erprint

perf(recording): compare the cursor bitmap before encoding its TIFF while the pointer moves
…single-frames

perf(gpui): draw one frame per wake while a window is idle instead of restarting the display link
…cierge-migration-20260921

feat: add Cap Pro Loom concierge migration
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.