From 430c8f235aed3d2e791b8e70e254247b501e809a Mon Sep 17 00:00:00 2001 From: Jon Kazama Date: Tue, 16 Jun 2026 19:13:45 +0200 Subject: [PATCH] perf(baseline): 1000-frame steady-state capture with quad-proxy draw calls + JSON sink --- .../SelfTests/PerformanceBaselineLog.cs | 68 +++++++++++ .../SelfTests/PerformanceBaselineStep.cs | 115 ++++++++++++++---- 2 files changed, 159 insertions(+), 24 deletions(-) create mode 100644 HellionChat/SelfTests/PerformanceBaselineLog.cs diff --git a/HellionChat/SelfTests/PerformanceBaselineLog.cs b/HellionChat/SelfTests/PerformanceBaselineLog.cs new file mode 100644 index 0000000..20f396b --- /dev/null +++ b/HellionChat/SelfTests/PerformanceBaselineLog.cs @@ -0,0 +1,68 @@ +using System.Globalization; +using System.IO; + +namespace HellionChat.SelfTests; + +// Disk sink for the B5 performance baseline. Kept separate from the SelfTest +// step so the per-frame hot path never references file IO. Writes one +// perf-baseline.json into the plugin ConfigDirectory, atomically (tmp + move) +// like ThemeRegistry's theme writer, so a mid-write crash leaves either the +// old file or the new file, never a half JSON. Field names track §7.5: +// steady-state Draw cost (avg/max ms), the quad-proxy draw-call count +// (avg/max), and frame delta (avg/max). First-frame-HITCH is read off +// drawMs max/avg by the human author, platform-annotated in the notes. +internal static class PerformanceBaselineLog +{ + internal static string Write( + double avgDrawMs, + double maxDrawMs, + double avgDrawCallsProxy, + double maxDrawCallsProxy, + double avgDeltaMs, + double maxDeltaMs, + int frames + ) + { + var targetPath = Path.Join(Plugin.Interface.ConfigDirectory.FullName, "perf-baseline.json"); + + var json = + "{\n" + + $" \"frames\": {frames},\n" + + $" \"avgDrawMs\": {Fmt(avgDrawMs)},\n" + + $" \"maxDrawMs\": {Fmt(maxDrawMs)},\n" + + $" \"avgDrawCallsProxy\": {Fmt(avgDrawCallsProxy)},\n" + + $" \"maxDrawCallsProxy\": {Fmt(maxDrawCallsProxy)},\n" + + $" \"avgDeltaMs\": {Fmt(avgDeltaMs)},\n" + + $" \"maxDeltaMs\": {Fmt(maxDeltaMs)}\n" + + "}\n"; + + // Atomic replace — same volume rename is atomic on POSIX and Windows. + var tmpPath = targetPath + ".tmp"; + File.WriteAllText(tmpPath, json); + try + { + File.Move(tmpPath, targetPath, overwrite: true); + } + catch + { + // Avoid .tmp litter if Move fails (target locked). + try + { + File.Delete(tmpPath); + } + catch + { + // best effort + } + + throw; + } + + return targetPath; + } + + private static string Fmt(double value) + { + return value.ToString("F2", CultureInfo.InvariantCulture); + } +} diff --git a/HellionChat/SelfTests/PerformanceBaselineStep.cs b/HellionChat/SelfTests/PerformanceBaselineStep.cs index 3845b21..aae0744 100644 --- a/HellionChat/SelfTests/PerformanceBaselineStep.cs +++ b/HellionChat/SelfTests/PerformanceBaselineStep.cs @@ -1,19 +1,42 @@ -using System.Diagnostics; using Dalamud.Bindings.ImGui; using Dalamud.Plugin.SelfTest; namespace HellionChat.SelfTests; -// Optional metric capture. Walks one frame's ImGui IO counters and -// prints a single JSON block so the cycle-notes author can copy/paste -// the snapshot without standing up a separate profiling harness. -// Investigations themselves are deferred to the polish cycle — this -// step only records, it never fails on threshold. +// Optional metric capture. Accumulates 1000 steady-state frames of ImGui IO +// counters plus the plugin's full-Draw wall-time (Plugin.LastDrawMs, B5-1), +// then writes a single perf-baseline.json into the plugin ConfigDirectory so +// the cycle-notes author can copy the §7.5 figures without a separate +// profiling harness. The step only records — it never fails on a threshold +// (the budgets are evaluated by a human against the JSON, §7.5 "optional, +// manual"). It returns Waiting until the sample window fills, mirroring the +// per-frame poll idiom of ThemeSwitchSelfTestStep. internal sealed class PerformanceBaselineStep : ISelfTestStep { + // §7.5 steady-state window. 1000 frames ≈ 16s at 60fps, long enough to + // average out GC blips without making the manual step tedious. + private const int TargetFrames = 1000; + + // Quad proxy: ImGui emits 4 vertices + 6 indices per quad, so vertices/6 + // approximates the draw-quad count. NOT the real ImDrawData command count — + // the <500/frame budget is checked approximately (API-3). + private const int VerticesPerQuadProxy = 6; + + private readonly Plugin _plugin; + + private int _frames; + private ulong _lastFrameCount; + private double _drawMsSum; + private double _drawMsMax; + private long _vertexSum; + private long _vertexMax; + private double _deltaMsSum; + private double _deltaMsMax; + private string? _logPath; + public PerformanceBaselineStep(Plugin plugin) { - _ = plugin; + _plugin = plugin; } public string Name => "Hellion Chat - Performance baseline capture"; @@ -21,25 +44,69 @@ internal sealed class PerformanceBaselineStep : ISelfTestStep public SelfTestStepResult RunStep() { var io = ImGui.GetIO(); - var stopwatch = Stopwatch.StartNew(); - // No actual probe — we just sample the counters that ImGui keeps - // updated each frame. Stopwatch is started so the JSON line - // includes a non-zero wall-time figure even when ImGui has not - // accumulated frame stats yet. - stopwatch.Stop(); - ImGui.Text( - "{ " - + $"\"renderVertices\": {io.MetricsRenderVertices}, " - + $"\"renderIndices\": {io.MetricsRenderIndices}, " - + $"\"renderWindows\": {io.MetricsRenderWindows}, " - + $"\"activeWindows\": {io.MetricsActiveWindows}, " - + $"\"deltaTimeMs\": {io.DeltaTime * 1000f:F2}, " - + $"\"sampleWallTimeMs\": {stopwatch.Elapsed.TotalMilliseconds:F2}" - + " }" - ); + // Count each real frame once. Without the FrameCount gate a step that + // is polled more than once per frame would inflate the sample count. + var frameCount = Plugin.Interface.UiBuilder.FrameCount; + if (frameCount != _lastFrameCount) + { + _lastFrameCount = frameCount; + _frames++; + + var drawMs = _plugin.LastDrawMs; + _drawMsSum += drawMs; + if (drawMs > _drawMsMax) + _drawMsMax = drawMs; + + long vertices = io.MetricsRenderVertices; + _vertexSum += vertices; + if (vertices > _vertexMax) + _vertexMax = vertices; + + var deltaMs = io.DeltaTime * 1000f; + _deltaMsSum += deltaMs; + if (deltaMs > _deltaMsMax) + _deltaMsMax = deltaMs; + } + + if (_frames < TargetFrames) + { + ImGui.Text( + $"Sampling steady-state… {_frames}/{TargetFrames} frames. " + + "Keep the chat window visible and idle." + ); + return SelfTestStepResult.Waiting; + } + + _logPath ??= WriteBaselineLog(); + ImGui.Text($"Baseline captured ({TargetFrames} frames). Wrote: {_logPath}"); return SelfTestStepResult.Pass; } - public void CleanUp() { } + public void CleanUp() + { + _frames = 0; + _lastFrameCount = 0; + _drawMsSum = 0; + _drawMsMax = 0; + _vertexSum = 0; + _vertexMax = 0; + _deltaMsSum = 0; + _deltaMsMax = 0; + _logPath = null; + } + + private string WriteBaselineLog() + { + // Disk write happens here, never in the per-frame hot path. + return PerformanceBaselineLog.Write( + avgDrawMs: _drawMsSum / TargetFrames, + maxDrawMs: _drawMsMax, + avgDrawCallsProxy: _vertexSum / (double)TargetFrames / VerticesPerQuadProxy, + maxDrawCallsProxy: _vertexMax / (double)VerticesPerQuadProxy, + avgDeltaMs: _deltaMsSum / TargetFrames, + maxDeltaMs: _deltaMsMax, + frames: TargetFrames + ); + } }