Skip to content

perf: AVX2 UYVY/YUYV conversion in the H264 encoder - #2980

Open
kyzer1023 wants to merge 4 commits into
CapSoftware:mainfrom
kyzer1023:perf/camera-packed-yuv-nv12
Open

kyzer1023 wants to merge 4 commits into
CapSoftware:mainfrom
kyzer1023:perf/camera-packed-yuv-nv12

Conversation

@kyzer1023

@kyzer1023 kyzer1023 commented Oct 23, 2026 •

Copy link
Copy Markdown
Contributor

Summary

Camera frames that arrive as UYVY422 or YUYV422 at the encoder's output size are now converted inside the H264 encoder with an AVX2 kernel instead of swscale. This is the path recordings actually use. The conversion step is 1.4x to 2.6x faster depending on format and size. Odd sizes, scaling, other pixel formats, and CPUs without AVX2 keep the existing swscale context.

Change

  • crates/enc-ffmpeg/src/video/packed_yuv.rs: AVX2 UYVY/YUYV to YUV420P and NV12 kernels, plus convert_or_scale. It copies luma and averages each vertical chroma pair, rounding half up. try_convert returns false for odd sizes, a size change, any other format, or no AVX2 at runtime, and the caller then runs the encoder's swscale context.
  • crates/enc-ffmpeg/src/video/h264.rs and h264_packet.rs: queue_frame_to, queue_frame_reusable, encode_frame and encode_frame_reusable call packed_yuv::convert_or_scale instead of converter.run. libx264 gets YUV420P. NV12 is produced only when the opened encoder's format is NV12.
  • The earlier frame-converter wiring, output frame pool, and output_frame.rs are removed. Nothing in the app or CLI constructs AsyncCameraMp4Muxer, so that path never ran on a recording, and the pool could hold about 190 MiB at 4K.

Camera call paths that reach this code: macOS MacOSFragmentedM4SMuxer through SegmentedVideoEncoder::queue_frame, Windows win.rs through H264Encoder::queue_frame (after UYVY to YUYV), Linux Mp4Muxer through queue_frame, and the out-of-process muxer through H264PacketEncoder::encode_frame.

Overlap with #2981: both PRs change the same four converter.run lines. Whichever merges second should call one helper that tries packed YUV, then the RGB path, then swscale.

Benchmarks

Conversion step only: the previous scaler.run (FAST_BILINEAR, YUV420P) versus convert_or_scale, output frame reused. 5 warmup samples, then the median of 31 runs.

cargo test -p cap-enc-ffmpeg --release --offline --lib packed_yuv::tests -- --include-ignored --nocapture --test-threads=1

Hardware: Intel Xeon, family 6 model 207, 4 vCPU, KVM, AVX2. rustc 1.88.0, libavcodec 61.19.100.

Format Size swscale kernel Speedup
UYVY422 1280x720 0.298 ms 0.117 ms 2.55x
UYVY422 1920x1080 0.533 ms 0.255 ms 2.09x
UYVY422 3840x2160 1.892 ms 0.950 ms 1.99x
YUYV422 1280x720 0.155 ms 0.108 ms 1.44x
YUYV422 1920x1080 0.387 ms 0.246 ms 1.57x
YUYV422 3840x2160 1.783 ms 0.948 ms 1.88x

Tests

  • packed_yuv::tests: 6 passed with --include-ignored (5 tests plus the bench).
  • Even-size UYVY and YUYV match an exact reference and stay within swscale. YUV420P luma max 2, mean under 1. Chroma max 3, mean under 1.5. NV12 luma is allowed up to 4 because swscale's NV12 path drifts on widths that are not a multiple of 16.
  • Odd sizes make try_convert return false, and convert_or_scale matches swscale byte for byte.
  • libx264_queue_accepts_packed_yuv skips on the cloud machine because its libavcodec was built without libx264. It runs wherever libx264 is linked.
  • cargo fmt --all -- --check passes. cargo clippy -p cap-enc-ffmpeg --all-targets -- -D warnings is clean on x86_64 Linux and on aarch64 Linux. The AVX2 helpers (Job, positive_linesize, covers) are compiled only on x86_64, so ARM builds have no dead code. An aarch64-apple-darwin cross check could not build on Linux because there is no Apple SDK, so macOS CI confirms that target.

Test plan

  • cargo test -p cap-enc-ffmpeg --release --lib packed_yuv::tests -- --include-ignored --nocapture --test-threads=1
  • Record with a UYVY or YUYV camera (common on Windows and Linux webcams) and confirm the camera track looks the same as on main.

Risk

The fast path runs only for same-size, even-dimension UYVY or YUYV with AVX2. Everything else uses the encoder's existing swscale context unchanged. Output differs from swscale by at most 2 in luma and 3 in chroma on YUV420P.

RetriggerView in GreptileConfidence Score: 5/5

The PR appears safe to merge, with both previous findings addressed and no new blocking issues found.

Summary

Adds AVX2 conversion from same-size, even-dimension UYVY/YUYV frames to YUV420P or NV12 in both H.264 encoder paths.

  • H264 encoders convert eligible packed camera frames with AVX2.

Reviews (3) · Last reviewed commit: "Gate x86 only helpers and fix formatting" · Reviewed by Greptile

Same-size UYVY422 and YUYV422 frames copy to NV12 in scalar or AVX2 code. convert reuses output buffers after the last frame reference drops.
@vercel

vercel Bot commented Oct 23, 2026

Copy link
Copy Markdown
Contributor

@kyzer1023 is attempting to deploy a commit to the Cap Software Inc Team on Vercel.

A member of the Team first needs to authorize it.

Comment on lines +185 to +188
let mut pool = OUTPUT_POOL.lock();
let same_size = pool.iter().filter(|buf| buf.size == size).count();
if same_size < POOLED_PER_SIZE {
pool.push(PooledBuffer { ptr: data, size });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Old sizes keep memory

OUTPUT_POOL limits buffers per allocation size but never limits total cached bytes or removes old sizes. Changing output sizes leaves each size's buffers allocated until the app exits, even after every converter and frame pool drops. Sixteen cached 4K NV12 frames hold roughly 190 MiB before padding.

Consider adding a total byte limit with eviction, or tying the cache to the converter's lifetime.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/frame-converter/src/output_frame.rs
Line: 185-188

Comment:
**Old sizes keep memory**

`OUTPUT_POOL` limits buffers per allocation size but never limits total cached bytes or removes old sizes. Changing output sizes leaves each size's buffers allocated until the app exits, even after every converter and frame pool drops. Sixteen cached 4K NV12 frames hold roughly 190 MiB before padding.

Consider adding a total byte limit with eviction, or tying the cache to the converter's lifetime.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Note: If this suggestion doesn't match your team's coding style, reply to this and let me know. I'll remember it for next time!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 8734f17 by removing the pool. output_frame.rs is deleted and frame_pool.rs allocates with frame::Video::new again. The encoder only reuses the one converted frame it already owned.

@richiemcilroy

Copy link
Copy Markdown
Member

Thanks @kyzer1023, the kernel itself is careful work (stride, odd size and overlap checks all look right). The problem is where it's wired in. SwscaleConverter::convert/convert_into is only reached through AsyncConverterPool (crates/frame-converter/src/pool.rs), and that is only used by AsyncCameraMp4Muxer in crates/recording/src/output_pipeline/async_camera.rs. Nothing in the app or CLI constructs that muxer; only the camera-benchmark and encoding-benchmark examples do. On Linux the camera is encoded by H264Encoder's own swscale context, created in open_video_encoder_inner and run from queue_frame_to / queue_frame_reusable in crates/enc-ffmpeg/src/video/h264.rs (via Mp4Muxer in crates/recording/src/output_pipeline/ffmpeg.rs). So this PR adds about 1.4k lines without changing what users get. Could you move the packed UYVY/YUYV kernel to that encoder path, the same way #2981 adds convert_or_scale? Note that libx264 gets YUV420P there, not NV12, so the kernel needs a planar output too. Please also drop the output_frame.rs / frame_pool.rs pooling. OUTPUT_POOL (output_frame.rs:19) is process-wide, keeps up to 16 buffers per frame size, and never frees sizes that are no longer used, so a 4K session can leave around 200 MB resident after recording stops. If you still want pooling after the move, please cap the total retained bytes and release a size once its last converter is dropped.

UYVY422 and YUYV422 frames become YUV420P or NV12 through AVX2 on the encoder queue path. Odd sizes, scaling, other formats, and machines without AVX2 stay on swscale. The unused frame-converter output pool is removed.
@kyzer1023 kyzer1023 changed the title perf: convert packed UYVY and YUYV camera frames to NV12 without swscale perf: AVX2 UYVY/YUYV conversion in the H264 encoder Oct 23, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @richiemcilroy, you were right: nothing constructs AsyncCameraMp4Muxer, so the frame-converter path never ran. In 8734f17 the kernel moved to where camera frames are actually converted: H264Encoder::queue_frame_to / queue_frame_reusable and H264PacketEncoder::encode_frame / encode_frame_reusable, through a packed_yuv::convert_or_scale helper like the one in #2981. Same-size, even UYVY/YUYV becomes YUV420P for libx264, and NV12 only when the encoder is NV12. Odd sizes, scaling, other formats, and no AVX2 keep the existing swscale context. The frame-converter wiring and the pool are removed, so the diff is now 4 files in enc-ffmpeg, +908/-8.

On the real path the conversion step is 2.09x at 1080p UYVY and 1.57x at 1080p YUYV (2.55x and 1.44x at 720p, 1.99x and 1.88x at 4K), median of 31. The earlier 8x was against frame-converter's swscale setup, which was slower than the encoder's. The description has the full table and commands. Both PRs change the same four converter.run lines, so whichever merges second will chain packed YUV, then RGB, then swscale.

@greptileai review

Yuyv,
}

struct Job {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Apple Silicon checks fail

Job, positive_linesize, and covers are compiled on every target, but all their uses are inside x86_64-only code. On Apple Silicon they produce unused-code warnings, which fail the macOS CI job because it runs Clippy with -D warnings.

Add #[cfg(target_arch = "x86_64")] to all three declarations or move them into the gated module.

Prompt To Fix With AI
This is a comment left during a code review.
Path: crates/enc-ffmpeg/src/video/packed_yuv.rs
Line: 10

Comment:
**Apple Silicon checks fail**

`Job`, `positive_linesize`, and `covers` are compiled on every target, but all their uses are inside `x86_64`-only code. On Apple Silicon they produce unused-code warnings, which fail the macOS CI job because it runs Clippy with `-D warnings`.

Add `#[cfg(target_arch = "x86_64")]` to all three declarations or move them into the gated module.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 333451c. Job, positive_linesize and covers are now #[cfg(target_arch = "x86_64")], and the needless return in the non-x86 tail is gone. Clippy with -D warnings is clean for aarch64 Linux and x86_64. I also fixed the mod packed_yuv ordering in video/mod.rs for rustfmt.

Copy link
Copy Markdown
Contributor Author

@greptileai review

This branch has not been deployed

No deployments
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.

2 participants