perf(render): take-4 slice 2 — IsOpaque memcpy, integer bilinear, pump scratch pool
Take 4: pacing held (sync perfect) but avg render stayed 58.9ms — 2M managed row-walk iterations + a fresh 8.3MB buffer every tick (LOH churn into GC stalls inside the render measurement). - VideoFrame.IsOpaque: producer-contract flag (screen capture + webcam — DWM/MF fill alpha 255; media/chat/web/static NOT flagged). Full-cover aligned opaque backdrop = ONE Buffer.BlockCopy; black pre-fill skipped when it covers. - General BlitContent: integer 8.8 fixed-point bilinear + blend, row invariants hoisted, no per-pixel division/Math.Round. Within ±1 of the float reference (pixel tests allow ±2). Research per derivative-work rule: libyuv row/scale kernels (chromium.googlesource.com/libyuv/libyuv). - FramePump scratch pool (max 4, length-keyed, owned-by-reference): release strictly AFTER SubmitFrameAsync returns (stdin write copies); Contains-guard makes the transition Cut alias safe. - Removed the dead per-tick fromScene render + fromSceneProvider seam — BlendFrame consumes TransitionService.FromFrame captured at Start; the pump's render fed nothing. MainViewModel call site updated (signature). Bugs caught by the pixel probes pre-ship (recorded MyMistakes): first Bilinear double-shifted both stages (solid-255 sampled to ~1 -> general path drew nothing); sentinel 0xAB collided with an x+y pixel. FakeEncoder snapshots submitted frames (mirrors real copy semantics under recycling). ONE integration test: Pump_Pools_ScratchBuffers_Across_Frames_Without_ Stale_Pixels (alternating backdrops + repeated backing identity). Direct pin: Composite_OpaqueFullCover_Backdrop_CopiesEveryPixel_Into_Scratch. Clean build 0 warnings; 59/59 per-class + RealApp boot-smoke. take 5 verdict: expect avg render <= ~10ms, ~300/300 frames. Docs same commit: ai.md pipeline section, TASKS.md TASK 18, HANDOFF rewritten (Unit B spec + settled decisions queued).
This commit is contained in:
@@ -30,10 +30,40 @@ public sealed class FramePump : IDisposable
|
||||
private readonly Func<TimeSpan, CancellationToken, Task> _pacingDelay;
|
||||
private readonly Func<(VideoFrame? Frame, SocialBarPosition Position)>? _socialBar;
|
||||
private readonly TransitionService? _transition;
|
||||
private readonly Func<Scene?>? _fromSceneProvider;
|
||||
private readonly SceneGraph? _sceneGraph;
|
||||
private readonly SceneCompositor _compositor = 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
|
||||
// set), so bake-cache / social-bar / static-cache frames are never touched;
|
||||
// SubmitFrameAsync copies the bytes to the encoder's stdin before returning,
|
||||
// so recycling after submit is safe (MyMistakes recipe).
|
||||
private readonly HashSet<byte[]> _ownedScratch = new(ReferenceEqualityComparer.Instance);
|
||||
private readonly List<byte[]> _freeScratch = new();
|
||||
private const int MaxScratchPooled = 4;
|
||||
|
||||
private byte[] AcquireScratch(int size)
|
||||
{
|
||||
for (var i = _freeScratch.Count - 1; i >= 0; i--)
|
||||
{
|
||||
if (_freeScratch[i].Length != size) continue;
|
||||
var buffer = _freeScratch[i];
|
||||
_freeScratch.RemoveAt(i);
|
||||
return buffer;
|
||||
}
|
||||
var fresh = new byte[size];
|
||||
_ownedScratch.Add(fresh);
|
||||
return fresh;
|
||||
}
|
||||
|
||||
private void ReleaseScratch(byte[]? buffer)
|
||||
{
|
||||
if (buffer == null || !_ownedScratch.Contains(buffer)) return;
|
||||
if (_freeScratch.Count >= MaxScratchPooled || _freeScratch.Contains(buffer)) return;
|
||||
_freeScratch.Add(buffer);
|
||||
}
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IFfmpegEncoder? _encoder;
|
||||
private CancellationTokenSource? _cts;
|
||||
@@ -56,7 +86,6 @@ public sealed class FramePump : IDisposable
|
||||
Func<TimeSpan, CancellationToken, Task>? pacingDelay = null,
|
||||
Func<(VideoFrame? Frame, SocialBarPosition Position)>? socialBar = null,
|
||||
TransitionService? transition = null,
|
||||
Func<Scene?>? fromSceneProvider = null,
|
||||
SceneGraph? sceneGraph = null)
|
||||
{
|
||||
_sceneProvider = sceneProvider ?? throw new ArgumentNullException(nameof(sceneProvider));
|
||||
@@ -68,7 +97,6 @@ public sealed class FramePump : IDisposable
|
||||
_pacingDelay = pacingDelay ?? ((delay, ct) => Task.Delay(delay, ct));
|
||||
_socialBar = socialBar;
|
||||
_transition = transition;
|
||||
_fromSceneProvider = fromSceneProvider;
|
||||
_sceneGraph = sceneGraph;
|
||||
}
|
||||
|
||||
@@ -211,6 +239,8 @@ public sealed class FramePump : IDisposable
|
||||
{
|
||||
_log?.Invoke($"FramePump: dispose failed: {ex.Message}");
|
||||
}
|
||||
_freeScratch.Clear();
|
||||
_ownedScratch.Clear();
|
||||
}
|
||||
|
||||
private async Task PumpAsync(EncoderOptions options, CancellationToken ct)
|
||||
@@ -273,23 +303,25 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
|
||||
VideoFrame frame;
|
||||
var scratchSize = compositorOptions.SourceRectWidth
|
||||
* compositorOptions.SourceRectHeight * 4;
|
||||
// The transition "from" frame lives in TransitionService.FromFrame
|
||||
// (captured at Start by the VM). The old per-tick fromScene render
|
||||
// here was dead weight — a full extra scene composite every
|
||||
// transition tick that BlendFrame never read; removed with the
|
||||
// pooling change because its buffer's only consumer was its own release.
|
||||
renderSw.Restart();
|
||||
if (_transition is { Active: true } transition && _fromSceneProvider != null)
|
||||
var scratch = AcquireScratch(scratchSize);
|
||||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop, scratch);
|
||||
if (_transition is { Active: true } transition)
|
||||
{
|
||||
var fromScene = _fromSceneProvider();
|
||||
var fromFrame = fromScene != null
|
||||
? RenderScene(fromScene, compositorOptions, socialBarFrame, socialBarTop)
|
||||
: RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop);
|
||||
var toFrame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop);
|
||||
frame = transition.BlendFrame(toFrame);
|
||||
frame = transition.BlendFrame(frame);
|
||||
transition.Tick(lastTick.Elapsed.TotalMilliseconds);
|
||||
lastTick.Restart();
|
||||
}
|
||||
else
|
||||
{
|
||||
frame = RenderScene(scene, compositorOptions, socialBarFrame, socialBarTop);
|
||||
lastTick.Restart();
|
||||
}
|
||||
// Restarted EVERY frame (transition or not) — the old per-frame reset
|
||||
// is what stops a transition that begins after idle from inheriting
|
||||
// a giant ElapsedMs and completing instantly on its first tick.
|
||||
lastTick.Restart();
|
||||
renderSw.Stop();
|
||||
renderTicks += renderSw.ElapsedTicks;
|
||||
|
||||
@@ -300,6 +332,11 @@ public sealed class FramePump : IDisposable
|
||||
await encoder.SubmitFrameAsync(frame, ct);
|
||||
submitSw.Stop();
|
||||
submitTicks += submitSw.ElapsedTicks;
|
||||
// Submit copied the bytes — everything from this tick is recyclable.
|
||||
// Release AFTER submit, and the free-list Contains guard makes the
|
||||
// Cut path (BlendFrame returns toFrame itself, aliasing scratch) safe.
|
||||
ReleaseScratch(frame.BgraPixels);
|
||||
ReleaseScratch(scratch);
|
||||
statFrames++;
|
||||
ReportStats();
|
||||
|
||||
@@ -349,15 +386,18 @@ public sealed class FramePump : IDisposable
|
||||
/// <summary>Render a scene, using the baked-crust optimization when a <see cref="SceneGraph"/>
|
||||
/// is wired in: bake/cache the static layers below the split point, then composite the
|
||||
/// dynamic/above-split layers per frame. Without a SceneGraph, falls back to a full render
|
||||
/// (identical output — see SceneCompositorTests).</summary>
|
||||
/// (identical output — see SceneCompositorTests). <paramref name="scratch"/> is a pooled
|
||||
/// master buffer when supplied; the fully-static path returns the bake-cache frame itself
|
||||
/// (never pooled — the release side checks owned-by-reference).</summary>
|
||||
private VideoFrame RenderScene(
|
||||
Scene scene,
|
||||
CompositorOptions options,
|
||||
VideoFrame? socialBarFrame,
|
||||
int socialBarTop)
|
||||
int socialBarTop,
|
||||
byte[]? scratch = null)
|
||||
{
|
||||
if (_sceneGraph == null)
|
||||
return _compositor.Render(scene, _frameResolver, null, options, socialBarFrame, socialBarTop);
|
||||
return _compositor.Render(scene, _frameResolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
|
||||
|
||||
var split = _sceneGraph.GetSplitPoint(scene);
|
||||
if (split == scene.Elements.Count)
|
||||
@@ -372,11 +412,11 @@ public sealed class FramePump : IDisposable
|
||||
if (baseFrame != null)
|
||||
{
|
||||
return SceneCompositor.CompositeLayers(
|
||||
baseFrame, scene, split, _frameResolver, options, socialBarFrame, socialBarTop);
|
||||
baseFrame, scene, split, _frameResolver, options, socialBarFrame, socialBarTop, scratch: scratch);
|
||||
}
|
||||
|
||||
// No static base (first layer is dynamic or empty scene) — full render.
|
||||
return _compositor.Render(scene, _frameResolver, null, options, socialBarFrame, socialBarTop);
|
||||
return _compositor.Render(scene, _frameResolver, null, options, socialBarFrame, socialBarTop, scratch: scratch);
|
||||
}
|
||||
|
||||
private void OnProcessFailed(object? sender, string message)
|
||||
|
||||
Reference in New Issue
Block a user