Skip to content

perf(export): hand packed NV12 readbacks to the encoder without an extra AVFrame copy - #2838

Draft
richiemcilroy wants to merge 2 commits into
mainfrom
perf/export-readback-into-avframe
Draft

richiemcilroy wants to merge 2 commits into
mainfrom
perf/export-readback-into-avframe

Conversation

@richiemcilroy

@richiemcilroy richiemcilroy commented Oct 21, 2026 •

Copy link
Copy Markdown
Member

On Windows and Linux, MP4 export reads the NV12 frame back into a pooled Vec and then copies that whole buffer again into a reusable ffmpeg AVFrame before encoding (fill_nv12_frame_direct). That second copy costs one full frame per exported frame: 3.1 MB at 1080p and 12.4 MB at 4K.

The pooled buffer is already tightly packed NV12, because export widths are a multiple of 4 and heights are even, so y_stride == width with the UV plane right after Y. The encoder thread now wraps that buffer as a read-only, refcounted AVFrame: av_buffer_create takes ownership of a clone of the SharedNv12Buffer, and the frame is queued directly. The buffer only goes back to the pool once ffmpeg drops its last reference, so an encoder that keeps a reference past send_frame still reads valid data. Frames that are not packed (padded stride, odd height, short buffer) still take the existing copy into the reusable frame.

Unchanged: the macOS VideoToolbox/IOSurface input and its IOSurface software fallback, ProRes, encoder selection and settings, frame timing, and output pixels. The encoder gets the same plane bytes. Only the frame's linesize differs: packed width instead of ffmpeg's 32-byte-aligned stride.

Measurements

Measured on a scratch A/B (ci/export-readback-avframe-ab) with base = this commit's parent. Both sides carry the same timing instrumentation, and both are release builds of cap-export --example export-benchmark-runner -- quick --duration 10 with CAP_RENDER_LOOP_STATS=1. Values are medians across 3 rounds (6 for 1080p, which appears twice in quick). "Prep" is the encoder thread's time to turn the received frame into the AVFrame it queues. CPU is process user+sys time for the export.

Linux, Blacksmith 8 vCPU, llvmpipe, libx264 (two separate runs):

preset prep per frame AVFrame copy per frame wall CPU
720p30 base 389 / 153 µs 1,382,400 B 4.94 / 4.54 s 27.2 / 25.3 s
720p30 head 5.3 / 2.9 µs 0 B 5.11 / 3.97 s 28.2 / 22.8 s
1080p30 base 955 / 507 µs 3,110,400 B 6.33 / 5.03 s 35.3 / 29.4 s
1080p30 head 5.0 / 3.5 µs 0 B 6.07 / 5.04 s 34.0 / 29.0 s

Windows, Blacksmith windows-2025 8 vCPU, WARP, libx264:

preset prep per frame AVFrame copy per frame wall CPU
720p30 base 137 µs 1,382,400 B 136.0 s 325 s
720p30 head 11.0 µs 0 B 142.6 s 339 s
1080p30 base 461 µs 3,110,400 B 192.2 s 656 s
1080p30 head 10.1 µs 0 B 176.2 s 605 s

Every head frame was wrapped (300 of 300 per export). That removes 933 MB of memcpy from a 10 s 1080p30 export and 3.7 GB from a 10 s 4K30 export. The remaining copy into the pooled buffer is unchanged. Wall time and CPU barely move: rendering (llvmpipe, and about 0.5 s per frame on WARP) and libx264 dominate both runners, so those columns mostly show runner noise. On a GPU machine with hardware encoding, the per-frame copy is a larger share of the encoder thread's time.

Both CI adapters are software, so frames reach the encoder through convert_rgba_to_nv12 (RGBA readback, then CPU conversion into the same pooled buffer). The GPU NV12 readback path used on hardware adapters writes the same packed layout into the same pool (aligned_stride(width) == width for every export width), so it is wrapped too. No CI runner has a GPU to measure that path directly.

Output identity

export-cli ran on a generated 6 s testsrc2 project (padding and rounding on) at 1920x1080, 1280x720 and 1366x768 (1364x768 output, a linesize that differs from ffmpeg's aligned stride). Each variant was compared three ways: a hash of every plane row the encoder receives, an md5 of the H.264 video stream, and per-frame framemd5 of the decoded output. A second base run is the control.

  • Linux (two runs): base and head are identical on all three checks at every size, in both runs, and the control matches too, so the outputs are bitstream-identical.
  • Windows: WARP output is not repeatable from run to run at some sizes. In one run the 1080p base export rendered the background without the display layer, and base vs base differs on every frame there. Where the control was stable (720p and 768p in the last run, 768p in the one before), head was bitstream-identical to base. To check the wrap itself, independent of rendering, head also copied every wrapped frame the old way inside the process and compared planes, pts and format: 540 of 540 frames matched, with 0 mismatches.
  • New unit tests wrapped_nv12_frame_matches_copied_frame and padded_or_odd_nv12_frames_are_not_wrapped pass on Linux and Windows.

GitHub-hosted windows-2022 loses its WARP device on the first queue submit, for base and head alike (wgpu-core queue.rs:402, Err(Lost), before any frame). So the Windows A/B ran on Blacksmith windows-2025, which does expose WARP, the same as Engine verify.

Not fixed here

With CAP_LINUX_GPU_FRAMES=1 on a hardware adapter, FrameRenderer turns on CUDA external output for every NV12 consumer, desktop export included. nv12_from_rendered_frame ignores frame.gpu and treats the empty data as a CPU frame, so the encoder gets stale or uninitialised planes. A real fix needs either a CUDA device-to-host copy or a way for callers of render_video_to_channel_nv12 to opt out of external output (the render farm shares that function and wants the output), so it is left for a follow-up. This change keeps the current behaviour for those frames, because empty buffers are never wrapped.

@vercel

vercel Bot commented Oct 21, 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 26, 2026 12:45pm UTC

This branch was successfully deployed

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