Enhance Profiler with Label Metrics and Rolling Windows

- Introduced LabelMetrics and RollingLabelMetrics structs to capture detailed profiling data.
- Implemented a RollingWindow class to maintain a rolling average of metrics.
- Updated Frame class to collect and store label metrics, including memory allocation and garbage collection statistics.
- Enhanced ScopeNode class to support self-time calculations and child duration tracking.
- Improved the Profiler class to manage frame budgets and rolling metrics.
- Refactored the ProfilerVisualizer to support new timeline rendering features, including zoom and pan functionality.
- Added tooltip support for detailed node information in the flame graph.
- Enhanced BoundedQueue to be thread-safe with proper locking mechanisms for concurrent access.
This commit is contained in:
max
2026-08-04 17:19:00 +02:00
parent 5eaf3547dc
commit 2d4139fb2c
4 changed files with 1021 additions and 234 deletions
+104 -60
View File
@@ -1,6 +1,7 @@
using ImGuiNET;
using MoonTools.ECS;
using Nerfed.Runtime;
using System.Numerics;
namespace Nerfed.Editor.Systems
{
@@ -12,7 +13,9 @@ namespace Nerfed.Editor.Systems
private int selectedFrame = 0;
private int previousSelectedFrame = -1;
private IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> orderedCombinedData = null;
private IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)>> orderedCombinedData = null;
private readonly ProfilerVisualizer.TimelineState timelineState = new ProfilerVisualizer.TimelineState();
private readonly List<Profiler.Frame> frameSnapshot = new List<Profiler.Frame>(256);
public EditorProfilerWindow(World world) : base(world)
{
@@ -25,49 +28,88 @@ namespace Nerfed.Editor.Systems
return;
}
if (Profiler.CopyFramesTo(frameSnapshot) <= 0)
{
return;
}
selectedFrame = Math.Clamp(selectedFrame, 0, frameSnapshot.Count - 1);
timelineState.SelectedFrameIndex = Math.Clamp(timelineState.SelectedFrameIndex, -1, frameSnapshot.Count - 1);
timelineState.VisibleFrameCount = Math.Clamp(timelineState.VisibleFrameCount, 1, frameSnapshot.Count);
ImGui.Begin("Profiler");
ImGui.BeginChild("Toolbar", new System.Numerics.Vector2(0, 0), ImGuiChildFlags.AutoResizeY);
ImGui.BeginChild("Toolbar", new Vector2(0, 0), ImGuiChildFlags.AutoResizeY);
if (ImGui.RadioButton("Recording", Profiler.IsRecording))
{
Profiler.SetActive(!Profiler.IsRecording);
}
ImGui.SameLine();
ImGui.Text("Follow");
ImGui.SameLine();
ImGui.Checkbox("##follow-timeline", ref timelineState.FollowLatest);
ImGui.SameLine();
int visibleFrameCount = timelineState.VisibleFrameCount;
ImGui.SetNextItemWidth(130f);
if (ImGui.SliderInt("Window", ref visibleFrameCount, 1, frameSnapshot.Count))
{
timelineState.VisibleFrameCount = visibleFrameCount;
timelineState.FollowLatest = false;
}
ImGui.SameLine();
if (ImGui.Button("Reset Zoom"))
{
timelineState.Zoom = 1f;
timelineState.PanTicks = 0d;
timelineState.FollowLatest = true;
}
if (Profiler.IsRecording)
{
// Select last frame when recording to see latest frame data.
selectedFrame = Profiler.Frames.Count - 1;
}
if (ImGui.SliderInt(string.Empty, ref selectedFrame, 0, Profiler.Frames.Count - 1))
{
// Stop recording when browsing frames.
Profiler.SetActive(false);
selectedFrame = frameSnapshot.Count - 1;
}
Profiler.Frame frame = Profiler.Frames.ElementAt(selectedFrame);
int sliderFrame = selectedFrame;
if (ImGui.SliderInt("Frame", ref sliderFrame, 0, frameSnapshot.Count - 1))
{
selectedFrame = sliderFrame;
timelineState.SelectedFrameIndex = selectedFrame;
timelineState.FollowLatest = false;
}
Profiler.Frame frame = frameSnapshot[selectedFrame];
double ms = frame.ElapsedMilliseconds();
double s = 1000;
ImGui.Text($"Frame: {frame.FrameCount} ({ms:0.000} ms | {(s / ms):0} fps)");
ImGui.Text($"Budget: {frame.BudgetMilliseconds:0.00} ms ({(frame.OverBudget ? "over" : "within")})");
ImGui.Text($"Alloc: {frame.AllocatedBytesDelta / 1024d:0.0} KB | GC: G0 {frame.Gen0CollectionsDelta}, G1 {frame.Gen1CollectionsDelta}, G2 {frame.Gen2CollectionsDelta}");
ImGui.EndChild();
if (!Profiler.IsRecording) {
if (previousSelectedFrame != selectedFrame)
{
previousSelectedFrame = selectedFrame;
orderedCombinedData = CalculateCombinedData(frame);
}
DrawFlameGraph(frame);
DrawHierachy(frame);
ImGui.SameLine();
DrawCombined(orderedCombinedData);
ProfilerVisualizer.TimelineRenderResult timelineResult = DrawFlameGraph(frameSnapshot, timelineState);
if (timelineResult.SelectionChanged)
{
selectedFrame = timelineResult.SelectedFrameIndex;
}
selectedFrame = Math.Clamp(selectedFrame, 0, frameSnapshot.Count - 1);
frame = frameSnapshot[selectedFrame];
if (previousSelectedFrame != selectedFrame)
{
previousSelectedFrame = selectedFrame;
orderedCombinedData = CalculateCombinedData(frame);
}
DrawHierachy(frame);
ImGui.SameLine();
DrawCombined(orderedCombinedData);
ImGui.End();
}
@@ -78,13 +120,14 @@ namespace Nerfed.Editor.Systems
return;
}
ImGui.BeginChild("Hierachy", new System.Numerics.Vector2(150, 0), ImGuiChildFlags.ResizeX);
ImGui.BeginChild("Hierachy", new Vector2(150, 0), ImGuiChildFlags.ResizeX);
if (ImGui.BeginTable("ProfilerData", 3, tableFlags, new System.Numerics.Vector2(0, 0)))
if (ImGui.BeginTable("ProfilerData", 4, tableFlags, new Vector2(0, 0)))
{
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.8f, 0);
ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.6f, 0);
ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.15f, 1);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.15f, 1);
ImGui.TableSetupColumn("self", ImGuiTableColumnFlags.WidthStretch, 0.15f, 2);
ImGui.TableSetupScrollFreeze(0, 1); // Make row always visible
ImGui.TableHeadersRow();
@@ -119,6 +162,8 @@ namespace Nerfed.Editor.Systems
ImGui.Text($"{node.ManagedThreadId}");
ImGui.TableNextColumn();
ImGui.Text($"{node.ElapsedMilliseconds():0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{node.SelfMilliseconds():0.000}");
if (isOpen)
{
@@ -130,24 +175,27 @@ namespace Nerfed.Editor.Systems
}
}
private static void DrawCombined(in IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> orderedCombinedData)
private static void DrawCombined(in IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)>> orderedCombinedData)
{
if(orderedCombinedData == null)
{
return;
}
ImGui.BeginChild("Combined", new System.Numerics.Vector2(0, 0));
ImGui.BeginChild("Combined", new Vector2(0, 0));
if (ImGui.BeginTable("ProfilerCombinedData", 3, tableFlags, new System.Numerics.Vector2(0, 0)))
if (ImGui.BeginTable("ProfilerCombinedData", 6, tableFlags, new Vector2(0, 0)))
{
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.6f, 0);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("calls", ImGuiTableColumnFlags.WidthStretch, 0.2f, 2);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.45f, 0);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.14f, 1);
ImGui.TableSetupColumn("self", ImGuiTableColumnFlags.WidthStretch, 0.14f, 2);
ImGui.TableSetupColumn("calls", ImGuiTableColumnFlags.WidthStretch, 0.10f, 3);
ImGui.TableSetupColumn("avg", ImGuiTableColumnFlags.WidthStretch, 0.10f, 4);
ImGui.TableSetupColumn("p95", ImGuiTableColumnFlags.WidthStretch, 0.10f, 5);
ImGui.TableSetupScrollFreeze(0, 1); // Make row always visible
ImGui.TableHeadersRow();
foreach (KeyValuePair<string, (double ms, uint calls)> combinedData in orderedCombinedData)
foreach (KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)> combinedData in orderedCombinedData)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
@@ -155,7 +203,13 @@ namespace Nerfed.Editor.Systems
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.ms:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.selfMs:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.calls}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.avgMs:0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.p95Ms:0.000}");
}
ImGui.EndTable();
@@ -164,41 +218,31 @@ namespace Nerfed.Editor.Systems
ImGui.EndChild();
}
private static IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> CalculateCombinedData(Profiler.Frame frame)
private static IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)>> CalculateCombinedData(Profiler.Frame frame)
{
Dictionary<string, (double ms, uint calls)> combinedRecordData = new Dictionary<string, (double ms, uint calls)>(128);
foreach (Profiler.ScopeNode node in frame.RootNodes)
IReadOnlyDictionary<string, Profiler.RollingLabelMetrics> rollingData = Profiler.GetRollingLabelMetricsSnapshot();
Dictionary<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)> combinedRecordData = new Dictionary<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)>(128);
foreach (KeyValuePair<string, Profiler.LabelMetrics> metric in frame.LabelMetrics)
{
CalculateCombinedData(node, in combinedRecordData);
Profiler.RollingLabelMetrics rolling = default;
if (rollingData.TryGetValue(metric.Key, out Profiler.RollingLabelMetrics found))
{
rolling = found;
}
combinedRecordData[metric.Key] = (metric.Value.InclusiveMs, metric.Value.SelfMs, metric.Value.Calls, rolling.AverageMs, rolling.P95Ms);
}
return combinedRecordData.OrderByDescending(x => x.Value.ms);
}
private static void CalculateCombinedData(Profiler.ScopeNode node, in Dictionary<string, (double ms, uint calls)> combinedRecordData)
private static ProfilerVisualizer.TimelineRenderResult DrawFlameGraph(IReadOnlyList<Profiler.Frame> frames, ProfilerVisualizer.TimelineState timelineState)
{
if (combinedRecordData.TryGetValue(node.Label, out (double ms, uint calls) combined))
if (frames == null || frames.Count == 0)
{
combinedRecordData[node.Label] = (combined.ms + node.ElapsedMilliseconds(), combined.calls + 1);
}
else
{
combinedRecordData.Add(node.Label, (node.ElapsedMilliseconds(), 1));
return default;
}
for (int i = 0; i < node.Children.Count; i++)
{
CalculateCombinedData(node.Children[i], combinedRecordData);
}
}
private static void DrawFlameGraph(Profiler.Frame frame)
{
if (frame == null)
{
return;
}
ProfilerVisualizer.RenderFlameGraph(frame);
return ProfilerVisualizer.RenderTimeline(frames, timelineState);
}
}
}