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:
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user