perf(macos): convert colour through Accelerate/vImage - #747
Draft
wagenet wants to merge 3 commits into
Draft
Conversation
…ersion
`detect_gpu_capabilities()` set the process-wide `VideoConvertMode` by asking
whether `nvh264enc` exists. On a Mac it never does, so every Mac fell through to
`Software`: a right answer reached by asking about hardware the machine does not
have, and one that hid the question that does matter.
Split the decision per platform. Linux and Windows keep the existing WSL /
NVENC / CUDA-GL interop path untouched. macOS answers for itself and still picks
`Software`, for two reasons that apply to the hardware:
- `autovideoconvert` has no GPU path to offer. For the system-memory frames our
blocks feed it, it selects `videoconvertscale` and never creates a GL context
(checked with `GST_DEBUG=gl*:5`, zero GL lines). It measured within 1% of
plain single-threaded `videoconvert`, the same CPU work wrapped in extra bin
and negotiation machinery.
- It is a bin, so it exposes no `n-threads` and cannot forward one to the
converter it picks.
The second point is what the NVENC probe was hiding. `videoconvert` ships with
`n-threads=1` and the string appeared nowhere in the tree, so every colour
conversion on an 8-core M2 ran on one core. `configure_video_convert()` now sets
the pool at all five call sites (ndi, devicesrc, videoenc, videoformat and the
vision mixer's CPU pipeline).
1080p RGBA->I420 on a 4+4 M2, paired runs alternating the within-pair order:
concurrent converts n-threads 1 -> 4 pairs won
1 -23.8% 20/20
2 -18.4% 16/16
4 -8.3% 14/16
Isolating the convert stage against a source-only baseline, its cost drops from
1.05 s to 0.24 s over 300 frames. The win narrows as the cores saturate and
stays positive.
The pool counts every performance tier macOS reports except the efficiency one.
Reading tier 0 alone held only while each part had a single fast tier, which the
M5 Ultra ends: its 36-core CPU is 12 super cores plus 24 performance cores with
no efficiency tier, so tier 0 there is 12 and would discard 24 fast cores.
Naming the tier to exclude is the durable form, since "Efficiency" has stayed
stable while the fast tiers gain names, and an unrecognised name fails open at a
cost of ~2%.
Excluding the efficiency tier is a mechanism call, because measurement could not
settle it: 4 threads against 8 landed within 2% and disagreed about the sign
across the three contention levels, well inside the machine's +/-11% noise.
Equal-stripe splitting makes an efficiency core the straggler that the whole
frame waits on.
A bound of 32 guards against a bogus syscall result and logs when it binds. It
stays loose because the errors are asymmetric: 16, 32 and 64 threads cost 1.2%,
1.7% and 1.4% against the 4-thread baseline, with no cliff even at 17-row
stripes, while 1 thread instead of 4 costs 24%. `STROM_VIDEOCONVERT_THREADS`
(1-64) overrides it, and the resolution logic is split out from the environment
so the wide-machine paths are covered by tests.
`n-threads` is scoped to macOS. No Linux or Windows machine was available to
measure, and a Linux container's visible CPU count routinely overstates its
cgroup quota, so sizing a pool there unmeasured risks oversubscribing shared
hosts. Linux and Windows behaviour is unchanged.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The block built `videoscale -> videoconvert -> capsfilter`, so a flow that changed both resolution and pixel format walked every frame twice with an intermediate buffer in between. `videoconvertscale` does both in one walk. `autovideoconvert` already covers both passes on the GPU path — it is `Bin/Colorspace/Scale/Video/Converter` and autoplugs a scaler when the caps ask for one — so the collapse applies in both convert modes. The new `VideoConvertMode::convert_scale_element_name()` names the right element for each, leaving `element_name()` alone for the convert-only call sites. Two effects, measured separately on an M2 (4 performance cores), 1080p RGBA source, 300 frames, 20 paired runs with the within-pair order alternated: - Merging the two passes is worth ~10% on its own. - The larger part is that the scale half is now threaded. `videoscale` has the same `n-threads` property and the same default of 1, but nothing ever set it; the merged element goes through `configure_video_convert()`. Together: the isolated conversion stage runs ~62% faster (20/20 pairs), and a full Strom flow ending in x264enc runs 34-41% faster, median 38%, 12/12 pairs. Both order-symmetric. Where only the format changes and scaling is passthrough there is no measurable difference (median -0.9%, 10/20 pairs), well inside this machine's noise floor. The element ID stays "videoscale" so the block's external input pad still resolves and saved flows that link into this block keep working. Both elements default to bilinear scaling, so the output is unchanged; a test pins that and fails if upstream ever diverges.
Colour conversion is the largest fixed CPU cost left in Strom's macOS pipelines: every encoder and WebRTC sink wants Y'CbCr while the HTML renderer and compositor produce packed RGB. Eyevinn#726 raised `videoconvert`'s thread count, which is as far as that element goes. Accelerate's vImage does the same work in hand-tuned NEON kernels across its own pool. `stromvimageconvert` is a drop-in for `videoconvert` and `videoconvertscale`. It carries the same `n-threads` property, so `configure_video_convert` reaches it unchanged and no call site moves; `VideoConvertMode` gains a macOS-only `VImage` variant that names it. Format pairs vImage has no path for — and every resize — run on `GstVideoConverter`, which is the code `videoconvert` is built on, so the element cannot fail a conversion `videoconvert` would have managed. Measured on an M4 Max, 1080p, 300 frames, 40 counterbalanced pairs, net of source: RGBA to NV12 goes from 186 ms to 54 ms, 3.4x the threaded path and 13.9x stock. The A/A control over the same harness returned +0.4% (t=0.60, not significant) and the identity baseline t=-0.20. Fixation departs from `videoconvert` in one place, and the measurement is why. Feeding `vtenc_h264_hw` from RGBA the encoder offers { AYUV64, UYVY, NV12, I420, P010_10LE, ARGB64_BE, RGBA64_LE }; `videoconvert` takes AYUV64, sixteen bits per component for an eight-bit source that the encoder then converts again. Scoring a deeper format ahead of a shallower one cost 15.7% end to end (t=-163), so a depth increase now outweighs every other penalty combined. The element picks UYVY there and the regression goes to zero. `vimage_output_matches_videoconvert` is the load-bearing test: it pushes one frame through this element and through stock `videoconvert` for all eleven claimed pairs and compares them pixel for pixel, asserting via the new read-only `conversion-path` property that the vImage path was actually taken rather than the fallback. Swapping the red and blue entries of the RGBA permute map fails it by 149; treating limited-range Y'CbCr as full range fails it by 20. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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.
What this adds
stromvimageconvert— a GStreamer element that converts colour through Accelerate/vImage on macOS. It lives inbackend/src/gst/vimage/, registers itself statically the first timedetect_gpu_capabilities()runs, and is selected by a new macOS-onlyVideoConvertMode::VImage.No call site changed. The element carries the same
n-threadsproperty asvideoconvert, soconfigure_video_convert()from #726 reaches it unchanged and all five sites pick it up throughelement_name()/convert_scale_element_name().Four modules, one job each:
accelerate.rs(FFI),plan.rs(decide, once per negotiation, whether vImage can do the pair),convert.rs(execute, per frame),imp.rs(the element and its caps negotiation).Fallback
The element never fails a conversion
videoconvertwould have managed. Anything vImage has no path for — an unlisted format pair, a resize, interlaced content, a colour matrix outside BT.601/BT.709 — is built as aGstVideoConverter, which is the codevideoconvertandvideoconvertscaleare themselves built on, configured with the same thread count. A new read-onlyconversion-pathproperty reportsvimage/fallback/unnegotiated.vImage paths: packed RGB32 (
RGBA/BGRA/ARGB/ABGRand theirxvariants) to and fromNV12,I420,YV12,UYVY,YUY2; RGB32 to RGB32; andNV12to and fromI420/YV12.Which pairs Strom actually negotiates
Checked against a running backend with
GST_DEBUG=stromvimageconvert:5, drivingvideotestsrc → Video Format → Video Encoder → fakesink:RGBA→NV12,RGBA→I420,RGBA→UYVY,BGRA→NV12vtenc_h264_hwdownstreamRGBA→UYVY(wasRGBA→ARGB64_BE)NV12→NV12etc.So the headline
RGBA→NV12is real, but only when an output format is pinned. Unpinned, the encoder's own preference decides, and that turned out to be worth reporting on its own:vtenc_h264_hwoffers{ AYUV64, UYVY, NV12, I420, P010_10LE, ARGB64_BE, RGBA64_LE }, and stockvideoconvertpicksAYUV64— sixteen bits per component for an eight-bit source, which the encoder then converts again. That is pre-existing behaviour, not something this PR introduced.The one deliberate divergence from
videoconvertMy first fixation scored the same menu and picked
ARGB64_BE, also 16-bit. Measured end to end that cost 15.7% against picking an 8-bit target (t = −163, n = 40). So a bit-depth increase now outweighs every other penalty combined: a same-or-shallower format always wins when one exists, and a deeper one is still chosen when it is the only option. Widening cannot add information and it doubles the bytes touched per frame. The element picksUYVYthere, the regression goes to zero, and that path now reaches vImage too.fixate_capsis otherwise a smaller model thangst_video_convert'sscore_value— colour space, alpha, depth, chroma subsampling, ties to the peer's order. The case that matters most, "the input format is on the menu, take it", is exact.Numbers
M4 Max (12P+4E, 64 GB), 1080p RGBA→NV12, 300 frames, 40 counterbalanced pairs per experiment (half each order, shuffled — not alternating), run in chunks of fresh processes so macOS App Nap cannot reach them. Host load average 2.5–9 across the run, recorded per chunk in the CSV. Debug build, which if anything favours the C-heavy
videoconvertarm.videoconvertvs itself)videoconvert n-threads=12vs vImage)videoconvert n-threads=1vs vImage)vtenc_h264_hwdownstream)† The A/A figure shown is from the clean run (+0.4%). The repeat run caught a load spike and returned +5.8%, t = 1.26 — also not significant, which is the point. Both A/B runs agreed at 3.43× and 3.44×.
Against the figures this work was scoped from: 3.4× vs a predicted 3.6× over the threaded path, and 13.9× vs a predicted ~14× over stock. Different machine (M4 Max, not M2), same conclusion.
The harness is committed as
#[ignore]d tests inbench.rswith the method documented, so the numbers are reproducible. It asserts nothing about timing.Tests
cargo test— 566 lib tests + all integration binaries pass, run in full.cargo clippy --all-targets -- -D warningsandcargo fmt --checkclean.The load-bearing test is
vimage_output_matches_videoconvert: for all eleven claimed pairs it pushes one frame through this element and through stockvideoconvertand compares pixel for pixel, tolerating ±2 for chroma rounding. It asserts viaconversion-paththat the vImage path was actually taken — without that the comparison would pass vacuously whenever negotiation quietly chose the fallback.I verified it bites, rather than assuming it: swapping the red and blue entries of the RGBA permute map fails it by 149; treating limited-range Y'CbCr as full range fails it by 20. Both restored afterwards.
Also covered: unsupported pairs and resizes take the fallback and match
videoconvertexactly;n-threadsis exposed andconfigure_video_convertmoves it; identical caps negotiate without converting; the depth-increase rule and its "only option" escape.Where these tests run
Locally on macOS only. None of the 14 new tests execute in PR CI. The module is
#[cfg(target_os = "macos")]throughout, andbuild-macosinci.ymlis gated ongithub.event_name == 'workflow_dispatch', so the Linux jobs compile the whole thing out. They will run on a manual dispatch withplatforms: macos(orboth), and they need no new package — everything used is ingst-plugins-base.This is a pre-existing property of the repo's CI rather than something this PR introduces: the macOS-only tests #726 added to
gpu.rsare in the same position. Worth deciding separately whether a macOS test job should run on PRs; I have not changed CI here.What CI did verify on this PR: Linux x86_64 and ARM64 builds,
Check (Linux)(clippy pluscargo testwith the module cfg'd out), the WASM build, and the API contract check — all green. Windows is built by no job on a PR either, so it is unverified by anyone; the non-macOS code paths it shares with Linux are identical.gstreamer-baseis a macOS-only dependency and already transitive viagstreamer-video, so nothing new compiles anywhere.Not done
Scaling has no vImage path —
vImageScaleplus a convert would be two passes, so a resize stays onGstVideoConverter.NV21, 10-bit andv210are not covered. Worth revisiting if a flow turns out to need them.🤖 Generated with Claude Code