Enhance Profiler with pre-allocated sort buffers and frame management improvements

This commit is contained in:
max
2026-08-05 12:15:21 +02:00
parent 83f77d1ebe
commit 7853a768de
3 changed files with 135 additions and 57 deletions
+107 -52
View File
@@ -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<ScopeNode> rootNodes = new List<ScopeNode>(8);
private readonly object rootNodesLock = new object();
private readonly Dictionary<string, LabelMetrics> labelMetrics = new Dictionary<string, LabelMetrics>(128, StringComparer.Ordinal);
private readonly Dictionary<string, LabelMetrics> categoryMetrics = new Dictionary<string, LabelMetrics>(32, StringComparer.Ordinal);
private readonly Dictionary<int, ThreadMetrics> threadMetrics = new Dictionary<int, ThreadMetrics>(16);
private readonly List<int> knownThreadIds = new List<int>(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<ScopeNode> RootNodes => rootNodes;
public IReadOnlyDictionary<string, LabelMetrics> LabelMetrics => labelMetrics;
public IReadOnlyDictionary<string, LabelMetrics> CategoryMetrics => categoryMetrics;
public IReadOnlyDictionary<int, ThreadMetrics> ThreadMetrics => threadMetrics;
public long AllocatedBytesStart { get; } = GC.GetTotalAllocatedBytes(false);
// Return concrete types so callers can use the struct enumerator and avoid boxing.
internal Dictionary<string, LabelMetrics> LabelMetricsRaw => labelMetrics;
internal Dictionary<int, ThreadMetrics> 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<Frame> Frames = new(maxFrames);
// Use ThreadLocal to store a stack of ScopeNodes per thread and enable tracking of thread-local values.
private static readonly ThreadLocal<ThreadProfilerState> threadStates = new ThreadLocal<ThreadProfilerState>(() => new ThreadProfilerState(), true);
// trackAllValues=false; registeredThreadStates avoids ThreadLocal.Values allocating a ReadOnlyCollection each call
private static readonly ThreadLocal<ThreadProfilerState> threadStates =
new ThreadLocal<ThreadProfilerState>(() =>
{
ThreadProfilerState state = new ThreadProfilerState();
lock (registeredThreadStatesLock)
{
registeredThreadStates.Add(state);
}
return state;
});
private static readonly List<ThreadProfilerState> registeredThreadStates = new List<ThreadProfilerState>(8);
private static readonly object registeredThreadStatesLock = new object();
private static readonly ConcurrentDictionary<int, string> threadRootLabelCache = new ConcurrentDictionary<int, string>();
private static readonly ConcurrentBag<ScopeNode> nodePool = new ConcurrentBag<ScopeNode>();
private static readonly ConcurrentBag<Frame> framePool = new ConcurrentBag<Frame>(); // pooled; avoids per-frame Frame allocation
private static readonly Dictionary<string, RollingWindow> rollingWindows = new Dictionary<string, RollingWindow>(256, StringComparer.Ordinal);
private static readonly Dictionary<int, RollingThreadWindow> rollingThreadWindows = new Dictionary<int, RollingThreadWindow>(16);
private static readonly object rollingWindowsLock = new object();
@@ -474,13 +523,7 @@ public static class Profiler
public static int CopyFramesTo(List<Frame> destination)
{
destination.Clear();
foreach (Frame frame in Frames)
{
destination.Add(frame);
}
return destination.Count;
return Frames.CopyTo(destination);
}
public static IReadOnlyDictionary<string, RollingLabelMetrics> 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<ScopeNode> 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<ScopeNode> 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<string, LabelMetrics> pair in frame.LabelMetrics)
foreach (KeyValuePair<string, LabelMetrics> pair in frame.LabelMetricsRaw)
{
if (!rollingWindows.TryGetValue(pair.Key, out RollingWindow window))
{
@@ -708,7 +763,7 @@ public static class Profiler
{
lock (rollingWindowsLock)
{
foreach (KeyValuePair<int, ThreadMetrics> pair in frame.ThreadMetrics)
foreach (KeyValuePair<int, ThreadMetrics> pair in frame.ThreadMetricsRaw)
{
if (!rollingThreadWindows.TryGetValue(pair.Key, out RollingThreadWindow window))
{
+12 -4
View File
@@ -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;
}
+15
View File
@@ -78,6 +78,21 @@ public class BoundedQueue<T> : IEnumerable<T>, ICollection, IReadOnlyCollection<
}
}
// Iterates the internal Queue<T> directly (struct enumerator, no allocation) under the lock.
public int CopyTo(List<T> destination)
{
lock (syncLock)
{
destination.Clear();
foreach (T item in queue)
{
destination.Add(item);
}
return destination.Count;
}
}
public IEnumerator<T> GetEnumerator()
{
T[] snapshot;