- 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.
249 lines
10 KiB
C#
249 lines
10 KiB
C#
using ImGuiNET;
|
|
using MoonTools.ECS;
|
|
using Nerfed.Runtime;
|
|
using System.Numerics;
|
|
|
|
namespace Nerfed.Editor.Systems
|
|
{
|
|
internal class EditorProfilerWindow : MoonTools.ECS.System
|
|
{
|
|
const ImGuiTableFlags tableFlags = ImGuiTableFlags.Resizable | ImGuiTableFlags.BordersOuter | ImGuiTableFlags.NoBordersInBody | ImGuiTableFlags.ScrollY | ImGuiTableFlags.ScrollX;
|
|
const ImGuiTreeNodeFlags treeNodeFlags = ImGuiTreeNodeFlags.SpanAllColumns;
|
|
const ImGuiTreeNodeFlags treeNodeLeafFlags = ImGuiTreeNodeFlags.SpanAllColumns | ImGuiTreeNodeFlags.Leaf | ImGuiTreeNodeFlags.NoTreePushOnOpen;
|
|
|
|
private int selectedFrame = 0;
|
|
private int previousSelectedFrame = -1;
|
|
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)
|
|
{
|
|
}
|
|
|
|
public override void Update(TimeSpan delta)
|
|
{
|
|
if (Profiler.Frames.Count <= 0)
|
|
{
|
|
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 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 = frameSnapshot.Count - 1;
|
|
}
|
|
|
|
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();
|
|
|
|
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();
|
|
}
|
|
|
|
private static void DrawHierachy(Profiler.Frame frame)
|
|
{
|
|
if(frame == null)
|
|
{
|
|
return;
|
|
}
|
|
|
|
ImGui.BeginChild("Hierachy", new Vector2(150, 0), ImGuiChildFlags.ResizeX);
|
|
|
|
if (ImGui.BeginTable("ProfilerData", 4, tableFlags, new Vector2(0, 0)))
|
|
{
|
|
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();
|
|
|
|
foreach (Profiler.ScopeNode node in frame.RootNodes)
|
|
{
|
|
DrawHierachyNode(node);
|
|
}
|
|
|
|
ImGui.EndTable();
|
|
}
|
|
|
|
ImGui.EndChild();
|
|
}
|
|
|
|
private static void DrawHierachyNode(Profiler.ScopeNode node)
|
|
{
|
|
ImGui.TableNextRow();
|
|
ImGui.TableNextColumn();
|
|
|
|
bool isOpen = false;
|
|
bool isLeaf = node.Children.Count == 0;
|
|
|
|
if (isLeaf) {
|
|
ImGui.TreeNodeEx(node.Label, treeNodeLeafFlags);
|
|
}
|
|
else
|
|
{
|
|
isOpen = ImGui.TreeNodeEx(node.Label, treeNodeFlags);
|
|
}
|
|
|
|
ImGui.TableNextColumn();
|
|
ImGui.Text($"{node.ManagedThreadId}");
|
|
ImGui.TableNextColumn();
|
|
ImGui.Text($"{node.ElapsedMilliseconds():0.000}");
|
|
ImGui.TableNextColumn();
|
|
ImGui.Text($"{node.SelfMilliseconds():0.000}");
|
|
|
|
if (isOpen)
|
|
{
|
|
for (int i = 0; i < node.Children.Count; i++)
|
|
{
|
|
DrawHierachyNode(node.Children[i]);
|
|
}
|
|
ImGui.TreePop();
|
|
}
|
|
}
|
|
|
|
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 Vector2(0, 0));
|
|
|
|
if (ImGui.BeginTable("ProfilerCombinedData", 6, tableFlags, new Vector2(0, 0)))
|
|
{
|
|
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, double selfMs, uint calls, double avgMs, double p95Ms)> combinedData in orderedCombinedData)
|
|
{
|
|
ImGui.TableNextRow();
|
|
ImGui.TableNextColumn();
|
|
ImGui.Text($"{combinedData.Key}");
|
|
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();
|
|
}
|
|
|
|
ImGui.EndChild();
|
|
}
|
|
|
|
private static IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms)>> CalculateCombinedData(Profiler.Frame frame)
|
|
{
|
|
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)
|
|
{
|
|
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 ProfilerVisualizer.TimelineRenderResult DrawFlameGraph(IReadOnlyList<Profiler.Frame> frames, ProfilerVisualizer.TimelineState timelineState)
|
|
{
|
|
if (frames == null || frames.Count == 0)
|
|
{
|
|
return default;
|
|
}
|
|
|
|
return ProfilerVisualizer.RenderTimeline(frames, timelineState);
|
|
}
|
|
}
|
|
}
|