perf(pump): slice 6 — break the 15.6ms sleep quantum (the REAL ceiling behind takes 7-9)
Take 9's numbers were decisive: paste cache moved work to ~25ms/frame but the period stayed ~37ms. The missing ~12ms per tick is Task.Delay rounding every sub-tick request up to the Windows system-clock tick (~15.6ms default — documented: learn.microsoft.com/en-us/dotnet/api/system.threading.tasks.task.delay). A frame finishing 3ms early requested 3ms and slept 15.6. Producer capped at ~27fps no matter how fast the compositor got — which is why two real render fixes read as 'zero change' in playback. Game-loop/OBS canon for this (stackoverflow.com/questions/5441464; learn.microsoft.com/en-us/windows/win32/ api/timeapi/nf-timeapi-timebeginperiod): raise the timer resolution for the session, sleep only the bulk of the remainder, and SPIN the last ~2ms across the deadline. - FramePump: timeBeginPeriod(1) on entering the pump loop, timeEndPeriod(1) in the finally; pacing = bulk _pacingDelay(ahead - 2ms) + bounded Thread.SpinWait tail; blown deadlines rebase unchanged (never burst). - Stats now report avg wait: render+submit+wait must equal the period — the accounting is closed, no stage can hide in an unmeasured gap again. - Webcam dropped its IsOpaque paste-cache bypass: it re-sampled ~156k px every tick even between identical device frames; cached paste beats the sampler on hits, costs the same on misses. 52/52 per-class green (pacing + pixel suites unchanged — output byte-stable), clean build 0 warnings. Docs same commit (ai.md slice 6, TASKS.md take-10 gate, MyMistakes #3 + renumber, HANDOFF). User's top-bar spec remains next in queue (Unit B) — re-sent many times, captured, no open questions.
This commit is contained in:
@@ -64,6 +64,22 @@ public sealed class FramePump : IDisposable
|
||||
_freeScratch.Add(buffer);
|
||||
}
|
||||
|
||||
// Windows sleep quantum (take-9 finding, 2026-09-04): Task.Delay rounds every
|
||||
// request up to the system clock tick (~15.6ms default — learn.microsoft.com/en-us/
|
||||
// dotnet/api/system.threading.tasks.task.delay: "approximately 15 milliseconds on
|
||||
// Windows systems"), so a pacer requesting 3-15ms actually sleeps 15.6ms. Takes
|
||||
// 5-9 measured work ~25ms but period ~37ms: one padded wait per frame hid every
|
||||
// compositor improvement. Established media-app practice (game-loop/OBS canon —
|
||||
// stackoverflow.com/questions/5441464; and raise the resolution for the session —
|
||||
// learn.microsoft.com/en-us/windows/win32/api/timeapi/nf-timeapi-timebeginperiod):
|
||||
// timeBeginPeriod(1) while the pump runs, sleep only the BULK of the remainder,
|
||||
// and spin the last ~2ms across the deadline.
|
||||
[System.Runtime.InteropServices.DllImport("winmm.dll")]
|
||||
private static extern uint timeBeginPeriod(uint uMilliseconds);
|
||||
[System.Runtime.InteropServices.DllImport("winmm.dll")]
|
||||
private static extern uint timeEndPeriod(uint uMilliseconds);
|
||||
private static readonly long SpinTailTicks = System.Diagnostics.Stopwatch.Frequency * 2 / 1000; // 2ms
|
||||
|
||||
private readonly object _gate = new();
|
||||
private IFfmpegEncoder? _encoder;
|
||||
private CancellationTokenSource? _cts;
|
||||
@@ -265,11 +281,12 @@ public sealed class FramePump : IDisposable
|
||||
// Log the render/submit split every 5s so the next take names the stage.
|
||||
var renderSw = new System.Diagnostics.Stopwatch();
|
||||
var submitSw = new System.Diagnostics.Stopwatch();
|
||||
var waitSw = new System.Diagnostics.Stopwatch();
|
||||
// Resolve-vs-composite split (2026-09-04, take-6 ambiguity): "render" was a
|
||||
// black box — the stats line now reports resolver time separately so a take
|
||||
// names the stage (get-frame vs blit) instead of feeding another guess.
|
||||
var resolveSw = new System.Diagnostics.Stopwatch();
|
||||
long renderTicks = 0, submitTicks = 0, resolveTicks = 0;
|
||||
long renderTicks = 0, submitTicks = 0, resolveTicks = 0, waitTicks = 0;
|
||||
int statFrames = 0;
|
||||
var statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
void ReportStats()
|
||||
@@ -281,14 +298,16 @@ public sealed class FramePump : IDisposable
|
||||
: $"FramePump stats: {statFrames}/{target:F0} frames per 5s, " +
|
||||
$"avg render {renderTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms " +
|
||||
$"(resolve {resolveTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}), " +
|
||||
$"avg submit {submitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms");
|
||||
renderTicks = submitTicks = resolveTicks = 0;
|
||||
$"avg submit {submitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms, "
|
||||
+ $"avg wait {waitTicks / (double)System.Diagnostics.Stopwatch.Frequency * 1000 / statFrames:F1}ms");
|
||||
renderTicks = submitTicks = resolveTicks = waitTicks = 0;
|
||||
statFrames = 0;
|
||||
statsNext = DateTime.UtcNow + TimeSpan.FromSeconds(5);
|
||||
}
|
||||
|
||||
// One wrapper shared by every render of the run — resolve time accumulates
|
||||
// inside the render measurement, and the stats line reports the split.
|
||||
timeBeginPeriod(1); // pairs with timeEndPeriod in the finally — see field note
|
||||
VideoFrame? TimedResolver(SceneElement element)
|
||||
{
|
||||
resolveSw.Restart();
|
||||
@@ -334,12 +353,9 @@ public sealed class FramePump : IDisposable
|
||||
frame = transition.BlendFrame(frame);
|
||||
transition.Tick(lastTick.Elapsed.TotalMilliseconds);
|
||||
}
|
||||
// Restarted EVERY frame (transition or not) so a transition's first
|
||||
// Tick sees per-frame time, not the pump's whole uptime.
|
||||
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.
|
||||
// Restarted EVERY frame (transition or not): a transition begun
|
||||
// after idle must not inherit a giant ElapsedMs and complete
|
||||
// instantly on its first Tick.
|
||||
lastTick.Restart();
|
||||
renderSw.Stop();
|
||||
renderTicks += renderSw.ElapsedTicks;
|
||||
@@ -361,18 +377,28 @@ public sealed class FramePump : IDisposable
|
||||
|
||||
// Advance the deadline; cost already spent is not slept again.
|
||||
// Blew the frame budget: skip the wait AND the missed ticks —
|
||||
// rebase the clock rather than bursting a catch-up pile
|
||||
// (OBS rewinds its tick the same way; a burst would only
|
||||
// queue stale frames into the encoder).
|
||||
// rebase rather than bursting a catch-up pile (OBS rewinds its
|
||||
// tick; a burst only queues stale frames). Otherwise sleep the
|
||||
// BULK and SPIN the 2ms tail — never hand a sub-tick remainder
|
||||
// to the sleep quantum (see the timeBeginPeriod note).
|
||||
nextTick += intervalTicks;
|
||||
var lag = nextTick - System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
if (lag <= 0)
|
||||
waitSw.Restart();
|
||||
var ahead = nextTick - System.Diagnostics.Stopwatch.GetTimestamp();
|
||||
if (ahead <= 0)
|
||||
{
|
||||
nextTick = System.Diagnostics.Stopwatch.GetTimestamp() + intervalTicks;
|
||||
lag = 0;
|
||||
}
|
||||
await _pacingDelay(
|
||||
TimeSpan.FromSeconds(lag / (double)System.Diagnostics.Stopwatch.Frequency), ct);
|
||||
else
|
||||
{
|
||||
if (ahead > SpinTailTicks)
|
||||
await _pacingDelay(TimeSpan.FromSeconds(
|
||||
(ahead - SpinTailTicks) / (double)System.Diagnostics.Stopwatch.Frequency), ct);
|
||||
while (!ct.IsCancellationRequested
|
||||
&& System.Diagnostics.Stopwatch.GetTimestamp() < nextTick)
|
||||
Thread.SpinWait(400);
|
||||
}
|
||||
waitSw.Stop();
|
||||
waitTicks += waitSw.ElapsedTicks;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -396,6 +422,7 @@ public sealed class FramePump : IDisposable
|
||||
}
|
||||
finally
|
||||
{
|
||||
timeEndPeriod(1);
|
||||
lock (_gate) IsRunning = false;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user