using System; using System.Threading; namespace ytLive.Helpers; /// /// A fixed pool of four DEDICATED background threads at /// that split one composite into horizontal row strips. The compositor needs the split to get /// render inside a 16.67ms slot, but it must not compete with the encoder, the WGC capture /// callbacks, the WebView2 chat or the game the user is playing — hence the hard cap of four /// and the demotion. /// /// Research (2026-10-05, Unit 5): the demotion must NOT be done by poking /// Thread.CurrentThread.Priority inside Parallel.For — the pool is shared, so the /// demotion leaks into every unrelated work item that thread later runs /// (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"). A real failed attempt confirmed it: a /// full test run after demoting the pool threads broke an unrelated audio-mix timing test. /// This class IS that producer/consumer queue; the caller thread is the producer and also runs /// one strip itself, so four workers never means five-way oversubscription. /// /// Contract: blocks until every strip has run, each strip must write only its /// own rows (the compositor's blits already partition by row), and it must not be called from /// inside a strip (the blits never nest). /// internal static class StripPool { /// Four workers: the measured sweet spot on this box. Fewer leaves render over /// budget; more steals cycles from the game and the encoder and made render worse, not better. public const int Workers = 4; /// Below this many rows the fan-out costs more than it saves — run it inline. private const int MinRowsForFanOut = 64; private static readonly object[] _queueLocks = CreateLocks(); private static readonly Queue[] _queues = CreateQueues(); private static readonly Queue[] _free = CreateQueues(); private static readonly SemaphoreSlim[] _signals = CreateSignals(); private static Thread[]? _threads; private static int _roundRobin = -1; private sealed class Strip { public int From; public int To; // exclusive public Action? Body; public Batch? Owner; } private sealed class Batch { public int Remaining; public Exception? Error; } /// Run for every row in [from, to), split across the pool. public static void For(int from, int to, Action body) { if (from >= to) return; var rows = to - from; if (rows < MinRowsForFanOut || !EnsureStarted()) { for (var y = from; y < to; y++) body(y); return; } // Strips are handed to whole workers (not per-row tasks) so the strip boundaries — // and therefore the destination row each strip owns — are fixed before any of them run. var strips = Math.Min(Workers, rows); var chunk = (rows + strips - 1) / strips; var batch = new Batch { Remaining = strips - 1 }; // the caller runs the last one var first = from; for (var k = 0; k < strips - 1; k++) { var end = Math.Min(first + chunk, to); var w = NextWorker(); var strip = Rent(w); strip.From = first; strip.To = end; strip.Body = body; strip.Owner = batch; lock (_queueLocks[w]) _queues[w].Enqueue(strip); _signals[w].Release(); first = end; } try { for (var y = first; y < to; y++) body(y); } catch (Exception ex) { batch.Error ??= ex; } if (batch.Remaining > 0) { lock (batch) { while (batch.Remaining > 0) Monitor.Wait(batch); } } if (batch.Error != null) throw new InvalidOperationException("A compositor strip failed.", batch.Error); } private static void Worker(int index) { var signal = _signals[index]; while (true) { signal.Wait(); Strip? strip = null; lock (_queueLocks[index]) { if (_queues[index].Count > 0) strip = _queues[index].Dequeue(); } if (strip == null) continue; // released for an already-drained queue var batch = strip.Owner!; try { for (var y = strip.From; y < strip.To; y++) strip.Body!(y); } catch (Exception ex) { batch.Error ??= ex; } finally { strip.Body = null; strip.Owner = null; lock (_queueLocks[index]) _free[index].Enqueue(strip); lock (batch) { if (--batch.Remaining == 0) Monitor.PulseAll(batch); } } } } private static Strip Rent(int worker) { lock (_queueLocks[worker]) return _free[worker].Count > 0 ? _free[worker].Dequeue() : new Strip(); } private static int NextWorker() { var w = Interlocked.Increment(ref _roundRobin) % Workers; return w < 0 ? w + Workers : w; } /// Start the workers on first use, so a WPF cold start pays nothing for them. private static bool EnsureStarted() { if (_threads != null) return true; lock (_signals) { if (_threads != null) return true; var threads = new Thread[Workers]; for (var i = 0; i < Workers; i++) { var index = i; // capture per-iteration: the loop variable would be Workers by the time the thread runs var t = new Thread(() => Worker(index), 256 * 1024) { IsBackground = true, Name = $"compositor-strip-{i}", Priority = ThreadPriority.BelowNormal, }; threads[i] = t; t.Start(); } _threads = threads; return true; } } private static object[] CreateLocks() { var l = new object[Workers]; for (var i = 0; i < Workers; i++) l[i] = new object(); return l; } private static Queue[] CreateQueues() { var q = new Queue[Workers]; for (var i = 0; i < Workers; i++) q[i] = new Queue(); return q; } private static SemaphoreSlim[] CreateSignals() { var s = new SemaphoreSlim[Workers]; for (var i = 0; i < Workers; i++) s[i] = new SemaphoreSlim(0, int.MaxValue); return s; } }