perf(linux): take the encode off the critical path, and stop interleaving to NV12 - #559
Open
EtienneLescot wants to merge 6 commits into
Open
perf(linux): take the encode off the critical path, and stop interleaving to NV12#559EtienneLescot wants to merge 6 commits into
EtienneLescot wants to merge 6 commits into
Conversation
… it (Linux) The export ran on ONE thread: decode, compose, read back, de-pad, encode, mux, in a line, while seven cores did nothing. `avcodec_send_frame` alone was 29.5 s of a ~57 s export. Nothing about that work needs to be on the critical path. An `EncodeWorker` now owns the `VideoEncoder` and the muxer and consumes frames from a channel, so the timeline walk composes frame n+1 while the encoder is still on frame n. Measured on the same project, same binary otherwise (3600 frames, 1080p60): | | before | after | |---|---|---| | wall | 58.43 s | 40.90 s | | CPU | 98% (one core) | 189% | Output is BYTE-IDENTICAL — same md5, same 12,997,062 bytes. This buys throughput and changes nothing else. THE BOUND IS THE POOL CENSUS, NOT A CHANNEL CAPACITY. The walk thread is faster than the encoder, so an unbounded queue would accumulate all 3600 frames -- ~11.2 GB. Instead exactly `depth` AVFrames exist and circulate between an `empty` channel and a `full` one. Overrun is not avoided, it is unrepresentable, and `take_free` is the single place where the walk waits on the encoder. WHY THE DE-PAD STAYED ON THE WALK THREAD. Stripping the 256-byte row padding costs ~0.67 ms/frame. It could be removed entirely by handing the encoder the mapped staging buffer directly (an AVFrame whose linesize IS the GPU stride -- verified bit-exact against libopenh264). But once encoding is off the critical path the ENCODER is the bottleneck at ~29.5 s, so moving that copy to the thread with slack lands on the same total as deleting it, without holding a wgpu staging slot mapped across a thread boundary. This repo keeps its one existing worker (`segmentation.rs`) off the GPU entirely; this follows that. The muxer became one type because it has to travel as one: `av_interleaved_write_frame` touches `octx`, `ostream` and `opkt`, and `AacEncoder` holds an `*mut AVStream` pointing INTO `octx`'s stream table. Splitting them would leave a pointer into an object owned by another thread. It comes back through `join`, which is the happens-before edge that makes `octx` usable again here for the audio and the trailer. `Muxer` has a `Drop`, so an early `?` between opening and finishing no longer leaks the format context, the IO context and the packet. The frame returns to the pool even when the encode fails, so a dead worker surfaces as its real error instead of wedging the walk on an empty pool. And the readback now hands the mapped range to a closure instead of returning a `Vec`, which drops a 3.3 MB allocate-copy-free per frame and returns the staging slot to the ring even when the reader errors. The `ectx` alias is documented rather than removed: it is read only before the worker starts. A second copy living alongside the worker would be an unofficial `Sync` on a type that is deliberately `Send` and not `Sync`.
…leaving to NV12 (Linux) `CpuFrames::present` ran a CPU `sws_scale` on every decoded frame to turn the decoder's YUV420P into NV12, purely so the carrier could be a two-texture Y + interleaved-UV pair. On a scene with a webcam that is TWICE per output frame, on the thread that is now the export's bottleneck. The conversion was never needed. YUV420P already has U and V as separate planes, and the GPU samples two R8 textures exactly as happily as one Rg8. So the carrier now holds three R8Unorm planes and the decoder's own buffers go straight to `write_texture`. Measured on a 3600-frame 1080p60 export: 40.90 s -> 39.92 s. Output is BYTE-IDENTICAL (same md5), which is the expected result — this removed an interleave, not a resampling. 191 tests pass. Less than the 2-3 s this was predicted to save. The prediction assumed the conversion cost what an isolated benchmark of it costs; in place, some of it was evidently already overlapping with the GPU. THE FALLBACK STILL EXISTS, and now targets YUV420P rather than NV12. A source that is not already 4:2:0 planar (4:2:2, 10-bit, an odd import) still goes through `sws_scale` — it just converts to the layout the textures now want. Everything this app records is h264 4:2:0, so the fast path is the normal one and the slow path is for imports. The V plane binds at 5, not 3: bindings 0-4 were already taken when the chroma plane split in two, and renumbering would have touched every bind group in the file to no purpose. Draws that bind no video (annotations, glyph atlas, cursor sprites) pass their one texture for all three slots, as they already did for two — those modes never sample chroma. Test helpers still speak interleaved NV12, because that is the readable form for writing a case; `nv12_textures` de-interleaves into the two planes rather than making every test carry the split.
Contributor
|
Important Review skippedAuto reviews are disabled on base/target branches other than the default branch. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Team Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The copy from the readback buffer into the encoder's AVFrame was 2160 short strided memcpys per frame, for one reason: `av_frame_get_buffer` picks its own linesizes -- 1920 and 960 at 1080p -- while `copy_texture_to_buffer` aligns every `bytes_per_row` to 256, giving 2048 and 1024. Two conventions, so the copy had to reformat row by row. But we allocate those frames. `alloc_padded_yuv_frame` now builds them with linesize EQUAL to the GPU stride and the three planes laid out in one allocation in the same order, so the same bytes move as a single contiguous block. libopenh264 reads `linesize[i]` and `data[i]` as given; an over-strided plane does not bother it. Measured on a fixed project, 3600 frames at 1080p60: | | wall | |---|---| | before | 39.92 s | | after | 38.82 s / 39.26 s (two runs) | Output is BYTE-IDENTICAL, same md5 as the previous two commits. 194 tests pass. THE FRAME STAYS REFCOUNTED. `av_buffer_alloc` rather than a bare pointer, because with `buf[0]` null `av_frame_ref` inside `avcodec_send_frame` takes the "duplicate unrefcounted data" branch and redoes a full allocate-and-copy -- inside the encoder, which is the last place anyone would look for it. `av_frame_make_writable` is gone rather than kept "just in case". The frame comes from the pool, is never shared, and its refcount is back to 1 the moment `avcodec_send_frame` returns. The call was a no-op at best; at worst, on a buffer carrying `AV_BUFFER_FLAG_READONLY`, it is another allocation and copy. THE COPY ITSELF REMAINS, DELIBERATELY. Removing it means encoding straight from the staging buffer, which means keeping a wgpu slot mapped across a thread boundary while the worker holds it. That trades ~0.30 ms/frame for a slot whose lifetime depends on the encoder, and this repo keeps its one existing worker off the GPU entirely. Not the right trade while this thread is not the one being waited on. `YuvLayout` exists so the geometry is computed in exactly one place: the producer (compositor) and the consumer (pool frame) have to agree to the byte, and two copies of that arithmetic would eventually disagree. The dimension check stays alongside the size check -- a buffer that is merely big enough but the wrong shape would produce a silently shifted image, which is far worse to diagnose than an outright failure.
…nux) `EncodeWorker` kept a clone of the pool's `Sender` so `give_back` could return a frame borrowed but never filled. That clone was alive for as long as the worker struct, which meant `empty_rx.recv()` in `take_free` could never observe a disconnect -- its `Err` arm was unreachable code. So if the encode thread panicked, the walk thread did not get an error. It blocked in `take_free` forever: the frames in flight died with the worker, the pool was empty, and the only sender left was the one the caller itself held. `finish` was never reached, and nothing timed out. Found by re-reading the branch against its own description, which claimed a panic was "reported rather than re-raised". It was not reported at all. The unfilled frame is now kept in a `Cell` on the worker handle instead of being posted back through the channel. The channel has exactly one sender -- the worker's -- so its disappearance is observable, and `take_free` returns the latched error, or failing that a plain "the encode thread stopped". Output is unchanged: byte-identical md5 to the three preceding commits, and 194 unit tests pass (`cargo test --lib --tests`). The frames still in flight when a worker panics are leaked rather than freed. That is deliberate for now -- it is an abort path where the export has already failed, and reclaiming them would mean tracking ownership across the unwind for no benefit to the user. The deadlock was the bug worth fixing.
It read 'le contexte de l'encodeur n'est PAS recopie ici' immediately above the line copying it. The invariant it was reaching for is real -- the alias is read only before the worker starts -- but nowhere written down, so a reader sent there by the review found documentation contradicting the code beneath it.
EtienneLescot
force-pushed
the
perf/linux-export-worker
branch
from
September 1, 2026 16:04
4cd123f to
9d814b1
Compare
… (Linux) Groundwork for feeding h264_vaapi from the compositor without a readback. This commit enables the capability and nothing else: no dmabuf is exported, no encoder changes, no pixel moves. `request_device` has no way to ask for a Vulkan extension -- wgpu enables only what its own `Features` imply, and dmabuf export has no `Feature` equivalent. The only entry point is to build the `VkDevice` by hand and hand it back through `create_device_from_hal`, which is what `open_device_with_dmabuf_export` does. It appends three extensions to the ones wgpu already requires: VK_KHR_external_memory_fd VK_EXT_external_memory_dma_buf VK_EXT_image_drm_format_modifier and returns `None` if any is missing, leaving the caller on the ordinary path. THE FALLBACK IS A NORMAL CASE, NOT AN EDGE. lavapipe does not expose `VK_EXT_image_drm_format_modifier`, so every software-rasteriser host takes it. Verified both ways on this machine: RADV -> backend Hardware, export dmabuf actif llvmpipe -> backend Cpu, export dmabuf indisponible and the adapter log now carries which one happened, so a host that silently lost the capability is diagnosable from a bug report rather than by guesswork. Behaviour is unchanged: a 3600-frame 1080p60 export produces a byte-identical file (same md5 as the four preceding commits) in the same time, and 194 unit tests pass. WHY THIS LANDS ALONE. It is the step most likely to fail on someone else's machine -- a driver without one of the three, a wgpu version whose `physical_device_features` no longer round-trips -- and it is verifiable by itself, from one line of log. Landing it before anything depends on it means a failure here is a fallback rather than a broken export. `ash` and `wgpu-hal` are pinned to the versions wgpu 24 already pulls in; taking others would put two sets of bindings on one `VkDevice`. The path this prepares is proven end to end on this hardware: an exportable NV12 `VkImage` with `DRM_FORMAT_MOD_LINEAR`, mapped via `av_hwframe_map` to a VAAPI frame and encoded by `h264_vaapi`. It never calls `av_hwframe_transfer_data`, which is what aborts on libva 2.20 (#552).
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.
Stacked on #555 — based on
perf/linux-gpu-yuv-export, notmain. The first commithere rewrites
readback_submit_yuvand renamessend_yuv420p, both introduced by that PR,so it genuinely cannot stand alone. Review #555 first; this diff shows only the four commits
on top.
Four commits, each verified to produce byte-identical output.
1. The encode runs on its own thread
The export ran on ONE thread — decode, compose, read back, copy, encode, mux, in a line —
while seven cores did nothing.
avcodec_send_framealone was 29.5 s of a ~57 s export, andnone of it needs to be on the critical path.
An
EncodeWorkernow owns theVideoEncoderand the muxer and consumes frames from achannel, so the timeline walk composes frame n+1 while the encoder is still on frame n.
The bound is the pool census, not a channel capacity. The walk thread is faster than the
encoder, so an unbounded queue would accumulate all 3600 frames — ~11.9 GB at the pool frames' GPU-strided size. Instead exactly
depthAVFrames exist and circulate between anemptychannel and afullone. Overrun isnot avoided, it is unrepresentable.
2. The decoder's three planes go straight to the GPU
CpuFrames::presentran a CPUsws_scaleon every decoded frame to turn YUV420P into NV12,purely so the carrier could be a Y + interleaved-UV pair. On a scene with a webcam that is
twice per output frame, on the thread that is now the bottleneck.
The conversion was never needed — YUV420P already has U and V as separate planes, and the GPU
samples two R8 textures as happily as one Rg8. The carrier now holds three
R8Unormplanes.The
swsfallback still exists for sources that are not already 4:2:0 planar (4:2:2, 10-bit,odd imports); it now targets YUV420P rather than NV12. Everything this app records is
h264 4:2:0, so the fast path is the normal one.
3. The pool frames carry the GPU's own strides
Copying the readback buffer into the encoder's AVFrame was 2160 short strided memcpys per
frame, for one reason:
av_frame_get_bufferpicks its own linesizes — 1920 and 960 at 1080p —while
copy_texture_to_bufferaligns everybytes_per_rowto 256, giving 2048 and 1024. Twoconventions, so the copy had to reformat row by row.
But we allocate those frames. They are now built with linesize EQUAL to the GPU stride and the
three planes laid out in one allocation in the same order, so the same bytes move as a single
contiguous block. libopenh264 reads
linesize[i]anddata[i]as given; an over-stridedplane does not bother it.
The frame stays refcounted (
av_buffer_alloc): withbuf[0]null,av_frame_refinsideavcodec_send_frametakes the "duplicate unrefcounted data" branch and redoes a fullallocate-and-copy — inside the encoder, which is the last place anyone would look for it.
Measured
Full harness run, both legs in one session, 3 scoring runs after a discarded warm-up. The
measured build is
main+ #555 + these commits — the right column is not this PR alone:CPU seconds went DOWN, 121 → 95. I expected roughly flat total CPU with better spread
across cores. It fell because work was deleted, not moved: the two
sws_scaleconversions(encoder-side by #555, decode-side by commit 2) and the row-by-row reformatting (commit 3). So
this is not only "use more cores", and the win should partly survive on machines with fewer of
them.
RSS is up 5.5% (+53.5 MiB) and I cannot fully account for it. The frame pool is 3 x
3,317,760 B = ~9.5 MiB, and the three-plane switch is byte-NEUTRAL (Y + two R8 chroma planes at
quarter size each is the same total as Y + one Rg8 plane at quarter size). So ~10 MiB of ~53 MiB
is explained. The rest is unattributed.
Why I trust the headline, and where I don't
Trustworthy:
last five of 13 passes.
effectively the same unit.
result reproduces to ~2%.
Where I would not push the numbers:
opening floor — a drift ratio of ~1.022, against 0.998 on the previous run. Per-leg paired
floors absorb most of that, but it is not nothing.
figure 1.80× → 1.75×, but that compares two SEPARATE runs whose floors differ, and the
effect is the same order as the drift above. A standalone project measured 39.92 s → 38.82 /
39.26 s. Direction is consistent, magnitude is not settled. Measuring it properly needs both
variants inside one run.
Isolating the commits on a fixed project (standalone, single runs — same caveat):
Correctness
Output is byte-identical at every step — same md5, same 12,997,062 bytes, 3600 frames,
60.000 s. That is the expected result for all of them: threading does not touch pixels, removing
an interleave is not a resampling, matching strides moves the same bytes, and the fourth commit
only changes an abort path. 194 unit tests pass (
cargo test --lib --tests; an earliercommit message says 191, which undercounted by omitting the integration binary). (Some doctests in the generated
out/ffi.rsfail — verified identical on an unmodifiedtree; they are prose from ffmpeg C headers that bindgen emits as Rust code blocks.)
What a reviewer should push on
?paths.Muxerhas aDrop, so an early return no longer leaks the formatcontext, IO context and packet — but only ONCE THE MUXER IS BUILT. The five early exits in the
setup block above it (
avformat_new_stream,params_from_ctx,avio_open,AacEncoder::open,write_header) still leak, exactly as before. The worker also adds error edges that did notexist before: the frame returns to the pool even when the encode fails, so a dead worker
surfaces its real error instead of wedging the walk on an empty pool.
worker handle kept a clone of the pool
Sender, soempty_rx.recv()could never observe adisconnect and
take_freeblocked forever withfinishunreachable. The unfilled frame nowlives in a
Cellinstead, leaving the worker as the channel's only sender. Frames in flightat the moment of a panic are still leaked — deliberate, on a path where the export has already
failed.
av_frame_make_writablewas removed from the export path (copy_into). The call insend_rgbaremains — that path is now unreferenced on Linux, along with theVideoEncoder::swframe it writes into; both are worth deleting separately. The pool frames are never shared and
their refcount is back to 1 the moment
avcodec_send_framereturns. If that assumption iswrong — say libopenh264 ever advertises
AV_CODEC_CAP_FRAME_THREADS— this is where it breaks.ectxis kept rather than deleted: it is read only beforeEncodeWorker::spawn, todescribe the stream to the muxer. The comment above it previously claimed the context was not
copied at all, directly above the line copying it; it now states the real rule. Using it after
spawn would be an unofficial
Syncon a type that is deliberatelySendand notSync.touched every bind group for no gain.
Not addressed here — and where the time actually goes now
The encoder is still software (
libopenh264), and after these four commits it is thecritical path. Instrumented on a 3600-frame export:
device.poll(WaitForSubmissionIndex), totalmap_asyncwait, totalTwo things follow, and both correct assumptions I was working from earlier in this branch.
The readback is no longer a cost. 44 ms across the whole export. Moving the encode off the
walk thread (commit 1) also moved the GPU well ahead of the consumer, so the blocking poll now
returns immediately. Earlier figures putting the readback near 9.75 s were measured before that
commit, when the same call also performed the harvest.
The walk thread is not the bottleneck; the encoder is. The walk does 11.3 s of work in a
38.1 s export and spends the rest waiting. So the remaining copy (1.97 s, and the one this
branch's third commit shrank) sits on the thread with 70% slack — removing it would buy close
to nothing, which is a better argument for leaving it than the one given above.
Raising the readback ring depth was tried twice and rejected on measurement both times:
40.76 s vs 40.90 s before, and 37.95 s vs 38.13 s after. The second measurement also explains
the first — extra staging slots cannot help when the consumer is saturated.
The lever that is left is the encoder itself.
h264_vaapiencodes these same 3600 frames in11.2 s against ~29.5 s for libopenh264 on this machine, and unlike the readback that difference
is squarely on the critical path. What stands in the way is #552; note that the capture side
already does zero-copy dmabuf→VAAPI (#508) using
av_hwframe_map, which is precisely the callthat avoids the
vaMapBuffer2abort inav_hwframe_transfer_data.Measured on AMD Ryzen 5 7520U / Radeon 610M, Ubuntu 24.04, Wayland, Mesa RADV. One machine,
one scenario.