Perf: dedicated strip-parallel composite (4 threads, BelowNormal) — UNIT 5

A composite splits into four horizontal row strips across four DEDICATED
background threads; the calling thread takes a strip too (so four workers
never mean five-way oversubscription), and anything under 64 rows runs
inline. BlitContentRaw, BlitBakedElement and BlitOverlay now fan out.
Output is byte-identical — strips already partitioned by row.

The demotion deliberately does NOT come from Parallel.For. The pool is
shared, so Thread.CurrentThread.Priority = BelowNormal inside it leaks
into every unrelated work item that thread later runs — a full test run
after trying it broke AudioPipelineTests.Mix_HonorsProviderGains_AndGameMute_
KillsTheLoopback. Established guidance agrees: use your own producer/consumer
queue with your own Threads and set their priority yourself
(https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority,
https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool).
Helpers/StripPool.cs is that queue. Four threads is the measured sweet
spot: fewer leaves render over budget, more steals cycles from the game
and the encoder and made render worse.

The scaled blit's column LUT moves from a shared static buffer to
ArrayPool<float>.Shared with a finally — a single static buffer is a data
race the moment strips run in parallel.

Gate: SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical —
8 renders of a scaled round webcam + mirrored image + master-sized flash over
a short backdrop must be byte-for-byte identical, and must differ from a
backdrop-only render so it cannot pass vacuously. It caught a loop-variable
capture (new Thread(() => Worker(i)) saw i == Workers) that crashed the test
host on the first run.
This commit is contained in:
2026-10-05 18:54:47 -07:00
parent 3901cb8298
commit 6b6284d05c
7 changed files with 452 additions and 42 deletions
+52
View File
@@ -675,6 +675,58 @@ design.
`SetMediaStreamPropertiesAsync` attempts are refused under `SharedReadOnly`, so 720p MJPG is available
— but see above, it is a quality win only.
### Render budget — getting a composite inside 16.67ms (UNITs 4-5, 2026-10-05)
The pump emits one encoder frame per 16.67ms slot and **pads with a duplicate when a composite
overruns**, so the whole problem is "render + submit + untimed work ≤ 16.67ms, ideally ≤12ms to
leave headroom". `FramePump` now reports per-stage timings (`social`/`ticker`/`flash`/`acquireScratch`)
so a slow stage names itself, and logs the scene name + compositor source rect at pump start.
**Why a 21:9 monitor cost 141ms and a 16:9 one 33ms — the fast path is EXACTLY 1920×1080.** A
21:9 display shrinks the source rect to 1920×803 ("fit", not "fill"), so the opaque-backdrop 1:1 row
walk is skipped, the base is prefilled with opaque black (~2M byte writes), and the backdrop is
resampled per-pixel (~1.54M pixels with an alpha branch). Every recorded A/B lines up:
21:9 → 141ms, 16:9 → 30-33ms, 2560×1440 (fits to 1920×1080 exactly) → fast path. It was never
Debug-vs-Release; today's Debug run on 16:9 with the game up measured 33ms.
**Unit 4 — cheap wins, all pixel-identical:**
- Opaque 1:1 fast paths expanded to the whole-canvas opaque case (a `BlockCopy`, not a per-pixel
alpha branch).
- **Prefill only the uncovered rows.** When the backdrop does not cover the canvas, the letterbox
bands are filled instead of the whole buffer; with no backdrop at all it still fills everything.
- **Per-column LUT** for the scaled blit (`BlitContentRaw`): the source column, its neighbour index
and the 8.8 weights are computed once per column instead of a divide + clamp per pixel.
The table comes from `ArrayPool<float>.Shared` and is returned in a `finally` — a single static
buffer is a **data race** the moment strips run in parallel (see below).
**Unit 5 — strip-parallel composite: `Helpers/StripPool.cs`.** One composite splits into four
horizontal row strips; the calling thread takes a strip too, so four workers never means five-way
oversubscription. Below 64 rows the fan-out costs more than it saves and runs inline. Each strip must
write only its own rows — the blits already partition by row, which is why the output stays
byte-identical.
**The demotion must come from DEDICATED threads, not the shared pool.** The obvious
`Thread.CurrentThread.Priority = BelowNormal` inside `Parallel.For` is wrong: `Parallel.For` runs on
shared ThreadPool threads, so the demotion leaks into every unrelated work item that thread later
picks up. Research (https://stackoverflow.com/questions/524483/net-backgroundworker-objects-thread-priority
— "you have next to no control of which thread pool thread will actually run your task";
https://stackoverflow.com/questions/1578599/c-sharp-lower-thread-priority-in-thread-pool — "You
shouldn't mess with the priority of the regular ThreadPool, since you aren't the only consumer ...
write your own producer/consumer queue, using your own Thread(s) as the consumer(s) - setting the
thread priority yourself") says exactly that, and **a real failed attempt confirmed it**: a full test
run after demoting the pool threads broke an unrelated audio-mix timing test. `StripPool` IS that
producer/consumer queue — four `Thread`s with `Priority = BelowNormal`, `IsBackground = true`,
started lazily on first use, each fed a `Queue<Strip>` + `SemaphoreSlim`. Four is the measured sweet
spot: fewer leaves render over budget, more steals cycles from the game and the encoder and made
render **worse**.
Gate test: `SceneCompositorTests.StripParallel_RepeatedRenders_AreByteIdentical` — the same scene
(scaled round webcam + mirrored image + master-sized flash over a short backdrop, 240 rows so all
three fanned-out paths run) must come out byte-for-byte identical across 8 renders, and must differ
from a backdrop-only render so it cannot pass vacuously. It earned its keep immediately: it caught a
loop-variable capture (`new Thread(() => Worker(i))` where `i` was `Workers` by the time the thread
ran) that crashed the test host on first run.
### Scene compositor (TASK 4 ship step 1 — shipped 2026-08-10, plan in TASKS.md)
The encoder needs the master 1920×1080 frame **without** the preview's editing chrome (SelectionOverlay,