Skip to content

perf(rendering): convert IOSurface frames straight into the layer frame texture - #2601

Merged
richiemcilroy merged 4 commits into
mainfrom
perf/rendering-convert-into-layer-texture
Oct 18, 2026
Merged

richiemcilroy merged 4 commits into
mainfrom
perf/rendering-convert-into-layer-texture

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 14, 2026 •

Copy link
Copy Markdown
Member

On macOS the display and camera layers now convert decoded IOSurface frames straight into their own frame texture. This removes a full-size copy per frame and the converter's own pair of output textures. The source CVPixelBuffer is still held until the GPU has read it.

flowchart LR
  subgraph Before
    A1[Decoded IOSurface NV12] -->|NV12 compute| B1[Converter output x2<br/>3072x1984 RGBA, 24 MB each]
    B1 -->|copy every frame| C1[Layer frame texture x2]
  end
  subgraph After
    A2[Decoded IOSurface NV12] -->|NV12 compute| C2[Layer frame texture x2]
  end
  C1 --> D[Composite pass]
  C2 --> D
Loading
frame_source = screen_frame
    .with_image_buf(|image_buf| {
        self.yuv_converter
            .convert_nv12_from_iosurface_into(device, encoder, image_buf, &self.frame_texture_views[next_texture])
            .ok()
    })
    .flatten();
// ...
RetainedImageBuf::replace(queue, &mut self.frame_sources[next_texture], frame_source);

Changes

  • convert_nv12_from_iosurface_into (crates/rendering/src/yuv_converter.rs:1115) runs the existing NV12 shader, bind layout and dispatch, but writes into a caller-owned view. convert_nv12_from_iosurface_to_encoder shares the same encode helper (yuv_converter.rs:1134).
  • Display and camera layers use it on macOS (crates/rendering/src/layers/display.rs:781, crates/rendering/src/layers/camera.rs:478). Every fallback path (CPU NV12, YUV420P, RGBA, CUDA, D3D11) and every other platform is unchanged.
  • On macOS, the converter's own textures start at 64x64 (yuv_converter.rs:108). They grow on first use only if a fallback conversion needs them. Exports no longer pre-size them (crates/rendering/src/lib.rs:7359), and the camera sizes them only on fallback (camera.rs:493).
  • On macOS, frame textures drop RENDER_ATTACHMENT (crates/rendering/src/composite_frame.rs:424). Nothing renders into them. With that flag, wgpu's lazy zero-init ran a render pass ahead of the first conversion.
  • Output is identical by construction. The shader only uses the output size for a bounds check, and the dispatch covers exactly the region the old copy copied.

Source buffer lifetime

  • The Metal textures alias the IOSurface without retaining it (the lifetime fix(rendering): keep IOSurface sources alive until the GPU has sampled them #2625 handles for the converter's own outputs). convert_nv12_from_iosurface_into returns a RetainedImageBuf. Each layer stores it per frame-texture slot (display.rs:947, camera.rs:575).
  • Retention is tracked per destination slot. A frame that falls back to the converter's own outputs therefore cannot release a buffer that this path still needs.
  • When a slot is reused, the buffer it held is released from queue.on_submitted_work_done (yuv_converter.rs:445) instead of being dropped in place. By then the encoder that sampled it has always been submitted, so the release waits for that GPU work to finish. Slot reuse alone would not be enough: the pipelined export (finish_encoder_nv12_pooled) only waits for the previous frame, and transitions (produce_transition_texture, crates/rendering/src/lib.rs:8023) prepare the layers twice per frame. Tying the release to queue completion covers that case without depending on how many conversions are in flight, the same case the 4-slot ring from fix(rendering): keep IOSurface sources alive until the GPU has sampled them #2625 covers for the converter-owned path.

Results

Rebased on main (fa92b8556)

#2595 was closed in favour of #2638, so this PR now carries only its own four commits on top of main. M4 Max, cap export --fps 60, main and this PR built with the same profile, interleaved, 2 rounds. Fixtures are two real camera recordings: a 3024x1964 display (1243 output frames) and a 1706x1200 area (1162 frames).

Fixture Resolution Peak footprint (MB), main This PR Wall (s), main / PR Video framemd5
Display + camera 1920x1080 772, 787 706, 708 4.47 / 4.50 identical in all 4 runs
Display + camera 3840x2160 947, 948 881, 882 13.22 / 13.22 identical in all 4 runs
Area + camera 1920x1080 834, 848 784, 800 4.41 / 4.43 identical in all 4 runs
Area + camera 3840x2160 1008, 1013 949, 966 12.65 / 12.65 identical in all 4 runs
Display + camera, --force-ffmpeg-decoder 1080p / 4K 1103 / 1276 1103 / 1272 4.42 / 4.44, 13.22 / 13.23 identical
  • Peak memory is 34 to 79 MB lower on the hardware decoder path. The FFmpeg decoder path has no IOSurface frames, so it is unchanged, as expected.
  • Instructions retired are 0.3 to 0.5% lower. Wall time is unchanged (the first main run, 5.55 s, was a cold start and is excluded above).

Earlier, on the #2595 base

M4 Max. The baseline is the #2595 head (905e0933d). Runs are interleaved A/B.

Editor, studio-cam-60s (3024x1964 display + 720p camera). The editor is opened, renders its first frame, idles for 2.5 s, seeks 4 times, plays for 3 s, then closes. Footprint is sampled every 2 ms, 3 pairs:

Footprint (MB) #2595 This PR
Peak at first frame 500 / 504 / 499 468 / 471 / 468
Settled after first frame 96 / 97 / 96 64 / 65 / 65
Lifetime peak 583.7 / 583.3 / 583.0 518.8 / 520.4 / 520.3
Settled after playback 161 / 162 / 160 97 / 98 / 98

Steady playback, 600 sequential frames from 10 s. GPU time is summed GPUEndTime - GPUStartTime per command buffer, 12 pairs:

#2595 This PR
GPU time (ms), median 1155 (954-1345) 1025 (854-1364), -11%, lower in 10 of 12 pairs
Command buffers 7677 7398
Rendered frames, all 3600 (RGBA hash) identical

cap export --fps 60 --resolution 1920x1080:

Fixture Peak footprint (MB) #2595 This PR Instructions (1e9) #2595 This PR Video framemd5
studio-cam-180s 744.9 677.8, 672.3, 675.2 220.3 219.3, 219.4, 219.1 all identical, audio identical
studio-cam-60s (3 pairs) 659.1-663.8 586.5-594.4 78.14-78.32 77.51-77.82 all identical, audio identical
scroll-30s (3 pairs) 626.1-632.8 576.3-579.9 14.64-14.79 14.30-14.47 all identical
  • Wall time is unchanged. Over eight studio-cam-60s pairs, the medians were 11.84 s for fix(rendering): stop exported camera frames running ahead on long recordings #2595 and 11.83 s for this PR. Each of the five lowest-load pairs was within 0.1 s.
  • The retention is load bearing. I built this PR with #2595's hole-map fix reverted, so camera frames are evicted from the decoder cache straight away. With the RetainedImageBuf release disabled, two re-runs of studio-cam-180s differed from the first in 2344 and 1345 frames. With it, all 3 runs were bit-identical.

Metal driver memory

  • The remaining ~450 MB footprint spike when the first frame renders is driver memory, not Cap's. A 41-line Metal program with no Cap code reproduces it: the first render pass adds about 232 MB and the first blit or compute dispatch adds 161-167 MB. Meanwhile MTLDevice.currentAllocatedSize stays flat, and the memory turns reclaimable about 1.5 s after the GPU goes idle.
  • In the app, GPUI already pays the render-pass part, because it draws with Metal on the same device.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge; no actionable issues remain in the reviewed changes.

Summary

On macOS, display and camera layers now convert IOSurface frames directly into their frame textures.

  • Display and camera layers convert macOS IOSurface frames straight into their frame textures.
  • macOS converter textures stay small until a fallback conversion needs them.

Reviews (5) · Last reviewed commit: "perf(rendering): start the YUV converter..." · Reviewed by Greptile

Comment thread crates/rendering/src/yuv_converter.rs

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found no issues.

View 2 other findings in Capy.

Open in Capy

@vercel

vercel Bot commented Oct 14, 2026 •

Copy link
Copy Markdown
Contributor

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
cap-status Ready Ready Preview Oct 18, 2026 3:04pm UTC

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found no issues.

Open in Capy

@richiemcilroy

Copy link
Copy Markdown
Member Author

hey @greptileai, please re-review the PR

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found no issues.

View 1 other finding in Capy.

Open in Capy

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@richiemcilroy

Copy link
Copy Markdown
Member Author

@greptileai review

@capy-ai capy-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Capy found no issues.

Open in Capy

@richiemcilroy
richiemcilroy merged commit 207aaf1 into main Oct 18, 2026
50 of 52 checks passed

This branch was successfully deployed

2 active deployments
Preview – cap-web — 2f41dab9 Deployed Oct 18, 2026 by vercel[bot]
Preview – cap-status — 2f41dab9 Deployed Oct 18, 2026 by vercel[bot]
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