From c748b2f7a3cf47057e81926516692abae8506cf8 Mon Sep 17 00:00:00 2001 From: Etienne Lescot Date: Fri, 4 Sep 2026 13:23:21 +0200 Subject: [PATCH 1/2] docs(perf): capping the macOS decoder's threads is a loss, and why the fixture said otherwise MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #592 proposed capping `thread_count` on the export's software decoder to give back the CPU-seconds that switching to it cost. Measured, it does neither: it does not return the CPU, and it costs the wall clock the decode change bought. decode threads cost CPU s auto (default) 1.044x 30.3 2 1.056x 29.2 1 1.775x 27.4 One thread is +70 % of wall clock to return 9.6 % of CPU — back near the 2.002x the shipped build measures. The premise was wrong: decoding N frames costs the same total work however many threads do it, so threads move wall clock and core occupancy, never CPU-seconds. The stage profile shows the mechanism. At one thread `decode.screen` goes 1.05 s -> 6.17 s while `enc.send_frame` goes 7.61 s -> 2.16 s: the decoder eats the slack an encoder-bound pipeline was leaving it, and the total holds — until the slack runs out, which on real content it does. A SECOND HAZARD, and the one worth reading. This experiment was run first on the generated fixture and concluded thread count did not matter at all (one thread: -0.0 % wall, -12.6 % CPU). The same experiment on the public bundle inverts it. The fixture is flat fills and sharp text, built to exercise the compositor; it is trivially decodable, so the decoder was never near being the constraint and the knob had nothing to act on. Every gate on that run was clean — drift, spread, output equality — on an answer that was wrong. Match the fixture to the stage under test. The known gap on energy is corrected at the same time. It said the CPU jump was 3.5x the energy; it is not a proxy at all, and the jump is not waste either — VideoToolbox does the same decoding in a fixed-function block that CPU accounting never sees, so the work moved somewhere visible and got 12x faster on the way. What capping threads would buy is lower peak core occupancy, which is a different question and still unmeasured. --- .../engineering/rendering-performance.md | 22 ++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index 8016e24ff..878db160d 100644 --- a/technical-documentation/engineering/rendering-performance.md +++ b/technical-documentation/engineering/rendering-performance.md @@ -376,6 +376,14 @@ The trail ends here: the D3D11 fast path that replaced all of it is measured in ## Measurement hazards +### A synthetic fixture cannot price a decoder + +The thread-count experiment above was run first on the generated fixture and concluded that thread count **did not matter at all** — one thread measured −0.0 % wall and −12.6 % CPU, which read as "the decoder has so much slack the knob does nothing". The same experiment on the public bundle inverts it completely: one thread is **+70 % wall**. + +The fixture is flat fills and sharp text, generated to exercise the compositor. It is trivially decodable, so the decoder was never close to being the constraint and the knob had nothing to act on. It is a fine instrument for a composite measurement and a misleading one for a decode measurement — and nothing in the run's own gates says so: drift, spread and output equality were all clean on the wrong answer. + +**Rule: match the fixture to the stage under test.** If the thing being measured is decode, the source has to be something a decoder actually works at. + ### A baseline you built yourself is not a control The most expensive mistake in this record's macOS chapter, and it passed every gate the harness has. An entry-point change measured **−17.2 %** against what was called "before" — three cycles, closing drift 0.9990, byte-identical output, MAD of 6 ms. Every check green. The "before" was **a variant of the same branch**, rebuilt from an incremental `dist/`. Measured again against `origin/main` built clean, the same change was worth **−0.1 %**, and the 3.9 s it claimed to remove turned out to be absent from the *shipped, unmodified* application too — 4208 ms in the morning, 481 ms in the evening, same binary, most likely memory pressure on an 8 GiB machine. @@ -562,6 +570,18 @@ Unit tests never look at a pixel. The `native*` arms write real files: export th ## Rejected routes +### Capping the macOS decoder's thread count + +**What it was.** After the export moved to the software H.264 decoder it runs with `thread_count = 0`, i.e. one thread per core, and the export's CPU-seconds went 8.4 → 29.8. Since the walk is bound by the encoder and the decoder has seconds of slack, capping its threads looked like free CPU. **What the measurement said.** It is not free and it does not return CPU. Public bundle, S4, three cycles with a floor inside each, closing drift 0.9979, output identical across variants: + +| decode threads | cost | CPU s | +|---|---:|---:| +| **auto (default)** | **1.044×** | 30.3 | +| 2 | 1.056× | 29.2 | +| 1 | **1.775×** | 27.4 | + +**One thread costs +70 % of wall clock to return 9.6 % of CPU** — it throws away nearly the whole decode gain, landing back near the 2.002× the shipped build measures. The premise was simply wrong: decoding N frames costs the same total work however many threads do it, so threads move wall clock and core occupancy, never CPU-seconds. The profile shows the mechanism cleanly — at one thread `decode.screen` goes 1.05 s → 6.17 s while `enc.send_frame` goes 7.61 s → 2.16 s, the decoder eating the slack the encoder-bound pipeline left it, until the slack runs out. **One-line reason not to re-propose:** threads do not buy CPU-seconds back, and by the time the cap is low enough to matter it has already cost the export more than the optimisation gained. + ### A dedicated encode thread on macOS **What it was.** After the decode change above, the macOS export profile was two waits and almost nothing else: `enc.send_frame` 7.658 s (40.6 %, the CPU waiting on VideoToolbox) and `gpu.wait` 7.501 s (39.8 %, the CPU waiting on Metal). Two different engines, one serial loop. Put the encoder on its own thread — bounded queue, backpressure, error propagation, `av_frame_free` on the consumer side — and the two waits overlap. It is also the move that was worth −30 % on the Linux path. **What the measurement said.** **+0.2 %.** Three cycles, an ffmpeg floor inside each, closing drift 1.0003, machine 84–85 % idle: serial 23 039 ms / 1.308× floor, threaded 22 995 ms / 1.305×. Output byte-identical either way. A probe on the producer's blocking says exactly why: blocking on a full queue measured **7.631 s** where blocking inside `avcodec_send_frame` had measured **7.658 s** — 0.4 % apart. The thread **moved** the wait, it did not remove it. The export is bound by VideoToolbox's encode throughput, not by CPU serialisation: the main thread's own work is 9.64 s across 3600 frames (2.68 ms/frame, enough to feed 373 fps) against an encoder that absorbs about 191. **One-line reason not to re-propose:** the encoder is the constraint and no scheduling changes that; it could pay on an M-series Pro/Max/Ultra with more encode blocks, but that is a hypothesis and shipping unmeasured concurrency on it is the mistake this record exists to prevent. @@ -668,7 +688,7 @@ the bench runs on the reference machine. - **macOS export startup can cost 4 s, and nobody has reproduced it on demand.** Measured repeatedly at 4208–4502 ms between the CLI's `started` event and the first composed frame — 18 % of a 60 s export, 71 % of a 5 s one — then gone, on the same shipped binary, hours later (481 ms). It is not the compositor (init is 2.4 ms, runtime MSL compilation included), not the `