perf(baseline): 1000-frame steady-state capture with quad-proxy draw calls + JSON sink

This commit is contained in:
2026-06-16 19:13:45 +02:00
parent 68c4e28495
commit 430c8f235a
2 changed files with 159 additions and 24 deletions
@@ -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);
}
}
@@ -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
);
}
}