Stopwatch Groups in C#
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:
Interfaces
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.
StopwatchInfos
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 and StopwatchGroup
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,
stopwatchMapandgroupMap, are just dictionaries mapping a key/recorder name to some info provider. Because theStopwatchGroupcan have “leaf” providers (providers that end the hierarchy likeStopwatchRecorder) or deeper providers (otherStopwatchGroups), we need to separate these to properly handle access in the future. GetInfo()gathers up all the immediateIStopwatchInfoProvidersunder this instance’s jurisdiction and feeds them to aStopwatchGroupInfoconstructor, 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 (pathargument) 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 anyIStopwatchInfoProviderat the given path, so we descend down the hierarchy by calling the childStopwatchGroups‘GetInfoProvider()method while not at the end of the path. Once at the end of the path, we MUST have either aStopwatchRecorderorStopwatchGroupthat matches the last path piece, otherwise, we have no such recorder and thus return null.GetRecorder()deals specifically with getting aStopwatchRecorderfrom a given path. Again, as long as there is a path hierarchy to descend, we do so. If there is noStopwatchGroupon 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 theIStopwatchInfoProviderat this point MUST be aStopwatchGroup. Once we get to the end of the path, we first try to retrieve an existingStopwatchRecorder, but if one doesn’t exist, then we can just create one first, for a similar reason as with theStopwatchGroup.CreateRecorderAtPath()can create aStopwatchRecorderat any depth relative to the callingStopwatchGroupby, again, making any necessary intermediateStopwatchGroups.
Utilities
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.
