From 83f77d1ebe2c3cece5ced400660a7b95cff50344 Mon Sep 17 00:00:00 2001 From: max Date: Wed, 5 Aug 2026 11:47:21 +0200 Subject: [PATCH] Add thread metrics and category support to Profiler and visualizer --- Nerfed.Editor/Systems/EditorProfilerWindow.cs | 84 +++++- Nerfed.Runtime/Profiler.cs | 282 +++++++++++++++++- Nerfed.Runtime/ProfilerVisualizer.cs | 2 + 3 files changed, 350 insertions(+), 18 deletions(-) diff --git a/Nerfed.Editor/Systems/EditorProfilerWindow.cs b/Nerfed.Editor/Systems/EditorProfilerWindow.cs index 486e01c..55af743 100644 --- a/Nerfed.Editor/Systems/EditorProfilerWindow.cs +++ b/Nerfed.Editor/Systems/EditorProfilerWindow.cs @@ -14,6 +14,7 @@ namespace Nerfed.Editor.Systems private int selectedFrame = 0; private int previousSelectedFrame = -1; private IOrderedEnumerable> orderedCombinedData = null; + private IOrderedEnumerable> orderedThreadRollingData = null; private readonly ProfilerVisualizer.TimelineState timelineState = new ProfilerVisualizer.TimelineState(); private readonly List frameSnapshot = new List(256); @@ -67,6 +68,22 @@ namespace Nerfed.Editor.Systems timelineState.FollowLatest = true; } + ImGui.SameLine(); + int mode = (int)Profiler.Mode; + ImGui.SetNextItemWidth(130f); + if (ImGui.Combo("Mode", ref mode, "Instrumented\0Sampled\0")) + { + Profiler.Mode = (Profiler.CaptureMode)mode; + } + + ImGui.SameLine(); + int stride = Profiler.SamplingStride; + ImGui.SetNextItemWidth(90f); + if (ImGui.SliderInt("Stride", ref stride, 1, 64)) + { + Profiler.SamplingStride = stride; + } + if (Profiler.IsRecording) { // Select last frame when recording to see latest frame data. @@ -86,6 +103,7 @@ namespace Nerfed.Editor.Systems 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($"Thread Budget: {Profiler.ThreadBudgetMilliseconds:0.00} ms | Capture: {Profiler.Mode}"); ImGui.Text($"Alloc: {frame.AllocatedBytesDelta / 1024d:0.0} KB | GC: G0 {frame.Gen0CollectionsDelta}, G1 {frame.Gen1CollectionsDelta}, G2 {frame.Gen2CollectionsDelta}"); ImGui.EndChild(); @@ -102,8 +120,11 @@ namespace Nerfed.Editor.Systems { previousSelectedFrame = selectedFrame; orderedCombinedData = CalculateCombinedData(frame); + orderedThreadRollingData = CalculateThreadRollingData(); } + DrawThreadRolling(orderedThreadRollingData); + DrawHierachy(frame); ImGui.SameLine(); @@ -122,12 +143,14 @@ namespace Nerfed.Editor.Systems ImGui.BeginChild("Hierachy", new Vector2(150, 0), ImGuiChildFlags.ResizeX); - if (ImGui.BeginTable("ProfilerData", 4, tableFlags, new Vector2(0, 0))) + if (ImGui.BeginTable("ProfilerData", 6, 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.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.40f, 0); + ImGui.TableSetupColumn("category", ImGuiTableColumnFlags.WidthStretch, 0.15f, 1); + ImGui.TableSetupColumn("tags", ImGuiTableColumnFlags.WidthStretch, 0.10f, 2); + ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.10f, 3); + ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.10f, 4); + ImGui.TableSetupColumn("self", ImGuiTableColumnFlags.WidthStretch, 0.10f, 5); ImGui.TableSetupScrollFreeze(0, 1); // Make row always visible ImGui.TableHeadersRow(); @@ -158,6 +181,10 @@ namespace Nerfed.Editor.Systems isOpen = ImGui.TreeNodeEx(node.Label, treeNodeFlags); } + ImGui.TableNextColumn(); + ImGui.Text($"{node.Category}"); + ImGui.TableNextColumn(); + ImGui.Text($"0x{node.TagMask:X}"); ImGui.TableNextColumn(); ImGui.Text($"{node.ManagedThreadId}"); ImGui.TableNextColumn(); @@ -235,6 +262,53 @@ namespace Nerfed.Editor.Systems return combinedRecordData.OrderByDescending(x => x.Value.ms); } + private static IOrderedEnumerable> CalculateThreadRollingData() + { + IReadOnlyDictionary rollingData = Profiler.GetRollingThreadMetricsSnapshot(); + return rollingData.OrderByDescending(x => x.Value.P95Ms); + } + + private static void DrawThreadRolling(in IOrderedEnumerable> orderedThreadRollingData) + { + if (orderedThreadRollingData == null) + { + return; + } + + ImGui.BeginChild("ThreadRolling", new Vector2(0, 140), ImGuiChildFlags.Border); + if (ImGui.BeginTable("ProfilerThreadRollingData", 6, tableFlags, new Vector2(0, 0))) + { + ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.15f, 0); + ImGui.TableSetupColumn("avg", ImGuiTableColumnFlags.WidthStretch, 0.20f, 1); + ImGui.TableSetupColumn("p95", ImGuiTableColumnFlags.WidthStretch, 0.20f, 2); + ImGui.TableSetupColumn("max", ImGuiTableColumnFlags.WidthStretch, 0.20f, 3); + ImGui.TableSetupColumn("samples", ImGuiTableColumnFlags.WidthStretch, 0.15f, 4); + ImGui.TableSetupColumn("misses", ImGuiTableColumnFlags.WidthStretch, 0.15f, 5); + ImGui.TableHeadersRow(); + + foreach (KeyValuePair metric in orderedThreadRollingData) + { + ImGui.TableNextRow(); + ImGui.TableNextColumn(); + ImGui.Text($"T{metric.Key}"); + ImGui.TableNextColumn(); + ImGui.Text($"{metric.Value.AverageMs:0.000}"); + ImGui.TableNextColumn(); + ImGui.Text($"{metric.Value.P95Ms:0.000}"); + ImGui.TableNextColumn(); + ImGui.Text($"{metric.Value.MaxMs:0.000}"); + ImGui.TableNextColumn(); + ImGui.Text($"{metric.Value.Samples}"); + ImGui.TableNextColumn(); + ImGui.Text($"{metric.Value.BudgetMisses}"); + } + + ImGui.EndTable(); + } + + ImGui.EndChild(); + } + private static ProfilerVisualizer.TimelineRenderResult DrawFlameGraph(IReadOnlyList frames, ProfilerVisualizer.TimelineState timelineState) { if (frames == null || frames.Count == 0) diff --git a/Nerfed.Runtime/Profiler.cs b/Nerfed.Runtime/Profiler.cs index f5d79f5..e0f7394 100644 --- a/Nerfed.Runtime/Profiler.cs +++ b/Nerfed.Runtime/Profiler.cs @@ -10,6 +10,11 @@ public struct ProfilerScope : IDisposable Profiler.BeginSample(label); } + public ProfilerScope(string label, string category, ulong tagMask = 0) + { + Profiler.BeginSample(label, category, tagMask); + } + public void Dispose() { Profiler.EndSample(); @@ -18,6 +23,20 @@ public struct ProfilerScope : IDisposable public static class Profiler { + public enum CaptureMode + { + Instrumented = 0, + SampledInstrumentation = 1, + } + + private sealed class ThreadProfilerState + { + public readonly Stack Scopes = new Stack(); + public readonly Stack CaptureDecisions = new Stack(); + public int SampleCursor; + public int ThreadId; + } + public readonly struct LabelMetrics { public LabelMetrics(double inclusiveMs, double selfMs, uint calls, double minInclusiveMs, double maxInclusiveMs) @@ -54,6 +73,40 @@ public static class Profiler public int Samples { get; } } + public readonly struct ThreadMetrics + { + public ThreadMetrics(double inclusiveMs, double selfMs, uint calls, bool overBudget) + { + InclusiveMs = inclusiveMs; + SelfMs = selfMs; + Calls = calls; + OverBudget = overBudget; + } + + public double InclusiveMs { get; } + public double SelfMs { get; } + public uint Calls { get; } + public bool OverBudget { get; } + } + + public readonly struct RollingThreadMetrics + { + public RollingThreadMetrics(double averageMs, double p95Ms, double maxMs, int samples, int budgetMisses) + { + AverageMs = averageMs; + P95Ms = p95Ms; + MaxMs = maxMs; + Samples = samples; + BudgetMisses = budgetMisses; + } + + public double AverageMs { get; } + public double P95Ms { get; } + public double MaxMs { get; } + public int Samples { get; } + public int BudgetMisses { get; } + } + private sealed class RollingWindow { private readonly double[] values; @@ -104,6 +157,61 @@ public static class Profiler } } + private sealed class RollingThreadWindow + { + private readonly double[] durations; + private readonly byte[] misses; + private int index; + private int count; + + public RollingThreadWindow(int capacity) + { + int size = Math.Max(8, capacity); + durations = new double[size]; + misses = new byte[size]; + } + + public void Add(double durationMs, bool budgetMiss) + { + durations[index] = durationMs; + misses[index] = budgetMiss ? (byte)1 : (byte)0; + index = (index + 1) % durations.Length; + if (count < durations.Length) + { + count++; + } + } + + public RollingThreadMetrics Snapshot() + { + if (count == 0) + { + return default; + } + + double sum = 0; + double max = double.MinValue; + int budgetMisses = 0; + double[] sorted = new double[count]; + + int start = (index - count + durations.Length) % durations.Length; + for (int i = 0; i < count; i++) + { + int at = (start + i) % durations.Length; + double value = durations[at]; + sum += value; + max = Math.Max(max, value); + budgetMisses += misses[at]; + sorted[i] = value; + } + + Array.Sort(sorted); + int percentileIndex = (int)Math.Ceiling((count - 1) * 0.95d); + double p95 = sorted[Math.Clamp(percentileIndex, 0, count - 1)]; + return new RollingThreadMetrics(sum / count, p95, max, count, budgetMisses); + } + } + public class Frame(uint frameCount) { public uint FrameCount { get; } = frameCount; @@ -113,10 +221,14 @@ public static class Profiler private readonly List rootNodes = new List(8); private readonly object rootNodesLock = new object(); private readonly Dictionary labelMetrics = new Dictionary(128, StringComparer.Ordinal); + private readonly Dictionary categoryMetrics = new Dictionary(32, StringComparer.Ordinal); + private readonly Dictionary threadMetrics = new Dictionary(16); public IReadOnlyList RootNodes => rootNodes; public IReadOnlyDictionary LabelMetrics => labelMetrics; + public IReadOnlyDictionary CategoryMetrics => categoryMetrics; + public IReadOnlyDictionary ThreadMetrics => threadMetrics; public long AllocatedBytesStart { get; } = GC.GetTotalAllocatedBytes(false); public long AllocatedBytesEnd { get; private set; } public long AllocatedBytesDelta { get; private set; } @@ -168,13 +280,25 @@ public static class Profiler private void BuildLabelMetrics() { labelMetrics.Clear(); + categoryMetrics.Clear(); + threadMetrics.Clear(); lock (rootNodesLock) { for (int i = 0; i < rootNodes.Count; i++) { AccumulateLabelMetrics(rootNodes[i]); } + + foreach (ScopeNode rootNode in rootNodes) + { + for (int i = 0; i < rootNode.Children.Count; i++) + { + AccumulateThreadMetrics(rootNode.ManagedThreadId, rootNode.Children[i]); + } + } } + + ApplyThreadBudgetFlags(); } private void AccumulateLabelMetrics(ScopeNode node) @@ -196,16 +320,69 @@ public static class Profiler labelMetrics[node.Label] = new LabelMetrics(inclusiveMs, selfMs, 1, inclusiveMs, inclusiveMs); } + if (categoryMetrics.TryGetValue(node.Category, out LabelMetrics categoryCurrent)) + { + categoryMetrics[node.Category] = new LabelMetrics( + categoryCurrent.InclusiveMs + inclusiveMs, + categoryCurrent.SelfMs + selfMs, + categoryCurrent.Calls + 1, + Math.Min(categoryCurrent.MinInclusiveMs, inclusiveMs), + Math.Max(categoryCurrent.MaxInclusiveMs, inclusiveMs)); + } + else + { + categoryMetrics[node.Category] = new LabelMetrics(inclusiveMs, selfMs, 1, inclusiveMs, inclusiveMs); + } + for (int i = 0; i < node.Children.Count; i++) { AccumulateLabelMetrics(node.Children[i]); } } + + private void AccumulateThreadMetrics(int threadId, ScopeNode node) + { + double inclusiveMs = node.ElapsedMilliseconds(); + double selfMs = node.SelfMilliseconds(); + + if (threadMetrics.TryGetValue(threadId, out ThreadMetrics current)) + { + threadMetrics[threadId] = new ThreadMetrics(current.InclusiveMs + inclusiveMs, current.SelfMs + selfMs, current.Calls + 1, false); + } + else + { + threadMetrics[threadId] = new ThreadMetrics(inclusiveMs, selfMs, 1, false); + } + + for (int i = 0; i < node.Children.Count; i++) + { + AccumulateThreadMetrics(threadId, node.Children[i]); + } + } + + private void ApplyThreadBudgetFlags() + { + double perThreadBudget = Math.Max(0d, ThreadBudgetMilliseconds); + if (perThreadBudget <= 0d) + { + return; + } + + int[] keys = threadMetrics.Keys.ToArray(); + for (int i = 0; i < keys.Length; i++) + { + int key = keys[i]; + ThreadMetrics metric = threadMetrics[key]; + threadMetrics[key] = new ThreadMetrics(metric.InclusiveMs, metric.SelfMs, metric.Calls, metric.InclusiveMs > perThreadBudget); + } + } } public class ScopeNode { public string Label { get; private set; } = string.Empty; + public string Category { get; private set; } = DefaultCategory; + public ulong TagMask { get; private set; } public long StartTime { get; private set; } public long EndTime { get; private set; } public int ManagedThreadId { get; private set; } @@ -213,9 +390,11 @@ public static class Profiler internal ScopeNode Parent { get; private set; } internal long ChildrenDurationTicks { get; private set; } - internal void Reset(string label, int managedThreadId, ScopeNode parent) + internal void Reset(string label, string category, ulong tagMask, int managedThreadId, ScopeNode parent) { Label = label; + Category = string.IsNullOrWhiteSpace(category) ? DefaultCategory : category; + TagMask = tagMask; ManagedThreadId = managedThreadId; Parent = parent; StartTime = Stopwatch.GetTimestamp(); @@ -251,9 +430,9 @@ public static class Profiler } // Add a child node (used for nested scopes) - internal ScopeNode AddChild(string label) + internal ScopeNode AddChild(string label, string category, ulong tagMask) { - ScopeNode child = RentNode(label, ManagedThreadId, this); + ScopeNode child = RentNode(label, category, tagMask, ManagedThreadId, this); Children.Add(child); return child; } @@ -261,18 +440,23 @@ public static class Profiler private const int maxFrames = 128; private const int rollingWindowSize = 240; + private const string DefaultCategory = "General"; public static bool IsRecording { get; private set; } = true; public static double FrameBudgetMilliseconds { get; set; } = 16.667; + public static double ThreadBudgetMilliseconds { get; set; } = 8.333; + public static CaptureMode Mode { get; set; } = CaptureMode.Instrumented; + public static int SamplingStride { get; set; } = 8; // Store only the last x amount of frames in memory. public static readonly BoundedQueue Frames = new(maxFrames); // Use ThreadLocal to store a stack of ScopeNodes per thread and enable tracking of thread-local values. - private static readonly ThreadLocal> threadLocalScopes = new ThreadLocal>(() => new Stack(), true); + private static readonly ThreadLocal threadStates = new ThreadLocal(() => new ThreadProfilerState(), true); private static readonly ConcurrentDictionary threadRootLabelCache = new ConcurrentDictionary(); private static readonly ConcurrentBag nodePool = new ConcurrentBag(); private static readonly Dictionary rollingWindows = new Dictionary(256, StringComparer.Ordinal); + private static readonly Dictionary rollingThreadWindows = new Dictionary(16); private static readonly object rollingWindowsLock = new object(); private static Frame currentFrame = null; @@ -313,6 +497,20 @@ public static class Profiler } } + public static IReadOnlyDictionary GetRollingThreadMetricsSnapshot() + { + lock (rollingWindowsLock) + { + Dictionary snapshot = new Dictionary(rollingThreadWindows.Count); + foreach (KeyValuePair pair in rollingThreadWindows) + { + snapshot[pair.Key] = pair.Value.Snapshot(); + } + + return snapshot; + } + } + [Conditional("PROFILING")] public static void BeginFrame() { @@ -342,30 +540,49 @@ public static class Profiler [Conditional("PROFILING")] public static void BeginSample(string label) + { + BeginSample(label, DefaultCategory, 0); + } + + [Conditional("PROFILING")] + public static void BeginSample(string label, string category, ulong tagMask = 0) { if (!IsRecording || currentFrame == null) { return; } - Stack scopes = threadLocalScopes.Value; // Get the stack for the current thread + ThreadProfilerState state = threadStates.Value; + state.ThreadId = Environment.CurrentManagedThreadId; + + bool parentCaptured = state.CaptureDecisions.Count > 0 && state.CaptureDecisions.Peek(); + bool capture = parentCaptured || Mode == CaptureMode.Instrumented || ShouldSample(state); + state.CaptureDecisions.Push(capture); + + if (!capture) + { + return; + } + + Stack scopes = state.Scopes; Frame frame = currentFrame; if (frame == null) { + state.CaptureDecisions.Pop(); return; } if (scopes.Count == 0) { // First scope for this thread (new root for this thread) - int threadId = Environment.CurrentManagedThreadId; - ScopeNode rootScopeNode = RentNode(GetThreadRootLabel(threadId), threadId, null); + int threadId = state.ThreadId; + ScopeNode rootScopeNode = RentNode(GetThreadRootLabel(threadId), DefaultCategory, 0, threadId, null); scopes.Push(rootScopeNode); frame.AddRootNode(rootScopeNode); } // Create a new child under the current top of the stack - ScopeNode newScope = scopes.Peek().AddChild(label); + ScopeNode newScope = scopes.Peek().AddChild(label, category, tagMask); scopes.Push(newScope); // Push new scope to the thread's stack } @@ -378,7 +595,19 @@ public static class Profiler return; } - Stack scopes = threadLocalScopes.Value; + ThreadProfilerState state = threadStates.Value; + if (state.CaptureDecisions.Count == 0) + { + return; + } + + bool captured = state.CaptureDecisions.Pop(); + if (!captured) + { + return; + } + + Stack scopes = state.Scopes; if (scopes.Count > 1) { @@ -387,19 +616,26 @@ public static class Profiler } } + private static bool ShouldSample(ThreadProfilerState state) + { + int stride = Math.Max(1, SamplingStride); + state.SampleCursor++; + return state.SampleCursor % stride == 0; + } + private static string GetThreadRootLabel(int threadId) { return threadRootLabelCache.GetOrAdd(threadId, static id => $"Thread-{id}"); } - private static ScopeNode RentNode(string label, int managedThreadId, ScopeNode parent) + private static ScopeNode RentNode(string label, string category, ulong tagMask, int managedThreadId, ScopeNode parent) { if (!nodePool.TryTake(out ScopeNode node)) { node = new ScopeNode(); } - node.Reset(label, managedThreadId, parent); + node.Reset(label, category, tagMask, managedThreadId, parent); return node; } @@ -410,7 +646,7 @@ public static class Profiler ReturnNodeTree(node.Children[i]); } - node.Reset(string.Empty, 0, null); + node.Reset(string.Empty, DefaultCategory, 0, 0, null); nodePool.Add(node); } @@ -422,8 +658,9 @@ public static class Profiler return; } - foreach (Stack scopes in threadLocalScopes.Values) + foreach (ThreadProfilerState state in threadStates.Values) { + Stack scopes = state.Scopes; while (scopes.Count > 0) { ScopeNode currentScope = scopes.Pop(); @@ -431,6 +668,7 @@ public static class Profiler } scopes.Clear(); + state.CaptureDecisions.Clear(); } frame.End(FrameBudgetMilliseconds); @@ -444,6 +682,7 @@ public static class Profiler } UpdateRollingWindows(frame); + UpdateRollingThreadWindows(frame); frameCount++; currentFrame = null; } @@ -464,4 +703,21 @@ public static class Profiler } } } + + private static void UpdateRollingThreadWindows(Frame frame) + { + lock (rollingWindowsLock) + { + foreach (KeyValuePair pair in frame.ThreadMetrics) + { + if (!rollingThreadWindows.TryGetValue(pair.Key, out RollingThreadWindow window)) + { + window = new RollingThreadWindow(rollingWindowSize); + rollingThreadWindows.Add(pair.Key, window); + } + + window.Add(pair.Value.InclusiveMs, pair.Value.OverBudget); + } + } + } } \ No newline at end of file diff --git a/Nerfed.Runtime/ProfilerVisualizer.cs b/Nerfed.Runtime/ProfilerVisualizer.cs index 69e7710..9ce3275 100644 --- a/Nerfed.Runtime/ProfilerVisualizer.cs +++ b/Nerfed.Runtime/ProfilerVisualizer.cs @@ -352,6 +352,8 @@ public static class ProfilerVisualizer ImGui.BeginTooltip(); ImGui.Text($"{hover.Node.Label}"); ImGui.Separator(); + ImGui.Text($"Category: {hover.Node.Category}"); + ImGui.Text($"Tags: 0x{hover.Node.TagMask:X}"); ImGui.Text($"Frame: {hover.Frame.FrameCount} (idx {hover.FrameIndex})"); ImGui.Text($"Thread: {hover.Node.ManagedThreadId}"); ImGui.Text($"Depth: {hover.Depth}");