Skip to content

perf(gpu): let macOS choose its own convert mode, and thread the conversion - #726

Open
wagenet wants to merge 1 commit into
Eyevinn:mainfrom
wagenet:wagenet/macos-convert-mode
Open

perf(gpu): let macOS choose its own convert mode, and thread the conversion#726
wagenet wants to merge 1 commit into
Eyevinn:mainfrom
wagenet:wagenet/macos-convert-mode

Conversation

@wagenet

@wagenet wagenet commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

The bug

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.

That hid the question that matters. 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.

(a) macOS decides for itself

The mode decision is split per platform. Linux and Windows keep the existing WSL / NVENC / CUDA-GL interop path unchanged. macOS gets its own detect_convert_mode() and still picks Software, for two reasons that apply to the hardware:

  • autovideoconvert offers no GPU path for the frames these call sites see. Given GL memory it picks glcolorconvert, but our blocks feed it system memory, because the GL chains in this tree download at their boundary. For system memory it selects videoconvertscale on the CPU and never creates a GL context, verified with GST_DEBUG=gl*:5. It measured 0.7% slower than single-threaded videoconvert.
  • Being a bin, it exposes no n-threads, so it would forfeit (b).

(b) n-threads

configure_video_convert() sets the pool in one place, and all five call sites route through it (ndi.rs ×4, devicesrc.rs, videoenc.rs, videoformat.rs, vision_mixer/builder/pipeline_cpu.rs).

Measurements come from the authoring machine, an 8-core M2 (4 performance + 4 efficiency), whose paired-run noise floor is ±11%. 1080p RGBA→I420, paired runs alternating the within-pair order every pair:

concurrent converts n-threads 1 → 4 pairs won order check (Δ A-first / B-first)
1 −23.8% 20/20 +0.695 / +0.705
2 −18.4% 16/16 +0.573 / +0.569
4 −8.3% 14/16 +0.416 / +0.403

The effect is the same size whichever variant ran first, so it is not order bias. The convert stage alone drops from 1.05 s to 0.24 s per 300 frames.

Thread count

macOS reports its cores as named performance tiers, and the pool counts every tier except the efficiency one: 4 on the M2, 10 on an M4 Pro, 36 on an M5 Ultra. Reading tier 0 alone would give the Ultra 12 and discard its 24 performance cores. The tiers are distinct rather than renamed; an M6 has 2 super, 4 performance and 6 efficiency cores, and Apple describes performance cores as joining the super cores for demanding multithreaded work while efficiency cores handle background tasks.

Excluding the efficiency tier rests on mechanism, since measurement could not settle it: 4 threads against 8 landed within 2% and disagreed about the sign across contention levels. 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% against a 4-thread baseline, while running 1 instead of 4 costs 24%. STROM_VIDEOCONVERT_THREADS (1–64) overrides it for anyone able to measure on a wider machine.

Scope: macOS only

configure_video_convert() is a no-op elsewhere, so Linux and Windows are unchanged. Non-NVIDIA Linux hosts are single-threaded too, and this could have gone cross-platform. It does not, because the count is an Apple-silicon heterogeneous-core heuristic that does not transfer, and because a Linux container's visible CPU count routinely overstates its cgroup quota.

Tests

  • cargo test --lib, 546 passed (10 new gpu::tests); cargo test --test pipeline_lifecycle_test, 3 passed
  • cargo clippy --all-targets -- -D warnings, and with --features efp,nvidia, both clean
  • Not run: the full integration suite. It links dozens of ~280 MB debug binaries, the authoring machine was at ~11 GB free, and two attempts stalled. CI covers it.

configure_video_convert_sets_thread_count fails if the fix is reverted, with left: 1, right: 4. No Max or Ultra was available, so resolve_thread_count is split out from the environment and the wide-machine paths are pinned by unit tests: 10/12/24 cores, the bound, and override parsing.

autovideoconvert_still_cannot_be_threaded and no_applemedia_converter_to_reconsider guard assumptions instead of behaviour. The second fires if applemedia ever ships a Converter-klass element, which autovideoconvert would discover automatically and which would make GpuAccelerated worth re-measuring. Pointing its query at opengl confirms it trips.

All of these are #[cfg(target_os = "macos")], and the macOS CI job is workflow_dispatch-only, so they do not run on a normal PR.

Beyond the microbenchmarks, an isolated headless instance running a builtin.videoformat block showed 3 threads in gst_parallelized_task_thread_func plus the caller, giving 4 stripes. At n-threads=1 there are none.

Follow-up (not in this PR)

videoformat.rs builds videoscale → videoconvert unconditionally, walking each frame twice when both resolution and format change. A single videoconvertscale measured 25–30% faster there, and identical when only the format changes. Separate PR, different rationale and blast radius.

@srperens srperens mentioned this pull request Aug 31, 2026
@wagenet
wagenet force-pushed the wagenet/macos-convert-mode branch 9 times, most recently from b747efb to 6c2273c Compare August 31, 2026 17:11

@wagenet wagenet left a comment

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.

This code all makes sense to me.

@wagenet
wagenet marked this pull request as ready for review August 31, 2026 18:11
@wagenet
wagenet force-pushed the wagenet/macos-convert-mode branch from 6c2273c to 61fc7bc Compare August 31, 2026 19:31
…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>
@wagenet
wagenet force-pushed the wagenet/macos-convert-mode branch from 61fc7bc to 57c1ee2 Compare September 1, 2026 04:46

@srperens srperens left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Verdict: Comment — correct diagnosis and thorough, well-designed test coverage, but this is ~400 lines of new macOS-only unsafe FFI and threading logic that this repo's CI has never compiled, which blocks approval per REVIEW.md.

Claims

Claim Verdict Evidence
The Linux/Windows CUDA-GL interop path is unchanged, only extracted into its own function CONFIRMED backend/src/gpu.rs:259`fn detect_convert_mode() -> VideoConvertMode {` under #[cfg(not(target_os = "macos"))], same WSL/NVENC/interop steps as before, same outcomes
macOS's n-threads sizing and sysctl reads are covered by unit tests CONFIRMED backend/src/gpu.rs:593`fn configure_video_convert_sets_thread_count() {` plus 8 more #[cfg(target_os = "macos")] tests exercising resolve_thread_count, fast_cores_from_tiers, and a live sysctlbyname round-trip
Every n-threads-eligible call site is covered CONFIRMED 8 call sites across devicesrc.rs, ndi.rs (×4), videoenc.rs, videoformat.rs, pipeline_cpu.rs all call gpu::configure_video_convert() right after building the element_name()-selected converter — no other such construction site remains uncovered
The new macOS code (sysctl_u32/sysctl_string/video_convert_threads/configure_video_convert's macOS arm) has been compiled by this repo's CI UNVERIFIED gh pr checks 726: Build (macOS) skipping; .github/workflows/ci.yml:543if: github.event_name == 'workflow_dispatch' && contains(fromJSON('["both","macos"]'), inputs.platforms) — gates build-macos, which this PR run is not

Diagnosis — Root cause matches the PR's own trace: detect_gpu_capabilities() asked an NVENC question that is always "no" on a Mac, which hid the real cost (n-threads=1 serializing every frame). Splitting detect_convert_mode() per platform and threading the pool is the right layer — it's a startup-time decision plus a per-element property, not a runtime workaround. Coverage is ABSOLUTE for the stated bug (every element_name()-selected converter now gets configure_video_convert()), not BOUNDED.

Blast radiusGLOBAL: configure_video_convert() is called from 8 sites spanning device input, NDI in/out, video encode, format conversion and the CPU vision-mixer path, and detect_convert_mode() replaces the process-wide startup decision VIDEO_CONVERT_MODE is set from. The macOS branch is a no-op elsewhere (backend/src/gpu.rs:495`let _ = element;` under the not(macos) arm of configure_video_convert), and the non-macOS detect_convert_mode body is byte-for-byte the prior logic, so Linux/Windows behaviour is unaffected — but that equivalence, and the sysctl-based core count, rest entirely on macOS, the one platform this run cannot exercise.

Tests & CICheck (Linux), both Build (Linux) jobs, Check & Build (WASM), API Contract Check all green at 57c1ee2. sysctl_u32/sysctl_string reading real Apple integer/string sysctls (hw.nperflevels, hw.perflevelN.name/.logicalcpu) is EXTERNAL (Darwin's own ABI), stated as an assumption, not this repo's to verify — but whether the calls compile and the property actually lands on a live videoconvert (configure_video_convert_sets_thread_count) is settleable and unsettled here. Dispatch before merge: gh workflow run ci.yml --ref wagenet/macos-convert-mode -f platforms=macos — that job does run cargo test --package strom, which would execute all 9 new macOS tests.

Design record — States the rejected alternative (measuring n-threads uniformly across platforms) and why it doesn't transfer to Linux (cgroup quota overstatement).

Confidence: HIGH

wagenet added a commit to wagenet/strom that referenced this pull request Sep 4, 2026
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.

2 participants