Skip to content

perf(macos): convert colour through Accelerate/vImage - #747

Draft
wagenet wants to merge 3 commits into
Eyevinn:mainfrom
wagenet:vimage-convert
Draft

perf(macos): convert colour through Accelerate/vImage#747
wagenet wants to merge 3 commits into
Eyevinn:mainfrom
wagenet:vimage-convert

Conversation

@wagenet

@wagenet wagenet commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

Stacked on #731 (which is itself stacked on #726). Review the last commit only; the first two belong to those PRs. Merge them first.

What this adds

stromvimageconvert — a GStreamer element that converts colour through Accelerate/vImage on macOS. It lives in backend/src/gst/vimage/, registers itself statically the first time detect_gpu_capabilities() runs, and is selected by a new macOS-only VideoConvertMode::VImage.

No call site changed. The element carries the same n-threads property as videoconvert, so configure_video_convert() from #726 reaches it unchanged and all five sites pick it up through element_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 videoconvert would 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 a GstVideoConverter, which is the code videoconvert and videoconvertscale are themselves built on, configured with the same thread count. A new read-only conversion-path property reports vimage / fallback / unnegotiated.

vImage paths: packed RGB32 (RGBA/BGRA/ARGB/ABGR and their x variants) to and from NV12, I420, YV12, UYVY, YUY2; RGB32 to RGB32; and NV12 to and from I420/YV12.

Which pairs Strom actually negotiates

Checked against a running backend with GST_DEBUG=stromvimageconvert:5, driving videotestsrc → Video Format → Video Encoder → fakesink:

Configuration Negotiated Path
Video Format with an output format set RGBA→NV12, RGBA→I420, RGBA→UYVY, BGRA→NV12 vImage
Video Format left unset, vtenc_h264_hw downstream RGBA→UYVY (was RGBA→ARGB64_BE) vImage
Second converter, format already pinned NV12→NV12 etc. passthrough

So the headline RGBA→NV12 is 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_hw offers { AYUV64, UYVY, NV12, I420, P010_10LE, ARGB64_BE, RGBA64_LE }, and stock videoconvert picks AYUV64 — 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 videoconvert

My 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 picks UYVY there, the regression goes to zero, and that path now reaches vImage too.

fixate_caps is otherwise a smaller model than gst_video_convert's score_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 videoconvert arm.

Experiment Arm A Arm B Net of source t
baseline (identity vs identity) 237.9 ms 238.0 ms −0.20
A/A control (videoconvert vs itself) 472.0 ms 444.7 ms 1.01× 0.60 †
A/B (videoconvert n-threads=12 vs vImage) 186.2 ms 54.3 ms 3.43× 33.6
vs stock (videoconvert n-threads=1 vs vImage) 738.2 ms 53.1 ms 13.91× 791.8
unpinned (vtenc_h264_hw downstream) 444.5 ms 444.2 ms 1.00× 5.96

† 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 in bench.rs with the method documented, so the numbers are reproducible. It asserts nothing about timing.

Tests

cargo test566 lib tests + all integration binaries pass, run in full. cargo clippy --all-targets -- -D warnings and cargo fmt --check clean.

The load-bearing test is vimage_output_matches_videoconvert: for all eleven claimed pairs it pushes one frame through this element and through stock videoconvert and compares pixel for pixel, tolerating ±2 for chroma rounding. It asserts via conversion-path that 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 videoconvert exactly; n-threads is exposed and configure_video_convert moves 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, and build-macos in ci.yml is gated on github.event_name == 'workflow_dispatch', so the Linux jobs compile the whole thing out. They will run on a manual dispatch with platforms: macos (or both), and they need no new package — everything used is in gst-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.rs are 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 plus cargo test with 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-base is a macOS-only dependency and already transitive via gstreamer-video, so nothing new compiles anywhere.

Not done

Scaling has no vImage path — vImageScale plus a convert would be two passes, so a resize stays on GstVideoConverter. NV21, 10-bit and v210 are not covered. Worth revisiting if a flow turns out to need them.

🤖 Generated with Claude Code

wagenet and others added 3 commits August 31, 2026 21:46
…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>
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