Skip to content

Reuse one scaler destination frame per decode thread - #47

Merged
nnmarcoo merged 2 commits into
mainfrom
remove-double-alloc
Aug 19, 2026
Merged

Reuse one scaler destination frame per decode thread#47
nnmarcoo merged 2 commits into
mainfrom
remove-double-alloc

Conversation

@nnmarcoo

Copy link
Copy Markdown
Owner

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_image built a fresh ffmpeg::frame::Video::empty() every time it ran. ffmpeg-next's Context::run allocates whenever the destination frame is empty:

if output.is_empty() {
    output.alloc(self.output.format, self.output.width, self.output.height);
}

So every decoded frame called av_frame_get_buffer on 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 into ImageData even starts.

The odd part is that the decode loop already keeps a Scaler and a decoded frame alive across iterations. The destination frame was the only one being rebuilt. It now sits beside them in run_decode, which is why build_frame and frame_to_image each take one more argument.

The copy that stays

to_vec is 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, since ImageData owns its Vec<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.

resolution bytes per frame run 1 run 2
1920x1080 8.3 MB +0.5% -0.1%
2880x1920 22 MB -13.1% -12.7%
3840x2160 33 MB -11.1% -10.7%

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 the out.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.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 compiles. On a current distro the av feature 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.yml and release.yml, the win64 URL in setup-av.ps1, and the extracted-directory filters matching on the ffmpeg-n8.1 prefix.

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.sh on 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. A pkg-config --atleast-version guard would turn it into a real check.

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.
@nnmarcoo
nnmarcoo merged commit c3d7840 into main Aug 19, 2026
1 check passed
@nnmarcoo
nnmarcoo deleted the remove-double-alloc branch August 21, 2026 02:40
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