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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user