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:
@@ -92,6 +92,28 @@ public sealed class FramePump : IDisposable
|
||||
/// stall line, then cleared.</summary>
|
||||
private string _renderDetail = "";
|
||||
|
||||
/// <summary>C4 blit-on-change cache (2026-09-15): the FULL-render path (split=0
|
||||
/// when the live-capture backdrop sits at element 0, or no SceneGraph) re-composites
|
||||
/// the ENTIRE frame every tick even when no input changed — the ty-1841 take logged
|
||||
/// a render stall on EVERY iteration (totalMs 21-44, one composite per ~30ms while
|
||||
/// the content moved ~20 updates/s). The SceneGraph split can never help here: the
|
||||
/// backdrop is element 0 and must stay dynamic (a baked capture goes stale), so the
|
||||
/// cache lives at the pump: the last full-render output plus the input identity that
|
||||
/// produced it. Unchanged identity → ONE BlockCopy (~3ms) reuses the composite;
|
||||
/// changed identity → re-composite. The cache buffer is a SEPARATE long-lived array,
|
||||
/// never the scratch pool — the caller burns the frame counter and recycles the
|
||||
/// scratch AFTER RenderScene returns, so the cache is written from the rendered
|
||||
/// scratch BEFORE returning (pre-burn, pre-recycle). The identity mirror is
|
||||
/// <see cref="BuildFullRenderSignature"/>: it resolves the same frames the compositor
|
||||
/// will (same resolver seam), so a changed frame (new capture, new webcam, new chat
|
||||
/// raster) forces a re-render and an unchanged one never serves stale bytes. Internal
|
||||
/// counters feed the Good Dog test + the 5s cache detail in the stats line.</summary>
|
||||
private byte[]? _fullCachePixels;
|
||||
private bool _fullCacheValid;
|
||||
private ulong _lastFullSignature;
|
||||
internal long CacheHits;
|
||||
internal long CacheRenders;
|
||||
|
||||
/// <summary>Forwards the encoder's parsed health — ship step 6 binds this to the bottom bar.</summary>
|
||||
public event EventHandler<StreamHealth>? HealthUpdated;
|
||||
|
||||
@@ -124,6 +146,11 @@ public sealed class FramePump : IDisposable
|
||||
|
||||
public bool IsRunning { get; private set; }
|
||||
|
||||
/// <summary>The burned counter of the LAST frame submitted to the encoder. Stable
|
||||
/// across a tick's multiple resolver passes (it advances only at burn/submit, which
|
||||
/// happens AFTER render) — tests key per-tick content on it.</summary>
|
||||
internal long OutputIndex => _outputIndex;
|
||||
|
||||
/// <summary>Never throws: failures are logged and surfaced via <see cref="Failed"/>,
|
||||
/// so the VM can fire-and-forget it from a sync command handler.</summary>
|
||||
public async Task StartAsync(CancellationToken cancellationToken = default)
|
||||
@@ -303,6 +330,7 @@ public sealed class FramePump : IDisposable
|
||||
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0, worstRender = 0, worstSubmit = 0;
|
||||
int statFrames = 0;
|
||||
int stalls = 0;
|
||||
long lastCacheHits = 0, lastCacheRenders = 0;
|
||||
var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
void ReportStats()
|
||||
{
|
||||
@@ -313,6 +341,17 @@ public sealed class FramePump : IDisposable
|
||||
var dropped = encoder == null ? 0 : encoder.DroppedFrames;
|
||||
var detail = _renderDetail;
|
||||
_renderDetail = "";
|
||||
var cacheDetail = "";
|
||||
if (statFrames > 0)
|
||||
{
|
||||
// C4 window detail: rendered composites (R) vs cache reuses (H) since the
|
||||
// last report — hard numeric proof the blit-on-change cache engaged.
|
||||
var hits = CacheHits - lastCacheHits;
|
||||
var renders = CacheRenders - lastCacheRenders;
|
||||
lastCacheHits = CacheHits;
|
||||
lastCacheRenders = CacheRenders;
|
||||
if (hits > 0 || renders > 0) cacheDetail = $", cache {renders}R/{hits}H";
|
||||
}
|
||||
_log?.Invoke(statFrames == 0
|
||||
? "FramePump stats: NO frames produced in 5s (loop stalled?)" + (detail.Length > 0 ? " | " + detail : "")
|
||||
: $"FramePump stats: {statFrames}/{target:F0} frames per 5s, " +
|
||||
@@ -322,7 +361,7 @@ public sealed class FramePump : IDisposable
|
||||
+ $"avg wait {waitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
|
||||
+ $"worst render {worstRender / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
|
||||
+ $"worst submit {worstSubmit / (double)System.Diagnostics.Stopwatch.Frequency * 1000:F1}ms, "
|
||||
+ $"dropped {dropped}, stalls {stalls}" + (detail.Length > 0 ? " | " + detail : ""));
|
||||
+ $"dropped {dropped}, stalls {stalls}" + cacheDetail + (detail.Length > 0 ? " | " + detail : ""));
|
||||
worstRender = 0;
|
||||
worstSubmit = 0;
|
||||
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
|
||||
@@ -523,7 +562,7 @@ public sealed class FramePump : IDisposable
|
||||
if (_sceneGraph == null)
|
||||
{
|
||||
var sw = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame = _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
|
||||
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, scratch, resolver);
|
||||
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
|
||||
return frame;
|
||||
}
|
||||
@@ -554,11 +593,163 @@ public sealed class FramePump : IDisposable
|
||||
|
||||
// No static base (first layer is dynamic or empty scene) — full render.
|
||||
var swFull = System.Diagnostics.Stopwatch.StartNew();
|
||||
var frame2 = _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
|
||||
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, scratch, resolver);
|
||||
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
|
||||
return frame2;
|
||||
}
|
||||
|
||||
/// <summary>The full-render path (no SceneGraph, or no static base) wrapped in the
|
||||
/// C4 blit-on-change cache: when every resolved input plus every element's
|
||||
/// layout/visual bits match the last composite, reuse it with one BlockCopy instead
|
||||
/// of re-compositing. Mirror the compositor's resolution <i>before</i> deciding so a
|
||||
/// hit costs ~3ms of pure copy and a miss costs exactly the old full render.</summary>
|
||||
private VideoFrame RenderFull(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
// 1:1 guard: the cache stores the OUTPUT bytes and scratch is source-sized —
|
||||
// at 1:1 they are the same buffer size. Off-size tiers (a vertical 1080×1920
|
||||
// tier, an H264-1080p tier) fall back to a per-tick full render; the deployed
|
||||
// config is master-sized and caching a per-tick fresh scale buffer is the known
|
||||
// vertical-tier follow-up (see ai.md), not silently baked here.
|
||||
var useCache = scratch != null && options.SourceRectWidth == options.OutputWidth
|
||||
&& options.SourceRectHeight == options.OutputHeight;
|
||||
var signature = useCache
|
||||
? BuildFullRenderSignature(scene, options, socialBarFrame, socialBarTop, resolver)
|
||||
: 0UL;
|
||||
|
||||
if (useCache && _fullCacheValid && signature == _lastFullSignature
|
||||
&& _fullCachePixels!.Length == scratch!.Length)
|
||||
{
|
||||
Buffer.BlockCopy(_fullCachePixels, 0, scratch, 0, scratch.Length);
|
||||
CacheHits++;
|
||||
return new VideoFrame(options.SourceRectWidth, options.SourceRectHeight, scratch);
|
||||
}
|
||||
|
||||
var rendered = _compositor.Render(scene, resolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
|
||||
CacheRenders++;
|
||||
if (useCache)
|
||||
{
|
||||
var pixels = rendered.BgraPixels;
|
||||
if (_fullCachePixels == null || _fullCachePixels.Length != pixels.Length)
|
||||
_fullCachePixels = new byte[pixels.Length];
|
||||
// PRE-burn / PRE-recycle: the caller burns the counter and recycles this
|
||||
// scratch after RenderScene returns — the cache keeps a clean copy, and the
|
||||
// burn on the next hit writes to a FRESH scratch copy, never the cache.
|
||||
Buffer.BlockCopy(pixels, 0, _fullCachePixels, 0, pixels.Length);
|
||||
_lastFullSignature = signature;
|
||||
_fullCacheValid = true;
|
||||
}
|
||||
else
|
||||
{
|
||||
_fullCacheValid = false; // size changed — never reuse a wrong-size buffer
|
||||
}
|
||||
return rendered;
|
||||
}
|
||||
|
||||
/// <summary>Hash the complete set of inputs the compositor's FULL render consumes:
|
||||
/// the crop/output dimensions, the social bar, and — in element order, mirroring
|
||||
/// <c>SceneCompositor.Render</c>'s iteration — every element's layout/visual fields
|
||||
/// plus the frame each one resolves (through the SAME resolver seam the render will
|
||||
/// use). A changed frame (new capture, new webcam Epoch, new chat raster) changes the
|
||||
/// hash → re-composite; an unchanged one reuses the cache. Buffer-array identity +
|
||||
/// Epoch + CropBounds is the compositor's own paste-key shape — producers hand out
|
||||
/// fresh immutable arrays, or recycled-ring arrays whose monotonic Epoch
|
||||
/// distinguishes generations (the capture take-11 fix), so address-identity alone is
|
||||
/// never trusted to key recycled content.</summary>
|
||||
private static ulong BuildFullRenderSignature(
|
||||
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
|
||||
Func<SceneElement, VideoFrame?> resolver)
|
||||
{
|
||||
var h = 14695981039346656037UL; // FNV-1a offset basis
|
||||
h = Mix(h, (ulong)options.SourceRectX);
|
||||
h = Mix(h, (ulong)options.SourceRectY);
|
||||
h = Mix(h, (ulong)options.SourceRectWidth);
|
||||
h = Mix(h, (ulong)options.SourceRectHeight);
|
||||
h = Mix(h, (ulong)options.OutputWidth);
|
||||
h = Mix(h, (ulong)options.OutputHeight);
|
||||
h = Mix(h, (ulong)socialBarTop);
|
||||
if (socialBarFrame != null) h = MixFrame(h, socialBarFrame);
|
||||
else h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
|
||||
var backdropResolved = false;
|
||||
foreach (var element in scene.Elements)
|
||||
{
|
||||
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(element));
|
||||
h = Mix(h, element.IsVisible ? 1UL : 0UL);
|
||||
h = MixDouble(h, element.X);
|
||||
h = MixDouble(h, element.Y);
|
||||
h = MixDouble(h, element.Width);
|
||||
h = MixDouble(h, element.Height);
|
||||
h = MixDouble(h, element.Opacity);
|
||||
h = Mix(h, (ulong)element.ClipShape);
|
||||
h = Mix(h, element.IsMirrored ? 1UL : 0UL);
|
||||
h = Mix(h, (ulong)element.BorderWidth);
|
||||
h = MixDouble(h, element.BorderOpacity);
|
||||
if (element.TryGetBorderColor(out var bcR, out var bcG, out var bcB))
|
||||
h = Mix(h, (ulong)((bcR << 16) | (bcG << 8) | bcB));
|
||||
else
|
||||
h = Mix(h, 0UL);
|
||||
|
||||
// Mirror the compositor's per-element frame resolution EXACTLY, so the hash
|
||||
// reacts to the same frames a render uses and never to a frame a render
|
||||
// ignores (a false-positive change only wastes one render; a false NEGATIVE
|
||||
// would serve stale bytes — that is what this mirror prevents).
|
||||
VideoFrame? frame = null;
|
||||
if (element is Source { IsBackground: true })
|
||||
{
|
||||
h = Mix(h, 1UL); // the live backdrop — first visible one is resolved, once
|
||||
if (element.IsVisible && !backdropResolved)
|
||||
{
|
||||
frame = resolver(element);
|
||||
backdropResolved = true;
|
||||
}
|
||||
}
|
||||
else if (element is Source { Type: SourceType.TextOverlay })
|
||||
{
|
||||
h = Mix(h, 2UL); // skipped kind — never resolved by the compositor
|
||||
}
|
||||
else if (element is Source { Type: SourceType.Background })
|
||||
{
|
||||
h = Mix(h, 3UL); // background image — resolved even when invisible (Render does)
|
||||
frame = resolver(element);
|
||||
}
|
||||
else
|
||||
{
|
||||
h = Mix(h, 4UL); // regular layer — resolved only when visible
|
||||
if (element.IsVisible) frame = resolver(element);
|
||||
}
|
||||
if (frame != null) h = MixFrame(h, frame);
|
||||
else h = Mix(h, 0UL);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
private static ulong Mix(ulong h, ulong v) => (h ^ v) * 0x9E3779B97F4A7C15UL;
|
||||
|
||||
private static ulong MixDouble(ulong h, double v)
|
||||
=> Mix(h, unchecked((ulong)BitConverter.DoubleToInt64Bits(v)));
|
||||
|
||||
private static ulong MixFrame(ulong h, VideoFrame frame)
|
||||
{
|
||||
h = Mix(h, (ulong)System.Runtime.CompilerServices.RuntimeHelpers.GetHashCode(frame.BgraPixels));
|
||||
h = Mix(h, (ulong)frame.Epoch);
|
||||
h = Mix(h, (ulong)frame.Width);
|
||||
h = Mix(h, (ulong)frame.Height);
|
||||
if (frame.CropBounds is { } cb)
|
||||
{
|
||||
h = Mix(h, (ulong)cb.X);
|
||||
h = Mix(h, (ulong)cb.Y);
|
||||
h = Mix(h, (ulong)cb.W);
|
||||
h = Mix(h, (ulong)cb.H);
|
||||
}
|
||||
else
|
||||
{
|
||||
h = Mix(h, 0xFFFFFFFFFFFFFFFFUL);
|
||||
}
|
||||
return h;
|
||||
}
|
||||
|
||||
/// <summary>2026-09-12 half-speed-render probe: the pump only produces ~27fps
|
||||
/// (render ~35ms vs the 16.7ms deadline), and rawvideo muxes at the DECLARED fps,
|
||||
/// so every take muxes at ~half its wall length (the truncation complaint). This
|
||||
|
||||
Reference in New Issue
Block a user