perf(diag): complete trustworthy Slice A capture tooling

Correct whole-frame GPU timestamps so they bracket only the accepted render transaction and associate delayed query results with the owning CPU frame. Add route-wide frame-history summaries, fixed-camera screenshot comparison, process counters, contention traces, a pinned Arwic workload, and credential-safe launch disclosure.

The reference hardware/display contract now keeps local and RDP populations separate and defines the screenshot and re-baseline rules needed by later prepared-content gates.

Co-Authored-By: Codex <noreply@openai.com>
This commit is contained in:
Erik 2026-07-24 12:46:51 +02:00
parent bf7ec12f68
commit 3ee8ec537a
23 changed files with 1639 additions and 170 deletions

View file

@ -256,9 +256,7 @@ internal sealed class FrameRootCompositionPhase
interaction.RetainedUi?.Host.TextRenderer,
live.ClipFrame,
foundation.Terrain,
foundation.SceneLighting,
d.FrameProfiler,
d.Gl),
foundation.SceneLighting),
new RuntimeRenderFrameClearPhase(
d.Gl,
d.WorldTime,
@ -454,6 +452,7 @@ internal sealed class FrameRootCompositionPhase
renderWeatherFrame);
var renderFrame = new RenderFrameOrchestrator(
host.GpuFrameFlights,
new FrameProfilerGpuMeasurement(d.FrameProfiler, d.Gl),
framePreparation,
worldSceneRenderer,
privatePresentation,

View file

@ -32,35 +32,25 @@ public enum FrameStage
/// together. <c>GpuUs</c> is <c>-1</c> for a frame with no available GPU
/// sample (warm-up, or <c>ACDREAM_WB_DIAG=1</c> self-disable).
/// </summary>
internal readonly struct FrameHistoryRecord(
int frameIndex,
double timestampMs,
long cpuUs,
long gpuUs,
long allocBytes,
long updateUs,
long uploadUs,
long imGuiUs,
long pacingUs)
{
public readonly int FrameIndex = frameIndex;
public readonly double TimestampMs = timestampMs;
public readonly long CpuUs = cpuUs;
public readonly long GpuUs = gpuUs;
public readonly long AllocBytes = allocBytes;
public readonly long UpdateUs = updateUs;
public readonly long UploadUs = uploadUs;
public readonly long ImGuiUs = imGuiUs;
public readonly long PacingUs = pacingUs;
}
internal readonly record struct FrameHistoryRecord(
int FrameIndex,
double TimestampMs,
long CpuUs,
long GpuUs,
long AllocBytes,
long UpdateUs,
long UploadUs,
long ImGuiUs,
long PacingUs);
/// <summary>
/// MP0 (2026-07-05) — the permanent honest frame profiler. One
/// <c>FrameBoundary</c> call at the top of <c>GameWindow.OnRender</c>
/// <c>FrameBoundary</c> call at the top of the accepted render transaction
/// measures CPU frame time as the delta between consecutive boundaries
/// (captures the FULL frame including present), brackets the frame in a
/// GPU <c>TimeElapsed</c> query (via <see cref="GpuFrameTimer"/>), and
/// samples per-frame allocated bytes + GC collection counts. Stage scopes
/// (captures the FULL frame including present) and samples per-frame allocated
/// bytes + GC collection counts. <see cref="BeginGpuFrame"/> and
/// <see cref="EndGpuFrame"/> separately bracket only submitted render work.
/// Stage scopes
/// (<see cref="BeginStage"/>) attribute CPU time to Update / Upload /
/// ImGui. Emits one <c>[frame-prof]</c> line every ~5 s while
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is true; costs one
@ -79,8 +69,9 @@ internal readonly struct FrameHistoryRecord(
/// preallocated, grow-as-needed <see cref="List{T}"/> (1 int + 1 double +
/// 7 longs ≈ 72 bytes/record; a multi-hour capture at 165 fps is roughly
/// 165 * 3600 * 72 bytes ≈ 43 MB/hour — fine for a bounded diagnostic run,
/// not for unattended day-long capture). ZERO frame-thread I/O: the list only grows during
/// <see cref="FrameBoundary"/>; the CSV is written once, from
/// not for unattended day-long capture). Its initial capacity is about 9 MiB
/// and covers the canonical route above 300 FPS without a frame-thread resize.
/// ZERO frame-thread I/O: the CSV is written once, from
/// <see cref="Dispose"/>, at shutdown. Recording only takes effect while
/// <see cref="RenderingDiagnostics.FrameProfEnabled"/> is ALSO true — it
/// reuses that instrumentation rather than duplicating it. Does not
@ -90,7 +81,7 @@ internal readonly struct FrameHistoryRecord(
public sealed class FrameProfiler : IDisposable
{
private const int WindowCapacity = 2048; // ~12 s at 165 fps
private const int HistoryInitialCapacity = 4096;
private const int HistoryInitialCapacity = 131072;
private const long ReportIntervalTicks = 5 * TimeSpan.TicksPerSecond;
private static readonly int StageCount = Enum.GetValues<FrameStage>().Length;
@ -104,7 +95,8 @@ public sealed class FrameProfiler : IDisposable
Environment.GetEnvironmentVariable("ACDREAM_WB_DIAG") == "1";
private readonly List<FrameHistoryRecord>? _history;
private readonly long _profilerStartTimestamp;
private int _historyFrameIndex;
private readonly DateTime _profilerStartUtc;
private int _currentFrameIndex = -1;
private GpuFrameTimer? _gpuTimer;
private long _lastBoundaryTimestamp;
@ -127,12 +119,14 @@ public sealed class FrameProfiler : IDisposable
_stageAccumTicks = new long[StageCount];
_lastStageUs = new long[StageCount];
_profilerStartTimestamp = Stopwatch.GetTimestamp();
_profilerStartUtc = DateTime.UtcNow;
if (RenderingDiagnostics.FrameHistoryPath is not null)
_history = new List<FrameHistoryRecord>(HistoryInitialCapacity);
}
/// <summary>
/// Call as the FIRST statement of <c>GameWindow.OnRender</c>.
/// Call once at the accepted render-transaction boundary, before
/// <see cref="BeginGpuFrame"/>.
/// </summary>
public void FrameBoundary(GL gl)
{
@ -150,6 +144,7 @@ public sealed class FrameProfiler : IDisposable
_gpuTimer = null;
_wasEnabled = false;
_lastBoundaryTimestamp = 0;
_currentFrameIndex = -1;
}
return;
}
@ -164,8 +159,6 @@ public sealed class FrameProfiler : IDisposable
long now = Stopwatch.GetTimestamp();
long allocNow = GC.GetAllocatedBytesForCurrentThread();
bool historyFrame = false;
long historyCpuUs = 0, historyAllocBytes = 0;
if (!_wasEnabled)
{
// First enabled frame (startup or runtime toggle-on): establish
@ -179,6 +172,7 @@ public sealed class FrameProfiler : IDisposable
Array.Clear(_stageAccumTicks);
_gc0Base = GC.CollectionCount(0); _gc1Base = GC.CollectionCount(1); _gc2Base = GC.CollectionCount(2);
if (_gpuTimer is null && !_wbDiagActive) _gpuTimer = new GpuFrameTimer(gl);
_currentFrameIndex = 0;
if (_wbDiagActive && !_wbDiagNoticePrinted)
{
_wbDiagNoticePrinted = true;
@ -199,32 +193,25 @@ public sealed class FrameProfiler : IDisposable
_stageAccumTicks[i] = 0;
}
_framesInWindow++;
historyFrame = _history is not null;
historyCpuUs = cpuUs;
historyAllocBytes = allocDelta;
if (_history is not null)
{
_history.Add(new FrameHistoryRecord(
_currentFrameIndex,
(now - _profilerStartTimestamp) * 1000.0 / Stopwatch.Frequency,
cpuUs,
-1L,
allocDelta,
_lastStageUs[(int)FrameStage.Update],
_lastStageUs[(int)FrameStage.Upload],
_lastStageUs[(int)FrameStage.ImGui],
_lastStageUs[(int)FrameStage.Pacing]));
}
_currentFrameIndex++;
}
_lastBoundaryTimestamp = now;
_lastAllocBytes = allocNow;
long? gpuUsSample = _gpuTimer?.FrameBoundary();
if (gpuUsSample is long gpuUs)
_gpuUs.Push(gpuUs);
if (historyFrame)
{
_history!.Add(new FrameHistoryRecord(
_historyFrameIndex++,
(now - _profilerStartTimestamp) * 1000.0 / Stopwatch.Frequency,
historyCpuUs,
gpuUsSample ?? -1L,
historyAllocBytes,
_lastStageUs[(int)FrameStage.Update],
_lastStageUs[(int)FrameStage.Upload],
_lastStageUs[(int)FrameStage.ImGui],
_lastStageUs[(int)FrameStage.Pacing]));
}
long nowTicks = DateTime.UtcNow.Ticks;
if (nowTicks - _lastReportTicks >= ReportIntervalTicks && _framesInWindow > 0)
{
@ -242,6 +229,38 @@ public sealed class FrameProfiler : IDisposable
}
}
/// <summary>
/// Begin GPU timing immediately before render-resource, world, and private
/// presentation submission. Completed delayed samples are associated with
/// their owning history row rather than the frame that happened to poll
/// them.
/// </summary>
public void BeginGpuFrame()
{
if (!_wasEnabled || _gpuTimer is null || _currentFrameIndex < 0)
return;
Span<GpuFrameSample> completed = stackalloc GpuFrameSample[4];
int completedCount = _gpuTimer.BeginFrame(_currentFrameIndex, completed);
for (int index = 0; index < completedCount; index++)
{
GpuFrameSample sample = completed[index];
_gpuUs.Push(sample.ElapsedUs);
if (_history is not null
&& (uint)sample.FrameIndex < (uint)_history.Count)
{
FrameHistoryRecord row = _history[sample.FrameIndex];
_history[sample.FrameIndex] = row with { GpuUs = sample.ElapsedUs };
}
}
}
/// <summary>End GPU timing immediately after render submission.</summary>
public void EndGpuFrame()
{
_gpuTimer?.EndFrame();
}
/// <summary>
/// Attribute the enclosed CPU time to <paramref name="stage"/>.
/// Usage: <c>using var _ = profiler.BeginStage(FrameStage.Update);</c>.
@ -283,14 +302,22 @@ public sealed class FrameProfiler : IDisposable
}
/// <summary>Pure CSV formatter — unit-tested; invariant culture. One header row plus one row per record.</summary>
internal static void WriteHistoryCsv(IEnumerable<FrameHistoryRecord> records, TextWriter writer)
internal static void WriteHistoryCsv(
IEnumerable<FrameHistoryRecord> records,
TextWriter writer,
DateTime profilerStartUtc)
{
var ci = CultureInfo.InvariantCulture;
writer.WriteLine("frame,timestamp_ms,cpu_us,gpu_us,alloc_bytes,update_us,upload_us,imgui_us,pacing_us");
DateTime startUtc = profilerStartUtc.ToUniversalTime();
writer.WriteLine(
"frame,timestamp_ms,timestamp_utc,cpu_us,gpu_us,alloc_bytes,"
+ "update_us,upload_us,imgui_us,pacing_us");
foreach (FrameHistoryRecord r in records)
{
writer.Write(r.FrameIndex.ToString(ci)); writer.Write(',');
writer.Write(r.TimestampMs.ToString("0.000", ci)); writer.Write(',');
writer.Write(startUtc.AddMilliseconds(r.TimestampMs).ToString("O", ci));
writer.Write(',');
writer.Write(r.CpuUs.ToString(ci)); writer.Write(',');
writer.Write(r.GpuUs.ToString(ci)); writer.Write(',');
writer.Write(r.AllocBytes.ToString(ci)); writer.Write(',');
@ -316,7 +343,7 @@ public sealed class FrameProfiler : IDisposable
try
{
using var writer = new StreamWriter(path, append: false);
WriteHistoryCsv(_history, writer);
WriteHistoryCsv(_history, writer, _profilerStartUtc);
Console.WriteLine($"[frame-prof] wrote {_history.Count} history record(s) to '{path}'");
}
catch (Exception ex)

View file

@ -3,18 +3,27 @@ using Silk.NET.OpenGL;
namespace AcDream.App.Diagnostics;
internal readonly record struct GpuFrameSample(int FrameIndex, long ElapsedUs);
/// <summary>
/// MP0 (2026-07-05) — whole-frame GPU time via a ring of
/// MP0 (2026-07-05) — render-transaction GPU time via a ring of
/// <see cref="QueryTarget.TimeElapsed"/> queries (depth 4, so results are
/// read ~3 frames late and never stall). Mirrors WbDrawDispatcher's query
/// read several frames late and never stall). Mirrors WbDrawDispatcher's query
/// idiom including the #125 lesson: a glGenQueries name is not a query
/// OBJECT until first glBeginQuery, so never-begun slots are skipped via
/// the Begun flags.
/// the pending flags.
///
/// <para>MUST NOT be active while ACDREAM_WB_DIAG=1: GL forbids two
/// simultaneously active TimeElapsed queries and WbDrawDispatcher brackets
/// its passes with them under that flag. The caller (FrameProfiler)
/// enforces the exclusion; this class just does the ring.</para>
///
/// <para>The query begins immediately before render-resource/world/private
/// presentation submission and ends immediately after it. It is deliberately
/// NOT left active until the next render callback: doing so includes display
/// pacing and an arbitrary CPU idle interval in the GPU clock. Completed
/// samples retain the frame index that owned the query so the delayed result
/// can be paired with the correct CPU/history row.</para>
/// </summary>
internal sealed class GpuFrameTimer : IDisposable
{
@ -22,9 +31,10 @@ internal sealed class GpuFrameTimer : IDisposable
private readonly GL _gl;
private readonly uint[] _queries = new uint[RingDepth];
private readonly bool[] _begun = new bool[RingDepth];
private int _frameIndex;
private bool _queryActive;
private readonly bool[] _pending = new bool[RingDepth];
private readonly int[] _frameIndices = new int[RingDepth];
private int _nextSlot;
private int _activeSlot = -1;
public GpuFrameTimer(GL gl)
{
@ -34,48 +44,74 @@ internal sealed class GpuFrameTimer : IDisposable
}
/// <summary>
/// Call once per frame at the frame boundary. Ends the previous
/// frame's query, polls the oldest slot non-blocking, begins this
/// frame's query. Returns the completed GPU time in microseconds for
/// a ~RingDepth-frames-old frame, or null when no result is ready.
/// Poll every ended query non-blocking, then begin this frame's render
/// transaction in the first free ring slot. If all slots are still in
/// flight the current frame is intentionally left unmeasured rather than
/// stalling or overwriting a pending result.
/// </summary>
public long? FrameBoundary()
public int BeginFrame(int frameIndex, Span<GpuFrameSample> completed)
{
if (_queryActive)
if (_activeSlot >= 0)
throw new InvalidOperationException("GPU frame timing was begun twice without an end.");
if (completed.Length < RingDepth)
throw new ArgumentException(
$"Completed-sample storage must hold at least {RingDepth} entries.",
nameof(completed));
int completedCount = 0;
for (int slot = 0; slot < RingDepth; slot++)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
if (!_pending[slot])
continue;
_gl.GetQueryObject(
_queries[slot],
QueryObjectParameterName.ResultAvailable,
out int available);
if (available == 0)
continue;
_gl.GetQueryObject(
_queries[slot],
QueryObjectParameterName.Result,
out ulong elapsedNanoseconds);
completed[completedCount++] = new GpuFrameSample(
_frameIndices[slot],
(long)(elapsedNanoseconds / 1000UL));
_pending[slot] = false;
}
long? completedUs = null;
int readSlot = _frameIndex % RingDepth; // about to be reused — oldest
if (_begun[readSlot])
for (int offset = 0; offset < RingDepth; offset++)
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.ResultAvailable, out int avail);
if (avail != 0)
{
_gl.GetQueryObject(_queries[readSlot], QueryObjectParameterName.Result, out ulong ns);
completedUs = (long)(ns / 1000UL);
}
// Not available ⇒ sample silently dropped (same policy as
// WbDrawDispatcher) — percentiles tolerate missing samples.
int slot = (_nextSlot + offset) % RingDepth;
if (_pending[slot])
continue;
_gl.BeginQuery(QueryTarget.TimeElapsed, _queries[slot]);
_frameIndices[slot] = frameIndex;
_activeSlot = slot;
_nextSlot = (slot + 1) % RingDepth;
break;
}
_gl.BeginQuery(QueryTarget.TimeElapsed, _queries[readSlot]);
_begun[readSlot] = true;
_queryActive = true;
_frameIndex++;
return completedUs;
return completedCount;
}
/// <summary>End any active query without beginning a new one (used when the profiler is toggled off mid-session).</summary>
/// <summary>End the query around the current render transaction.</summary>
public void EndFrame()
{
if (_activeSlot < 0)
return;
_gl.EndQuery(QueryTarget.TimeElapsed);
_pending[_activeSlot] = true;
_activeSlot = -1;
}
/// <summary>End an active query without beginning another.</summary>
public void Stop()
{
if (_queryActive)
{
_gl.EndQuery(QueryTarget.TimeElapsed);
_queryActive = false;
}
EndFrame();
}
public void Dispose()

View file

@ -0,0 +1,32 @@
using AcDream.App.Diagnostics;
using Silk.NET.OpenGL;
namespace AcDream.App.Rendering;
/// <summary>
/// Production render-transaction measurement adapter. CPU frame boundaries
/// remain owned by <see cref="FrameProfiler"/> while this adapter places the
/// GPU query around only the GL-producing render phases.
/// </summary>
internal sealed class FrameProfilerGpuMeasurement : IRenderFrameGpuMeasurement
{
private readonly FrameProfiler _profiler;
private readonly GL _gl;
public FrameProfilerGpuMeasurement(FrameProfiler profiler, GL gl)
{
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
public void BeginFrame()
{
_profiler.FrameBoundary(_gl);
_profiler.BeginGpuFrame();
}
public void EndFrame()
{
_profiler.EndGpuFrame();
}
}

View file

@ -43,6 +43,18 @@ internal interface IRenderFrameResourcePhase
void Prepare(RenderFrameInput input);
}
/// <summary>
/// Diagnostic GPU timer bracketing only render-resource, world, and private
/// presentation submission. It deliberately excludes diagnostics, pacing,
/// and the idle interval before the next render callback.
/// </summary>
internal interface IRenderFrameGpuMeasurement
{
void BeginFrame();
void EndFrame();
}
internal interface IWorldSceneFramePhase
{
WorldRenderFrameOutcome Render(RenderFrameInput input);
@ -117,6 +129,7 @@ internal sealed class NullRenderFrameFailureRecovery : IRenderFrameFailureRecove
internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
{
private readonly IRenderFrameLifetime _lifetime;
private readonly IRenderFrameGpuMeasurement _gpuMeasurement;
private readonly IRenderFrameResourcePhase _resources;
private readonly IWorldSceneFramePhase _world;
private readonly IPrivatePresentationFramePhase _presentation;
@ -126,6 +139,7 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
public RenderFrameOrchestrator(
IRenderFrameLifetime lifetime,
IRenderFrameGpuMeasurement gpuMeasurement,
IRenderFrameResourcePhase resources,
IWorldSceneFramePhase world,
IPrivatePresentationFramePhase presentation,
@ -134,6 +148,8 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
IRenderFrameFailureRecovery recovery)
{
_lifetime = lifetime ?? throw new ArgumentNullException(nameof(lifetime));
_gpuMeasurement = gpuMeasurement
?? throw new ArgumentNullException(nameof(gpuMeasurement));
_resources = resources ?? throw new ArgumentNullException(nameof(resources));
_world = world ?? throw new ArgumentNullException(nameof(world));
_presentation = presentation ?? throw new ArgumentNullException(nameof(presentation));
@ -149,10 +165,37 @@ internal sealed class RenderFrameOrchestrator : IGameRenderFrameRoot
Exception? renderFailure = null;
try
{
_resources.Prepare(input);
WorldRenderFrameOutcome world = _world.Render(input);
PrivatePresentationFrameOutcome presentation =
_presentation.Render(input, world);
WorldRenderFrameOutcome world;
PrivatePresentationFrameOutcome presentation;
Exception? measuredRenderFailure = null;
_gpuMeasurement.BeginFrame();
try
{
_resources.Prepare(input);
world = _world.Render(input);
presentation = _presentation.Render(input, world);
}
catch (Exception error)
{
measuredRenderFailure = error;
throw;
}
finally
{
try
{
_gpuMeasurement.EndFrame();
}
catch (Exception measurementFailure)
when (measuredRenderFailure is not null)
{
throw new AggregateException(
"Rendering failed and its GPU measurement could not be closed.",
measuredRenderFailure,
measurementFailure);
}
}
var outcome = new RenderFrameOutcome(world, presentation);
_diagnostics.Publish(input, outcome);
_postDiagnostics.Process(input, outcome);

View file

@ -93,9 +93,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
private readonly ClipFrame? _clip;
private readonly TerrainModernRenderer? _terrain;
private readonly SceneLightingUboBinding? _lighting;
private readonly FrameProfiler _profiler;
private readonly GL _gl;
public RuntimeRenderFrameBeginResources(
TextureCache? textures,
WbDrawDispatcher? dispatcher,
@ -105,9 +102,7 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
TextRenderer? uiText,
ClipFrame? clip,
TerrainModernRenderer? terrain,
SceneLightingUboBinding? lighting,
FrameProfiler profiler,
GL gl)
SceneLightingUboBinding? lighting)
{
_textures = textures;
_dispatcher = dispatcher;
@ -118,8 +113,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
_clip = clip;
_terrain = terrain;
_lighting = lighting;
_profiler = profiler ?? throw new ArgumentNullException(nameof(profiler));
_gl = gl ?? throw new ArgumentNullException(nameof(gl));
}
public void Begin(int gpuSlot)
@ -134,7 +127,6 @@ internal sealed class RuntimeRenderFrameBeginResources : IRenderFrameBeginResour
_clip?.BeginFrame(gpuSlot);
_terrain?.BeginFrame(gpuSlot);
_lighting?.BeginFrame(gpuSlot);
_profiler.FrameBoundary(_gl);
}
}