perf(compositor): reuse per-element rasters in-place — take-16 GC-churn fix
Slice 10 from today-slices (d1126dd): the paste cache minted a fresh raster array per new source frame (webcam ~30 keys/s ≈ 18MB/s LOH churn → gen2 pauses that ate camera frames). Fix: _elementRasters — re-rasterize INTO the element's existing array when geometry holds, so steady-state allocation ≈ 0. The paste cache keys on (array+epoch) still protect correctness; _elementRasters[element] tracks the element's current raster so stale keys can never paste a mid-update array. MaxPasteEntries 48→256 (was a leak guard, not an allocation stream). Regression test: PasteCache_RingRecycledSources_ReRasterInPlace_WithoutAllocationChurn. Build 0 warnings, 289 tests.
This commit is contained in:
@@ -25,8 +25,9 @@ public sealed class SceneCompositor
|
|||||||
// target rect" ⇒ same raster. Bounds-checked eviction clears wholesale.
|
// target rect" ⇒ same raster. Bounds-checked eviction clears wholesale.
|
||||||
private sealed record PasteKey(byte[] Pixels, long Epoch, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror);
|
private sealed record PasteKey(byte[] Pixels, long Epoch, int SrcW, int SrcH, int DstW, int DstH, bool Round, bool Mirror);
|
||||||
private static readonly Dictionary<PasteKey, VideoFrame> _pasteCache = new();
|
private static readonly Dictionary<PasteKey, VideoFrame> _pasteCache = new();
|
||||||
|
private static readonly Dictionary<SceneElement, VideoFrame> _elementRasters = new(ReferenceEqualityComparer.Instance);
|
||||||
private static readonly object _pasteGate = new();
|
private static readonly object _pasteGate = new();
|
||||||
private const int MaxPasteEntries = 48;
|
private const int MaxPasteEntries = 256;
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
|
/// Composite <paramref name="scene"/> into the tier's output frame. Layer order (back →
|
||||||
@@ -294,14 +295,26 @@ public sealed class SceneCompositor
|
|||||||
// Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255
|
// Rasterize in ELEMENT space on a TRANSPARENT base: the sampler writes 255
|
||||||
// alpha only where it actually draws, so untouched margins (outside the
|
// alpha only where it actually draws, so untouched margins (outside the
|
||||||
// round clip, past the UniformToFill edges) stay alpha 0 and the paste
|
// round clip, past the UniformToFill edges) stay alpha 0 and the paste
|
||||||
// reveals the master beneath instead of black.
|
// reveals the master beneath instead of black. The ARRAY is reused per
|
||||||
var pixels = new byte[dw * dh * 4];
|
// element (see _elementRasters note) — new source generations re-raster
|
||||||
BlitContentRaw(pixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror);
|
// in place, so the steady state allocates nothing.
|
||||||
raster = new VideoFrame(dw, dh, pixels);
|
|
||||||
lock (_pasteGate)
|
lock (_pasteGate)
|
||||||
{
|
{
|
||||||
|
if (_elementRasters.TryGetValue(element, out var existing)
|
||||||
|
&& existing.Width == dw && existing.Height == dh)
|
||||||
|
raster = existing;
|
||||||
|
}
|
||||||
|
if (raster == null) raster = new VideoFrame(dw, dh, new byte[dw * dh * 4]);
|
||||||
|
Array.Clear(raster.BgraPixels);
|
||||||
|
BlitContentRaw(raster.BgraPixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror);
|
||||||
|
lock (_pasteGate)
|
||||||
|
{
|
||||||
|
// Only the SOURCE frame's identity is a real key owner; drop any older
|
||||||
|
// entry that shared this element's reused raster so a stale key can never
|
||||||
|
// paste a mid-update array.
|
||||||
if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear();
|
if (_pasteCache.Count >= MaxPasteEntries) _pasteCache.Clear();
|
||||||
_pasteCache[key!] = raster;
|
_pasteCache[key!] = raster;
|
||||||
|
_elementRasters[element] = raster;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -254,6 +254,69 @@ public class SceneCompositorTests
|
|||||||
Assert.Equal(out2.BgraPixels, out3.BgraPixels);
|
Assert.Equal(out2.BgraPixels, out3.BgraPixels);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>take-16 regression gate — GC churn convicted mechanically: a ring-style
|
||||||
|
/// producer hands out a small set of rotating arrays with a NEW EPOCH every frame
|
||||||
|
/// (exactly what the capture/camera rings do). The compositor must re-rasterize INTO
|
||||||
|
/// its reused per-element raster, so steady-state allocation per render is near zero.
|
||||||
|
/// Before the fix every new cache key minted a fresh raster array (~625KB for the
|
||||||
|
/// webcam at 30fps ≈ 18MB/s) — gen2 pauses followed, and they ate camera frames in
|
||||||
|
/// the real app. The content assertion rides along: the pasted pixels must match the
|
||||||
|
/// CURRENT ring generation, never a stale one.</summary>
|
||||||
|
[Fact]
|
||||||
|
public void PasteCache_RingRecycledSources_ReRasterInPlace_WithoutAllocationChurn()
|
||||||
|
{
|
||||||
|
const int SrcW = 640, SrcH = 360;
|
||||||
|
var ring = new byte[4][];
|
||||||
|
for (var s = 0; s < ring.Length; s++)
|
||||||
|
{
|
||||||
|
ring[s] = new byte[SrcW * SrcH * 4];
|
||||||
|
for (var i = 0; i < ring[s].Length; i += 4)
|
||||||
|
{
|
||||||
|
ring[s][i] = 0; ring[s][i + 1] = 0;
|
||||||
|
ring[s][i + 2] = (byte)(40 + s * 50);
|
||||||
|
ring[s][i + 3] = 255;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var background = new Source { Type = SourceType.DisplayCapture, IsBackground = true, CaptureKey = "monitor:0" };
|
||||||
|
var webcam = new WebcamSceneConfig { X = 100, Y = 100, Width = 320, Height = 180 };
|
||||||
|
var scene = new Scene { Name = "Live" };
|
||||||
|
scene.Elements.Add(background);
|
||||||
|
scene.Elements.Add(webcam);
|
||||||
|
var options = new CompositorOptions
|
||||||
|
{
|
||||||
|
SourceRectX = 0, SourceRectY = 0, SourceRectWidth = 1280, SourceRectHeight = 720,
|
||||||
|
OutputWidth = 1280, OutputHeight = 720,
|
||||||
|
};
|
||||||
|
|
||||||
|
var bg = Solid(1280, 720, 0, 255, 0);
|
||||||
|
var compositor = new SceneCompositor();
|
||||||
|
long epoch = 0;
|
||||||
|
VideoFrame? FrameFor(SceneElement e) => e switch
|
||||||
|
{
|
||||||
|
WebcamSceneConfig => new VideoFrame(SrcW, SrcH, ring[epoch % ring.Length])
|
||||||
|
{ IsOpaque = true, Epoch = ++epoch },
|
||||||
|
_ => bg,
|
||||||
|
};
|
||||||
|
|
||||||
|
var scratch = new byte[1280 * 720 * 4];
|
||||||
|
|
||||||
|
for (var i = 0; i < 8; i++) compositor.Render(scene, FrameFor, null, options, scratch: scratch);
|
||||||
|
|
||||||
|
var before = GC.GetAllocatedBytesForCurrentThread();
|
||||||
|
const int iterations = 200;
|
||||||
|
VideoFrame? last = null;
|
||||||
|
for (var i = 0; i < iterations; i++) last = compositor.Render(scene, FrameFor, null, options, scratch: scratch);
|
||||||
|
|
||||||
|
var perFrame = (GC.GetAllocatedBytesForCurrentThread() - before) / (double)iterations;
|
||||||
|
Assert.True(perFrame < 16 * 1024,
|
||||||
|
$"compositor allocated {perFrame:F0} bytes/frame under a ring producer — raster reuse regressed");
|
||||||
|
|
||||||
|
Assert.NotNull(last);
|
||||||
|
AssertColor(last!, 260, 190, (byte)(40 + 3 * 50), 0, 0);
|
||||||
|
AssertColor(last!, 5, 700, 0, 255, 0);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
|
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user