Per-element composite timing + scene dump (measurement only)

The cadence work ended with a question the log could not answer: the compositor
manages 6.8 composites/s at ~141ms while the 10:03 take did 29-34ms on the "same
scene shape" (full-render split=0 elements=6 dynamic=5). Nothing in the stats line
said WHICH of the six elements cost what, and nothing recorded the scene itself, so
the two takes could only be compared by eye. This closes that gap and nothing else:
no fix is attempted until a real take names the culprit.

- Services/Compositor/CompositeTimings.cs (new): per-window collector.
  Add(stage, ticks) accumulates; Describe(renders, minMs = 0.1) prints the averaged
  breakdown slowest-first, dropping sub-0.1ms noise.
- SceneCompositor.Render / CompositeLayers / BakeStaticBase / BlitBakedElement /
  BlitCachedLayer + SceneGraph.GetBakedBase: trailing optional CompositeTimings?
  Every stage is timed (backdrop, base, flash, social, ticker, zoom, scale, overlay,
  bake backdrop, bake overlays) and EVERY element gets its own entry keyed by
  z-index + name ("#2 Webcam"), with the nested "raster" sub-stage recording the
  paste-cache rasterize miss - the split between "re-samples every tick" and "slow
  paste" without a second session. Bake-pass elements are prefixed "bake #..." so a
  one-off rebake cannot masquerade as a per-composite cost. Callers that pass
  nothing (thumbnails, existing tests) are unaffected and pay nothing.
- FramePump: owns the collector, threads it through RenderScene/RenderFull/
  GetBakedBase/CompositeLayers, and logs "FramePump parts (N renders/5s): ..." once
  per 5s window beside the stats line (averaged per render, so it compares directly
  with "avg render"), then resets it. Also dumps the scene - "FramePump scene dump:"
  header plus one line per element (z-index, name, kind, source type, visibility,
  rect, opacity, clip) - at pump start and on every scene change.
- One Good Dog integration test (ytLive.Tests/CompositeTimingTests.cs): a real
  composite of a full-canvas element plus a 64x64 one through the real collector
  must rank the full-canvas element first, carry the raster sub-stage and the fixed
  stages, and the dump must name both elements with z-index/type/rect and echo the
  split point.

Derivative work (AGENTS.md): OBS's answer to "which source is slow" is the Source
Profiler - average execution time of each source's tick and render functions
(https://docs.obsproject.com/reference-libobs-util-source-profiler, util/profiler.h).
Same shape here: one timer per element per composite, reported as an average, plus
the stage split this case needs.

Docs in the same commit: ai.md (new subsection "Per-element composite timing + scene
dump", the closed gap corrected in "Frame cadence"), Services/index.md (new file row
+ FramePump/SceneCompositor rows), TASKS.md (unit marked shipped, next = creator
take), HANDOFF.md (rewritten: unpushed commit, next step, verify.sh whitelist hole).

Also recorded this session (from the creator): the launch command is
`dotnet run -c Release` - Release is the DEFAULT, Debug only when explicitly named,
and perf takes must read Release. ai.md -> Run updated.

Full suite 428 tests, 0 warnings; the only failures are the known audio env flakes
(both pass in isolation). Scope check passed.
This commit is contained in:
2026-10-16 18:23:20 -07:00
parent 3cb29ea45c
commit a3a4681aac
9 changed files with 408 additions and 60 deletions
+75 -9
View File
@@ -35,6 +35,12 @@ public sealed class FramePump : IDisposable
private readonly SceneGraph? _sceneGraph;
private readonly SceneCompositor _compositor = new();
/// <summary>Per-stage composite timing for the CURRENT stats window (webcam-cadence
/// unit, 2026-10-16): the compositor records each stage into this collector and
/// <see cref="ReportStats"/> logs the averaged breakdown, then clears it — the
/// "which of the 6 elements eats ~140ms" reading (see the parts line).</summary>
private readonly CompositeTimings _timings = new();
// Master-buffer scratch pool (take-4 starvation fix, slice 2 of 2): a fresh
// 8.3MB byte[] every tick is ~500MB/s of LOH churn — GC stalls masquerading
// as render cost. The pump pools ONLY buffers it handed out (reference-equality
@@ -402,6 +408,19 @@ public sealed class FramePump : IDisposable
+ $"worst submit {worstSubmit / freq * 1000:F1}ms, "
+ $"dropped {dropped}, stalls {stalls}{cacheDetail}, {webcamCadence.Summarize().Describe("webcam")}"
+ (detail.Length > 0 ? " | " + detail : ""));
// Per-stage breakdown (webcam-cadence unit, 2026-10-16): the stats line above
// says WHAT a composite cost; this says WHERE it went — one entry per stage,
// slowest first, averaged per render so the numbers compare with `avg render`.
// No entries (fully-static shortcut, or every render a C4 cache hit) means no
// per-element work happened — that IS the reading. The sub-stage
// `#i <name> raster` is the paste-cache rasterize miss and nests in its
// element's total.
var parts = _timings.Describe(statRenders);
if (parts.Length > 0)
_log?.Invoke($"FramePump parts ({statRenders} renders/5s): {parts}");
_timings.Reset();
worstRender = 0;
worstSubmit = 0;
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
@@ -430,6 +449,8 @@ public sealed class FramePump : IDisposable
return frame;
}
Scene? lastDumpedScene = null;
try
{
while (!ct.IsCancellationRequested)
@@ -437,6 +458,18 @@ public sealed class FramePump : IDisposable
var scene = _sceneProvider();
if (scene != null)
{
// Scene dump (webcam-cadence unit, 2026-10-16): the 141ms regression
// could not be compared take-to-take because the log recorded no scene
// shape at all. Dump it when the pump starts on a scene and whenever
// the active scene changes — one header line plus one line per element
// (z-index, name, kind, source type, visibility, rect, opacity, clip).
if (!ReferenceEquals(scene, lastDumpedScene))
{
lastDumpedScene = scene;
foreach (var line in FormatSceneDump(scene, _sceneGraph?.GetSplitPoint(scene) ?? -1))
_log?.Invoke(line);
}
var iterStart = System.Diagnostics.Stopwatch.GetTimestamp();
var compositorOptions = _compositorOptions();
VideoFrame? socialBarFrame = null;
@@ -470,7 +503,7 @@ public sealed class FramePump : IDisposable
sw.Restart();
var scratch = AcquireScratch(scratchSize);
acquireTicks += sw.ElapsedTicks;
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver);
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, TimedResolver, _timings);
if (_transition is { Active: true } transition)
{
frame = transition.BlendFrame(frame);
@@ -614,13 +647,14 @@ public sealed class FramePump : IDisposable
VideoFrame? tickerFrame,
VideoFrame? flashFrame,
byte[]? scratch = null,
Func<SceneElement, VideoFrame?>? resolver = null)
Func<SceneElement, VideoFrame?>? resolver = null,
CompositeTimings? timings = null)
{
resolver ??= _frameResolver;
if (_sceneGraph == null)
{
var sw = System.Diagnostics.Stopwatch.StartNew();
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
var frame = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver, timings);
ProbeRender("full-no-graph", scene, -1, sw.ElapsedMilliseconds, 0);
return frame;
}
@@ -630,7 +664,7 @@ public sealed class FramePump : IDisposable
{
// Fully static scene: bake once, reuse.
var sw = System.Diagnostics.Stopwatch.StartNew();
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
var baked = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions, timings: timings);
if (baked != null)
{
// BUG (pre-existing, found while wiring the branding credit 2026-09-26):
@@ -639,31 +673,35 @@ public sealed class FramePump : IDisposable
// cached bake is not mutated and the flash still cannot get sticky in it.
if (socialBarFrame != null || tickerFrame != null || flashFrame != null)
{
var tOverlay = System.Diagnostics.Stopwatch.GetTimestamp();
var withOverlays = baked;
if (flashFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, flashFrame, 0, 0);
if (tickerFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, tickerFrame, tickerFrame.OriginX, tickerFrame.OriginY);
if (socialBarFrame != null) withOverlays = SceneCompositor.Overlay(withOverlays, options, socialBarFrame, socialBarTop, 0);
baked = withOverlays;
timings?.Add("overlay", System.Diagnostics.Stopwatch.GetTimestamp() - tOverlay);
}
var tScale = System.Diagnostics.Stopwatch.GetTimestamp();
var stretched = StretchMath.BilinearScale(baked, options.OutputWidth, options.OutputHeight);
timings?.Add("scale", System.Diagnostics.Stopwatch.GetTimestamp() - tScale);
ProbeRender("fully-static", scene, split, sw.ElapsedMilliseconds, (int)sw.ElapsedMilliseconds);
return stretched;
}
}
var swBase = System.Diagnostics.Stopwatch.StartNew();
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions);
var baseFrame = _sceneGraph.GetBakedBase(scene, _frameResolver, _compositorOptions, timings: timings);
if (baseFrame != null)
{
var frame = SceneCompositor.CompositeLayers(
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch);
baseFrame, scene, split, resolver, options, socialBarFrame, socialBarTop, flashFrame: flashFrame, tickerFrame: tickerFrame, scratch: scratch, timings: timings);
ProbeRender("base+layers", scene, split, swBase.ElapsedMilliseconds, 0);
return frame;
}
// No static base (first layer is dynamic or empty scene) — full render.
var swFull = System.Diagnostics.Stopwatch.StartNew();
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver);
var frame2 = RenderFull(scene, options, socialBarFrame, socialBarTop, tickerFrame, flashFrame, scratch, resolver, timings);
ProbeRender("full-render", scene, split, swFull.ElapsedMilliseconds, 0);
return frame2;
}
@@ -676,7 +714,8 @@ public sealed class FramePump : IDisposable
private VideoFrame RenderFull(
Scene scene, CompositorOptions options, VideoFrame? socialBarFrame, int socialBarTop,
VideoFrame? tickerFrame, VideoFrame? flashFrame,
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver)
byte[]? scratch, Func<SceneElement, VideoFrame?> resolver,
CompositeTimings? timings = null)
{
// 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
@@ -697,7 +736,7 @@ public sealed class FramePump : IDisposable
return new VideoFrame(options.SourceRectWidth, options.SourceRectHeight, scratch);
}
var rendered = _compositor.Render(scene, resolver, flashFrame, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame);
var rendered = _compositor.Render(scene, resolver, flashFrame, options, socialBarFrame, socialBarTop, scratch: scratch, tickerFrame: tickerFrame, timings: timings);
CacheRenders++;
if (useCache)
{
@@ -718,6 +757,33 @@ public sealed class FramePump : IDisposable
return rendered;
}
/// <summary>The pump's scene dump (webcam-cadence unit, 2026-10-16): one header line
/// plus one line per element, in z-order, carrying the shape two takes must be
/// compared by — index, name, kind, source type, visibility, rect in master pixels,
/// opacity, clip. <paramref name="split"/> is the SceneGraph split point (-1 when no
/// graph is wired: the full-render path). The 10:03 vs 12:01 regression
/// (29–34ms → 143–155ms at "the same scene shape") could not be compared because the
/// log recorded no scene at all; this is that record.</summary>
internal static string[] FormatSceneDump(Scene scene, int split)
{
var lines = new List<string>
{
$"FramePump scene dump: '{scene.Name}' elements={scene.Elements.Count}"
+ (split >= 0 ? $" split={split}" : "")
};
for (var i = 0; i < scene.Elements.Count; i++)
{
var e = scene.Elements[i];
var source = e is Source s
? $" type={s.Type}{(s.IsBackground ? " background" : "")}"
: "";
lines.Add($" [{i}] {e.Name} kind={e.Kind}{source}"
+ $" vis={(e.IsVisible ? 1 : 0)} rect={e.X:F0},{e.Y:F0} {e.Width:F0}x{e.Height:F0}"
+ $" op={e.Opacity:F2} clip={e.ClipShape}");
}
return lines.ToArray();
}
/// <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