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 sealed class RollingWindow
{ {
private readonly double[] values; private readonly double[] values;
private readonly double[] sortBuffer; // pre-allocated; avoids per-snapshot heap allocation
private int index; private int index;
private int count; private int count;
public RollingWindow(int capacity) 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) public void Add(double value)
@@ -138,21 +141,20 @@ public static class Profiler
double sum = 0; double sum = 0;
double min = double.MaxValue; double min = double.MaxValue;
double max = double.MinValue; double max = double.MinValue;
double[] sorted = new double[count];
int start = (index - count + values.Length) % values.Length; int start = (index - count + values.Length) % values.Length;
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
{ {
double value = values[(start + i) % values.Length]; double value = values[(start + i) % values.Length];
sorted[i] = value; sortBuffer[i] = value;
sum += value; sum += value;
min = Math.Min(min, value); min = Math.Min(min, value);
max = Math.Max(max, value); max = Math.Max(max, value);
} }
Array.Sort(sorted); Array.Sort(sortBuffer, 0, count);
int percentileIndex = (int)Math.Ceiling((count - 1) * 0.95d); 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); return new RollingLabelMetrics(sum / count, min, max, p95, count);
} }
} }
@@ -160,6 +162,7 @@ public static class Profiler
private sealed class RollingThreadWindow private sealed class RollingThreadWindow
{ {
private readonly double[] durations; private readonly double[] durations;
private readonly double[] sortBuffer; // pre-allocated; avoids per-snapshot heap allocation
private readonly byte[] misses; private readonly byte[] misses;
private int index; private int index;
private int count; private int count;
@@ -168,6 +171,7 @@ public static class Profiler
{ {
int size = Math.Max(8, capacity); int size = Math.Max(8, capacity);
durations = new double[size]; durations = new double[size];
sortBuffer = new double[size];
misses = new byte[size]; misses = new byte[size];
} }
@@ -192,7 +196,6 @@ public static class Profiler
double sum = 0; double sum = 0;
double max = double.MinValue; double max = double.MinValue;
int budgetMisses = 0; int budgetMisses = 0;
double[] sorted = new double[count];
int start = (index - count + durations.Length) % durations.Length; int start = (index - count + durations.Length) % durations.Length;
for (int i = 0; i < count; i++) for (int i = 0; i < count; i++)
@@ -202,39 +205,44 @@ public static class Profiler
sum += value; sum += value;
max = Math.Max(max, value); max = Math.Max(max, value);
budgetMisses += misses[at]; 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); 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); 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 List<ScopeNode> rootNodes = new List<ScopeNode>(8);
private readonly object rootNodesLock = new object(); private readonly object rootNodesLock = new object();
private readonly Dictionary<string, LabelMetrics> labelMetrics = new Dictionary<string, LabelMetrics>(128, StringComparer.Ordinal); 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<string, LabelMetrics> categoryMetrics = new Dictionary<string, LabelMetrics>(32, StringComparer.Ordinal);
private readonly Dictionary<int, ThreadMetrics> threadMetrics = new Dictionary<int, ThreadMetrics>(16); 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 IReadOnlyList<ScopeNode> RootNodes => rootNodes;
public IReadOnlyDictionary<string, LabelMetrics> LabelMetrics => labelMetrics; public IReadOnlyDictionary<string, LabelMetrics> LabelMetrics => labelMetrics;
public IReadOnlyDictionary<string, LabelMetrics> CategoryMetrics => categoryMetrics; public IReadOnlyDictionary<string, LabelMetrics> CategoryMetrics => categoryMetrics;
public IReadOnlyDictionary<int, ThreadMetrics> ThreadMetrics => threadMetrics; 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 AllocatedBytesEnd { get; private set; }
public long AllocatedBytesDelta { get; private set; } public long AllocatedBytesDelta { get; private set; }
public int Gen0CollectionsStart { get; } = GC.CollectionCount(0); public int Gen0CollectionsStart { get; private set; }
public int Gen1CollectionsStart { get; } = GC.CollectionCount(1); public int Gen1CollectionsStart { get; private set; }
public int Gen2CollectionsStart { get; } = GC.CollectionCount(2); public int Gen2CollectionsStart { get; private set; }
public int Gen0CollectionsEnd { get; private set; } public int Gen0CollectionsEnd { get; private set; }
public int Gen1CollectionsEnd { get; private set; } public int Gen1CollectionsEnd { get; private set; }
public int Gen2CollectionsEnd { get; private set; } public int Gen2CollectionsEnd { get; private set; }
@@ -244,6 +252,31 @@ public static class Profiler
public bool OverBudget { get; private set; } public bool OverBudget { get; private set; }
public double BudgetMilliseconds { 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) internal void AddRootNode(ScopeNode rootNode)
{ {
lock (rootNodesLock) lock (rootNodesLock)
@@ -282,6 +315,7 @@ public static class Profiler
labelMetrics.Clear(); labelMetrics.Clear();
categoryMetrics.Clear(); categoryMetrics.Clear();
threadMetrics.Clear(); threadMetrics.Clear();
knownThreadIds.Clear();
lock (rootNodesLock) lock (rootNodesLock)
{ {
for (int i = 0; i < rootNodes.Count; i++) for (int i = 0; i < rootNodes.Count; i++)
@@ -289,11 +323,12 @@ public static class Profiler
AccumulateLabelMetrics(rootNodes[i]); 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 else
{ {
threadMetrics[threadId] = new ThreadMetrics(inclusiveMs, selfMs, 1, false); threadMetrics[threadId] = new ThreadMetrics(inclusiveMs, selfMs, 1, false);
knownThreadIds.Add(threadId);
} }
for (int i = 0; i < node.Children.Count; i++) for (int i = 0; i < node.Children.Count; i++)
@@ -368,10 +404,10 @@ public static class Profiler
return; return;
} }
int[] keys = threadMetrics.Keys.ToArray(); // knownThreadIds avoids Keys.ToArray() allocation
for (int i = 0; i < keys.Length; i++) for (int i = 0; i < knownThreadIds.Count; i++)
{ {
int key = keys[i]; int key = knownThreadIds[i];
ThreadMetrics metric = threadMetrics[key]; ThreadMetrics metric = threadMetrics[key];
threadMetrics[key] = new ThreadMetrics(metric.InclusiveMs, metric.SelfMs, metric.Calls, metric.InclusiveMs > perThreadBudget); 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) internal void Reset(string label, string category, ulong tagMask, int managedThreadId, ScopeNode parent)
{ {
Label = label; Label = label;
Category = string.IsNullOrWhiteSpace(category) ? DefaultCategory : category; Category = string.IsNullOrEmpty(category) ? DefaultCategory : category;
TagMask = tagMask; TagMask = tagMask;
ManagedThreadId = managedThreadId; ManagedThreadId = managedThreadId;
Parent = parent; Parent = parent;
@@ -429,7 +465,6 @@ public static class Profiler
return ((double)selfTicks) * 1000 / Stopwatch.Frequency; return ((double)selfTicks) * 1000 / Stopwatch.Frequency;
} }
// Add a child node (used for nested scopes)
internal ScopeNode AddChild(string label, string category, ulong tagMask) internal ScopeNode AddChild(string label, string category, ulong tagMask)
{ {
ScopeNode child = RentNode(label, category, tagMask, ManagedThreadId, this); 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 CaptureMode Mode { get; set; } = CaptureMode.Instrumented;
public static int SamplingStride { get; set; } = 8; 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); 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. // trackAllValues=false; registeredThreadStates avoids ThreadLocal.Values allocating a ReadOnlyCollection each call
private static readonly ThreadLocal<ThreadProfilerState> threadStates = new ThreadLocal<ThreadProfilerState>(() => new ThreadProfilerState(), true); 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 ConcurrentDictionary<int, string> threadRootLabelCache = new ConcurrentDictionary<int, string>();
private static readonly ConcurrentBag<ScopeNode> nodePool = new ConcurrentBag<ScopeNode>(); 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<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 Dictionary<int, RollingThreadWindow> rollingThreadWindows = new Dictionary<int, RollingThreadWindow>(16);
private static readonly object rollingWindowsLock = new object(); private static readonly object rollingWindowsLock = new object();
@@ -474,13 +523,7 @@ public static class Profiler
public static int CopyFramesTo(List<Frame> destination) public static int CopyFramesTo(List<Frame> destination)
{ {
destination.Clear(); return Frames.CopyTo(destination);
foreach (Frame frame in Frames)
{
destination.Add(frame);
}
return destination.Count;
} }
public static IReadOnlyDictionary<string, RollingLabelMetrics> GetRollingLabelMetricsSnapshot() public static IReadOnlyDictionary<string, RollingLabelMetrics> GetRollingLabelMetricsSnapshot()
@@ -524,7 +567,7 @@ public static class Profiler
FinalizeCurrentFrame(); FinalizeCurrentFrame();
} }
currentFrame = new Frame(frameCount); currentFrame = RentFrame(frameCount);
} }
[Conditional("PROFILING")] [Conditional("PROFILING")]
@@ -574,17 +617,14 @@ public static class Profiler
if (scopes.Count == 0) if (scopes.Count == 0)
{ {
// First scope for this thread (new root for this thread)
int threadId = state.ThreadId; int threadId = state.ThreadId;
ScopeNode rootScopeNode = RentNode(GetThreadRootLabel(threadId), DefaultCategory, 0, threadId, null); ScopeNode rootScopeNode = RentNode(GetThreadRootLabel(threadId), DefaultCategory, 0, threadId, null);
scopes.Push(rootScopeNode); scopes.Push(rootScopeNode);
frame.AddRootNode(rootScopeNode); frame.AddRootNode(rootScopeNode);
} }
// Create a new child under the current top of the stack
ScopeNode newScope = scopes.Peek().AddChild(label, category, tagMask); ScopeNode newScope = scopes.Peek().AddChild(label, category, tagMask);
scopes.Push(newScope);
scopes.Push(newScope); // Push new scope to the thread's stack
} }
[Conditional("PROFILING")] [Conditional("PROFILING")]
@@ -628,6 +668,17 @@ public static class Profiler
return threadRootLabelCache.GetOrAdd(threadId, static id => $"Thread-{id}"); 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) private static ScopeNode RentNode(string label, string category, ulong tagMask, int managedThreadId, ScopeNode parent)
{ {
if (!nodePool.TryTake(out ScopeNode node)) if (!nodePool.TryTake(out ScopeNode node))
@@ -658,17 +709,19 @@ public static class Profiler
return; return;
} }
foreach (ThreadProfilerState state in threadStates.Values) lock (registeredThreadStatesLock)
{ {
Stack<ScopeNode> scopes = state.Scopes; for (int i = 0; i < registeredThreadStates.Count; i++)
while (scopes.Count > 0)
{ {
ScopeNode currentScope = scopes.Pop(); ThreadProfilerState state = registeredThreadStates[i];
currentScope.End(); Stack<ScopeNode> scopes = state.Scopes;
} while (scopes.Count > 0)
{
scopes.Pop().End();
}
scopes.Clear(); state.CaptureDecisions.Clear();
state.CaptureDecisions.Clear(); }
} }
frame.End(FrameBudgetMilliseconds); frame.End(FrameBudgetMilliseconds);
@@ -679,6 +732,8 @@ public static class Profiler
{ {
ReturnNodeTree(evictedFrame.RootNodes[i]); ReturnNodeTree(evictedFrame.RootNodes[i]);
} }
framePool.Add(evictedFrame);
} }
UpdateRollingWindows(frame); UpdateRollingWindows(frame);
@@ -691,7 +746,7 @@ public static class Profiler
{ {
lock (rollingWindowsLock) 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)) if (!rollingWindows.TryGetValue(pair.Key, out RollingWindow window))
{ {
@@ -708,7 +763,7 @@ public static class Profiler
{ {
lock (rollingWindowsLock) 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)) if (!rollingThreadWindows.TryGetValue(pair.Key, out RollingThreadWindow window))
{ {
+12 -4
View File
@@ -283,15 +283,22 @@ public static class ProfilerVisualizer
return; return;
} }
bool isZoom = io.KeyCtrl; if (!io.KeyCtrl && !io.KeyShift)
if (isZoom) {
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; float previousZoom = state.Zoom;
double visibleStartTicksBefore = timelineStartTicks + state.PanTicks; double visibleStartTicksBefore = timelineStartTicks + state.PanTicks;
double mouseT = Math.Clamp((ImGui.GetMousePos().X - originX) / Math.Max(1f, canvasWidth), 0f, 1f); double mouseT = Math.Clamp((ImGui.GetMousePos().X - originX) / Math.Max(1f, canvasWidth), 0f, 1f);
double pivotTick = visibleStartTicksBefore + (visibleDurationTicks * mouseT); 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) if (Math.Abs(previousZoom - state.Zoom) > float.Epsilon)
{ {
double newVisibleDurationTicks = Math.Max(1d, timelineDurationTicks / state.Zoom); double newVisibleDurationTicks = Math.Max(1d, timelineDurationTicks / state.Zoom);
@@ -303,7 +310,8 @@ public static class ProfilerVisualizer
} }
else else
{ {
state.PanTicks -= io.MouseWheel * (visibleDurationTicks * 0.10d); // Shift + scroll: horizontal pan
state.PanTicks -= wheel * (visibleDurationTicks * 0.10d);
state.FollowLatest = false; state.FollowLatest = false;
userNavigated = true; 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() public IEnumerator<T> GetEnumerator()
{ {
T[] snapshot; T[] snapshot;