Profiling Multithreaded Unity3d Applications

Frame-Based Synchronization

The Unity applications I have been building are simulations, like most, if not all, games and applications developed in Unity. These applications are designed to run at a target simulation frame rate. Therefore, it is not strictly necessary to run any of the simulation’s threads at a frequency that is different than that of the main thread. Ultimately, we are synchronizing the work that all the threads are performing with the main world simulation and with the rendering thread. This applies to network threads, AI threads, and so on.

Making this assumption allows us to simplify the threading model as well as the profiler user interface we would like to build.

To see what I mean, take the following, somewhat naive thread class.

using System.Threading;
 
using UnityEngine;
 
namespace Amesgames.Behaviours
{
	public class UnityProfilerExceptionBehaviour : MonoBehaviour
	{
		void Start()
		{
			new Thread(new ThreadStart(Work)).Start();
		}
 
		void Work()
		{
			UnityEngine.Profiler.BeginSample("test");
			UnityEngine.Profiler.EndSample();
		}
	}
}

Using this class, we can illustrate a scenario where multiple worker threads are running their update loops at different rates than the main thread, as well as each other. Consider the following MonoBehaviour.

using System;
using System.Collections.Generic;
using System.Threading;
 
using UnityEngine;
using UnityEngine.UI;
 
using Amesgames.Threading;
 
namespace Amesgames.Behaviours
{
	public class WorkerThreadBehaviour : MonoBehaviour
	{
		public void Start()
		{
			new WorkerThread(ShortWork);
			new WorkerThread(LongWork);
 
			text = GameObject.FindObjectOfType<Text>();
		}
 
		void CountAndSleep(int milliseconds)
		{
			lock(stepCountByThreadId)
			{
				int threadId = Thread.CurrentThread.ManagedThreadId;
				int count;
				if(stepCountByThreadId.TryGetValue(threadId, out count))
					stepCountByThreadId[threadId] = count + 1;
				else
					stepCountByThreadId.Add(threadId, 1);
			}
			Thread.Sleep(milliseconds);
		}
 
		public void Update()
		{
			lock(stepCountByThreadId)
			{
				text.text = "";
				foreach(KeyValuePair<int, int> kv in stepCountByThreadId)
					text.text += "thread " + kv.Key + " count " + kv.Value + "\n";
			}
 
			CountAndSleep(25);
		}
 
		readonly Dictionary<int, int> stepCountByThreadId =
			new Dictionary<int, int>();
		Text text;
 
		void ShortWork()
		{
			CountAndSleep(10);
		}
 
		void LongWork()
		{
			CountAndSleep(50);
		}
	}
}

In this example, the MonoBehaviour creates two threads that keep track of how many times their step function has been called. Then, each of the threads sleeps for either a short or a long duration. The main update loop of the behaviour also counts how many times it is called, sleeps some, and displays each thread’s step counts in a UI text widget.

Notice we make sure to control multithreaded access l to stepCountByThreadId by negotiateing a lock on each access to that data structure.

If you drop the above MonoBehaviour into a scene with a UI Text component and run it, it will look something like the following screenshot.

MT-profiling-unity-free-threads

Instead of letting the threads of our simulation or game run freely like we have done in the example above, we will synchronize them with the main thread. This allows us to build a profiler that can show us the amount of time each thread is taking as a percentage of a frame. It also allows us to see how over- or under-utilized each thread is.

Following is a new thread class, FramedWorkerThread that uses two AutoResetEvent instances to synchronize with the main thread. This class has two public functions, Wait() and Run(), that the main thread uses to wait on the thread being done with its last frame and to signal the thread to start its next frame.

using System;
using System.Threading;
 
namespace Amesgames.Threading
{
	public class FramedWorkerThread
	{
		public FramedWorkerThread(Action step)
		{
			this.step = step;
 
			thread = new Thread(new ThreadStart(Work));
			thread.Start();
		}
 
		public void Wait()
		{
			ready.WaitOne();
		}
 
		public void Run()
		{
			go.Set();
		}
 
		Action step;
		Thread thread;
		AutoResetEvent ready = new AutoResetEvent(/*initialState=*/false);
		AutoResetEvent go = new AutoResetEvent(/*initialState=*/false);
 
		void Work()
		{
			while(true)
			{
				ready.Set();
				go.WaitOne();
 
				step();
			}
		}
	}
}

Using this thread class, we can demonstrate frame synchronization with the following MonoBehaviour.

using System;
using System.Collections.Generic;
using System.Threading;
 
using UnityEngine;
using UnityEngine.UI;
 
using Amesgames.Threading;
 
namespace Amesgames.Behaviours
{
	public class FramedWorkerThreadBehaviour : MonoBehaviour
	{
		public void Start()
		{
			threads.Add(new FramedWorkerThread(ShortWork));
			threads.Add(new FramedWorkerThread(LongWork));
 
			text = GameObject.FindObjectOfType<Text>();
		}
 
		void CountAndSleep(int milliseconds)
		{
			lock(stepCountByThreadId)
			{
				int threadId = Thread.CurrentThread.ManagedThreadId;
				int count;
				if(stepCountByThreadId.TryGetValue(threadId, out count))
					stepCountByThreadId[threadId] = count + 1;
				else
					stepCountByThreadId.Add(threadId, 1);
			}
			Thread.Sleep(milliseconds);
		}
 
		public void Update()
		{
			foreach(FramedWorkerThread thread in threads)
				thread.Wait();
 
			foreach(FramedWorkerThread thread in threads)
				thread.Run();
 
			lock(stepCountByThreadId)
			{
				text.text = "";
				foreach(KeyValuePair<int, int> kv in stepCountByThreadId)
					text.text += "thread " + kv.Key + " count " + kv.Value + "\n";
			}
 
			CountAndSleep(25);
		}
 
		readonly Dictionary<int, int> stepCountByThreadId =
			new Dictionary<int, int>();
		Text text;
		readonly List<FramedWorkerThread> threads = new List<FramedWorkerThread>();
 
		void ShortWork()
		{
			CountAndSleep(10);
		}
 
		void LongWork()
		{
			CountAndSleep(50);
		}
	}
}

In this class, we keep a list of all threads we have created so that we can iterate over all of them and make calls to Wait() and Run() at the top of the Update() method.

Following is an example of what this demonstration looks like while running.

MT-profiling-unity-sync-threads

Next, we will build a Profiler class that includes BeginSample() and EndSample() methods that can be invoked from our threads in order to time our code.

Leave a Reply

Your email address will not be published. Required fields are marked *

This site uses Akismet to reduce spam. Learn how your comment data is processed.