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
+37
View File
@@ -1090,6 +1090,43 @@ Full suite 290/291 passing, the sole failure the pre-existing compositor pixel t
`PublishGate_TryPublish_OnlyStrictlyNewerWins`. Full suite **296/296 green, 0
warnings**. NOT YET DEVICE-VERIFIED; target: telemetry frames/s jumps ≥ ~30-40 and the
band audit drops below ~50% frozen. No push.
- **Slice 18 — C4: the FramePump blit-on-change composite cache (2026-09-15, take ty-1841
verdict):** the 1841 take proved capture fixed (band ~20 fresh updates/s, no tears, pacing
clean) but logged a FramePump **stall on EVERY iteration** (`totalMs 21-44`,
`render=full-render split=0 elements=6 dynamic=4`, worst render 166ms startup spike) →
~22-28 composites/s hard-caps the desktop inside the 60fps file. The SceneGraph split
CANNOT fix this wired: `GetSplitPoint` returns **0** because the live-capture backdrop is
element 0 and must stay dynamic — a baked capture goes stale — so every tick runs the full
re-composite even when no input changed (the compositor's per-layer paste cache already makes
identical frames identical per-element; the waste is re-COMPOSITING the whole 8.3MB frame).
Same shape as OBS (sources cache their surface, the compositor renders on update only —
the pattern this file has cited since the 2026-09-04 paste-cache slice) at the FRAME level.
`FramePump.RenderFull` now wraps both full-render call sites (no-SceneGraph + split=0
fallback): it computes `BuildFullRenderSignature` (a deterministic hash of the crop/output
dims, the social bar identity + top, and — per element, MIRRORING the compositor's own
resolution — the element ref, layout/visual bits, and the frame each element would resolve
through the SAME resolver seam, using buffer-array identity + Epoch + CropBounds; a changed
frame forces a re-composite, an unchanged one never serves stale bytes) and, when it matches
the last composite, reuses the cache with ONE `Buffer.BlockCopy` (~3ms) instead. The cache
buffer is a **separate long-lived array** — never the scratch pool — because the caller burns
the frame counter and recycles the scratch AFTER `RenderScene` returns; the cache is written
from the rendered scratch BEFORE returning (pre-burn, pre-recycle). Engagement is gated on
the 1:1 config (`SourceRect == Output`, the only deployed tier); the vertical tier's cached
fresh-scale-buffer follow-up stays exactly where it was. Telemetry: internal `CacheHits`/
`CacheRenders` counters surface as `cache {renders}R/{hits}H` on the 5s stats line, and the
ticks' key is an internal `OutputIndex` (burned counter, stable across one tick's resolver
passes). **Good Dog test**
`FullRenderCache_StaticInputs_RenderOnce_Then_Reuse_UntilInputChanges`: static scene →
exactly ONE composite (renders==1, hits>0, byte-identical content above the burn strip),
then a new frame (new array + monotonic Epoch) invalidates and propagates. Existing pool test
now passes a STABLE scene (production hands `StagedScene`; a fresh scene per tick churns the
element refs inside the signature and hides the cache — the tests that care about cache
behavior must mirror production) and keys its red/blue alternation on `OutputIndex`/frame
count so the tick's two resolver passes (signature + render) don't double-advance a call-count
flip. Full suite **297/297 green, 0 warnings**; verify.sh scope-locked to FramePump.cs +
FramePumpTests.cs + docs. Target next take: `cache` hits dominate on TV holds, `avg render`
drops toward the BlockCopy, stalls vanish, then the band audit + clap re-measure decide push.
No push.
- **Stop ordering matters:** `StopAsync` stops the encoder — since slice 10 it FLUSHES the pending
queue (`Channel.TryComplete` → drain writes the leftovers, closes stdin → EOF → ffmpeg finalizes+exits;
an accepted frame is never lost) — **before** awaiting the loop. The old reverse-order deadlock was