Stopwatch Groups in C#

close upStopwatch Groups in C#close up

Posted: July 27, 2025

Last Updated: July 29, 2025

Stopwatch Groups in C#

Stopwatches. I use Stopwatches in C# as a basic way to time various systems in my games. Using those stopwatches, I can measure and display charts with my timing data to give me immediate, in-game feedback about what systems are causing slowdowns. But, I had a problem. Sometimes I had multiple parts to multiple systems. After 10 or so stopwatches, it became annoying to have a completely individual timing for each measured section of code. I needed a way to group timings together into categories. For example, I might want a tree of timings that I can combine or separate at-will to get a better look at some systems. Maybe a tree that looks like this:

  • Gameplay
    • Movement
    • Collision
      • Gather
      • Update
  • Rendering

If I want, I can check the processing time for whole groups (e.g. Gameplay, Gameplay/Collision, or Rendering), or for individual stopwatch timings (e.g. Gameplay/Collision/Gather). From these requirements, I came up with StopwatchGroups. Below is all the code for this, split into categories:

Code Implementation:

IStopwatchInfo:

// * * * * *
// Original Author: Orcsune
// Date: July 27, 2025
// License: MIT License
// * * * * *

namespace Orcsune.Core.ODebug.Profiling {
    public interface IStopwatchInfo {
        double[] windowTimes { get; set; }
        double lastTime { get; set; }
        int windowSize { get; set; }
        double avgTime { get; set; }
        double maxTime { get; set; }
        double minTime { get; set; }
    }
}

The IStopwatchInfo interface essentially defines the output structure of any output point in the tree of stopwatches. Whether you retrieve the information about an individual StopwatchRecorder or a whole StopwatchGroup, the output should be an IStopwatchInfo type. This type provides an array of times recorded by a Stopwatch as well as some basic statistics about the set of times.

IStopwatchInfoProvider:

// * * * * *
// Original Author: Orcsune
// Date: July 27, 2025
// License: MIT License
// * * * * *

namespace Orcsune.Core.ODebug.Profiling {
    public interface IStopwatchInfoProvider {
        public IStopwatchInfo GetInfo();
    }
}

The IStopwatchInfoProvider interface is exactly what it sounds like. It defines a type that can produce IStopwatchInfo type instances. The StopwatchRecorder and StopwatchGroup both implement this interface.

StopwatchInfo:

// * * * * *
// Original Author: Orcsune
// Date: July 27, 2025
// License: MIT License
// * * * * *

namespace Orcsune.Core.ODebug.Profiling {
    public struct StopwatchInfo : IStopwatchInfo {
        public double[] windowTimes { get; set; }
        public double lastTime { get; set; }
        public int windowSize { get; set; }
        public double avgTime { get; set; }
        public double maxTime { get; set; }
        public double minTime { get; set; }
    }
}

The StopwatchInfo is just a one-to-one implementation of the IStopwatchInfo interface as a struct. It just provides properties for each of those defined in the interface. The properties are filled when the provider produces the info output.

StopwatchGroupInfo:

// * * * * *
// Original Author: Orcsune
// Date: July 27, 2025
// License: MIT License
// * * * * *

using System.Collections.Generic;
using System.Linq;

namespace Orcsune.Core.ODebug.Profiling {
    public struct StopwatchGroupInfo : IStopwatchInfo {
        public double[] windowTimes { get; set; }
        public double lastTime { get; set; }
        public int windowSize { get; set; }
        public double avgTime { get; set; }
        public double maxTime { get; set; }
        public double minTime { get; set; }

        public StopwatchGroupInfo(IEnumerable<StopwatchRecorder> recorders) : this(recorders.Select(r=>r.GetInfo()).Cast<IStopwatchInfo>()) {}
        public StopwatchGroupInfo(IEnumerable<IStopwatchInfo> infos) {
            int maxWindowSize = 0;
            foreach (IStopwatchInfo info in infos) { if (info.windowSize > maxWindowSize) { maxWindowSize = info.windowSize; } }
            windowTimes = new double[maxWindowSize];
            lastTime = 0;
            windowSize = maxWindowSize;
            avgTime = 0;
            maxTime = 0;
            minTime = 0;
            // Sum relevant properties from my constituent recorder infos
            foreach (IStopwatchInfo info in infos) {
                ApplyInfo(info);
            }
        }

        private void ApplyInfo(IStopwatchInfo info) {
            for (int i = 0; i < info.windowTimes.Length; i++) { windowTimes[i] += info.windowTimes[i]; }
            lastTime += info.lastTime;
            // windowSize does not change
            avgTime += info.avgTime;
            maxTime += info.maxTime;
            minTime += info.minTime;
        }
    }
}

The StopwatchGroupInfo implementation gets a little more complicated. This structure represents the output from a StopwatchGroup. It is created from multiple other IStopwatchInfo types. For each IStopwatchInfo provided, the StopwatchGroupInfo accumulates all the relevant statistics from each into itself, thus representing the sum of times of each constituent info.

StopwatchRecorder:

// * * * * *
// Original Author: Orcsune
// Date: July 27, 2025
// License: MIT License
// * * * * *

using System;
using System.Diagnostics;

namespace Orcsune.Core.ODebug.Profiling {
    public class StopwatchRecorder : IStopwatchInfoProvider
    {
        public int counter { get; private set; }
        public double lastTime { get; private set; }
        double[] times;
        public int windowSize {
            get => times.Length;
        }
        double avgWindowTime;
        double maxTime;
        int maxTimeIndex;
        double minTime;
        int minTimeIndex;

        Stopwatch timer;

        public TimeSpan Elapsed { get => timer.Elapsed; }
        public long ElapsedMilliseconds { get => timer.ElapsedMilliseconds; }
        public long ElapsedTicks { get => timer.ElapsedTicks; }
        public bool IsRunning { get => timer.IsRunning; }

        public StopwatchRecorder() {
            timer = new Stopwatch();
            timer.Reset();
            ChangeWindowSize(30);
        }
        public StopwatchRecorder(int windowSize) {
            timer = new Stopwatch();
            timer.Reset();
            ChangeWindowSize(windowSize);
        }

        public IStopwatchInfo GetInfo() {
            avgWindowTime = 0;
            foreach (double t in times) { avgWindowTime += t; }
            avgWindowTime /= windowSize;
            return new StopwatchInfo {
                windowTimes = times,
                lastTime = lastTime,
                windowSize = windowSize,
                avgTime = avgWindowTime,
                maxTime = maxTime,
                minTime = minTime
            };
        }

        public void Reset() => timer.Reset();
        public void Restart() => timer.Restart();
        public void Start() => timer.Start();
        public void Stop() => timer.Stop();
        public override string ToString() {
            return $"StopwatchRecorder(Stopwatch={timer.ToString()})";
        }
        

        public void LogTime() {
            LogTimeAtCurrentIndex();
            counter += 1;
        }
        /// <summary>
        /// Adds the timer's currently elapsed time to the previously
        /// recorded time.
        /// This is useful when you have run LogTime, but continued the stopwatch
        /// and want to replace the old value with the new one.
        /// </summary>
        public void ReplaceTimeAtPriorIndex() {
            // Get the prior index
            int idx = (int)(counter+times.Length+1)%times.Length;
            lastTime = Elapsed.TotalMilliseconds;
            times[idx] = Elapsed.TotalMilliseconds;
            ReanalyzeWindowMetrics(idx);
            counter += 1;
        }

        public void LogTimeAtCurrentIndex() {
            int idx = (int)counter%times.Length;
            lastTime = Elapsed.TotalMilliseconds;
            times[idx] = Elapsed.TotalMilliseconds;
            ReanalyzeWindowMetrics(idx);
        }

        private void ReanalyzeWindowMetrics(int idx) {
            // Find the max time in the window
            if (maxTimeIndex == idx) {
                // If old max index is current index, then we could have replaced
                // the old value at index, so we should look through all times for a max.
                maxTime = 0;
                for (int i = 0; i < times.Length; i++) { if (times[i] > maxTime) { maxTime = times[i]; maxTimeIndex = i; }}
            } else {
                // Just look at the current index. If it is larger than the old max, replace the old max with this time
                if (times[idx] > maxTime) {
                    maxTime = times[idx];
                    maxTimeIndex = idx;
                }
            }
            // Find the min time in the window
            if (minTimeIndex == idx) {
                // If old min index is current index, then we could have replaced
                // the old value at index, so we should look through all times for a min.
                minTime = double.MaxValue;
                for (int i = 0; i < times.Length; i++) { if (times[i] < minTime) { minTime = times[i]; minTimeIndex = i; }}
            } else {
                // Just look at the current index. If it is smaller than the old min, replace the old min with this time
                if (times[idx] < minTime) {
                    minTime = times[idx];
                    minTimeIndex = idx;
                }
            }
        }

        public void ChangeWindowSize(int newSize) {
            if (newSize < 1) { return; }
            counter = 0;
            maxTime = 0;
            maxTimeIndex = 0;
            minTime = 0;
            minTimeIndex = 0;
            times = new double[newSize];
        }
    }
}

The StopwatchRecorder is like an amalgamation between a ComparableSlidingWindow and a stopwatch. The simplest way to use it is by running Start, Stop, and then LogTime. Make sure to run Reset afterwards as well. Note that this class implements IStopwatchInfoProvider. In it’s GetInfo method, it simply fills out a StopwatchInfo instance with the values recorded by the stopwatch and returns it. The sliding window functionality is helpful if you want to measure a moving average of times on the stopwatch. Alternatively, you can just ignore this feature by setting the window size to 1 or simply using the lastTime property in the StopwatchInfo.

StopwatchGroup:

// * * * * *
// Original Author: Orcsune
// Date: July 27, 2025
// License: MIT License
// * * * * *

using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;

namespace Orcsune.Core.ODebug.Profiling {
    public class StopwatchGroup : IStopwatchInfoProvider {
        public static char sep = '/';
        public ConcurrentDictionary<string, StopwatchRecorder> stopwatchMap;
        public ConcurrentDictionary<string, StopwatchGroup> groupMap;
        private int defaultWindowSize;

        public StopwatchGroup() {
            defaultWindowSize = 30;
            stopwatchMap = new ConcurrentDictionary<string, StopwatchRecorder>();
            groupMap = new ConcurrentDictionary<string, StopwatchGroup>();
        }
        public StopwatchGroup(int windowSize) {
            defaultWindowSize = windowSize;
            stopwatchMap = new ConcurrentDictionary<string, StopwatchRecorder>();
            groupMap = new ConcurrentDictionary<string, StopwatchGroup>();
        }

        public Dictionary<string, IStopwatchInfo> GetLeafInfos(Dictionary<string, IStopwatchInfo> currentInfos = null, string currentPath="") {
            Dictionary<string, IStopwatchInfo> infos = currentInfos == null ?
                                                        new Dictionary<string, IStopwatchInfo>() :
                                                        currentInfos;
            foreach ((string name, StopwatchRecorder recorder) in stopwatchMap) {
                infos.Add(currentPath + sep + name, recorder.GetInfo());
            }
            foreach ((string name, StopwatchGroup subgroup) in groupMap) {
                subgroup.GetLeafInfos(infos, name);
            }
            return infos;
        }

        private StopwatchGroupInfo GetMyStopwatchInfo() {
            return new StopwatchGroupInfo(
                stopwatchMap.Values
                    .Select(sw => sw.GetInfo())
            );
        }
        public IStopwatchInfo GetInfo() {
            StopwatchGroupInfo consolidatedInfo = new StopwatchGroupInfo(
                stopwatchMap.Values
                    .Select(sw => sw.GetInfo())
                    .Concat(groupMap.Values
                        .Select(g => g.GetInfo()))
            );
            return consolidatedInfo;
        }

        private string[] SplitBaseName(string path) {
            return path.Split(sep, 2);
        }

        public bool TryGetInfoProvider(string path, out IStopwatchInfoProvider provider) {
            provider = GetInfoProvider(path);
            return provider != null;
        }
        public IStopwatchInfoProvider GetInfoProvider(string path) {
            string[] parts = SplitBaseName(path);
            string baseName = parts[0];
            // If we are on the last part of the path, then we need a provider now
            if (parts.Length == 1) {
                if (stopwatchMap.ContainsKey(baseName)) {
                    return stopwatchMap[baseName];
                }
                else if (groupMap.ContainsKey(baseName)) {
                    return groupMap[baseName];
                }
                else {
                    return null;
                }
            }
            // We are not at the end of the path, now look through my subgroups
            else {
                string extraPath = parts[1];
                // If we don't have a group called baseName, then we can't
                // look any further
                if (!groupMap.ContainsKey(baseName)) {
                    return null;
                }
                // By here, we MUST have a group called baseName,
                // so call GetInfoProvider on it
                return groupMap[baseName].GetInfoProvider(extraPath);
            }
        }

        public StopwatchRecorder GetRecorder(string path) {
            string[] parts = SplitBaseName(path);
            string baseName = parts[0];
            // If we are on the last part of the path, then we need a recorder now
            if (parts.Length == 1) {
                if (!stopwatchMap.ContainsKey(baseName)) {
                    CreateRecorderAtPath(baseName);
                }
                return stopwatchMap[baseName];
            }
            // We are not at the end of the path, now look through my subgroups
            else {
                string extraPath = parts[1];
                // If we don't have a group called baseName, then we know we must add it
                if (!groupMap.ContainsKey(baseName)) {
                    groupMap.TryAdd(baseName, new StopwatchGroup(defaultWindowSize));
                }
                // By here, we MUST have a group called baseName,
                // so call GetRecorder on it
                return groupMap[baseName].GetRecorder(extraPath);
            }
        }

        public void CreateRecorderAtPath(string path) {
            string[] parts = SplitBaseName(path);
            // We will have only 1 string if path is not nested
            // in which case we make a recorder with name 'path' in this StopwatchGroup
            if (parts.Length == 1) {
                if (!stopwatchMap.ContainsKey(path)) {
                    stopwatchMap.TryAdd(path, new StopwatchRecorder(defaultWindowSize));
                }
            }
            // We have more path to traverse.
            // Look in my subgroups to see if a group already exists
            else {
                string baseGroupName = parts[0];
                string extraPath = parts[1];
                if (!groupMap.ContainsKey(baseGroupName)) {
                    groupMap.TryAdd(baseGroupName, new StopwatchGroup(defaultWindowSize));
                }
                groupMap[baseGroupName].CreateRecorderAtPath(extraPath);
            }
            // Check problems with group names and recorder names overlapping
            if (stopwatchMap.ContainsKey(parts[0]) && groupMap.ContainsKey(parts[0])) {
                throw new System.Exception();
            }
        }
    }
}

The main purpose of the StopwatchGroup is to finally provide a mapping/grouping between a set of keys/recorder names (strings), and other IStopwatchInfoProviders. To boil it down, the StopwatchGroup keeps track of StopwatchRecorders and other StopwatchGroups directly underneath itself in the hierarchy. This class deserves a bit more explanation, so let’s go through its implementation.

  • It’s two most important fields, stopwatchMap and groupMap, are just dictionaries mapping a key/recorder name to some info provider. Because the StopwatchGroup can have “leaf” providers (providers that end the hierarchy like StopwatchRecorder) or deeper providers (other StopwatchGroups), we need to separate these to properly handle access in the future.
  • GetInfo() gathers up all the immediate IStopwatchInfoProviders under this instance’s jurisdiction and feeds them to a StopwatchGroupInfo constructor, effectively combining the timings of all child providers into one output for this group.
  • GetInfoProvider() is the first serious method, but all the other methods generally follow the same pattern: they all split the recorder name (path argument) into pieces, using each piece as another level of the hierarchy. When we are on the last piece of the path, then we know that we need to do something special because we have arrived at the place we want to be. For this method, we are looking for any IStopwatchInfoProvider at the given path, so we descend down the hierarchy by calling the child StopwatchGroupsGetInfoProvider() method while not at the end of the path. Once at the end of the path, we MUST have either a StopwatchRecorder or StopwatchGroup that matches the last path piece, otherwise, we have no such recorder and thus return null.
  • GetRecorder() deals specifically with getting a StopwatchRecorder from a given path. Again, as long as there is a path hierarchy to descend, we do so. If there is no StopwatchGroup on an intermediate piece of the path, then we CAN just create one, because we know that we are not at the end of the hierarchy, so the IStopwatchInfoProvider at this point MUST be a StopwatchGroup. Once we get to the end of the path, we first try to retrieve an existing StopwatchRecorder, but if one doesn’t exist, then we can just create one first, for a similar reason as with the StopwatchGroup.
  • CreateRecorderAtPath() can create a StopwatchRecorder at any depth relative to the calling StopwatchGroup by, again, making any necessary intermediate StopwatchGroups.

StopwatchTracker:

// * * * * *
// Original Author: Orcsune
// Date: July 27, 2025
// License: MIT License
// * * * * *

using System.Collections.Generic;

namespace Orcsune.Core.ODebug.Profiling {
    public static class StopwatchTracker {
        public static StopwatchGroup unifiedGroup = new StopwatchGroup(30);

        public static IStopwatchInfoProvider GetInfoProvider(string key) {
            return unifiedGroup.GetInfoProvider(key);
        }
        public static StopwatchRecorder GetRecorder(string key) {
            return unifiedGroup.GetRecorder(key);
        }
        public static StopwatchRecorderContext GetRecorderContext(string key) {
            return new StopwatchRecorderContext(GetRecorder(key));
        }

        public static IStopwatchInfo GetInfo(string key) {
            return GetInfoProvider(key).GetInfo();
        }
        
        public static Dictionary<string, IStopwatchInfo> GetAllInfos() {
            return unifiedGroup.GetLeafInfos();
        }

        public static void BeginTiming(string key) {
            StopwatchRecorder recorder = GetRecorder(key);
            recorder.Start();
        }
        public static void EndTiming(string key) {
            StopwatchRecorder recorder = GetRecorder(key);
            recorder.Stop();
        }
        public static void LogTime(string key) {
            StopwatchRecorder recorder = GetRecorder(key);
            recorder.LogTime();
        }
        public static void LogTimeAndEnd(string key) {
            StopwatchRecorder recorder = GetRecorder(key);
            recorder.LogTime();
            recorder.Stop();
        }
    }
}

StopwatchTracker is, more or less, a glorified static wrapper around a StopwatchGroup instance. The goal of the StopwatchTracker is to provide a simple, app-wide utility to easily log and retrieve values for timed portions of code. You could easily turn it into a non-static class and create instances of it, but I had no need to, and you could probably just directly use a StopwatchGroup instance instead.

StopwatchRecorderContext:

// * * * * *
// Original Author: Orcsune
// Date: July 27, 2025
// License: MIT License
// * * * * *

using System;

namespace Orcsune.Core.ODebug.Profiling {
    /// <summary>
    /// Create a 'using' context for a StopwatchRecorder.
    /// Automatically log the time and reset the stopwatch at the end of scope.
    /// </summary>
    public class StopwatchRecorderContext : IDisposable {
        private StopwatchRecorder recorder;
        public StopwatchRecorderContext(StopwatchRecorder recorder) {
            this.recorder = recorder;
            recorder.Start();
        }

        public static implicit operator StopwatchRecorder(StopwatchRecorderContext context) => context.recorder;

        /// <summary>
        /// Intended to be used in 'using' context.
        /// Times the using block and logs the final time
        /// when disposed.
        /// </summary>
        public void Dispose()
        {
            recorder.LogTime();
            recorder.Reset();
        }
    }
}

The StopwatchRecorderContext class is just a context wrapper around a StopwatchRecorder. This just allows you to wrap measured code with a using context block.

Example Usage:

using Orcsune.Core.ODebug.Profiling;

class Program {
    static void Main(String[] args) {
        Console.WriteLine("Starting");
        int count = 0;
        string key1 = "Gameplay/Movement";
        string key2 = "Gameplay/Collision/Gather";
        string key3 = "Gameplay/Collision/Update";
        // Do some work and record timing with StopwatchRecorder
        // Movement
        StopwatchRecorder movementRecorder = StopwatchTracker.GetRecorder(key1);
        movementRecorder.Restart();
        for (int i = 0; i < 100000000; i++) {
            // Processing here...
            count += 1;
        }
        movementRecorder.LogTime();
        movementRecorder.Reset();
        
        // Use the StopwatchRecorderContext to record times
        using (var _ = StopwatchTracker.GetRecorderContext(key2)) {
            for (int i = 0; i < 100000000; i++) {
                // Processing here...
                count += 1;
            }
        }
        using (var _ = StopwatchTracker.GetRecorderContext(key3)) {
            for (int i = 0; i < 100000000; i++) {
                // Processing here...
                count += 1;
            }
        }
        
        // Use the timings from groups or individual recorders
        string keyGroupGameplay = "Gameplay";
        string keyGroupCollision = "Gameplay/Collision";
        Console.WriteLine($"Total: {StopwatchTracker.unifiedGroup.GetInfo().lastTime}ms");
        Console.WriteLine($"Gameplay: {StopwatchTracker.GetInfoProvider(keyGroupGameplay).GetInfo().lastTime}ms");
        Console.WriteLine($"Gameplay/Movement: {StopwatchTracker.GetInfoProvider(key1).GetInfo().lastTime}ms");
        Console.WriteLine($"Gameplay/Collision: {StopwatchTracker.GetInfoProvider(keyGroupCollision).GetInfo().lastTime}ms");
        Console.WriteLine($"Gameplay/Collision/Gather: {StopwatchTracker.GetInfoProvider(key2).GetInfo().lastTime}ms");
        Console.WriteLine($"Gameplay/Collision/Update: {StopwatchTracker.GetInfoProvider(key3).GetInfo().lastTime}ms");
    }
}

Alternatively, you can utilize the window averaging functionality of StopwatchRecorders to run multiple trials (or run once each frame, for example) to get an average time for parts of your code:

using Orcsune.Core.ODebug.Profiling;

class Program {
    static void Main(String[] args) {
        Console.WriteLine("Starting");
        int count = 0;
        int windowSize = 30;
        string key1 = "Gameplay/Movement";
        string key2 = "Gameplay/Collision/Gather";
        string key3 = "Gameplay/Collision/Update";
        // Do some work and record timing with StopwatchRecorder
        // Movement
        StopwatchRecorder movementRecorder = StopwatchTracker.GetRecorder(key1);
        for (int j = 0; j < windowSize; j++)
        {
            movementRecorder.Restart();
            for (int i = 0; i < 10000000; i++)
            {
                // Processing here...
                count += 1;
            }
            movementRecorder.LogTime();
            movementRecorder.Reset();
        }

        // Use the StopwatchRecorderContext to record times
        for (int j = 0; j < windowSize; j++)
        {
            using (var _ = StopwatchTracker.GetRecorderContext(key2))
            {
                for (int i = 0; i < 10000000; i++)
                {
                    // Processing here...
                    count += 1;
                }
            }
        }
        for (int j = 0; j < windowSize; j++)
        {
            using (var _ = StopwatchTracker.GetRecorderContext(key3))
            {
                for (int i = 0; i < 10000000; i++)
                {
                    // Processing here...
                    count += 1;
                }
            }
        }
        
        // Use the timings from groups or individual recorders
        string keyGroupGameplay = "Gameplay";
        string keyGroupCollision = "Gameplay/Collision";
        Console.WriteLine($"Total: {StopwatchTracker.unifiedGroup.GetInfo().avgTime}ms");
        Console.WriteLine($"Gameplay: {StopwatchTracker.GetInfoProvider(keyGroupGameplay).GetInfo().avgTime}ms");
        Console.WriteLine($"Gameplay/Movement: {StopwatchTracker.GetInfoProvider(key1).GetInfo().avgTime}ms");
        Console.WriteLine($"Gameplay/Collision: {StopwatchTracker.GetInfoProvider(keyGroupCollision).GetInfo().avgTime}ms");
        Console.WriteLine($"Gameplay/Collision/Gather: {StopwatchTracker.GetInfoProvider(key2).GetInfo().avgTime}ms");
        Console.WriteLine($"Gameplay/Collision/Update: {StopwatchTracker.GetInfoProvider(key3).GetInfo().avgTime}ms");
    }
}

Conclusion

Easily measure and group timings using a key-based hierarchy of Stopwatches using this approach.