using System.Collections.Concurrent; using System.Diagnostics; namespace Nerfed.Runtime; public struct ProfilerScope : IDisposable { public ProfilerScope(string label) { Profiler.BeginSample(label); } public ProfilerScope(string label, string category, ulong tagMask = 0) { Profiler.BeginSample(label, category, tagMask); } public void Dispose() { Profiler.EndSample(); } } public static class Profiler { public enum CaptureMode { Instrumented = 0, SampledInstrumentation = 1, } private sealed class ThreadProfilerState { public readonly Stack Scopes = new Stack(); public readonly Stack CaptureDecisions = new Stack(); public int SampleCursor; public int ThreadId; } public readonly struct LabelMetrics { public LabelMetrics(double inclusiveMs, double selfMs, uint calls, double minInclusiveMs, double maxInclusiveMs, 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 rootNodes = new List(8); private readonly object rootNodesLock = new object(); private readonly Dictionary labelMetrics = new Dictionary(128, StringComparer.Ordinal); private readonly Dictionary categoryMetrics = new Dictionary(32, StringComparer.Ordinal); private readonly Dictionary threadMetrics = new Dictionary(16); private readonly List knownThreadIds = new List(16); // avoids Keys.ToArray() in ApplyThreadBudgetFlags public uint FrameCount { get; private set; } public long StartTime { get; private set; } public long EndTime { get; private set; } public IReadOnlyList RootNodes => rootNodes; public IReadOnlyDictionary LabelMetrics => labelMetrics; public IReadOnlyDictionary CategoryMetrics => categoryMetrics; public IReadOnlyDictionary ThreadMetrics => threadMetrics; // Return concrete types so callers can use the struct enumerator and avoid boxing. internal Dictionary LabelMetricsRaw => labelMetrics; internal Dictionary ThreadMetricsRaw => threadMetrics; public long AllocatedBytesStart { get; private set; } public long AllocatedBytesEnd { get; private set; } public long AllocatedBytesDelta { get; private set; } public int Gen0CollectionsStart { get; 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() { 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 { public string Label { get; private set; } = string.Empty; public string Category { get; private set; } = DefaultCategory; public ulong TagMask { get; private set; } public long StartTime { get; private set; } public long EndTime { get; private set; } public int ManagedThreadId { get; private set; } public List Children { get; } = new List(); public long AllocatedBytes { 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; Children.Clear(); allocatedBytesAtStart = GC.GetAllocatedBytesForCurrentThread(); } internal void End() { if (EndTime != 0) { return; } EndTime = Stopwatch.GetTimestamp(); AllocatedBytes = Math.Max(0, GC.GetAllocatedBytesForCurrentThread() - allocatedBytesAtStart); if (Parent != null) { Parent.ChildrenDurationTicks += Math.Max(0, EndTime - StartTime); Parent.ChildrenAllocatedBytes += AllocatedBytes; } } public double ElapsedMilliseconds() { return ((double)(Math.Max(0, EndTime - StartTime))) * 1000 / Stopwatch.Frequency; } public double SelfMilliseconds() { 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); } 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; public static readonly BoundedQueue Frames = new(maxFrames); // trackAllValues=false; registeredThreadStates avoids ThreadLocal.Values allocating a ReadOnlyCollection each call private static readonly ThreadLocal threadStates = new ThreadLocal(() => { ThreadProfilerState state = new ThreadProfilerState(); lock (registeredThreadStatesLock) { registeredThreadStates.Add(state); } return state; }); private static readonly List registeredThreadStates = new List(8); private static readonly object registeredThreadStatesLock = new object(); private static readonly ConcurrentDictionary threadRootLabelCache = new ConcurrentDictionary(); private static readonly ConcurrentBag nodePool = new ConcurrentBag(); private static readonly ConcurrentBag framePool = new ConcurrentBag(); // pooled; avoids per-frame Frame allocation private static readonly Dictionary rollingWindows = new Dictionary(256, StringComparer.Ordinal); private static readonly Dictionary rollingThreadWindows = new Dictionary(16); private static readonly object rollingWindowsLock = new object(); 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 destination) { return Frames.CopyTo(destination); } public static IReadOnlyDictionary GetRollingLabelMetricsSnapshot() { lock (rollingWindowsLock) { Dictionary snapshot = new Dictionary(rollingWindows.Count, StringComparer.Ordinal); foreach (KeyValuePair pair in rollingWindows) { snapshot[pair.Key] = pair.Value.Snapshot(); } return snapshot; } } public static IReadOnlyDictionary GetRollingThreadMetricsSnapshot() { lock (rollingWindowsLock) { Dictionary snapshot = new Dictionary(rollingThreadWindows.Count); foreach (KeyValuePair pair in rollingThreadWindows) { snapshot[pair.Key] = pair.Value.Snapshot(); } return snapshot; } } [Conditional("PROFILING")] public static void BeginFrame() { if (!IsRecording) { return; } if (currentFrame != null) { FinalizeCurrentFrame(); } currentFrame = RentFrame(frameCount); } [Conditional("PROFILING")] public static void EndFrame() { if (!IsRecording) { return; } FinalizeCurrentFrame(); } [Conditional("PROFILING")] public static void BeginSample(string label) { BeginSample(label, DefaultCategory, 0); } [Conditional("PROFILING")] public static void BeginSample(string label, string category, ulong tagMask = 0) { if (!IsRecording || currentFrame == null) { return; } ThreadProfilerState state = threadStates.Value; state.ThreadId = Environment.CurrentManagedThreadId; bool parentCaptured = state.CaptureDecisions.Count > 0 && state.CaptureDecisions.Peek(); bool capture = parentCaptured || Mode == CaptureMode.Instrumented || ShouldSample(state); state.CaptureDecisions.Push(capture); if (!capture) { return; } Stack scopes = state.Scopes; Frame frame = currentFrame; if (frame == null) { state.CaptureDecisions.Pop(); return; } if (scopes.Count == 0) { int threadId = state.ThreadId; ScopeNode rootScopeNode = RentNode(GetThreadRootLabel(threadId), DefaultCategory, 0, threadId, null); scopes.Push(rootScopeNode); frame.AddRootNode(rootScopeNode); } ScopeNode newScope = scopes.Peek().AddChild(label, category, tagMask); scopes.Push(newScope); } [Conditional("PROFILING")] public static void EndSample() { if (!IsRecording || currentFrame == null) { return; } ThreadProfilerState state = threadStates.Value; if (state.CaptureDecisions.Count == 0) { return; } bool captured = state.CaptureDecisions.Pop(); if (!captured) { return; } Stack scopes = state.Scopes; if (scopes.Count > 1) { 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 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 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 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); } } } }