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
+35 -33
View File
@@ -1,5 +1,5 @@
using System.Buffers;
using System.Collections.Generic;
using System.Threading.Tasks;
using ytLive.Helpers;
using ytLive.Models;
@@ -33,7 +33,6 @@ public sealed class SceneCompositor
private static readonly Dictionary<SceneElement, VideoFrame> _elementRasters = new(ReferenceEqualityComparer.Instance);
private static readonly object _pasteGate = new();
private const int MaxPasteEntries = 256;
private static float[] _sxLutCache = Array.Empty<float>();
/// <summary>
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
@@ -397,8 +396,7 @@ public sealed class SceneCompositor
var y1 = System.Math.Min(cropH, py + dh);
if (x0 >= x1 || y0 >= y1) return;
var s = raster.BgraPixels;
var parallelOpts = new ParallelOptions { MaxDegreeOfParallelism = 4 };
Parallel.For(y0, y1, parallelOpts, y =>
StripPool.For(y0, y1, y =>
{
var si = ((y - py) * dw + (x0 - px)) * 4;
var len = x1 - x0;
@@ -547,37 +545,37 @@ public sealed class SceneCompositor
var s = src.BgraPixels;
var srcStride = src.Width; // canvas stride in pixels (not bytes)
// Build horizontal LUT for scaled blit to reduce per-pixel divides
// Build horizontal LUT for scaled blit to reduce per-pixel divides. The table is
// READ by the strip workers while it is being sampled, so it must not be shared
// mutable state: it comes from the thread-safe ArrayPool and goes back in a finally
// (the static single-buffer version raced the moment the strips went parallel).
var lutCount = x1 - x0 + 1;
if (_sxLutCache.Length < lutCount)
var sxLut = ArrayPool<float>.Shared.Rent(lutCount);
try
{
_sxLutCache = new float[Math.Max(lutCount, 4096)];
}
var sxLut = _sxLutCache;
var pxBase = x0 - ex;
for (var xi = 0; xi < lutCount; xi++)
{
var px = pxBase + xi;
float sxCanvas;
if (px < ox || px > ox + drawnW)
var pxBase = x0 - ex;
for (var xi = 0; xi < lutCount; xi++)
{
sxCanvas = float.NaN;
var px = pxBase + xi;
float sxCanvas;
if (px < ox || px > ox + drawnW)
{
sxCanvas = float.NaN;
}
else
{
var sxNorm = (px - ox) / drawnW;
sxNorm = sxNorm < 0f ? 0f : sxNorm > 1f ? 1f : sxNorm;
var sxCrop = sxNorm * cbW;
sxCanvas = cbX + sxCrop;
if (sxCanvas < 0f) sxCanvas = 0f;
if (sxCanvas > src.Width - 1f) sxCanvas = src.Width - 1f;
if (isMirror) sxCanvas = src.Width - 1f - sxCanvas;
}
sxLut[xi] = sxCanvas;
}
else
{
var sxNorm = (px - ox) / drawnW;
sxNorm = sxNorm < 0f ? 0f : sxNorm > 1f ? 1f : sxNorm;
var sxCrop = sxNorm * cbW;
sxCanvas = cbX + sxCrop;
if (sxCanvas < 0f) sxCanvas = 0f;
if (sxCanvas > src.Width - 1f) sxCanvas = src.Width - 1f;
if (isMirror) sxCanvas = src.Width - 1f - sxCanvas;
}
sxLut[xi] = sxCanvas;
}
var parallelOpts = new ParallelOptions { MaxDegreeOfParallelism = 4 };
Parallel.For(y0, y1 + 1, parallelOpts, y =>
StripPool.For(y0, y1 + 1, y =>
{
var py = y - ey;
if (py < oy || py > oy + drawnH) return;
@@ -658,7 +656,12 @@ public sealed class SceneCompositor
}
}
}
});
});
}
finally
{
ArrayPool<float>.Shared.Return(sxLut);
}
}
/// <summary>Two-stage bilinear in 8.8 fixed point; result within ±1 of the
@@ -823,8 +826,7 @@ public sealed class SceneCompositor
var dy0 = Math.Max(0, sy0 - options.SourceRectY);
var dx1 = Math.Min(dstW, sx0 - options.SourceRectX + overlay.Width);
var dy1 = Math.Min(dstH, sy0 - options.SourceRectY + overlay.Height);
var parallelOpts = new ParallelOptions { MaxDegreeOfParallelism = 4 };
Parallel.For(dy0, dy1, parallelOpts, y =>
StripPool.For(dy0, dy1, y =>
{
var si = ((y + options.SourceRectY - sy0) * overlay.Width
+ dx0 + options.SourceRectX - sx0) * 4;