diff --git a/technical-documentation/engineering/rendering-performance.md b/technical-documentation/engineering/rendering-performance.md index 8016e24f..ea5b63cf 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`, which in libavcodec means *automatic* — the decoder picks, from the CPU count and its own threading model, and the number it actually chose was never read back here. 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 wrong about the size of the effect, not its sign: CPU-seconds do fall, by 30.3 → 27.4, but nothing like proportionally, because decoding N frames costs roughly the same total work however many threads share it. Threads mostly redistribute that work rather than reduce it, and the ~10 % that does disappear is plausibly the thread pool's own overhead — which was not isolated, so treat the mechanism behind that last 10 % as unexplained rather than established. 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 `