Repository navigation
perf(export): hand packed NV12 readbacks to the encoder without an extra AVFrame copy - #2838
Draft
richiemcilroy wants to merge 2 commits into
Draft
richiemcilroy wants to merge 2 commits into
richiemcilroy wants to merge 2 commits into
Conversation
…ng them into an AVFrame
Contributor
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
This branch was successfully deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
On Windows and Linux, MP4 export reads the NV12 frame back into a pooled
Vecand then copies that whole buffer again into a reusable ffmpegAVFramebefore 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 == widthwith the UV plane right after Y. The encoder thread now wraps that buffer as a read-only, refcountedAVFrame:av_buffer_createtakes ownership of a clone of theSharedNv12Buffer, 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 pastsend_framestill 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 ofcap-export --example export-benchmark-runner -- quick --duration 10withCAP_RENDER_LOOP_STATS=1. Values are medians across 3 rounds (6 for 1080p, which appears twice inquick). "Prep" is the encoder thread's time to turn the received frame into theAVFrameit queues. CPU is process user+sys time for the export.Linux, Blacksmith 8 vCPU, llvmpipe, libx264 (two separate runs):
Windows, Blacksmith windows-2025 8 vCPU, WARP, libx264:
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) == widthfor every export width), so it is wrapped too. No CI runner has a GPU to measure that path directly.Output identity
export-cliran 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-frameframemd5of the decoded output. A second base run is the control.wrapped_nv12_frame_matches_copied_frameandpadded_or_odd_nv12_frames_are_not_wrappedpass 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=1on a hardware adapter,FrameRendererturns on CUDA external output for every NV12 consumer, desktop export included.nv12_from_rendered_frameignoresframe.gpuand treats the emptydataas 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 ofrender_video_to_channel_nv12to 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.