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:
parent
bf7ec12f68
commit
3ee8ec537a
23 changed files with 1639 additions and 170 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
32
src/AcDream.App/Rendering/FrameProfilerGpuMeasurement.cs
Normal file
32
src/AcDream.App/Rendering/FrameProfilerGpuMeasurement.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
533
src/AcDream.Cli/PerformanceTools.cs
Normal file
533
src/AcDream.Cli/PerformanceTools.cs
Normal file
|
|
@ -0,0 +1,533 @@
|
|||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.RegularExpressions;
|
||||
using SixLabors.ImageSharp;
|
||||
using SixLabors.ImageSharp.PixelFormats;
|
||||
|
||||
namespace AcDream.Cli;
|
||||
|
||||
public static partial class PerformanceTools
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
PropertyNamingPolicy = JsonNamingPolicy.CamelCase,
|
||||
WriteIndented = true,
|
||||
};
|
||||
|
||||
public static int SummarizeFrameHistory(string[] args)
|
||||
{
|
||||
if (args.Length < 4)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"usage: AcDream.Cli summarize-frame-history "
|
||||
+ "<frame-history.csv> <checkpoints.jsonl> <markers.log> <out.json>");
|
||||
return 2;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
IReadOnlyList<FrameRow> frames = ReadFrames(args[0]);
|
||||
IReadOnlyList<CheckpointRow> checkpoints = ReadCheckpoints(args[1]);
|
||||
IReadOnlyList<PortalMarker> portals = ReadPortalMarkers(args[2]);
|
||||
var summary = new FrameHistoryReport(
|
||||
SourceFrameHistory: Path.GetFileName(args[0]),
|
||||
FrameCount: frames.Count,
|
||||
FirstFrameUtc: frames.Count == 0 ? null : frames[0].TimestampUtc,
|
||||
LastFrameUtc: frames.Count == 0 ? null : frames[^1].TimestampUtc,
|
||||
Route: SummarizeWindow(frames),
|
||||
CheckpointWindows: checkpoints.Select(checkpoint =>
|
||||
new NamedWindowSummary(
|
||||
checkpoint.Name,
|
||||
checkpoint.TimestampUtc.AddSeconds(-5),
|
||||
checkpoint.TimestampUtc,
|
||||
SummarizeWindow(frames.Where(frame =>
|
||||
frame.TimestampUtc >= checkpoint.TimestampUtc.AddSeconds(-5)
|
||||
&& frame.TimestampUtc <= checkpoint.TimestampUtc)),
|
||||
checkpoint.ProcessAllocationRateBytesPerSecond))
|
||||
.ToArray(),
|
||||
PortalWindows: portals.Select(portal =>
|
||||
new PortalWindowSummary(
|
||||
portal.Destination,
|
||||
portal.TimestampUtc,
|
||||
SummarizeWindow(frames.Where(frame =>
|
||||
frame.TimestampUtc >= portal.TimestampUtc.AddSeconds(-10)
|
||||
&& frame.TimestampUtc <= portal.TimestampUtc.AddSeconds(5))),
|
||||
frames.Where(frame =>
|
||||
frame.TimestampUtc >= portal.TimestampUtc.AddSeconds(-10)
|
||||
&& frame.TimestampUtc <= portal.TimestampUtc.AddSeconds(5))
|
||||
.OrderByDescending(frame => frame.CpuUs)
|
||||
.Take(20)
|
||||
.Select(frame => new WorstFrame(
|
||||
frame.Frame,
|
||||
Math.Round(
|
||||
(frame.TimestampUtc - portal.TimestampUtc).TotalMilliseconds,
|
||||
3),
|
||||
frame.CpuUs,
|
||||
frame.GpuUs,
|
||||
frame.AllocBytes,
|
||||
frame.UpdateUs,
|
||||
frame.UploadUs,
|
||||
frame.PacingUs))
|
||||
.ToArray()))
|
||||
.ToArray());
|
||||
|
||||
string output = Path.GetFullPath(args[3]);
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(output)
|
||||
?? throw new InvalidOperationException("Output path has no directory."));
|
||||
File.WriteAllText(output, JsonSerializer.Serialize(summary, JsonOptions));
|
||||
Console.WriteLine($"wrote frame-history summary: {output}");
|
||||
return 0;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.Error.WriteLine($"frame-history summary failed: {error.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static int CompareScreenshots(string[] args)
|
||||
{
|
||||
if (args.Length < 3)
|
||||
{
|
||||
Console.Error.WriteLine(
|
||||
"usage: AcDream.Cli compare-screenshots "
|
||||
+ "<expected.png> <actual.png> <out.json> "
|
||||
+ "[channelTolerance=2] [maxDifferentFraction=0.001] [mask.png]");
|
||||
return 2;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
int tolerance = args.Length >= 4
|
||||
? int.Parse(args[3], CultureInfo.InvariantCulture)
|
||||
: 2;
|
||||
double maxDifferentFraction = args.Length >= 5
|
||||
? double.Parse(args[4], CultureInfo.InvariantCulture)
|
||||
: 0.001;
|
||||
string? maskPath = args.Length >= 6 ? args[5] : null;
|
||||
ScreenshotComparison comparison = CompareImages(
|
||||
args[0],
|
||||
args[1],
|
||||
tolerance,
|
||||
maxDifferentFraction,
|
||||
maskPath);
|
||||
|
||||
string output = Path.GetFullPath(args[2]);
|
||||
Directory.CreateDirectory(
|
||||
Path.GetDirectoryName(output)
|
||||
?? throw new InvalidOperationException("Output path has no directory."));
|
||||
File.WriteAllText(output, JsonSerializer.Serialize(comparison, JsonOptions));
|
||||
Console.WriteLine(
|
||||
$"screenshot comparison {(comparison.Passed ? "passed" : "failed")}: "
|
||||
+ $"{comparison.DifferentPixelFraction:P6} different");
|
||||
return comparison.Passed ? 0 : 1;
|
||||
}
|
||||
catch (Exception error)
|
||||
{
|
||||
Console.Error.WriteLine($"screenshot comparison failed: {error.Message}");
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
public static ScreenshotComparison CompareImages(
|
||||
string expectedPath,
|
||||
string actualPath,
|
||||
int channelTolerance,
|
||||
double maxDifferentFraction,
|
||||
string? maskPath = null)
|
||||
{
|
||||
if ((uint)channelTolerance > byte.MaxValue)
|
||||
throw new ArgumentOutOfRangeException(nameof(channelTolerance));
|
||||
if (maxDifferentFraction is < 0 or > 1 || double.IsNaN(maxDifferentFraction))
|
||||
throw new ArgumentOutOfRangeException(nameof(maxDifferentFraction));
|
||||
|
||||
using Image<Rgba32> expected = Image.Load<Rgba32>(expectedPath);
|
||||
using Image<Rgba32> actual = Image.Load<Rgba32>(actualPath);
|
||||
using Image<Rgba32>? mask = maskPath is null
|
||||
? null
|
||||
: Image.Load<Rgba32>(maskPath);
|
||||
|
||||
if (expected.Width != actual.Width || expected.Height != actual.Height)
|
||||
{
|
||||
return new ScreenshotComparison(
|
||||
Passed: false,
|
||||
Width: actual.Width,
|
||||
Height: actual.Height,
|
||||
ExpectedWidth: expected.Width,
|
||||
ExpectedHeight: expected.Height,
|
||||
ChannelTolerance: channelTolerance,
|
||||
MaxDifferentPixelFraction: maxDifferentFraction,
|
||||
ComparedPixels: 0,
|
||||
MaskedPixels: 0,
|
||||
DifferentPixels: 0,
|
||||
DifferentPixelFraction: 1,
|
||||
MaximumChannelDelta: byte.MaxValue,
|
||||
MeanAbsoluteChannelDelta: 0,
|
||||
RootMeanSquareChannelDelta: 0,
|
||||
ExpectedPath: Path.GetFileName(expectedPath),
|
||||
ActualPath: Path.GetFileName(actualPath),
|
||||
MaskPath: maskPath is null ? null : Path.GetFileName(maskPath));
|
||||
}
|
||||
|
||||
if (mask is not null
|
||||
&& (mask.Width != expected.Width || mask.Height != expected.Height))
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
"Screenshot mask dimensions must match the compared images.");
|
||||
}
|
||||
|
||||
long comparedPixels = 0;
|
||||
long maskedPixels = 0;
|
||||
long differentPixels = 0;
|
||||
long absoluteDeltaSum = 0;
|
||||
long squaredDeltaSum = 0;
|
||||
int maximumDelta = 0;
|
||||
|
||||
for (int y = 0; y < expected.Height; y++)
|
||||
{
|
||||
for (int x = 0; x < expected.Width; x++)
|
||||
{
|
||||
if (mask is not null && mask[x, y].A != 0)
|
||||
{
|
||||
maskedPixels++;
|
||||
continue;
|
||||
}
|
||||
|
||||
comparedPixels++;
|
||||
Rgba32 left = expected[x, y];
|
||||
Rgba32 right = actual[x, y];
|
||||
int redDelta = Math.Abs(left.R - right.R);
|
||||
int greenDelta = Math.Abs(left.G - right.G);
|
||||
int blueDelta = Math.Abs(left.B - right.B);
|
||||
int alphaDelta = Math.Abs(left.A - right.A);
|
||||
|
||||
absoluteDeltaSum += redDelta + greenDelta + blueDelta + alphaDelta;
|
||||
squaredDeltaSum +=
|
||||
redDelta * redDelta
|
||||
+ greenDelta * greenDelta
|
||||
+ blueDelta * blueDelta
|
||||
+ alphaDelta * alphaDelta;
|
||||
maximumDelta = Math.Max(
|
||||
maximumDelta,
|
||||
Math.Max(
|
||||
Math.Max(redDelta, greenDelta),
|
||||
Math.Max(blueDelta, alphaDelta)));
|
||||
bool different =
|
||||
redDelta > channelTolerance
|
||||
|| greenDelta > channelTolerance
|
||||
|| blueDelta > channelTolerance
|
||||
|| alphaDelta > channelTolerance;
|
||||
|
||||
if (different)
|
||||
differentPixels++;
|
||||
}
|
||||
}
|
||||
|
||||
double differingFraction = comparedPixels == 0
|
||||
? throw new InvalidDataException(
|
||||
"Screenshot mask excludes every pixel; no comparison is possible.")
|
||||
: differentPixels / (double)comparedPixels;
|
||||
long comparedChannels = comparedPixels * 4;
|
||||
return new ScreenshotComparison(
|
||||
Passed: differingFraction <= maxDifferentFraction,
|
||||
Width: actual.Width,
|
||||
Height: actual.Height,
|
||||
ExpectedWidth: expected.Width,
|
||||
ExpectedHeight: expected.Height,
|
||||
ChannelTolerance: channelTolerance,
|
||||
MaxDifferentPixelFraction: maxDifferentFraction,
|
||||
ComparedPixels: comparedPixels,
|
||||
MaskedPixels: maskedPixels,
|
||||
DifferentPixels: differentPixels,
|
||||
DifferentPixelFraction: differingFraction,
|
||||
MaximumChannelDelta: maximumDelta,
|
||||
MeanAbsoluteChannelDelta: comparedChannels == 0
|
||||
? 0
|
||||
: absoluteDeltaSum / (double)comparedChannels,
|
||||
RootMeanSquareChannelDelta: comparedChannels == 0
|
||||
? 0
|
||||
: Math.Sqrt(squaredDeltaSum / (double)comparedChannels),
|
||||
ExpectedPath: Path.GetFileName(expectedPath),
|
||||
ActualPath: Path.GetFileName(actualPath),
|
||||
MaskPath: maskPath is null ? null : Path.GetFileName(maskPath));
|
||||
}
|
||||
|
||||
private static IReadOnlyList<FrameRow> ReadFrames(string path)
|
||||
{
|
||||
string[] lines = File.ReadAllLines(path);
|
||||
if (lines.Length == 0)
|
||||
throw new InvalidDataException("Frame-history CSV is empty.");
|
||||
|
||||
string[] header = lines[0].Split(',');
|
||||
var columns = header
|
||||
.Select((name, index) => (name, index))
|
||||
.ToDictionary(pair => pair.name, pair => pair.index, StringComparer.Ordinal);
|
||||
string[] required =
|
||||
[
|
||||
"frame", "timestamp_utc", "cpu_us", "gpu_us", "alloc_bytes",
|
||||
"update_us", "upload_us", "imgui_us", "pacing_us",
|
||||
];
|
||||
foreach (string name in required)
|
||||
{
|
||||
if (!columns.ContainsKey(name))
|
||||
throw new InvalidDataException($"Frame-history CSV is missing '{name}'.");
|
||||
}
|
||||
|
||||
var rows = new List<FrameRow>(lines.Length - 1);
|
||||
for (int lineNumber = 1; lineNumber < lines.Length; lineNumber++)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(lines[lineNumber]))
|
||||
continue;
|
||||
string[] values = lines[lineNumber].Split(',');
|
||||
try
|
||||
{
|
||||
rows.Add(new FrameRow(
|
||||
ParseInt(values, columns, "frame"),
|
||||
DateTime.Parse(
|
||||
values[columns["timestamp_utc"]],
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal),
|
||||
ParseLong(values, columns, "cpu_us"),
|
||||
ParseLong(values, columns, "gpu_us"),
|
||||
ParseLong(values, columns, "alloc_bytes"),
|
||||
ParseLong(values, columns, "update_us"),
|
||||
ParseLong(values, columns, "upload_us"),
|
||||
ParseLong(values, columns, "imgui_us"),
|
||||
ParseLong(values, columns, "pacing_us")));
|
||||
}
|
||||
catch (Exception error) when (
|
||||
error is FormatException
|
||||
or IndexOutOfRangeException
|
||||
or KeyNotFoundException)
|
||||
{
|
||||
throw new InvalidDataException(
|
||||
$"Invalid frame-history row {lineNumber + 1}: {error.Message}",
|
||||
error);
|
||||
}
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<CheckpointRow> ReadCheckpoints(string path)
|
||||
{
|
||||
var rows = new List<CheckpointRow>();
|
||||
CheckpointRaw? previous = null;
|
||||
foreach (string line in File.ReadLines(path))
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(line))
|
||||
continue;
|
||||
using JsonDocument document = JsonDocument.Parse(line);
|
||||
JsonElement root = document.RootElement;
|
||||
JsonElement resources = root.GetProperty("resources");
|
||||
var current = new CheckpointRaw(
|
||||
root.GetProperty("name").GetString()
|
||||
?? throw new InvalidDataException("Checkpoint name is null."),
|
||||
root.GetProperty("timestampUtc").GetDateTime().ToUniversalTime(),
|
||||
resources.GetProperty("processTotalAllocatedBytes").GetInt64());
|
||||
double? allocationRate = null;
|
||||
if (previous is not null)
|
||||
{
|
||||
double seconds = (current.TimestampUtc - previous.TimestampUtc).TotalSeconds;
|
||||
if (seconds > 0)
|
||||
{
|
||||
allocationRate = Math.Max(
|
||||
0,
|
||||
current.ProcessAllocatedBytes - previous.ProcessAllocatedBytes)
|
||||
/ seconds;
|
||||
}
|
||||
}
|
||||
|
||||
rows.Add(new CheckpointRow(
|
||||
current.Name,
|
||||
current.TimestampUtc,
|
||||
allocationRate));
|
||||
previous = current;
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
private static IReadOnlyList<PortalMarker> ReadPortalMarkers(string path)
|
||||
{
|
||||
var markers = new List<PortalMarker>();
|
||||
foreach (string line in File.ReadLines(path))
|
||||
{
|
||||
Match match = PortalMarkerPattern().Match(line);
|
||||
if (!match.Success)
|
||||
continue;
|
||||
markers.Add(new PortalMarker(
|
||||
DateTime.Parse(
|
||||
match.Groups["timestamp"].Value,
|
||||
CultureInfo.InvariantCulture,
|
||||
DateTimeStyles.AdjustToUniversal | DateTimeStyles.AssumeUniversal),
|
||||
match.Groups["destination"].Value));
|
||||
}
|
||||
|
||||
return markers;
|
||||
}
|
||||
|
||||
private static WindowSummary SummarizeWindow(IEnumerable<FrameRow> source)
|
||||
{
|
||||
FrameRow[] rows = source.OrderBy(row => row.TimestampUtc).ToArray();
|
||||
return new WindowSummary(
|
||||
FrameCount: rows.Length,
|
||||
DurationSeconds: rows.Length < 2
|
||||
? 0
|
||||
: (rows[^1].TimestampUtc - rows[0].TimestampUtc).TotalSeconds,
|
||||
CpuUs: SummarizeMetric(rows.Select(row => row.CpuUs)),
|
||||
GpuUs: SummarizeMetric(rows.Where(row => row.GpuUs >= 0).Select(row => row.GpuUs)),
|
||||
AllocationBytes: SummarizeMetric(rows.Select(row => row.AllocBytes)),
|
||||
UpdateUs: SummarizeMetric(rows.Select(row => row.UpdateUs)),
|
||||
UploadUs: SummarizeMetric(rows.Select(row => row.UploadUs)),
|
||||
ImGuiUs: SummarizeMetric(rows.Select(row => row.ImGuiUs)),
|
||||
PacingUs: SummarizeMetric(rows.Select(row => row.PacingUs)),
|
||||
TotalPositiveAllocationBytes: rows.Sum(row => Math.Max(0, row.AllocBytes)),
|
||||
CpuStandardDeviationUs: StandardDeviation(rows.Select(row => row.CpuUs)));
|
||||
}
|
||||
|
||||
private static MetricSummary SummarizeMetric(IEnumerable<long> source)
|
||||
{
|
||||
long[] values = source.Order().ToArray();
|
||||
if (values.Length == 0)
|
||||
return new MetricSummary(0, 0, 0, 0, 0, 0, 0);
|
||||
return new MetricSummary(
|
||||
values.Length,
|
||||
Percentile(values, 0.50),
|
||||
Percentile(values, 0.95),
|
||||
Percentile(values, 0.99),
|
||||
Percentile(values, 0.999),
|
||||
values[^1],
|
||||
values.Average());
|
||||
}
|
||||
|
||||
private static long Percentile(long[] sorted, double percentile)
|
||||
{
|
||||
int index = (int)Math.Ceiling(sorted.Length * percentile) - 1;
|
||||
return sorted[Math.Clamp(index, 0, sorted.Length - 1)];
|
||||
}
|
||||
|
||||
private static double StandardDeviation(IEnumerable<long> source)
|
||||
{
|
||||
long[] values = source.ToArray();
|
||||
if (values.Length == 0)
|
||||
return 0;
|
||||
double average = values.Average();
|
||||
double variance = values.Sum(value => Math.Pow(value - average, 2)) / values.Length;
|
||||
return Math.Sqrt(variance);
|
||||
}
|
||||
|
||||
private static int ParseInt(
|
||||
string[] values,
|
||||
IReadOnlyDictionary<string, int> columns,
|
||||
string name) =>
|
||||
int.Parse(values[columns[name]], CultureInfo.InvariantCulture);
|
||||
|
||||
private static long ParseLong(
|
||||
string[] values,
|
||||
IReadOnlyDictionary<string, int> columns,
|
||||
string name) =>
|
||||
long.Parse(values[columns[name]], CultureInfo.InvariantCulture);
|
||||
|
||||
[GeneratedRegex(
|
||||
"^(?<timestamp>\\S+) materialized destination='(?<destination>[^']+)'",
|
||||
RegexOptions.CultureInvariant)]
|
||||
private static partial Regex PortalMarkerPattern();
|
||||
|
||||
private sealed record FrameRow(
|
||||
int Frame,
|
||||
DateTime TimestampUtc,
|
||||
long CpuUs,
|
||||
long GpuUs,
|
||||
long AllocBytes,
|
||||
long UpdateUs,
|
||||
long UploadUs,
|
||||
long ImGuiUs,
|
||||
long PacingUs);
|
||||
|
||||
private sealed record CheckpointRaw(
|
||||
string Name,
|
||||
DateTime TimestampUtc,
|
||||
long ProcessAllocatedBytes);
|
||||
|
||||
private sealed record CheckpointRow(
|
||||
string Name,
|
||||
DateTime TimestampUtc,
|
||||
double? ProcessAllocationRateBytesPerSecond);
|
||||
|
||||
private sealed record PortalMarker(DateTime TimestampUtc, string Destination);
|
||||
}
|
||||
|
||||
public sealed record MetricSummary(
|
||||
int Count,
|
||||
long P50,
|
||||
long P95,
|
||||
long P99,
|
||||
long P999,
|
||||
long Maximum,
|
||||
double Mean);
|
||||
|
||||
public sealed record WindowSummary(
|
||||
int FrameCount,
|
||||
double DurationSeconds,
|
||||
MetricSummary CpuUs,
|
||||
MetricSummary GpuUs,
|
||||
MetricSummary AllocationBytes,
|
||||
MetricSummary UpdateUs,
|
||||
MetricSummary UploadUs,
|
||||
MetricSummary ImGuiUs,
|
||||
MetricSummary PacingUs,
|
||||
long TotalPositiveAllocationBytes,
|
||||
double CpuStandardDeviationUs);
|
||||
|
||||
public sealed record NamedWindowSummary(
|
||||
string Name,
|
||||
DateTime StartUtc,
|
||||
DateTime EndUtc,
|
||||
WindowSummary Metrics,
|
||||
double? ProcessAllocationRateBytesPerSecond);
|
||||
|
||||
public sealed record WorstFrame(
|
||||
int Frame,
|
||||
double RelativeToMaterializationMs,
|
||||
long CpuUs,
|
||||
long GpuUs,
|
||||
long AllocationBytes,
|
||||
long UpdateUs,
|
||||
long UploadUs,
|
||||
long PacingUs);
|
||||
|
||||
public sealed record PortalWindowSummary(
|
||||
string Destination,
|
||||
DateTime MaterializedUtc,
|
||||
WindowSummary Metrics,
|
||||
IReadOnlyList<WorstFrame> WorstFrames);
|
||||
|
||||
public sealed record FrameHistoryReport(
|
||||
string SourceFrameHistory,
|
||||
int FrameCount,
|
||||
DateTime? FirstFrameUtc,
|
||||
DateTime? LastFrameUtc,
|
||||
WindowSummary Route,
|
||||
IReadOnlyList<NamedWindowSummary> CheckpointWindows,
|
||||
IReadOnlyList<PortalWindowSummary> PortalWindows);
|
||||
|
||||
public sealed record ScreenshotComparison(
|
||||
bool Passed,
|
||||
int Width,
|
||||
int Height,
|
||||
int ExpectedWidth,
|
||||
int ExpectedHeight,
|
||||
int ChannelTolerance,
|
||||
double MaxDifferentPixelFraction,
|
||||
long ComparedPixels,
|
||||
long MaskedPixels,
|
||||
long DifferentPixels,
|
||||
double DifferentPixelFraction,
|
||||
int MaximumChannelDelta,
|
||||
double MeanAbsoluteChannelDelta,
|
||||
double RootMeanSquareChannelDelta,
|
||||
string ExpectedPath,
|
||||
string ActualPath,
|
||||
string? MaskPath);
|
||||
|
|
@ -8,6 +8,16 @@ using DatReaderWriter.Types;
|
|||
using Env = System.Environment;
|
||||
|
||||
// ─── subcommand dispatch ────────────────────────────────────────────────────
|
||||
if (args.Length >= 1 && args[0] == "summarize-frame-history")
|
||||
{
|
||||
return PerformanceTools.SummarizeFrameHistory(args[1..]);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "compare-screenshots")
|
||||
{
|
||||
return PerformanceTools.CompareScreenshots(args[1..]);
|
||||
}
|
||||
|
||||
if (args.Length >= 1 && args[0] == "dump-vitals-bars")
|
||||
{
|
||||
string? dvbDatDir = args.ElementAtOrDefault(1) ?? Env.GetEnvironmentVariable("ACDREAM_DAT_DIR");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue