From 7853a768dec810485d6a3189967311120da85201 Mon Sep 17 00:00:00 2001 From: max Date: Wed, 5 Aug 2026 12:15:21 +0200 Subject: [PATCH] Enhance Profiler with pre-allocated sort buffers and frame management improvements --- Nerfed.Runtime/Profiler.cs | 161 ++++++++++++++++++--------- Nerfed.Runtime/ProfilerVisualizer.cs | 16 ++- Nerfed.Runtime/Util/BoundedQueue.cs | 15 +++ 3 files changed, 135 insertions(+), 57 deletions(-) diff --git a/Nerfed.Runtime/Profiler.cs b/Nerfed.Runtime/Profiler.cs index e0f7394..319d2bd 100644 --- a/Nerfed.Runtime/Profiler.cs +++ b/Nerfed.Runtime/Profiler.cs @@ -110,12 +110,15 @@ public static class Profiler private sealed class RollingWindow { private readonly double[] values; + private readonly double[] sortBuffer; // pre-allocated; avoids per-snapshot heap allocation private int index; private int count; public RollingWindow(int capacity) { - values = new double[Math.Max(8, capacity)]; + int size = Math.Max(8, capacity); + values = new double[size]; + sortBuffer = new double[size]; } public void Add(double value) @@ -138,21 +141,20 @@ public static class Profiler double sum = 0; double min = double.MaxValue; double max = double.MinValue; - double[] sorted = new double[count]; int start = (index - count + values.Length) % values.Length; for (int i = 0; i < count; i++) { double value = values[(start + i) % values.Length]; - sorted[i] = value; + sortBuffer[i] = value; sum += value; min = Math.Min(min, value); max = Math.Max(max, value); } - Array.Sort(sorted); + Array.Sort(sortBuffer, 0, count); int percentileIndex = (int)Math.Ceiling((count - 1) * 0.95d); - double p95 = sorted[Math.Clamp(percentileIndex, 0, count - 1)]; + double p95 = sortBuffer[Math.Clamp(percentileIndex, 0, count - 1)]; return new RollingLabelMetrics(sum / count, min, max, p95, count); } } @@ -160,6 +162,7 @@ public static class Profiler private sealed class RollingThreadWindow { private readonly double[] durations; + private readonly double[] sortBuffer; // pre-allocated; avoids per-snapshot heap allocation private readonly byte[] misses; private int index; private int count; @@ -168,6 +171,7 @@ public static class Profiler { int size = Math.Max(8, capacity); durations = new double[size]; + sortBuffer = new double[size]; misses = new byte[size]; } @@ -192,7 +196,6 @@ public static class Profiler 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++) @@ -202,39 +205,44 @@ public static class Profiler sum += value; max = Math.Max(max, value); budgetMisses += misses[at]; - sorted[i] = value; + sortBuffer[i] = value; } - Array.Sort(sorted); + Array.Sort(sortBuffer, 0, count); int percentileIndex = (int)Math.Ceiling((count - 1) * 0.95d); - double p95 = sorted[Math.Clamp(percentileIndex, 0, count - 1)]; + double p95 = sortBuffer[Math.Clamp(percentileIndex, 0, count - 1)]; return new RollingThreadMetrics(sum / count, p95, max, count, budgetMisses); } } - public class Frame(uint frameCount) + public class Frame { - public uint FrameCount { get; } = frameCount; - public long StartTime { get; } = Stopwatch.GetTimestamp(); - public long EndTime { get; private set; } - 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); + private readonly List knownThreadIds = new List(16); // avoids Keys.ToArray() in ApplyThreadBudgetFlags + + public uint FrameCount { get; private set; } + public long StartTime { get; private set; } + public long EndTime { get; private set; } public IReadOnlyList RootNodes => rootNodes; - public IReadOnlyDictionary LabelMetrics => labelMetrics; public IReadOnlyDictionary CategoryMetrics => categoryMetrics; public IReadOnlyDictionary ThreadMetrics => threadMetrics; - public long AllocatedBytesStart { get; } = GC.GetTotalAllocatedBytes(false); + + // Return concrete types so callers can use the struct enumerator and avoid boxing. + internal Dictionary LabelMetricsRaw => labelMetrics; + internal Dictionary ThreadMetricsRaw => threadMetrics; + + public long AllocatedBytesStart { get; private set; } public long AllocatedBytesEnd { get; private set; } public long AllocatedBytesDelta { get; private set; } - public int Gen0CollectionsStart { get; } = GC.CollectionCount(0); - public int Gen1CollectionsStart { get; } = GC.CollectionCount(1); - public int Gen2CollectionsStart { get; } = GC.CollectionCount(2); + public int Gen0CollectionsStart { get; private set; } + public int Gen1CollectionsStart { get; private set; } + public int Gen2CollectionsStart { get; private set; } public int Gen0CollectionsEnd { get; private set; } public int Gen1CollectionsEnd { get; private set; } public int Gen2CollectionsEnd { get; private set; } @@ -244,6 +252,31 @@ public static class Profiler public bool OverBudget { get; private set; } public double BudgetMilliseconds { get; private set; } + internal void Reset(uint frameCount) + { + FrameCount = frameCount; + StartTime = Stopwatch.GetTimestamp(); + EndTime = 0; + OverBudget = false; + BudgetMilliseconds = 0; + AllocatedBytesStart = GC.GetTotalAllocatedBytes(false); + AllocatedBytesEnd = 0; + AllocatedBytesDelta = 0; + Gen0CollectionsStart = GC.CollectionCount(0); + Gen1CollectionsStart = GC.CollectionCount(1); + Gen2CollectionsStart = GC.CollectionCount(2); + Gen0CollectionsEnd = 0; + Gen1CollectionsEnd = 0; + Gen2CollectionsEnd = 0; + Gen0CollectionsDelta = 0; + Gen1CollectionsDelta = 0; + Gen2CollectionsDelta = 0; + lock (rootNodesLock) + { + rootNodes.Clear(); + } + } + internal void AddRootNode(ScopeNode rootNode) { lock (rootNodesLock) @@ -282,6 +315,7 @@ public static class Profiler labelMetrics.Clear(); categoryMetrics.Clear(); threadMetrics.Clear(); + knownThreadIds.Clear(); lock (rootNodesLock) { for (int i = 0; i < rootNodes.Count; i++) @@ -289,11 +323,12 @@ public static class Profiler AccumulateLabelMetrics(rootNodes[i]); } - foreach (ScopeNode rootNode in rootNodes) + for (int i = 0; i < rootNodes.Count; i++) { - for (int i = 0; i < rootNode.Children.Count; i++) + ScopeNode rootNode = rootNodes[i]; + for (int j = 0; j < rootNode.Children.Count; j++) { - AccumulateThreadMetrics(rootNode.ManagedThreadId, rootNode.Children[i]); + AccumulateThreadMetrics(rootNode.ManagedThreadId, rootNode.Children[j]); } } } @@ -352,6 +387,7 @@ public static class Profiler else { threadMetrics[threadId] = new ThreadMetrics(inclusiveMs, selfMs, 1, false); + knownThreadIds.Add(threadId); } for (int i = 0; i < node.Children.Count; i++) @@ -368,10 +404,10 @@ public static class Profiler return; } - int[] keys = threadMetrics.Keys.ToArray(); - for (int i = 0; i < keys.Length; i++) + // knownThreadIds avoids Keys.ToArray() allocation + for (int i = 0; i < knownThreadIds.Count; i++) { - int key = keys[i]; + int key = knownThreadIds[i]; ThreadMetrics metric = threadMetrics[key]; threadMetrics[key] = new ThreadMetrics(metric.InclusiveMs, metric.SelfMs, metric.Calls, metric.InclusiveMs > perThreadBudget); } @@ -393,7 +429,7 @@ public static class Profiler internal void Reset(string label, string category, ulong tagMask, int managedThreadId, ScopeNode parent) { Label = label; - Category = string.IsNullOrWhiteSpace(category) ? DefaultCategory : category; + Category = string.IsNullOrEmpty(category) ? DefaultCategory : category; TagMask = tagMask; ManagedThreadId = managedThreadId; Parent = parent; @@ -429,7 +465,6 @@ public static class Profiler return ((double)selfTicks) * 1000 / Stopwatch.Frequency; } - // Add a child node (used for nested scopes) internal ScopeNode AddChild(string label, string category, ulong tagMask) { ScopeNode child = RentNode(label, category, tagMask, ManagedThreadId, this); @@ -448,13 +483,27 @@ public static class Profiler 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 threadStates = new ThreadLocal(() => new ThreadProfilerState(), true); + // trackAllValues=false; registeredThreadStates avoids ThreadLocal.Values allocating a ReadOnlyCollection each call + private static readonly ThreadLocal threadStates = + new ThreadLocal(() => + { + ThreadProfilerState state = new ThreadProfilerState(); + lock (registeredThreadStatesLock) + { + registeredThreadStates.Add(state); + } + + return state; + }); + + private static readonly List registeredThreadStates = new List(8); + private static readonly object registeredThreadStatesLock = new object(); + private static readonly ConcurrentDictionary threadRootLabelCache = new ConcurrentDictionary(); private static readonly ConcurrentBag nodePool = new ConcurrentBag(); + private static readonly ConcurrentBag framePool = new ConcurrentBag(); // pooled; avoids per-frame Frame allocation 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(); @@ -474,13 +523,7 @@ public static class Profiler public static int CopyFramesTo(List destination) { - destination.Clear(); - foreach (Frame frame in Frames) - { - destination.Add(frame); - } - - return destination.Count; + return Frames.CopyTo(destination); } public static IReadOnlyDictionary GetRollingLabelMetricsSnapshot() @@ -524,7 +567,7 @@ public static class Profiler FinalizeCurrentFrame(); } - currentFrame = new Frame(frameCount); + currentFrame = RentFrame(frameCount); } [Conditional("PROFILING")] @@ -574,17 +617,14 @@ public static class Profiler if (scopes.Count == 0) { - // First scope for this thread (new root for this thread) 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, category, tagMask); - - scopes.Push(newScope); // Push new scope to the thread's stack + scopes.Push(newScope); } [Conditional("PROFILING")] @@ -628,6 +668,17 @@ public static class Profiler return threadRootLabelCache.GetOrAdd(threadId, static id => $"Thread-{id}"); } + private static Frame RentFrame(uint count) + { + if (!framePool.TryTake(out Frame frame)) + { + frame = new Frame(); + } + + frame.Reset(count); + return frame; + } + private static ScopeNode RentNode(string label, string category, ulong tagMask, int managedThreadId, ScopeNode parent) { if (!nodePool.TryTake(out ScopeNode node)) @@ -658,17 +709,19 @@ public static class Profiler return; } - foreach (ThreadProfilerState state in threadStates.Values) + lock (registeredThreadStatesLock) { - Stack scopes = state.Scopes; - while (scopes.Count > 0) + for (int i = 0; i < registeredThreadStates.Count; i++) { - ScopeNode currentScope = scopes.Pop(); - currentScope.End(); - } + ThreadProfilerState state = registeredThreadStates[i]; + Stack scopes = state.Scopes; + while (scopes.Count > 0) + { + scopes.Pop().End(); + } - scopes.Clear(); - state.CaptureDecisions.Clear(); + state.CaptureDecisions.Clear(); + } } frame.End(FrameBudgetMilliseconds); @@ -679,6 +732,8 @@ public static class Profiler { ReturnNodeTree(evictedFrame.RootNodes[i]); } + + framePool.Add(evictedFrame); } UpdateRollingWindows(frame); @@ -691,7 +746,7 @@ public static class Profiler { lock (rollingWindowsLock) { - foreach (KeyValuePair pair in frame.LabelMetrics) + foreach (KeyValuePair pair in frame.LabelMetricsRaw) { if (!rollingWindows.TryGetValue(pair.Key, out RollingWindow window)) { @@ -708,7 +763,7 @@ public static class Profiler { lock (rollingWindowsLock) { - foreach (KeyValuePair pair in frame.ThreadMetrics) + foreach (KeyValuePair pair in frame.ThreadMetricsRaw) { if (!rollingThreadWindows.TryGetValue(pair.Key, out RollingThreadWindow window)) { @@ -720,4 +775,4 @@ public static class Profiler } } } -} \ No newline at end of file +} diff --git a/Nerfed.Runtime/ProfilerVisualizer.cs b/Nerfed.Runtime/ProfilerVisualizer.cs index 9ce3275..926e1bb 100644 --- a/Nerfed.Runtime/ProfilerVisualizer.cs +++ b/Nerfed.Runtime/ProfilerVisualizer.cs @@ -283,15 +283,22 @@ public static class ProfilerVisualizer return; } - bool isZoom = io.KeyCtrl; - if (isZoom) + if (!io.KeyCtrl && !io.KeyShift) + { + return; // plain scroll goes to ImGui vertical scrolling + } + + float wheel = io.MouseWheel; + io.MouseWheel = 0; // consume so the child window doesn't also scroll vertically + + if (io.KeyCtrl) { float previousZoom = state.Zoom; double visibleStartTicksBefore = timelineStartTicks + state.PanTicks; double mouseT = Math.Clamp((ImGui.GetMousePos().X - originX) / Math.Max(1f, canvasWidth), 0f, 1f); double pivotTick = visibleStartTicksBefore + (visibleDurationTicks * mouseT); - state.Zoom = Math.Clamp(state.Zoom * MathF.Pow(1.12f, io.MouseWheel), 1f, 128f); + state.Zoom = Math.Clamp(state.Zoom * MathF.Pow(1.12f, wheel), 1f, 128f); if (Math.Abs(previousZoom - state.Zoom) > float.Epsilon) { double newVisibleDurationTicks = Math.Max(1d, timelineDurationTicks / state.Zoom); @@ -303,7 +310,8 @@ public static class ProfilerVisualizer } else { - state.PanTicks -= io.MouseWheel * (visibleDurationTicks * 0.10d); + // Shift + scroll: horizontal pan + state.PanTicks -= wheel * (visibleDurationTicks * 0.10d); state.FollowLatest = false; userNavigated = true; } diff --git a/Nerfed.Runtime/Util/BoundedQueue.cs b/Nerfed.Runtime/Util/BoundedQueue.cs index 889542a..a784cf7 100644 --- a/Nerfed.Runtime/Util/BoundedQueue.cs +++ b/Nerfed.Runtime/Util/BoundedQueue.cs @@ -78,6 +78,21 @@ public class BoundedQueue : IEnumerable, ICollection, IReadOnlyCollection< } } + // Iterates the internal Queue directly (struct enumerator, no allocation) under the lock. + public int CopyTo(List destination) + { + lock (syncLock) + { + destination.Clear(); + foreach (T item in queue) + { + destination.Add(item); + } + + return destination.Count; + } + } + public IEnumerator GetEnumerator() { T[] snapshot;