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:
2026-09-05 16:47:49 -07:00
parent 87509bcf99
commit 6d11e8b3fc
2 changed files with 81 additions and 5 deletions
+63
View File
@@ -254,6 +254,69 @@ public class SceneCompositorTests
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]
public void Render_VerticalTier_Outputs_1080x1920_From_The_Center_Crop()
{