perf(pump): slice 18 — C4 blit-on-change composite cache

ty-1841: capture fixed (band ~20 updates/s, no tears) but FramePump stalls
on EVERY iteration (totalMs 21-44, render=full-render split=0 elements=6
dynamic=4) — the render ceiling, ~22-28 composites/s, caps the desktop in a
60fps file. SceneGraph can't help: the live backdrop is element 0 and cannot
be baked (a cached capture goes stale), so the cache lives at the pump.

RenderFull wraps both full-render call sites: BuildFullRenderSignature hashes
the full input identity (options rect, social bar, per-element layout/visual
bits + the frame each element would resolve through the SAME resolver seam,
using array identity + Epoch + CropBounds); unchanged identity reuses the last
composite with one Buffer.BlockCopy (~3ms) instead of a ~30ms re-composite.
Cache buffer is a separate long-lived array, written pre-burn/pre-recycle
(the caller burns the frame counter and recycles scratch AFTER render).
Engagement gated on the 1:1 config (the only deployed tier). Telemetry
surfaces `cache NR/WH` on the 5s stats line.

Same shape as OBS (sources cache their surface, the scene blits on update) —
docs.obsproject.com/backend-design, the pattern this repo cites since the
2026-09-04 paste-cache slice.

Good Dog: FullRenderCache_StaticInputs_RenderOnce_Then_Reuse_UntilInputChanges
(static scene renders ONCE + byte-identical reuse; new frame+Epoch invalidates).
297/297 green, 0 warnings, verify.sh scope-locked (FramePump.cs, FramePumpTests.cs
+ ai.md/HANDOFF/MyMistakes). No push — device re-verify next.
This commit is contained in:
2026-09-15 07:57:09 -07:00
parent b37b8a30f9
commit 64a5a6d06f
5 changed files with 378 additions and 67 deletions
+78 -5
View File
@@ -434,17 +434,23 @@ public class FramePumpTests
/// its master buffers across frames (the 8.3MB-per-tick LOH churn that cost GC
/// stalls inside "render") while EVERY frame's content stays correct — stale bytes
/// from an earlier tick would show as a wrong color. The backdrop alternates
/// red/blue per tick (submit snapshots pin the color), and a repeated backing-array
/// identity proves the pool actually recycles (an inert pool keeps the pixel
/// assertions green while leaving the churn in place).</summary>
/// red/blue per OUTPUT frame (keyed on the burned index, which is stable across the
/// tick's resolver passes) and a repeated backing-array identity proves the pool
/// actually recycles (an inert pool keeps the pixel assertions green while leaving
/// the churn in place). The scene instance is STABLE across ticks, like production
/// (MainViewModel's StagedScene), so the compositor signature doesn't churn on
/// unrelated element identity.</summary>
[Fact]
public async Task Pump_Pools_ScratchBuffers_Across_Frames_Without_Stale_Pixels()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var blue = SceneCompositorTests.Solid(64, 48, 0, 0, 255);
var flip = 0;
var scene = BackgroundScene();
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, resolve: _ => flip++ % 2 == 0 ? red : blue);
FramePump? pumpHolder = null;
using var pump = NewPump(encoder, scene: () => scene,
resolve: _ => pumpHolder!.OutputIndex % 2 == 0 ? red : blue);
pumpHolder = pump;
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(10);
@@ -462,6 +468,73 @@ public class FramePumpTests
"every frame rode a fresh buffer — the scratch pool is inert");
}
/// <summary>The ONE integration test for the C4 blit-on-change cache (2026-09-15):
/// the ty-1841 take logged a render stall on EVERY iteration (totalMs 21-44, one
/// full composite per ~30ms even though the content moved only ~20 updates/s) — the
/// SceneGraph split can't help because the live backdrop sits at element 0 and cannot
/// be baked (a cached capture goes stale). A static scene must render the composite
/// ONCE then reuse it per tick (re-render == 1, cache hits > 0, byte-identical
/// content above the burn strip); a new backdrop frame (new array + monotonic Epoch)
/// must invalidate the cache and propagate the changed pixels.</summary>
[Fact]
public async Task FullRenderCache_StaticInputs_RenderOnce_Then_Reuse_UntilInputChanges()
{
var red = SceneCompositorTests.Solid(64, 48, 255, 0, 0);
var current = red;
// STABLE scene across ticks (production hands StagedScene) — a fresh scene per
// tick would churn the element references inside the render signature and force
// a re-composite every frame, hiding the cache entirely.
var scene = BackgroundScene();
var encoder = new FakeEncoder();
using var pump = NewPump(encoder, scene: () => scene, resolve: _ => current);
await pump.StartAsync();
var deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && (encoder.Frames.Count < 8 || pump.CacheHits < 6))
await Task.Delay(10);
Assert.True(encoder.Frames.Count >= 8, $"only {encoder.Frames.Count} frames produced");
Assert.Equal(1, pump.CacheRenders); // one composite for the whole static phase
Assert.True(pump.CacheHits >= 6, $"cache reused only {pump.CacheHits} times");
// Every frame's content above the burn strip must be the same — the cache is
// serving the identical pre-burn composite; the burn counter varies only the
// bottom-right strip (rows 39..47, cols 27..64 in a 64×48 frame).
var reference = encoder.Frames[^1].BgraPixels.AsSpan(0, 37 * 64 * 4).ToArray();
Assert.All(encoder.Frames, f => Assert.True(
f.BgraPixels.AsSpan(0, 37 * 64 * 4).SequenceEqual(reference),
"a static input re-composited a different frame — the cache served stale bytes or never engaged"));
// Mutate: new backing array + Epoch guarantees a full signature miss.
var blue = new VideoFrame(64, 48, new byte[64 * 48 * 4]) { Epoch = 42 };
for (var i = 0; i < blue.BgraPixels.Length; i += 4)
{
blue.BgraPixels[i] = 255; // B
blue.BgraPixels[i + 3] = 255; // A
}
current = blue;
VideoFrame? changed = null;
deadline = DateTime.UtcNow.AddSeconds(5);
while (DateTime.UtcNow < deadline && changed == null)
{
foreach (var f in encoder.Frames)
{
if (!f.BgraPixels.AsSpan(0, 37 * 64 * 4).SequenceEqual(reference))
{
changed = f;
break;
}
}
if (changed == null) await Task.Delay(10);
}
Assert.NotNull(changed);
AssertColor(changed!, 0, 0, 0, 0, 255); // the new backdrop propagated
Assert.True(pump.CacheRenders >= 2, "the changed input never re-composited");
await pump.StopAsync();
}
/// <summary>One integration test for the take-8 off-UI fix: the pump loop must NOT
/// run on the starting thread's SynchronizationContext (StartAsync fires from a UI
/// command handler; the old loop inherited it, so every frame competed with the