Unity Script Performance Testing
Today’s article is the first to test Unity script performance speed. It establishes a way to set up and test C# scripts in Unity whether you have access to Pro or not. As a first example, I was reminded by the news this week that AddComponent(string)
is being removed in Unity 5.0. These alternative versions of AddComponent
and GetComponent
aren’t something I normally use, but the news got me thinking of their performance compared to the generic-typed versions: GetComponent<ComponentType>()
. The docs say to avoid the versions taking a string
, but how bad could the performance really be? Today’s article puts the two versions to the test to find out just that!
Today’s test is very straightforward. It simply tests two versions of GetComponent
:
// Generic type parameter version otherGameObject.GetComponent<ComponentType>(); // String version otherGameObject.GetComponent("ComponentType");
However, since this is the first Unity performance test I’ve done, a baseline procedure for testing needs to be established. To start, the excellent System.Diagnostics.Stopwatch class is my choice for measuring elapsed time. It’s easy to use and very precise. Here’s an example:
var stopwatch = new Stopwatch(); stopwatch.Start(); // ... run code to measure time for stopwatch.Stop(); Debug.Log("code took " + stopwatch.EllapsedMilliseconds + " milliseconds");
Since I run tests more than once and don’t want to incur any GC overhead, a single Stopwatch
is reused for each test. To reuse it, just call Reset
before you use it again:
Stopwatch stopwatch; void Start() { stopwatch = new Stopwatch(); } void Update() { stopwatch.Reset(); stopwatch.Start(); // ... run code to measure time for stopwatch.Stop(); Debug.Log("code took " + stopwatch.EllapsedMilliseconds + " milliseconds"); }
Another issue to work around is the incredible slowness of Debug.Log
. Instead, let’s draw the results on the screen using GUI.Label
:
void Update() { stopwatch.Reset(); stopwatch.Start(); // ... run code to measure time for stopwatch.Stop(); GUI.Label( new Rect(0, 0, Screen.width, Screen.height), "code took " + stopwatch.EllapsedMilliseconds + " milliseconds" ); }
The first parameter is the rectangle to draw in. We’ve sized it to the full screen so no text is clipped off. However, this new
call is also causing some GC overhead. Let’s share it between calls:
Stopwatch stopwatch; Rect drawRect; void Start() { stopwatch = new Stopwatch(); drawRect = new Rect(0, 0, Screen.width, Screen.height); } void OnGUI() { stopwatch.Reset(); stopwatch.Start(); // ... run code to measure time for stopwatch.Stop(); GUI.Label( drawRect, "code took " + stopwatch.EllapsedMilliseconds + " milliseconds" ); }
Another issue is the string concatenation that’s happening to make the GUI.Label
message. We can partially work around this by reducing the number of string
objects allocated to just one per frame. The key is the System.Text.StringBuilder class. It maintains an internal buffer of characters that are used until you’re finished building your string. Simply use Append
instead of the +
operator:
// Build the string var builder = new StringBuilder(); builder.Append("code took "); builder.Append(stopwatch.EllapsedMilliseconds); builder.Append(" milliseconds"); // Get the string we built builder.ToString();
As long as the StringBuilder
has enough Capacity
in its internal buffer, no allocations will occur and there will be no GC overhead. However, we don’t want to allocate the StringBuilder
every time we test because that would also cause GC overhead. We also want to preserve the internal buffer since it’s very likely to be sized well for the next test. The results shouldn’t change much between runs.
To reuse the StringBuilder
, just set the Length
field to zero and the previous string that was built will be overwritten:
StringBuilder stringBuilder; void Start() { stringBuilder = new StringBuilder(); } void Update() { // Build the string stringBuilder.Length = 0; stringBuilder.Append("code took "); stringBuilder.Append(stopwatch.EllapsedMilliseconds); stringBuilder.Append(" milliseconds"); // Get the string we built builder.ToString(); }
Putting all these techniques together with the actual GetComponent
calls, we end up with the final test script for today:
using System.Text; using UnityEngine; public class TestScript : MonoBehaviour { private const int REPS = 100000; private GameObject otherGameObject; private System.Diagnostics.Stopwatch stopwatch; private Rect drawRect; private StringBuilder stringBuilder; void Start() { otherGameObject = new GameObject(); otherGameObject.AddComponent<EventForwarder>(); stopwatch = new System.Diagnostics.Stopwatch(); drawRect = new Rect(0, 0, Screen.width, Screen.height); stringBuilder = new StringBuilder(); } void OnGUI() { stringBuilder.Length = 0; stopwatch.Reset(); stopwatch.Start(); for (var i = 0; i < REPS; ++i) { otherGameObject.GetComponent<EventForwarder>(); } stopwatch.Stop(); stringBuilder.Append("Type Param: "); stringBuilder.Append(stopwatch.ElapsedMilliseconds); stringBuilder.Append('\n'); stopwatch.Reset(); stopwatch.Start(); for (var i = 0; i < REPS; ++i) { otherGameObject.GetComponent("EventForwarder"); } stopwatch.Stop(); stringBuilder.Append("String: "); stringBuilder.Append(stopwatch.ElapsedMilliseconds); stringBuilder.Append('\n'); GUI.Label(drawRect, stringBuilder.ToString()); } }
Simply create a new project and attach this script to its “Main Camera” GameObject
.
The final issue is that code running in the Unity Editor is running in something of a debug or development mode. It’s roughly equivalent to the performance you can expect from a distributed game, but sometimes slower. For micro-benchmarks like these, it’s important to actually make a distribution build of the game. Luckily, that’s easy:
- File > Build Settings…
- Select “PC, Mac & Linux Standalone”
- Change “Architecture” to “x86_64”
- Make sure “Development Build” is not checked
- Click “Build”
- Enter a name for the app in the “Save As” field of the dialog
- Click “Save”
Then run the app with as little graphical overhead as possible:
- Run the app
- Select “640 x 480” in the “Screen Resolution” list
- Check “Windowed”
- Set “Graphics Quality” to “Fastest”
- Click “Play!”
I tested this app using the following environment:
- 2.3 Ghz Intel Core i7-3615QM
- Mac OS X 10.10.1
- Unity 4.6.1, Mac OS X Standalone, x86_64, non-development
- 640×480, Fastest, Windowed
And got these results:
Version | Time |
---|---|
Type Param | 13 |
String | 158 |
As you can see, there’s about a 12x performance boost to be had by switching from GetComponent(string)
to GetComponent<ComponentType>()
. Or you could look at it like a 12x slowdown when using the string
version. It’s easy to see that you should strongly prefer to pass a generic type over a string
and only fall back to a string
when you have no other alternative.
That said, this test calls GetComponent
100,000 times per test. That means that the per-call times are 1/100,000 of the above numbers. Even the string
version only takes .00158 milliseconds to complete, so it’s not going to slow down your app all on its own. At least as long as you aren’t calling it hundreds of times per frame with a string
.
That wraps up today’s article. I’ll use the testing system outlined here today in future articles, so please let me know in the comments if you see any way to improve it!
#1 by Benjamin Guihaire on December 14th, 2015 ·
also, stopwatch.ElapsedTicks is handy when delta times are tiny
#2 by jackson on December 14th, 2015 ·
Good point. I usually use milliseconds because it’s a real-world, relatable measure. That often necessitates more iterations of a test to get big enough numbers. Perhaps in some cases I’ll show the ticks.
Thanks for the idea!