perf(baseline): 1000-frame steady-state capture with quad-proxy draw calls + JSON sink
This commit is contained in:
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user