Author SHA1 Message Date
max 6ad5aa3f2c Add profiler warmup overhead tracking and tooltip display 2026-08-05 14:04:21 +02:00
max d9582aecdd Enhance Profiler with memory allocation metrics 2026-08-05 13:25:14 +02:00
max 7853a768de Enhance Profiler with pre-allocated sort buffers and frame management improvements 2026-08-05 12:15:21 +02:00
max 83f77d1ebe Add thread metrics and category support to Profiler and visualizer 2026-08-05 11:47:21 +02:00
max 2d4139fb2c Enhance Profiler with Label Metrics and Rolling Windows
- 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.
2026-08-04 17:19:00 +02:00
4 changed files with 1504 additions and 247 deletions
+195 -56
View File
@@ -1,6 +1,7 @@
using ImGuiNET;
using MoonTools.ECS;
using Nerfed.Runtime;
using System.Numerics;
namespace Nerfed.Editor.Systems
{
@@ -12,7 +13,10 @@ namespace Nerfed.Editor.Systems
private int selectedFrame = 0;
private int previousSelectedFrame = -1;
private IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> orderedCombinedData = null;
private IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)>> orderedCombinedData = null;
private IOrderedEnumerable<KeyValuePair<int, Profiler.RollingThreadMetrics>> orderedThreadRollingData = 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)
{
@@ -25,49 +29,108 @@ namespace Nerfed.Editor.Systems
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 System.Numerics.Vector2(0, 0), ImGuiChildFlags.AutoResizeY);
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;
}
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.
selectedFrame = Profiler.Frames.Count - 1;
}
if (ImGui.SliderInt(string.Empty, ref selectedFrame, 0, Profiler.Frames.Count - 1))
{
// Stop recording when browsing frames.
Profiler.SetActive(false);
selectedFrame = frameSnapshot.Count - 1;
}
Profiler.Frame frame = Profiler.Frames.ElementAt(selectedFrame);
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($"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();
if (!Profiler.IsRecording) {
if (previousSelectedFrame != selectedFrame)
{
previousSelectedFrame = selectedFrame;
orderedCombinedData = CalculateCombinedData(frame);
}
DrawFlameGraph(frame);
DrawHierachy(frame);
ImGui.SameLine();
DrawCombined(orderedCombinedData);
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);
orderedThreadRollingData = CalculateThreadRollingData();
}
DrawThreadRolling(orderedThreadRollingData);
DrawHierachy(frame);
ImGui.SameLine();
DrawCombined(orderedCombinedData);
ImGui.End();
}
@@ -78,13 +141,18 @@ namespace Nerfed.Editor.Systems
return;
}
ImGui.BeginChild("Hierachy", new System.Numerics.Vector2(150, 0), ImGuiChildFlags.ResizeX);
ImGui.BeginChild("Hierachy", new Vector2(150, 0), ImGuiChildFlags.ResizeX);
if (ImGui.BeginTable("ProfilerData", 3, tableFlags, new System.Numerics.Vector2(0, 0)))
if (ImGui.BeginTable("ProfilerData", 8, tableFlags, new Vector2(0, 0)))
{
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.8f, 0);
ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.30f, 0);
ImGui.TableSetupColumn("category", ImGuiTableColumnFlags.WidthStretch, 0.12f, 1);
ImGui.TableSetupColumn("tags", ImGuiTableColumnFlags.WidthStretch, 0.08f, 2);
ImGui.TableSetupColumn("thread", ImGuiTableColumnFlags.WidthStretch, 0.08f, 3);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.10f, 4);
ImGui.TableSetupColumn("self", ImGuiTableColumnFlags.WidthStretch, 0.10f, 5);
ImGui.TableSetupColumn("alloc(B)", ImGuiTableColumnFlags.WidthStretch, 0.11f, 6);
ImGui.TableSetupColumn("self alloc", ImGuiTableColumnFlags.WidthStretch, 0.11f, 7);
ImGui.TableSetupScrollFreeze(0, 1); // Make row always visible
ImGui.TableHeadersRow();
@@ -115,10 +183,29 @@ 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();
ImGui.Text($"{node.ElapsedMilliseconds():0.000}");
ImGui.TableNextColumn();
ImGui.Text($"{node.SelfMilliseconds():0.000}");
ImGui.TableNextColumn();
if (node.ProfilerSetupBytes > 0)
{
ImGui.Text($"{node.AllocatedBytes} !");
if (ImGui.IsItemHovered())
ImGui.SetTooltip($"{node.ProfilerSetupBytes} B is profiler warmup overhead");
}
else
{
ImGui.Text($"{node.AllocatedBytes}");
}
ImGui.TableNextColumn();
ImGui.Text($"{node.SelfAllocatedBytes()}");
if (isOpen)
{
@@ -130,24 +217,29 @@ namespace Nerfed.Editor.Systems
}
}
private static void DrawCombined(in IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> orderedCombinedData)
private static void DrawCombined(in IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)>> orderedCombinedData)
{
if(orderedCombinedData == null)
{
return;
}
ImGui.BeginChild("Combined", new System.Numerics.Vector2(0, 0));
ImGui.BeginChild("Combined", new Vector2(0, 0));
if (ImGui.BeginTable("ProfilerCombinedData", 3, tableFlags, new System.Numerics.Vector2(0, 0)))
if (ImGui.BeginTable("ProfilerCombinedData", 8, tableFlags, new Vector2(0, 0)))
{
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.6f, 0);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.2f, 1);
ImGui.TableSetupColumn("calls", ImGuiTableColumnFlags.WidthStretch, 0.2f, 2);
ImGui.TableSetupColumn("name", ImGuiTableColumnFlags.WidthStretch, 0.32f, 0);
ImGui.TableSetupColumn("ms", ImGuiTableColumnFlags.WidthStretch, 0.11f, 1);
ImGui.TableSetupColumn("self", ImGuiTableColumnFlags.WidthStretch, 0.11f, 2);
ImGui.TableSetupColumn("calls", ImGuiTableColumnFlags.WidthStretch, 0.08f, 3);
ImGui.TableSetupColumn("avg", ImGuiTableColumnFlags.WidthStretch, 0.09f, 4);
ImGui.TableSetupColumn("p95", ImGuiTableColumnFlags.WidthStretch, 0.09f, 5);
ImGui.TableSetupColumn("alloc(B)", ImGuiTableColumnFlags.WidthStretch, 0.10f, 6);
ImGui.TableSetupColumn("self alloc", ImGuiTableColumnFlags.WidthStretch, 0.10f, 7);
ImGui.TableSetupScrollFreeze(0, 1); // Make row always visible
ImGui.TableHeadersRow();
foreach (KeyValuePair<string, (double ms, uint calls)> combinedData in orderedCombinedData)
foreach (KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)> combinedData in orderedCombinedData)
{
ImGui.TableNextRow();
ImGui.TableNextColumn();
@@ -155,7 +247,17 @@ namespace Nerfed.Editor.Systems
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.TableNextColumn();
ImGui.Text($"{combinedData.Value.allocBytes}");
ImGui.TableNextColumn();
ImGui.Text($"{combinedData.Value.selfAllocBytes}");
}
ImGui.EndTable();
@@ -164,41 +266,78 @@ namespace Nerfed.Editor.Systems
ImGui.EndChild();
}
private static IOrderedEnumerable<KeyValuePair<string, (double ms, uint calls)>> CalculateCombinedData(Profiler.Frame frame)
private static IOrderedEnumerable<KeyValuePair<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)>> CalculateCombinedData(Profiler.Frame frame)
{
Dictionary<string, (double ms, uint calls)> combinedRecordData = new Dictionary<string, (double ms, uint calls)>(128);
foreach (Profiler.ScopeNode node in frame.RootNodes)
IReadOnlyDictionary<string, Profiler.RollingLabelMetrics> rollingData = Profiler.GetRollingLabelMetricsSnapshot();
Dictionary<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)> combinedRecordData = new Dictionary<string, (double ms, double selfMs, uint calls, double avgMs, double p95Ms, long allocBytes, long selfAllocBytes)>(128);
foreach (KeyValuePair<string, Profiler.LabelMetrics> metric in frame.LabelMetrics)
{
CalculateCombinedData(node, in combinedRecordData);
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, metric.Value.AllocatedBytes, metric.Value.SelfAllocatedBytes);
}
return combinedRecordData.OrderByDescending(x => x.Value.ms);
}
private static void CalculateCombinedData(Profiler.ScopeNode node, in Dictionary<string, (double ms, uint calls)> combinedRecordData)
private static IOrderedEnumerable<KeyValuePair<int, Profiler.RollingThreadMetrics>> CalculateThreadRollingData()
{
if (combinedRecordData.TryGetValue(node.Label, out (double ms, uint calls) combined))
{
combinedRecordData[node.Label] = (combined.ms + node.ElapsedMilliseconds(), combined.calls + 1);
}
else
{
combinedRecordData.Add(node.Label, (node.ElapsedMilliseconds(), 1));
}
for (int i = 0; i < node.Children.Count; i++)
{
CalculateCombinedData(node.Children[i], combinedRecordData);
}
IReadOnlyDictionary<int, Profiler.RollingThreadMetrics> rollingData = Profiler.GetRollingThreadMetricsSnapshot();
return rollingData.OrderByDescending(x => x.Value.P95Ms);
}
private static void DrawFlameGraph(Profiler.Frame frame)
private static void DrawThreadRolling(in IOrderedEnumerable<KeyValuePair<int, Profiler.RollingThreadMetrics>> orderedThreadRollingData)
{
if (frame == null)
if (orderedThreadRollingData == null)
{
return;
}
ProfilerVisualizer.RenderFlameGraph(frame);
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<int, Profiler.RollingThreadMetrics> 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<Profiler.Frame> frames, ProfilerVisualizer.TimelineState timelineState)
{
if (frames == null || frames.Count == 0)
{
return default;
}
return ProfilerVisualizer.RenderTimeline(frames, timelineState);
}
}
}
+695 -49
View File
@@ -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,18 +23,289 @@ public struct ProfilerScope : IDisposable
public static class Profiler
{
public class Frame(uint frameCount)
public enum CaptureMode
{
public uint FrameCount { get; } = frameCount;
public long StartTime { get; } = Stopwatch.GetTimestamp();
Instrumented = 0,
SampledInstrumentation = 1,
}
private sealed class ThreadProfilerState
{
public readonly Stack<ScopeNode> Scopes = new Stack<ScopeNode>();
public readonly Stack<bool> CaptureDecisions = new Stack<bool>();
public int SampleCursor;
public int ThreadId;
}
public readonly struct LabelMetrics
{
public LabelMetrics(double inclusiveMs, double selfMs, uint calls, double minInclusiveMs, double maxInclusiveMs, long allocatedBytes, long selfAllocatedBytes)
{
InclusiveMs = inclusiveMs;
SelfMs = selfMs;
Calls = calls;
MinInclusiveMs = minInclusiveMs;
MaxInclusiveMs = maxInclusiveMs;
AllocatedBytes = allocatedBytes;
SelfAllocatedBytes = selfAllocatedBytes;
}
public double InclusiveMs { get; }
public double SelfMs { get; }
public uint Calls { get; }
public double MinInclusiveMs { get; }
public double MaxInclusiveMs { get; }
public long AllocatedBytes { get; }
public long SelfAllocatedBytes { get; }
}
public readonly struct RollingLabelMetrics
{
public RollingLabelMetrics(double averageMs, double minMs, double maxMs, double p95Ms, int samples)
{
AverageMs = averageMs;
MinMs = minMs;
MaxMs = maxMs;
P95Ms = p95Ms;
Samples = samples;
}
public double AverageMs { get; }
public double MinMs { get; }
public double MaxMs { get; }
public double P95Ms { get; }
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;
private readonly double[] sortBuffer; // pre-allocated; avoids per-snapshot heap allocation
private int index;
private int count;
public RollingWindow(int capacity)
{
int size = Math.Max(8, capacity);
values = new double[size];
sortBuffer = new double[size];
}
public void Add(double value)
{
values[index] = value;
index = (index + 1) % values.Length;
if (count < values.Length)
{
count++;
}
}
public RollingLabelMetrics Snapshot()
{
if (count == 0)
{
return default;
}
double sum = 0;
double min = double.MaxValue;
double max = double.MinValue;
int start = (index - count + values.Length) % values.Length;
for (int i = 0; i < count; i++)
{
double value = values[(start + i) % values.Length];
sortBuffer[i] = value;
sum += value;
min = Math.Min(min, value);
max = Math.Max(max, value);
}
Array.Sort(sortBuffer, 0, count);
int percentileIndex = (int)Math.Ceiling((count - 1) * 0.95d);
double p95 = sortBuffer[Math.Clamp(percentileIndex, 0, count - 1)];
return new RollingLabelMetrics(sum / count, min, max, p95, count);
}
}
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;
public RollingThreadWindow(int capacity)
{
int size = Math.Max(8, capacity);
durations = new double[size];
sortBuffer = 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;
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];
sortBuffer[i] = value;
}
Array.Sort(sortBuffer, 0, count);
int percentileIndex = (int)Math.Ceiling((count - 1) * 0.95d);
double p95 = sortBuffer[Math.Clamp(percentileIndex, 0, count - 1)];
return new RollingThreadMetrics(sum / count, p95, max, count, budgetMisses);
}
}
public class Frame
{
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; }
// Use a concurrent list to collect all thread root nodes per frame.
public ConcurrentBag<ScopeNode> RootNodes = new ConcurrentBag<ScopeNode>();
public IReadOnlyList<ScopeNode> RootNodes => rootNodes;
public IReadOnlyDictionary<string, LabelMetrics> LabelMetrics => labelMetrics;
public IReadOnlyDictionary<string, LabelMetrics> CategoryMetrics => categoryMetrics;
public IReadOnlyDictionary<int, ThreadMetrics> ThreadMetrics => threadMetrics;
internal void End()
// 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; 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; }
public int Gen0CollectionsDelta { get; private set; }
public int Gen1CollectionsDelta { get; private set; }
public int Gen2CollectionsDelta { get; private set; }
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)
{
rootNodes.Add(rootNode);
}
}
internal void End(double budgetMilliseconds)
{
EndTime = Stopwatch.GetTimestamp();
BudgetMilliseconds = budgetMilliseconds;
OverBudget = budgetMilliseconds > 0 && ElapsedMilliseconds() > budgetMilliseconds;
AllocatedBytesEnd = GC.GetTotalAllocatedBytes(false);
AllocatedBytesDelta = AllocatedBytesEnd - AllocatedBytesStart;
Gen0CollectionsEnd = GC.CollectionCount(0);
Gen1CollectionsEnd = GC.CollectionCount(1);
Gen2CollectionsEnd = GC.CollectionCount(2);
Gen0CollectionsDelta = Gen0CollectionsEnd - Gen0CollectionsStart;
Gen1CollectionsDelta = Gen1CollectionsEnd - Gen1CollectionsStart;
Gen2CollectionsDelta = Gen2CollectionsEnd - Gen2CollectionsStart;
BuildLabelMetrics();
}
public double ElapsedMilliseconds()
@@ -37,53 +313,280 @@ public static class Profiler
long elapsedTicks = EndTime - StartTime;
return ((double)(elapsedTicks * 1000)) / Stopwatch.Frequency;
}
private void BuildLabelMetrics()
{
labelMetrics.Clear();
categoryMetrics.Clear();
threadMetrics.Clear();
knownThreadIds.Clear();
lock (rootNodesLock)
{
for (int i = 0; i < rootNodes.Count; i++)
{
AccumulateLabelMetrics(rootNodes[i]);
}
for (int i = 0; i < rootNodes.Count; i++)
{
ScopeNode rootNode = rootNodes[i];
for (int j = 0; j < rootNode.Children.Count; j++)
{
AccumulateThreadMetrics(rootNode.ManagedThreadId, rootNode.Children[j]);
}
}
}
ApplyThreadBudgetFlags();
}
private void AccumulateLabelMetrics(ScopeNode node)
{
double inclusiveMs = node.ElapsedMilliseconds();
double selfMs = node.SelfMilliseconds();
long allocBytes = node.AllocatedBytes;
long selfAllocBytes = node.SelfAllocatedBytes();
if (labelMetrics.TryGetValue(node.Label, out LabelMetrics current))
{
labelMetrics[node.Label] = new LabelMetrics(
current.InclusiveMs + inclusiveMs,
current.SelfMs + selfMs,
current.Calls + 1,
Math.Min(current.MinInclusiveMs, inclusiveMs),
Math.Max(current.MaxInclusiveMs, inclusiveMs),
current.AllocatedBytes + allocBytes,
current.SelfAllocatedBytes + selfAllocBytes);
}
else
{
labelMetrics[node.Label] = new LabelMetrics(inclusiveMs, selfMs, 1, inclusiveMs, inclusiveMs, allocBytes, selfAllocBytes);
}
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),
categoryCurrent.AllocatedBytes + allocBytes,
categoryCurrent.SelfAllocatedBytes + selfAllocBytes);
}
else
{
categoryMetrics[node.Category] = new LabelMetrics(inclusiveMs, selfMs, 1, inclusiveMs, inclusiveMs, allocBytes, selfAllocBytes);
}
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);
knownThreadIds.Add(threadId);
}
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;
}
// knownThreadIds avoids Keys.ToArray() allocation
for (int i = 0; i < knownThreadIds.Count; i++)
{
int key = knownThreadIds[i];
ThreadMetrics metric = threadMetrics[key];
threadMetrics[key] = new ThreadMetrics(metric.InclusiveMs, metric.SelfMs, metric.Calls, metric.InclusiveMs > perThreadBudget);
}
}
}
public class ScopeNode(string label)
public class ScopeNode
{
public string Label { get; } = label;
public long StartTime { get; private set; } = Stopwatch.GetTimestamp(); // Start time in ticks
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; } = Environment.CurrentManagedThreadId;
public int ManagedThreadId { get; private set; }
public List<ScopeNode> Children { get; } = new List<ScopeNode>();
public long AllocatedBytes { get; private set; }
public long ProfilerSetupBytes { get; private set; }
internal ScopeNode Parent { get; private set; }
internal long ChildrenDurationTicks { get; private set; }
internal long ChildrenAllocatedBytes { get; private set; }
private long allocatedBytesAtStart;
internal void Reset(string label, string category, ulong tagMask, int managedThreadId, ScopeNode parent)
{
Label = label;
Category = string.IsNullOrEmpty(category) ? DefaultCategory : category;
TagMask = tagMask;
ManagedThreadId = managedThreadId;
Parent = parent;
StartTime = Stopwatch.GetTimestamp();
EndTime = 0;
ChildrenDurationTicks = 0;
ChildrenAllocatedBytes = 0;
AllocatedBytes = 0;
ProfilerSetupBytes = 0;
Children.Clear();
allocatedBytesAtStart = GC.GetAllocatedBytesForCurrentThread();
}
internal void End()
{
EndTime = Stopwatch.GetTimestamp(); // End time in ticks
if (EndTime != 0)
{
return;
}
EndTime = Stopwatch.GetTimestamp();
if (Parent != null)
{
// Root nodes are ended from FinalizeCurrentFrame on the main thread, so their
// GC counter would be from the wrong thread. Only track alloc on non-root nodes.
AllocatedBytes = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - allocatedBytesAtStart);
Parent.ChildrenDurationTicks += Math.Max(0, EndTime - StartTime);
Parent.ChildrenAllocatedBytes += AllocatedBytes;
}
}
public double ElapsedMilliseconds()
{
return ((double)(EndTime - StartTime)) * 1000 / Stopwatch.Frequency; // Convert ticks to ms
return ((double)(Math.Max(0, EndTime - StartTime))) * 1000 / Stopwatch.Frequency;
}
// Add a child node (used for nested scopes)
internal ScopeNode AddChild(string label)
public double SelfMilliseconds()
{
ScopeNode child = new ScopeNode(label);
long elapsedTicks = Math.Max(0, EndTime - StartTime);
long selfTicks = Math.Max(0, elapsedTicks - ChildrenDurationTicks);
return ((double)selfTicks) * 1000 / Stopwatch.Frequency;
}
public long SelfAllocatedBytes()
{
return Math.Max(0, AllocatedBytes - ChildrenAllocatedBytes);
}
// Called after all profiler setup (Children.Add + scopes.Push) to measure overhead within this scope's window.
internal void NoteSetupOverhead()
{
ProfilerSetupBytes = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - allocatedBytesAtStart);
}
internal ScopeNode AddChild(string label, string category, ulong tagMask)
{
ScopeNode child = RentNode(label, category, tagMask, ManagedThreadId, this);
Children.Add(child);
return child;
}
}
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<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<Stack<ScopeNode>> threadLocalScopes = new ThreadLocal<Stack<ScopeNode>>(() => new Stack<ScopeNode>(), 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();
private static Frame currentFrame = null;
private static uint frameCount = 0;
public static void SetActive(bool isRecording)
{
if (IsRecording && !isRecording)
{
FinalizeCurrentFrame();
}
IsRecording = isRecording;
}
public static int CopyFramesTo(List<Frame> destination)
{
return Frames.CopyTo(destination);
}
public static IReadOnlyDictionary<string, RollingLabelMetrics> GetRollingLabelMetricsSnapshot()
{
lock (rollingWindowsLock)
{
Dictionary<string, RollingLabelMetrics> snapshot = new Dictionary<string, RollingLabelMetrics>(rollingWindows.Count, StringComparer.Ordinal);
foreach (KeyValuePair<string, RollingWindow> pair in rollingWindows)
{
snapshot[pair.Key] = pair.Value.Snapshot();
}
return snapshot;
}
}
public static IReadOnlyDictionary<int, RollingThreadMetrics> GetRollingThreadMetricsSnapshot()
{
lock (rollingWindowsLock)
{
Dictionary<int, RollingThreadMetrics> snapshot = new Dictionary<int, RollingThreadMetrics>(rollingThreadWindows.Count);
foreach (KeyValuePair<int, RollingThreadWindow> pair in rollingThreadWindows)
{
snapshot[pair.Key] = pair.Value.Snapshot();
}
return snapshot;
}
}
[Conditional("PROFILING")]
public static void BeginFrame()
{
@@ -92,7 +595,12 @@ public static class Profiler
return;
}
currentFrame = new Frame(frameCount);
if (currentFrame != null)
{
FinalizeCurrentFrame();
}
currentFrame = RentFrame(frameCount);
}
[Conditional("PROFILING")]
@@ -103,64 +611,202 @@ public static class Profiler
return;
}
foreach (Stack<ScopeNode> scopes in threadLocalScopes.Values)
{
if (scopes.Count > 0)
{
// Pop the left over root nodes.
ScopeNode currentScope = scopes.Pop();
currentScope.End();
}
// Clean up the thread-local stack to ensure it's empty for the next frame.
scopes.Clear();
}
currentFrame.End();
Frames.Enqueue(currentFrame);
frameCount++;
FinalizeCurrentFrame();
}
[Conditional("PROFILING")]
public static void BeginSample(string label)
{
if (!IsRecording)
BeginSample(label, DefaultCategory, 0);
}
[Conditional("PROFILING")]
public static void BeginSample(string label, string category, ulong tagMask = 0)
{
if (!IsRecording || currentFrame == null)
{
return;
}
Stack<ScopeNode> 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<ScopeNode> 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)
ScopeNode rootScopeNode = new ScopeNode($"Thread-{Environment.CurrentManagedThreadId}");
int threadId = state.ThreadId;
ScopeNode rootScopeNode = RentNode(GetThreadRootLabel(threadId), DefaultCategory, 0, threadId, null);
scopes.Push(rootScopeNode);
currentFrame.RootNodes.Add(rootScopeNode); // Add root node to the frame list
frame.AddRootNode(rootScopeNode);
}
// Create a new child under the current top of the stack
ScopeNode newScope = scopes.Peek().AddChild(label);
scopes.Push(newScope); // Push new scope to the thread's stack
ScopeNode newScope = scopes.Peek().AddChild(label, category, tagMask);
scopes.Push(newScope);
newScope.NoteSetupOverhead();
}
[Conditional("PROFILING")]
public static void EndSample()
{
if (!IsRecording)
if (!IsRecording || currentFrame == null)
{
return;
}
Stack<ScopeNode> scopes = threadLocalScopes.Value;
ThreadProfilerState state = threadStates.Value;
if (state.CaptureDecisions.Count == 0)
{
return;
}
if (scopes.Count > 0)
bool captured = state.CaptureDecisions.Pop();
if (!captured)
{
return;
}
Stack<ScopeNode> scopes = state.Scopes;
if (scopes.Count > 1)
{
// Only pop if this is not the root node.
//ScopeNode currentScope = scopes.Count > 1 ? scopes.Pop() : scopes.Peek();
ScopeNode currentScope = scopes.Pop();
currentScope.End();
}
}
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 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))
{
node = new ScopeNode();
}
node.Reset(label, category, tagMask, managedThreadId, parent);
return node;
}
private static void ReturnNodeTree(ScopeNode node)
{
for (int i = 0; i < node.Children.Count; i++)
{
ReturnNodeTree(node.Children[i]);
}
node.Reset(string.Empty, DefaultCategory, 0, 0, null);
nodePool.Add(node);
}
private static void FinalizeCurrentFrame()
{
Frame frame = currentFrame;
if (frame == null)
{
return;
}
lock (registeredThreadStatesLock)
{
for (int i = 0; i < registeredThreadStates.Count; i++)
{
ThreadProfilerState state = registeredThreadStates[i];
Stack<ScopeNode> scopes = state.Scopes;
while (scopes.Count > 0)
{
scopes.Pop().End();
}
state.CaptureDecisions.Clear();
}
}
frame.End(FrameBudgetMilliseconds);
if (Frames.Enqueue(frame, out Frame evictedFrame))
{
for (int i = 0; i < evictedFrame.RootNodes.Count; i++)
{
ReturnNodeTree(evictedFrame.RootNodes[i]);
}
framePool.Add(evictedFrame);
}
UpdateRollingWindows(frame);
UpdateRollingThreadWindows(frame);
frameCount++;
currentFrame = null;
}
private static void UpdateRollingWindows(Frame frame)
{
lock (rollingWindowsLock)
{
foreach (KeyValuePair<string, LabelMetrics> pair in frame.LabelMetricsRaw)
{
if (!rollingWindows.TryGetValue(pair.Key, out RollingWindow window))
{
window = new RollingWindow(rollingWindowSize);
rollingWindows.Add(pair.Key, window);
}
window.Add(pair.Value.InclusiveMs);
}
}
}
private static void UpdateRollingThreadWindows(Frame frame)
{
lock (rollingWindowsLock)
{
foreach (KeyValuePair<int, ThreadMetrics> pair in frame.ThreadMetricsRaw)
{
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);
}
}
}
}
+523 -123
View File
@@ -5,152 +5,552 @@ namespace Nerfed.Runtime;
public static class ProfilerVisualizer
{
private const float barHeight = 20f;
private const float barPadding = 2f;
// Render the flame graph across multiple threads
public static void RenderFlameGraph(Profiler.Frame frame)
public sealed class TimelineState
{
if (frame == null) return;
if (frame.RootNodes == null) return;
// Calculate the total timeline duration (max end time across all nodes)
double totalDuration = frame.EndTime - frame.StartTime;
double startTime = frame.StartTime;
// Precompute the maximum depth for each thread's call stack
Dictionary<int, int> threadMaxDepths = new Dictionary<int, int>();
foreach (IGrouping<int, Profiler.ScopeNode> threadGroup in frame.RootNodes.GroupBy(node => node.ManagedThreadId))
{
int maxDepth = 0;
foreach (Profiler.ScopeNode rootNode in threadGroup)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(rootNode, 0));
}
threadMaxDepths[threadGroup.Key] = maxDepth;
}
// Start a child window to support scrolling
ImGui.BeginChild("FlameGraph", new Vector2(0, 64), ImGuiChildFlags.Border | ImGuiChildFlags.ResizeY, ImGuiWindowFlags.HorizontalScrollbar | ImGuiWindowFlags.AlwaysVerticalScrollbar);
ImDrawListPtr drawList = ImGui.GetWindowDrawList();
Vector2 windowPos = ImGui.GetCursorScreenPos();
// Sort nodes by ThreadID, ensuring main thread (Thread ID 1) is on top
IOrderedEnumerable<IGrouping<int, Profiler.ScopeNode>> threadGroups = frame.RootNodes.GroupBy(node => node.ManagedThreadId).OrderBy(g => g.Key);
// Initial Y position for drawing
float baseY = windowPos.Y;
bool alternate = false;
float contentWidth = ImGui.GetContentRegionAvail().X;
// Draw each thread's flame graph row by row
foreach (IGrouping<int, Profiler.ScopeNode> threadGroup in threadGroups)
{
int threadId = threadGroup.Key;
// Compute the base Y position for this thread
float threadBaseY = baseY;
// Calculate the maximum height for this thread's flame graph
float threadHeight = (threadMaxDepths[threadId] + 1) * (barHeight + barPadding);
// Draw the alternating background for each thread row
uint backgroundColor = ImGui.ColorConvertFloat4ToU32(alternate ? new Vector4(0.2f, 0.2f, 0.2f, 1f) : new Vector4(0.1f, 0.1f, 0.1f, 1f));
drawList.AddRectFilled(new Vector2(windowPos.X, threadBaseY), new Vector2(windowPos.X + contentWidth, threadBaseY + threadHeight), backgroundColor);
alternate = !alternate;
// Draw each root node in the group (one per thread)
foreach (Profiler.ScopeNode rootNode in threadGroup)
{
RenderNode(drawList, rootNode, startTime, totalDuration, windowPos.X, threadBaseY, 0, contentWidth, false);
}
// Move to the next thread's row (max depth * height per level)
baseY += (threadMaxDepths[threadId] + 1) * (barHeight + barPadding);
}
// Ensure that ImGui knows the size of the content.
ImGui.Dummy(new Vector2(contentWidth, baseY));
ImGui.EndChild();
public int SelectedFrameIndex = -1;
public int WindowStartIndex = 0;
public int VisibleFrameCount = 64;
public bool FollowLatest = true;
public float Zoom = 1f;
public double PanTicks = 0;
}
private static void RenderNode(ImDrawListPtr drawList, Profiler.ScopeNode node, double startTime, double totalDuration, float startX, float baseY, int depth, float contentWidth, bool alternate)
public readonly struct TimelineRenderResult
{
if (node == null) return;
double nodeStartTime = node.StartTime - startTime;
double nodeEndTime = node.EndTime - startTime;
double nodeDuration = nodeEndTime - nodeStartTime;
// Calculate the position and width of the bar based on time
float xPos = (float)(startX + (nodeStartTime / totalDuration) * contentWidth);
float width = (float)((nodeDuration / totalDuration) * contentWidth);
// Calculate the Y position based on depth
float yPos = baseY + (depth * (barHeight + barPadding)) + (barPadding * 0.5f);
// Define the rectangle bounds for the node
Vector2 min = new Vector2(xPos, yPos);
Vector2 max = new Vector2(xPos + width, yPos + barHeight);
// Define color.
Vector4 barColor = alternate ? new Vector4(0.4f, 0.6f, 0.9f, 1f) : new Vector4(0.4f, 0.5f, 0.8f, 1f);
Vector4 textColor = new Vector4(1f, 1f, 1f, 1f);
if (depth != 0)
public TimelineRenderResult(int selectedFrameIndex, bool selectionChanged, bool userNavigated)
{
// Draw the bar for the node (colored based on thread depth)
drawList.AddRectFilled(min, max, ImGui.ColorConvertFloat4ToU32(barColor));
SelectedFrameIndex = selectedFrameIndex;
SelectionChanged = selectionChanged;
UserNavigated = userNavigated;
}
// Draw the label if it fits inside the bar
string label = $"{node.Label} ({node.ElapsedMilliseconds():0.000} ms)";
if (width > ImGui.CalcTextSize(label).X)
public int SelectedFrameIndex { get; }
public bool SelectionChanged { get; }
public bool UserNavigated { get; }
}
private readonly struct HoverEntry
{
public HoverEntry(Profiler.ScopeNode node, Profiler.Frame frame, int frameIndex, int depth, double timelineStartTicks)
{
Node = node;
Frame = frame;
FrameIndex = frameIndex;
Depth = depth;
DurationMs = TicksToMilliseconds(node.EndTime - node.StartTime);
SelfMs = node.SelfMilliseconds();
StartInFrameMs = TicksToMilliseconds(node.StartTime - frame.StartTime);
EndInFrameMs = TicksToMilliseconds(node.EndTime - frame.StartTime);
StartInTimelineMs = TicksToMilliseconds(node.StartTime - timelineStartTicks);
EndInTimelineMs = TicksToMilliseconds(node.EndTime - timelineStartTicks);
}
public Profiler.ScopeNode Node { get; }
public Profiler.Frame Frame { get; }
public int FrameIndex { get; }
public int Depth { get; }
public double DurationMs { get; }
public double SelfMs { get; }
public double StartInFrameMs { get; }
public double EndInFrameMs { get; }
public double StartInTimelineMs { get; }
public double EndInTimelineMs { get; }
}
private const float BarHeight = 18f;
private const float BarPadding = 2f;
private const float ThreadGap = 8f;
private const float HeaderHeight = 28f;
private const float TimelineHeight = 220f;
private const float MinTextWidth = 36f;
private static readonly double TickToMs = 1000d / System.Diagnostics.Stopwatch.Frequency;
// Backwards-compatible entry point used by existing call sites.
public static void RenderFlameGraph(Profiler.Frame frame)
{
if (frame == null)
{
return;
}
List<Profiler.Frame> frames = new List<Profiler.Frame>(1)
{
frame
};
TimelineState state = new TimelineState
{
VisibleFrameCount = 1,
SelectedFrameIndex = 0,
FollowLatest = true
};
RenderTimeline(frames, state);
}
public static TimelineRenderResult RenderTimeline(IReadOnlyList<Profiler.Frame> frames, TimelineState state)
{
if (frames == null || frames.Count == 0 || state == null)
{
return default;
}
bool selectionChanged = false;
bool userNavigated = false;
int frameCount = frames.Count;
state.VisibleFrameCount = Math.Clamp(state.VisibleFrameCount, 1, frameCount);
state.Zoom = Math.Clamp(state.Zoom, 1f, 128f);
int maxStartIndex = Math.Max(0, frameCount - state.VisibleFrameCount);
if (state.FollowLatest)
{
state.WindowStartIndex = maxStartIndex;
}
else
{
state.WindowStartIndex = Math.Clamp(state.WindowStartIndex, 0, maxStartIndex);
}
int visibleStartIndex = state.WindowStartIndex;
int visibleEndIndex = visibleStartIndex + state.VisibleFrameCount - 1;
if (state.SelectedFrameIndex < 0)
{
state.SelectedFrameIndex = visibleEndIndex;
selectionChanged = true;
}
state.SelectedFrameIndex = Math.Clamp(state.SelectedFrameIndex, visibleStartIndex, visibleEndIndex);
Profiler.Frame firstFrame = frames[visibleStartIndex];
Profiler.Frame lastFrame = frames[visibleEndIndex];
double timelineStartTicks = firstFrame.StartTime;
double timelineEndTicks = Math.Max(lastFrame.EndTime, firstFrame.StartTime + 1);
double timelineDurationTicks = Math.Max(1d, timelineEndTicks - timelineStartTicks);
double visibleDurationTicks = Math.Max(1d, timelineDurationTicks / state.Zoom);
double maxPanTicks = Math.Max(0d, timelineDurationTicks - visibleDurationTicks);
if (state.FollowLatest)
{
state.PanTicks = maxPanTicks;
}
else
{
state.PanTicks = Math.Clamp(state.PanTicks, 0d, maxPanTicks);
}
double visibleStartTicks = timelineStartTicks + state.PanTicks;
double visibleEndTicks = visibleStartTicks + visibleDurationTicks;
Dictionary<int, int> threadDepths = BuildThreadDepths(frames, visibleStartIndex, visibleEndIndex);
List<int> threadOrder = threadDepths.Keys.OrderBy(x => x).ToList();
Dictionary<int, float> threadBaseY = new Dictionary<int, float>(threadOrder.Count);
float yCursor = HeaderHeight;
for (int i = 0; i < threadOrder.Count; i++)
{
int threadId = threadOrder[i];
threadBaseY[threadId] = yCursor;
yCursor += ((threadDepths[threadId] + 1) * (BarHeight + BarPadding)) + ThreadGap;
}
float contentHeight = Math.Max(TimelineHeight, yCursor + 6f);
ImGui.BeginChild("ProfilerTimeline", new Vector2(0, TimelineHeight), ImGuiChildFlags.Border | ImGuiChildFlags.ResizeY, ImGuiWindowFlags.AlwaysVerticalScrollbar);
ImDrawListPtr drawList = ImGui.GetWindowDrawList();
Vector2 origin = ImGui.GetCursorScreenPos();
Vector2 viewSize = ImGui.GetContentRegionAvail();
float canvasWidth = Math.Max(1f, viewSize.X);
uint frameBgColor = ImGui.GetColorU32(ImGuiCol.FrameBg);
uint frameBgHoveredColor = ImGui.GetColorU32(ImGuiCol.FrameBgHovered);
uint headerColor = ImGui.GetColorU32(ImGuiCol.Header);
uint headerHoveredColor = ImGui.GetColorU32(ImGuiCol.HeaderHovered);
uint textColor = ImGui.GetColorU32(ImGuiCol.Text);
uint borderColor = ImGui.GetColorU32(ImGuiCol.Border);
float clipMinX = origin.X;
float clipMaxX = origin.X + canvasWidth;
float clipMinY = origin.Y;
float clipMaxY = origin.Y + Math.Max(1f, ImGui.GetWindowHeight());
DrawTimelineHeader(drawList, origin, canvasWidth, timelineStartTicks, visibleStartTicks, visibleDurationTicks, textColor, borderColor);
HoverEntry? hovered = null;
for (int frameIndex = visibleStartIndex; frameIndex <= visibleEndIndex; frameIndex++)
{
Profiler.Frame frame = frames[frameIndex];
float frameStartX = ToScreenX(frame.StartTime, visibleStartTicks, visibleDurationTicks, origin.X, canvasWidth);
float frameEndX = ToScreenX(frame.EndTime, visibleStartTicks, visibleDurationTicks, origin.X, canvasWidth);
if (frameEndX < clipMinX || frameStartX > clipMaxX)
{
drawList.AddText(new Vector2(xPos + barPadding, yPos + barPadding), ImGui.ColorConvertFloat4ToU32(textColor), label);
continue;
}
// Add tooltip on hover
if (ImGui.IsMouseHoveringRect(min, max))
bool isSelectedFrame = frameIndex == state.SelectedFrameIndex;
uint frameShadeColor = (frameIndex & 1) == 0 ? frameBgColor : frameBgHoveredColor;
if (isSelectedFrame)
{
// Show tooltip when hovering over the node
ImGui.BeginTooltip();
ImGui.Text($"{node.Label}");
ImGui.Text($"{node.ElapsedMilliseconds():0.000} ms");
ImGui.Text($"{node.ManagedThreadId}");
ImGui.EndTooltip();
frameShadeColor = LerpColor(headerColor, headerHoveredColor, 0.45f);
}
drawList.AddRectFilled(new Vector2(frameStartX, origin.Y + HeaderHeight), new Vector2(frameEndX, origin.Y + contentHeight), SetAlpha(frameShadeColor, isSelectedFrame ? 0.20f : 0.08f));
drawList.AddLine(new Vector2(frameStartX, origin.Y + HeaderHeight), new Vector2(frameStartX, origin.Y + contentHeight), SetAlpha(borderColor, 0.55f), 1f);
foreach (Profiler.ScopeNode root in frame.RootNodes)
{
if (!threadBaseY.TryGetValue(root.ManagedThreadId, out float baseY))
{
continue;
}
DrawThreadLabel(drawList, origin.X, origin.Y + baseY, root.ManagedThreadId, textColor);
for (int i = 0; i < root.Children.Count; i++)
{
RenderNode(
drawList,
root.Children[i],
frame,
frameIndex,
baseY,
0,
visibleStartTicks,
visibleDurationTicks,
origin.X,
origin.Y,
canvasWidth,
clipMinX,
clipMaxX,
clipMaxY,
ref hovered,
textColor,
headerColor,
headerHoveredColor,
frameIndex == state.SelectedFrameIndex);
}
}
drawList.AddLine(new Vector2(frameEndX, origin.Y + HeaderHeight), new Vector2(frameEndX, origin.Y + contentHeight), SetAlpha(borderColor, 0.30f), 1f);
}
ImGui.Dummy(new Vector2(canvasWidth, contentHeight));
bool windowHovered = ImGui.IsWindowHovered(ImGuiHoveredFlags.ChildWindows);
if (windowHovered)
{
HandleZoomAndPan(state, timelineStartTicks, timelineDurationTicks, visibleDurationTicks, origin.X, canvasWidth, ref userNavigated);
}
if (windowHovered && ImGui.IsMouseClicked(ImGuiMouseButton.Left))
{
int clickedFrame = FindFrameIndexByMouseX(frames, visibleStartIndex, visibleEndIndex, visibleStartTicks, visibleDurationTicks, origin.X, canvasWidth, ImGui.GetMousePos().X);
if (clickedFrame >= visibleStartIndex && clickedFrame <= visibleEndIndex && clickedFrame != state.SelectedFrameIndex)
{
state.SelectedFrameIndex = clickedFrame;
selectionChanged = true;
state.FollowLatest = false;
userNavigated = true;
}
}
if (hovered.HasValue)
{
if (ImGui.IsMouseClicked(ImGuiMouseButton.Left) && hovered.Value.FrameIndex != state.SelectedFrameIndex)
{
state.SelectedFrameIndex = hovered.Value.FrameIndex;
selectionChanged = true;
state.FollowLatest = false;
userNavigated = true;
}
DrawTooltip(hovered.Value);
}
ImGui.EndChild();
return new TimelineRenderResult(state.SelectedFrameIndex, selectionChanged, userNavigated);
}
private static void HandleZoomAndPan(TimelineState state, double timelineStartTicks, double timelineDurationTicks, double visibleDurationTicks, float originX, float canvasWidth, ref bool userNavigated)
{
ImGuiIOPtr io = ImGui.GetIO();
if (Math.Abs(io.MouseWheel) < float.Epsilon)
{
return;
}
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, wheel), 1f, 128f);
if (Math.Abs(previousZoom - state.Zoom) > float.Epsilon)
{
double newVisibleDurationTicks = Math.Max(1d, timelineDurationTicks / state.Zoom);
double newVisibleStartTicks = pivotTick - (newVisibleDurationTicks * mouseT);
state.PanTicks = Math.Clamp(newVisibleStartTicks - timelineStartTicks, 0d, Math.Max(0d, timelineDurationTicks - newVisibleDurationTicks));
state.FollowLatest = false;
userNavigated = true;
}
}
else
{
// Aka root node.
string label = $"{node.Label}";
drawList.AddText(new Vector2(startX + barPadding, yPos + barPadding), ImGui.ColorConvertFloat4ToU32(textColor), label);
}
// Draw each child node under this node
foreach (Profiler.ScopeNode child in node.Children)
{
alternate = !alternate;
RenderNode(drawList, child, startTime, totalDuration, startX, baseY, depth + 1, contentWidth, alternate);
// Shift + scroll: horizontal pan
state.PanTicks -= wheel * (visibleDurationTicks * 0.10d);
state.FollowLatest = false;
userNavigated = true;
}
}
// Recursive function to calculate the maximum depth of the node tree
private static int GetMaxDepth(Profiler.ScopeNode node, int currentDepth)
private static int FindFrameIndexByMouseX(IReadOnlyList<Profiler.Frame> frames, int visibleStartIndex, int visibleEndIndex, double visibleStartTicks, double visibleDurationTicks, float originX, float width, float mouseX)
{
if (node.Children == null || node.Children.Count == 0)
double t = Math.Clamp((mouseX - originX) / Math.Max(1f, width), 0f, 1f);
double timelineTicks = visibleStartTicks + (visibleDurationTicks * t);
for (int i = visibleStartIndex; i <= visibleEndIndex; i++)
{
return currentDepth;
Profiler.Frame frame = frames[i];
if (timelineTicks >= frame.StartTime && timelineTicks <= frame.EndTime)
{
return i;
}
}
int maxDepth = currentDepth;
foreach (Profiler.ScopeNode child in node.Children)
return -1;
}
private static void DrawTimelineHeader(ImDrawListPtr drawList, Vector2 origin, float width, double timelineStartTicks, double visibleStartTicks, double visibleDurationTicks, uint textColor, uint borderColor)
{
drawList.AddLine(new Vector2(origin.X, origin.Y + HeaderHeight), new Vector2(origin.X + width, origin.Y + HeaderHeight), SetAlpha(borderColor, 0.65f), 1f);
int tickCount = Math.Clamp((int)(width / 130f), 4, 20);
for (int i = 0; i <= tickCount; i++)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(child, currentDepth + 1));
float t = i / (float)tickCount;
float x = origin.X + (t * width);
drawList.AddLine(new Vector2(x, origin.Y + HeaderHeight - 8f), new Vector2(x, origin.Y + HeaderHeight), SetAlpha(borderColor, 0.8f), 1f);
double ms = TicksToMilliseconds((visibleStartTicks - timelineStartTicks) + (visibleDurationTicks * t));
drawList.AddText(new Vector2(x + 2f, origin.Y + 4f), textColor, $"+{ms:0.0} ms");
}
}
private static void DrawThreadLabel(ImDrawListPtr drawList, float x, float y, int threadId, uint textColor)
{
drawList.AddText(new Vector2(x + 4f, y + 2f), SetAlpha(textColor, 0.85f), $"T{threadId}");
}
private static void DrawTooltip(HoverEntry hover)
{
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}");
ImGui.Text($"Duration: {hover.DurationMs:0.000} ms");
ImGui.Text($"Self: {hover.SelfMs:0.000} ms");
ImGui.Text($"Frame Start: {hover.StartInFrameMs:0.000} ms");
ImGui.Text($"Frame End: {hover.EndInFrameMs:0.000} ms");
ImGui.Text($"Timeline Start: {hover.StartInTimelineMs:0.000} ms");
ImGui.Text($"Timeline End: {hover.EndInTimelineMs:0.000} ms");
ImGui.Text($"Children: {hover.Node.Children.Count}");
if (hover.Node.ProfilerSetupBytes > 0)
{
ImGui.Separator();
ImGui.TextColored(new Vector4(1f, 0.75f, 0f, 1f), $"\u26a0 {hover.Node.ProfilerSetupBytes} B of alloc is profiler warmup overhead");
}
ImGui.EndTooltip();
}
private static void RenderNode(
ImDrawListPtr drawList,
Profiler.ScopeNode node,
Profiler.Frame frame,
int frameIndex,
float baseY,
int depth,
double visibleStartTicks,
double visibleDurationTicks,
float originX,
float originY,
float width,
float clipMinX,
float clipMaxX,
float clipMaxY,
ref HoverEntry? hovered,
uint textColor,
uint headerColor,
uint headerHoveredColor,
bool selectedFrame)
{
long nodeEndTime = Math.Max(node.EndTime, node.StartTime + 1);
if (nodeEndTime < visibleStartTicks || node.StartTime > visibleStartTicks + visibleDurationTicks)
{
return;
}
float y = originY + baseY + (depth * (BarHeight + BarPadding));
if (y > clipMaxY)
{
return;
}
float minX = ToScreenX(node.StartTime, visibleStartTicks, visibleDurationTicks, originX, width);
float maxX = ToScreenX(nodeEndTime, visibleStartTicks, visibleDurationTicks, originX, width);
if (maxX < clipMinX || minX > clipMaxX)
{
return;
}
float barWidth = Math.Max(1f, maxX - minX);
Vector2 min = new Vector2(minX, y + (BarPadding * 0.5f));
Vector2 max = new Vector2(minX + barWidth, y + (BarPadding * 0.5f) + BarHeight);
uint barColor = BuildBarColor(node.Label, depth, selectedFrame);
uint borderColor = LerpColor(headerColor, headerHoveredColor, 0.45f);
drawList.AddRectFilled(min, max, barColor, 3f);
drawList.AddRect(min, max, SetAlpha(borderColor, 0.55f), 3f, ImDrawFlags.None, 1f);
Vector2 mousePos = ImGui.GetMousePos();
bool isHovered = mousePos.X >= min.X && mousePos.X <= max.X && mousePos.Y >= min.Y && mousePos.Y <= max.Y;
if (isHovered)
{
hovered = new HoverEntry(node, frame, frameIndex, depth, visibleStartTicks);
drawList.AddRect(min, max, headerHoveredColor, 3f, ImDrawFlags.None, 1.5f);
}
if (barWidth > MinTextWidth)
{
string label = node.Label;
float textWidth = ImGui.CalcTextSize(label).X;
if (textWidth + 8f <= barWidth)
{
drawList.AddText(new Vector2(min.X + 4f, min.Y + 2f), textColor, label);
}
}
for (int i = 0; i < node.Children.Count; i++)
{
RenderNode(
drawList,
node.Children[i],
frame,
frameIndex,
baseY,
depth + 1,
visibleStartTicks,
visibleDurationTicks,
originX,
originY,
width,
clipMinX,
clipMaxX,
clipMaxY,
ref hovered,
textColor,
headerColor,
headerHoveredColor,
selectedFrame);
}
}
private static Dictionary<int, int> BuildThreadDepths(IReadOnlyList<Profiler.Frame> frames, int startIndex, int endIndex)
{
Dictionary<int, int> threadMaxDepths = new Dictionary<int, int>(8);
for (int frameIndex = startIndex; frameIndex <= endIndex; frameIndex++)
{
foreach (Profiler.ScopeNode root in frames[frameIndex].RootNodes)
{
int maxDepth = 0;
for (int i = 0; i < root.Children.Count; i++)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(root.Children[i], 0));
}
if (threadMaxDepths.TryGetValue(root.ManagedThreadId, out int currentMax))
{
if (maxDepth > currentMax)
{
threadMaxDepths[root.ManagedThreadId] = maxDepth;
}
}
else
{
threadMaxDepths[root.ManagedThreadId] = maxDepth;
}
}
}
return threadMaxDepths;
}
private static int GetMaxDepth(Profiler.ScopeNode node, int depth)
{
if (node.Children.Count == 0)
{
return depth;
}
int maxDepth = depth;
for (int i = 0; i < node.Children.Count; i++)
{
maxDepth = Math.Max(maxDepth, GetMaxDepth(node.Children[i], depth + 1));
}
return maxDepth;
}
private static uint BuildBarColor(string label, int depth, bool selectedFrame)
{
int hash = label.GetHashCode();
float hue = ((hash & 1023) / 1023f + (depth * 0.031f)) % 1f;
ImGui.ColorConvertHSVtoRGB(hue, 0.52f, selectedFrame ? 0.82f : 0.68f, out float r, out float g, out float b);
Vector4 frameBg = ImGui.ColorConvertU32ToFloat4(ImGui.GetColorU32(ImGuiCol.FrameBg));
Vector4 accent = new Vector4(r, g, b, 1f);
Vector4 mixed = Vector4.Lerp(frameBg, accent, 0.72f);
return ImGui.ColorConvertFloat4ToU32(mixed);
}
private static float ToScreenX(double ticks, double visibleStartTicks, double visibleDurationTicks, float startX, float width)
{
double normalized = (ticks - visibleStartTicks) / visibleDurationTicks;
return startX + (float)(normalized * width);
}
private static double TicksToMilliseconds(double ticks)
{
return ticks * TickToMs;
}
private static uint SetAlpha(uint color, float alpha)
{
Vector4 c = ImGui.ColorConvertU32ToFloat4(color);
c.W *= alpha;
return ImGui.ColorConvertFloat4ToU32(c);
}
private static uint LerpColor(uint a, uint b, float t)
{
Vector4 av = ImGui.ColorConvertU32ToFloat4(a);
Vector4 bv = ImGui.ColorConvertU32ToFloat4(b);
return ImGui.ColorConvertFloat4ToU32(Vector4.Lerp(av, bv, Math.Clamp(t, 0f, 1f)));
}
}
+90 -18
View File
@@ -5,6 +5,7 @@ namespace Nerfed.Runtime;
public class BoundedQueue<T> : IEnumerable<T>, ICollection, IReadOnlyCollection<T>
{
private readonly Queue<T> queue = null;
private readonly object syncLock = new object();
private readonly int maxSize = 10;
private T lastAddedElement;
@@ -16,58 +17,129 @@ public class BoundedQueue<T> : IEnumerable<T>, ICollection, IReadOnlyCollection<
public void Enqueue(T item)
{
queue.Enqueue(item);
if (queue.Count > maxSize)
{
queue.Dequeue(); // Remove the oldest element
}
Enqueue(item, out _);
}
lastAddedElement = item;
public bool Enqueue(T item, out T evictedItem)
{
lock (syncLock)
{
queue.Enqueue(item);
if (queue.Count > maxSize)
{
evictedItem = queue.Dequeue();
lastAddedElement = item;
return true;
}
evictedItem = default;
lastAddedElement = item;
return false;
}
}
public T Dequeue()
{
return queue.Dequeue();
lock (syncLock)
{
return queue.Dequeue();
}
}
public T Peek()
{
return queue.Peek();
lock (syncLock)
{
return queue.Peek();
}
}
public T LastAddedElement()
{
return lastAddedElement;
lock (syncLock)
{
return lastAddedElement;
}
}
public void Clear()
{
queue.Clear();
lock (syncLock)
{
queue.Clear();
}
}
public bool Contains(T item)
{
return queue.Contains(item);
lock (syncLock)
{
return queue.Contains(item);
}
}
// 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()
{
return queue.GetEnumerator();
T[] snapshot;
lock (syncLock)
{
snapshot = queue.ToArray();
}
return ((IEnumerable<T>)snapshot).GetEnumerator();
}
IEnumerator IEnumerable.GetEnumerator()
{
return queue.GetEnumerator();
return GetEnumerator();
}
public void CopyTo(Array array, int index)
{
((ICollection)queue).CopyTo(array, index);
lock (syncLock)
{
((ICollection)queue).CopyTo(array, index);
}
}
public int Count
{
get
{
lock (syncLock)
{
return queue.Count;
}
}
}
public int Count => queue.Count;
public int Capacity => maxSize;
public bool IsSynchronized => ((ICollection)queue).IsSynchronized;
public object SyncRoot => ((ICollection)queue).SyncRoot;
int IReadOnlyCollection<T>.Count => queue.Count;
public bool IsSynchronized => true;
public object SyncRoot => syncLock;
int IReadOnlyCollection<T>.Count
{
get
{
lock (syncLock)
{
return queue.Count;
}
}
}
}