From 6d11e8b3fcbd18d34595e678cd6abfe24497c0a1 Mon Sep 17 00:00:00 2001 From: gramps Date: Sat, 5 Sep 2026 16:47:49 -0700 Subject: [PATCH] =?UTF-8?q?perf(compositor):=20reuse=20per-element=20raste?= =?UTF-8?q?rs=20in-place=20=E2=80=94=20take-16=20GC-churn=20fix?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- Services/Compositor/SceneCompositor.cs | 23 ++++++++-- ytLive.Tests/SceneCompositorTests.cs | 63 ++++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 5 deletions(-) diff --git a/Services/Compositor/SceneCompositor.cs b/Services/Compositor/SceneCompositor.cs index f3fb513..fa1a52a 100644 --- a/Services/Compositor/SceneCompositor.cs +++ b/Services/Compositor/SceneCompositor.cs @@ -25,8 +25,9 @@ public sealed class SceneCompositor // 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 static readonly Dictionary _pasteCache = new(); + private static readonly Dictionary _elementRasters = new(ReferenceEqualityComparer.Instance); private static readonly object _pasteGate = new(); - private const int MaxPasteEntries = 48; + private const int MaxPasteEntries = 256; /// /// Composite 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 // alpha only where it actually draws, so untouched margins (outside the // round clip, past the UniformToFill edges) stay alpha 0 and the paste - // reveals the master beneath instead of black. - var pixels = new byte[dw * dh * 4]; - BlitContentRaw(pixels, dw, dh, 0, 0, ew, eh, frame, 1f, isRound, mirror); - raster = new VideoFrame(dw, dh, pixels); + // reveals the master beneath instead of black. The ARRAY is reused per + // element (see _elementRasters note) — new source generations re-raster + // in place, so the steady state allocates nothing. 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(); _pasteCache[key!] = raster; + _elementRasters[element] = raster; } } diff --git a/ytLive.Tests/SceneCompositorTests.cs b/ytLive.Tests/SceneCompositorTests.cs index d160e34..aff1314 100644 --- a/ytLive.Tests/SceneCompositorTests.cs +++ b/ytLive.Tests/SceneCompositorTests.cs @@ -254,6 +254,69 @@ public class SceneCompositorTests Assert.Equal(out2.BgraPixels, out3.BgraPixels); } + /// 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. + [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() {