Unity programmers have their choice of two kinds of events. We could use the built-in C# event keyword or Unity’s UnityEvent classes. Which is faster? Which one creates more garbage? Today’s article finds out!

First off, let’s look at how you use C# events:

class MyClass
{
	// Declare an event for users to add their listeners to
	event Action<int,int> OnClick;
 
	void Foo()
	{
		// Call Invoke() to call the listeners
		OnClick.Invoke(11, 22);
 
		// Alternatively, call the event like a function
		OnClick(11, 22);
	}
}
 
// Use += to add a listener function to be called when the event is dispatched
var myc = new MyClass();
myc.OnClick += (x, y) => Debug.LogFormat("clicked at {0}, {0}", x, y);

Overall, it’s simple and built into the language. Now let’s look at Unity’s UnityEvent class:

// Use the namespace with UnityEvent in it
using UnityEngine.Events;
 
// Make a class extending UnityEvent, mark it [Serializable]
[Serializable]
class Int2Event : UnityEvent<int, int>
{
}
 
class MyClass
{
	// Declare and create an event for users to add their listeners to
	Int2Event OnClick = new Int2Event();
 
	void Foo()
	{
		// Call Invoke() to call the listeners
		OnClick.Invoke(11, 22);
	}
}
 
// Use AddListener to add a listener function to be called when the event is dispatched
var myc = new MyClass();
myc.OnClick.AddListener((x, y) => Debug.LogFormat("clicked at {0}, {0}", x, y));

The strange part here is the requirement that you create your own class extending UnityEvent. You can skip this if you don’t have any parameters. Otherwise, you end up making empty, [Serializable] classes for each event. Other than this, using these events is very much like using C# events.

Now let’s test C# events against UnityEvent. First up, let’s see how much memory is allocated by adding listeners to each kind of event. Here’s a little script that tests that:

using System;
 
using UnityEngine;
using UnityEngine.Events;
 
public class TestScript : MonoBehaviour
{
	event Action csharpEv0;
	UnityEvent unityEv0 = new UnityEvent();
 
	void Start()
	{
		AddCsharpListener();
		AddUnityListener();
		AddCsharpListener2();
		AddUnityListener2();
		AddCsharpListener3();
		AddUnityListener3();
		AddCsharpListener4();
		AddUnityListener4();
	}
 
	void AddCsharpListener()
	{
		csharpEv0 += NoOp;
	}
 
	void AddUnityListener()
	{
		unityEv0.AddListener(NoOp);
	}
 
	void AddCsharpListener2()
	{
		csharpEv0 += NoOp;
	}
 
	void AddUnityListener2()
	{
		unityEv0.AddListener(NoOp);
	}
 
	void AddCsharpListener3()
	{
		csharpEv0 += NoOp;
	}
 
	void AddUnityListener3()
	{
		unityEv0.AddListener(NoOp);
	}
 
	void AddCsharpListener4()
	{
		csharpEv0 += NoOp;
	}
 
	void AddUnityListener4()
	{
		unityEv0.AddListener(NoOp);
	}
 
	static void NoOp(){}
}

If you open the Profiler pane in the Unity Editor, enable Deep Profile mode, then run the script you’ll get these results:

Num Listeners C# Event Total GC Alloc UnityEvent Total GC Alloc
1 104 192
2 416 320
3 812 448
4 1332 576

UnityEvent GC Alloc Profile

C# Events GC Alloc Profile

Events GC Alloc Graph

C# events start out allocating less garbage than UnityEvent, but start allocating more as soon as you add a second listener. The gap widens more and more as you add more listeners, but those cases are less frequent in real-world programming. If you typically have zero or one listeners, C# events will create less garbage. If you have more, then UnityEvent will allocate less garbage.

How does that picture change as we dispatch the events? To test that, here’s a tiny script that dispatches each with two listeners added:

using System;
 
using UnityEngine;
using UnityEngine.Events;
 
public class TestScript : MonoBehaviour
{
	event Action csharpEv0;
	UnityEvent unityEv0 = new UnityEvent();
 
	string report;
 
	void Start()
	{
		csharpEv0 += NoOp0;
		csharpEv0 += NoOp0;
		unityEv0.AddListener(NoOp0);
		unityEv0.AddListener(NoOp0);
		DispatchCsharpEvent();
		DispatchUnityEvent();
	}
 
	void DispatchCsharpEvent()
	{
		csharpEv0.Invoke();
	}
 
	void DispatchUnityEvent()
	{
		unityEv0.Invoke();
	}
 
	static void NoOp0(){}
}

Again using the Profiler pane in the Unity Editor we get these results:

C# Events Dispatch GC Alloc Profile

UnityEvent Dispatch GC Alloc Profile

Events Dispatch GC Alloc Graph

When dispatched, C# events create no garbage whatsoever but UnityEvent creates 136 bytes. C# events are the clear winner in this regard.

Update: UnityEvent only creates garbage on the first dispatch. Subsequent dispatches create no garbage.

Finally, let’s test the performance of dispatching a bunch of events. It takes quite a few to get quality results, so this test dispatches 10 million of them with zero, one, or two arguments to 1-5 listeners.

using System;
 
using UnityEngine;
using UnityEngine.Events;
 
public class TestScript : MonoBehaviour
{
	const int NumReps = 10000000;
	const int MaxListeners = 5;
	const int MaxArgs = 3;
 
	[Serializable]
	class IntEvent1 : UnityEvent<int>
	{
	}
 
	[Serializable]
	class IntEvent2 : UnityEvent<int,int>
	{
	}
 
	event Action csharpEv0;
	event Action<int> csharpEv1;
	event Action<int,int> csharpEv2;
	UnityEvent unityEv0 = new UnityEvent();
	IntEvent1 unityEv1 = new IntEvent1();
	IntEvent2 unityEv2 = new IntEvent2();
 
	string report;
 
	void Start()
	{
		var stopwatch = new System.Diagnostics.Stopwatch();
		var csharpEvTimes = new long[MaxArgs,MaxListeners];
		var unityEvTimes = new long[MaxArgs,MaxListeners];
		for (var numListeners = 0; numListeners < MaxListeners; ++numListeners)
		{
			csharpEv0 += NoOp0;
			stopwatch.Reset();
			stopwatch.Start();
			for (var j = 0; j < NumReps; ++j)
			{
				csharpEv0.Invoke();
			}
			csharpEvTimes[0,numListeners] = stopwatch.ElapsedMilliseconds;
 
			unityEv0.AddListener(NoOp0);
			stopwatch.Reset();
			stopwatch.Start();
			for (var j = 0; j < NumReps; ++j)
			{
				unityEv0.Invoke();
			}
			unityEvTimes[0,numListeners] = stopwatch.ElapsedMilliseconds;
		}
		for (var numListeners = 0; numListeners < MaxListeners; ++numListeners)
		{
			csharpEv1 += NoOp1;
			stopwatch.Reset();
			stopwatch.Start();
			for (var j = 0; j < NumReps; ++j)
			{
				csharpEv1.Invoke(11);
			}
			csharpEvTimes[1,numListeners] = stopwatch.ElapsedMilliseconds;
 
			unityEv1.AddListener(NoOp1);
			stopwatch.Reset();
			stopwatch.Start();
			for (var j = 0; j < NumReps; ++j)
			{
				unityEv1.Invoke(11);
			}
			unityEvTimes[1,numListeners] = stopwatch.ElapsedMilliseconds;
		}
		for (var numListeners = 0; numListeners < MaxListeners; ++numListeners)
		{
			csharpEv2 += NoOp2;
			stopwatch.Reset();
			stopwatch.Start();
			for (var j = 0; j < NumReps; ++j)
			{
				csharpEv2.Invoke(11, 22);
			}
			csharpEvTimes[2,numListeners] = stopwatch.ElapsedMilliseconds;
 
			unityEv2.AddListener(NoOp2);
			stopwatch.Reset();
			stopwatch.Start();
			for (var j = 0; j < NumReps; ++j)
			{
				unityEv2.Invoke(11, 22);
			}
			unityEvTimes[2,numListeners] = stopwatch.ElapsedMilliseconds;
		}
 
		report = "Num Args,Num Listeners,C# Event Time,UnityEvent Time\n";
		for (var i = 0; i < MaxArgs; ++i)
		{
			for (var j = 0; j < MaxListeners; ++j)
			{
				report += i + "," + (j+1) + "," + csharpEvTimes[i,j] + "," + unityEvTimes[i,j] + "\n";
			}
		}
	}
 
	void OnGUI()
	{
		GUI.TextArea(new Rect(0, 0, Screen.width, Screen.height), report);
	}
 
	static void NoOp0(){}
	static void NoOp1(int a){}
	static void NoOp2(int a, int b){}
}

If you want to try out the test yourself, simply paste the above code into a TestScript.cs file in your Unity project’s Assets directory and attach it to the main camera game object in a new, empty project. Then build in non-development mode for 64-bit processors and run it windowed at 640×480 with fastest graphics. I ran it that way on this machine:

  • 2.3 Ghz Intel Core i7-3615QM
  • Mac OS X 10.11.2
  • Apple SSD SM256E, HFS+ format
  • Unity 5.3.1f1, Mac OS X Standalone, x86_64, non-development
  • 640×480, Fastest, Windowed

And here are the results I got:

Num Args Num Listeners C# Event Time UnityEvent Time
0 1 30 206
0 2 89 306
0 3 151 406
0 4 206 514
0 5 272 612
1 1 33 685
1 2 91 807
1 3 151 980
1 4 212 1096
1 5 274 1224
2 1 30 1187
2 2 102 1371
2 3 172 1547
2 4 226 1709
2 5 296 1879

Events Performance Graph (0 args)

Events Performance Graph (1 arg)

Events Performance Graph (2 args)

C# events beat UnityEvent for each number of arguments. The gap widens as more args are dispatched to the listeners. In the best case, UnityEvent takes 2.25x longer than C# events but in the worst case it takes almost 40x longer!

In conclusion, UnityEvent creates less garbage than C# events if you add more than two listeners to it, but creates more garbage otherwise. It creates garbage when dispatched (Update: the first time) where C# events do not. And it’s at least twice as slow as C# events. There doesn’t seem to be a compelling case to use it, at least by these metrics. If you know of a good reason why you might, please leave a comment to let me know!