Reuse one scaler destination frame per decode thread - #47
Merged
Conversation
ffmpeg-next 8 cannot build against FFmpeg 9 headers. The generated sys bindings no longer carry symbols the crate matches on exhaustively, so side_data.rs fails E0004 on AV_PKT_DATA_HEVC_CONF and codec/id.rs fails E0425 on AV_CODEC_ID_V410, before any of our code is reached. Anyone on a current distro (Arch is already on n9.0.1, libavcodec 63) cannot build the av feature at all. macOS release was already broken by this and nobody noticed: that job runs an unpinned `brew install ffmpeg`, and brew's stable is 9.0.1. The Linux and Windows jobs kept passing only because they pin an n8.1 build, so the failure was invisible to CI. ffmpeg-next 9 needs zero source changes here, so this is a version bump plus the pinned downloads that have to move with it: the BtbN asset regexes and error strings in ci.yml and release.yml, the win64 URL in setup-av.ps1, and the extracted-directory filters that match on the ffmpeg-n8.1 prefix. Verified: both n9.0 gpl-shared assets resolve through the same API query CI runs, the hardcoded ps1 URL returns 200, clippy is clean at -D warnings, and the suite passes 437 with GPU tests included. Note setup-av.sh still only checks that libavformat exists, with no version constraint, so it reports success on a machine where the build cannot work. That is worth a pkg-config --atleast-version guard separately.
frame_to_image built a fresh ffmpeg::frame::Video::empty() on every call. ffmpeg-next's Context::run allocates whenever the destination frame is empty, so each decoded frame did a full-size av_frame_get_buffer on entry and freed it again when the local dropped. At 4K that is 33 MB allocated and released per frame, on top of the copy into ImageData. The decode loop already keeps a scaler and a decoded frame alive across iterations; the destination frame was the one that was not. It now sits beside them in run_decode and is threaded through build_frame, which is why both call sites grow an argument. The to_vec copy stays and is now load-bearing: the scaler buffer is reused, so the frame data has to be copied out before the next call overwrites it. Removing that copy too would need a buffer pool with a return path from the consumer, which is a much larger change and not obviously worth it. Measured A/B in one process, release profile, timing only the scale and copy step, 7 reps alternating order, repeated twice. The win scales with frame size and is not general: 1080p (8.3 MB) +0.5% and -0.1%, i.e. nothing, because glibc's adaptive mmap threshold recycles a buffer that size from the heap; 2880x1920 (22 MB) -13.1% and -12.7%, about 384 us a frame; 4K (33 MB) -11.1% and -10.7%, about 579 us a frame. The audio path at the resampler has the same shape and is deliberately left alone. swresample allocates sized to input.samples(), so a reused frame caps at the first packet's sample count and later samples get buffered internally, which would perturb the out.samples() math and the tail at EOF. The payoff there is roughly 8 KB a frame against 33 MB here.
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.
One allocation per decoded frame, and the FFmpeg bump needed to build it
Two independent commits. The FFmpeg move stands on its own and fixes a release job that was already broken. The allocation fix is the actual optimization, and it only pays off above roughly 2.5K.
frame_to_image allocated a full frame per call
frame_to_imagebuilt a freshffmpeg::frame::Video::empty()every time it ran. ffmpeg-next'sContext::runallocates whenever the destination frame is empty:So every decoded frame called
av_frame_get_bufferon the way in and freed the buffer when the local dropped. At 4K that is 33 MB allocated and released per frame, before the copy intoImageDataeven starts.The odd part is that the decode loop already keeps a
Scalerand adecodedframe alive across iterations. The destination frame was the only one being rebuilt. It now sits beside them inrun_decode, which is whybuild_frameandframe_to_imageeach take one more argument.The copy that stays
to_vecis still there and is now load-bearing. Reusing the scaler buffer means the data has to be copied out before the next call overwrites it. Dropping that copy too would need a buffer pool with a return path from the consumer back to the decode thread, sinceImageDataowns itsVec<u8>and outlives the frame. That is a much bigger change and I have no evidence yet that it is worth it.Measured
A/B in one process, release profile, timing only the scale and copy step so decode cost does not drown the signal. Seven reps with alternating order, run twice.
1080p gains nothing, and that result is repeatable. glibc raises its mmap threshold when a large block is freed, so an 8.3 MB buffer gets recycled from the heap and the allocation I removed was already close to free. Past about 22 MB the recycling stops and every frame pays mmap, page faults, and munmap.
So the claim is narrow: 10 to 13% off the per-frame conversion cost at 2.5K and above, nothing at 1080p, no regression measured anywhere.
The audio path is left alone deliberately
The resampler call has the same shape and I am not touching it. swresample sizes its allocation to
input.samples(), so a reused frame caps at the first packet's sample count, and later samples get buffered inside swresample instead. That would perturb theout.samples()arithmetic and the tail at EOF. The prize is about 8 KB a frame against 33 MB on the video side.FFmpeg 9
ffmpeg-next 8 cannot compile against FFmpeg 9 headers. The generated bindings drop symbols the crate matches on exhaustively, so
side_data.rsfails E0004 onAV_PKT_DATA_HEVC_CONFandcodec/id.rsfails E0425 onAV_CODEC_ID_V410, before any of our code compiles. On a current distro theavfeature simply cannot be built. Arch is already on n9.0.1.macOS release was broken by this and nothing caught it. That job runs an unpinned
brew install ffmpeg, and brew's stable is 9.0.1. Linux and Windows kept passing because they pin an n8.1 build, so CI stayed green while one third of the matrix could not have worked.ffmpeg-next 9 needs zero source changes here, so the commit is a version bump plus the pinned downloads that have to move with it: the BtbN asset regexes and error strings in
ci.ymlandrelease.yml, the win64 URL insetup-av.ps1, and the extracted-directory filters matching on theffmpeg-n8.1prefix.Testing
437 tests pass with
--features heif,av, GPU tests included. Clippy is clean at-D warnings. The release binary builds and links against system libavcodec 63. Both n9.0 assets resolve through the same API query CI runs, and the hardcoded PowerShell URL returns 200.None of that touches the decode path, which has no test coverage at all. The change needs a real video played and scrubbed before I would trust it.
Known gap, not fixed here
setup-av.shon Linux only checks that libavformat exists, with no version constraint. It prints "Done. Run: cargo build --features av" on a machine where the build cannot possibly work, which is exactly how this started. Apkg-config --atleast-versionguard would turn it into a real check.