Delegates and Garbage Creation
Two facts are at odds in Unity programming. First, delegates like Action
, Func
, and EventHandler
are extremely common with or without events. Second, the garbage collector is a huge source of CPU spikes and memory fragmentation in our games. Why are these facts at odds? Because code that uses delegates is almost always written in a way that creates garbage. It’s an extremely easy trap to fall into, but this article will show you how to get out of it!
Consider a very simple function:
void TakeDelegate(Action del) { }
You’ve probably seen a thousand functions like this. Array.Find
takes a delegate and so does List.RemoveAll
. You’ve probably passed a thousand “callback” variables, each some kind of delegate. The same goes for every event
keyword you’ve ever seen: they’re just thinly veiled delegates.
So delegates are everywhere, but who cares? Well, if you care about preventing CPU spikes and not running out of memory due to fragmentation then you should care. That’s because the simplest way of calling that TakeDelegate
function involves creating 104 bytes of garbage that the garbage collector (GC) will someday collect. All in one frame. On the main thread. And fragment your memory.
Here’s the simple way that almost all code calls TakeDelegate
:
void MyFunction() { } TakeDelegate(MyFunction);
Did you spot the garbage creation? It’s really hard to see because the garbage creation is inserted into our code by the compiler! The compiler rewrites our code to leave out a missing step:
TakeDelegate(new Action(MyFunction));
And there you see the dreaded new
keyword. Every one of these calls to TakeDelegate
creates a new Action
which is 104 bytes of garbage. What if you call it every frame in your 30 FPS game? That’s about 3 KB of garbage per second for just that one function call!
To prove this, here’s a tiny test script:
using System; using UnityEngine; public class TestScript : MonoBehaviour { void Start() { TestFirst(); TestSecond(); } void TestFirst() { TestInstanceFunction(); TestStaticFunction(); TestLambda(); TestAnonymousMethod(); } void TestSecond() { TestInstanceFunction(); TestStaticFunction(); TestLambda(); TestAnonymousMethod(); } void TestInstanceFunction() { TakeDelegate(InstanceFunction); } void TestStaticFunction() { TakeDelegate(StaticFunction); } void TestLambda() { TakeDelegate(() => {}); } void TestAnonymousMethod() { TakeDelegate(delegate(){}); } void TakeDelegate(Action del) { } void InstanceFunction(){} static void StaticFunction(){} }
If you run this and look at the Unity profiler in “deep” profiling mode you’ll see the first frame has this:
All four types of functions—instance functions, static functions, lambdas, and anonymous methods—allocate 104 bytes the first time you call TakeDelegateTakeDelegate
, but the other types don’t. Why?
To find out, you have to decompile Library/ScriptAssemblies/Assembly-CSharp.dll
and look at what else the compiler is generating on your behalf. Here’s what ILSpy will show you in C# mode:
using System; using System.Runtime.CompilerServices; using UnityEngine; public class TestScript : MonoBehaviour { [CompilerGenerated] private static Action <>f__mg$cache0; private void Start() { this.TestFirst(); this.TestSecond(); } private void TestFirst() { this.TestInstanceFunction(); this.TestStaticFunction(); this.TestLambda(); this.TestAnonymousMethod(); } private void TestSecond() { this.TestInstanceFunction(); this.TestStaticFunction(); this.TestLambda(); this.TestAnonymousMethod(); } private void TestInstanceFunction() { this.TakeDelegate(new Action(this.InstanceFunction)); } private void TestStaticFunction() { if (TestScript.<>f__mg$cache0 == null) { TestScript.<>f__mg$cache0 = new Action(TestScript.StaticFunction); } this.TakeDelegate(TestScript.<>f__mg$cache0); } private void TestLambda() { this.TakeDelegate(delegate { }); } private void TestAnonymousMethod() { this.TakeDelegate(delegate { }); } private void TakeDelegate(Action del) { } private void InstanceFunction() { } private static void StaticFunction() { } }
For the static function, ILSpy generated a static Action
variable and adds a null
check every time you try to call TakeDelegate
. The effect is that only one delegate is ever created for the static function and that explains why the second time you call TakeDelegate
there’s no garbage created.
For the lambda and anonymous method you just see delegate{}
which isn’t very helpful. You have to switch to IL mode to see the actual IL instead of a C# representation of it. Here’s a snippet of the most interesting parts:
.class public auto ansi beforefieldinit TestScript extends [UnityEngine]UnityEngine.MonoBehaviour { // Fields .field private static class [System.Core]System.Action '<>f__mg$cache0' .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) .field private static class [System.Core]System.Action '<>f__am$cache0' .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) .field private static class [System.Core]System.Action '<>f__am$cache1' .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) .method private hidebysig instance void TestInstanceFunction () cil managed { // Method begins at RVA 0x209d // Code size 20 (0x14) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: ldarg.0 IL_0003: ldftn instance void TestScript::InstanceFunction() IL_0009: newobj instance void [System.Core]System.Action::.ctor(object, native int) IL_000e: call instance void TestScript::TakeDelegate(class [System.Core]System.Action) IL_0013: ret } // end of method TestScript::TestInstanceFunction .method private hidebysig instance void TestStaticFunction () cil managed { // Method begins at RVA 0x20b2 // Code size 37 (0x25) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: ldsfld class [System.Core]System.Action TestScript::'<>f__mg$cache0' IL_0007: brtrue.s IL_001a IL_0009: ldnull IL_000a: ldftn void TestScript::StaticFunction() IL_0010: newobj instance void [System.Core]System.Action::.ctor(object, native int) IL_0015: stsfld class [System.Core]System.Action TestScript::'<>f__mg$cache0' IL_001a: ldsfld class [System.Core]System.Action TestScript::'<>f__mg$cache0' IL_001f: call instance void TestScript::TakeDelegate(class [System.Core]System.Action) IL_0024: ret } // end of method TestScript::TestStaticFunction .method private hidebysig instance void TestLambda () cil managed { // Method begins at RVA 0x20d8 // Code size 37 (0x25) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: ldsfld class [System.Core]System.Action TestScript::'<>f__am$cache0' IL_0007: brtrue.s IL_001a IL_0009: ldnull IL_000a: ldftn void TestScript::'<TestLambda>m__0'() IL_0010: newobj instance void [System.Core]System.Action::.ctor(object, native int) IL_0015: stsfld class [System.Core]System.Action TestScript::'<>f__am$cache0' IL_001a: ldsfld class [System.Core]System.Action TestScript::'<>f__am$cache0' IL_001f: call instance void TestScript::TakeDelegate(class [System.Core]System.Action) IL_0024: ret } // end of method TestScript::TestLambda .method private hidebysig instance void TestAnonymousMethod () cil managed { // Method begins at RVA 0x20fe // Code size 37 (0x25) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: ldsfld class [System.Core]System.Action TestScript::'<>f__am$cache1' IL_0007: brtrue.s IL_001a IL_0009: ldnull IL_000a: ldftn void TestScript::'<TestAnonymousMethod>m__1'() IL_0010: newobj instance void [System.Core]System.Action::.ctor(object, native int) IL_0015: stsfld class [System.Core]System.Action TestScript::'<>f__am$cache1' IL_001a: ldsfld class [System.Core]System.Action TestScript::'<>f__am$cache1' IL_001f: call instance void TestScript::TakeDelegate(class [System.Core]System.Action) IL_0024: ret } // end of method TestScript::TestAnonymousMethod }
At the top you can see that the compiler generated not one, but three static Action
fields. In TestInstanceFunction
you can see the new
/newobj
for Action
. The other three types are all implemented the same way and they all do a null
check and reuse their static, cached field.
At this point you’ve seen that passing an instance function for a delegate parameter always creates garbage and passing any other type of function makes the compiler insert an if
every time. Both of these are undesirable. You really want to avoid the garbage creation and kind of want to avoid the slow branching of if
.
Thankfully, you can take manual control and make your own cached Action
fields. Then you can set them up when it’s convenient for you and avoid the if
check every time you use them. Even better, you can cache an Action
for instance functions and avoid the garbage creation!
Here’s a modified version of the test script to test out that idea:
using System; using UnityEngine; public class TestScript : MonoBehaviour { Action instanceFunctionDelegate; static Action staticFunctionDelegate; static Action lambdaDelegate; static Action anonymousMethodDelegate; void Start() { CreateDelegates(); TestFirst(); TestSecond(); } void CreateDelegates() { CreateInstanceFunctionDelegate(); CreateStaticFunctionDelegate(); CreateLambdaDelegate(); CreateAnonymousMethodDelegate(); } void CreateInstanceFunctionDelegate() { instanceFunctionDelegate = InstanceFunction; } void CreateStaticFunctionDelegate() { staticFunctionDelegate = StaticFunction; } void CreateLambdaDelegate() { lambdaDelegate = () => {}; } void CreateAnonymousMethodDelegate() { anonymousMethodDelegate = delegate(){}; } void TestFirst() { TestInstanceFunction(); TestStaticFunction(); TestLambda(); TestAnonymousMethod(); } void TestSecond() { TestInstanceFunction(); TestStaticFunction(); TestLambda(); TestAnonymousMethod(); } void TestInstanceFunction() { TakeDelegate(instanceFunctionDelegate); } void TestStaticFunction() { TakeDelegate(staticFunctionDelegate); } void TestLambda() { TakeDelegate(lambdaDelegate); } void TestAnonymousMethod() { TakeDelegate(anonymousMethodDelegate); } void TakeDelegate(Action del) { } void InstanceFunction(){} static void StaticFunction(){} }
And here’s how it looks in the profiler:
You can see that the garbage is created when setting up the cached Action
fields but never when actually using them later. The first goal is accomplished: passing instance methods no longer creates garbage. But what about the if
check? To confirm that it’s gone, check out the IL now:
.class public auto ansi beforefieldinit TestScript extends [UnityEngine]UnityEngine.MonoBehaviour { // Fields .field private class [System.Core]System.Action instanceFunctionDelegate .field private static class [System.Core]System.Action staticFunctionDelegate .field private static class [System.Core]System.Action lambdaDelegate .field private static class [System.Core]System.Action anonymousMethodDelegate .field private static class [System.Core]System.Action '<>f__mg$cache0' .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) .field private static class [System.Core]System.Action '<>f__am$cache0' .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) .field private static class [System.Core]System.Action '<>f__am$cache1' .custom instance void [mscorlib]System.Runtime.CompilerServices.CompilerGeneratedAttribute::.ctor() = ( 01 00 00 00 ) .method private hidebysig instance void TestInstanceFunction () cil managed { // Method begins at RVA 0x2142 // Code size 14 (0xe) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: ldarg.0 IL_0003: ldfld class [System.Core]System.Action TestScript::instanceFunctionDelegate IL_0008: call instance void TestScript::TakeDelegate(class [System.Core]System.Action) IL_000d: ret } // end of method TestScript::TestInstanceFunction .method private hidebysig instance void TestStaticFunction () cil managed { // Method begins at RVA 0x2151 // Code size 13 (0xd) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: ldsfld class [System.Core]System.Action TestScript::staticFunctionDelegate IL_0007: call instance void TestScript::TakeDelegate(class [System.Core]System.Action) IL_000c: ret } // end of method TestScript::TestStaticFunction .method private hidebysig instance void TestLambda () cil managed { // Method begins at RVA 0x215f // Code size 13 (0xd) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: ldsfld class [System.Core]System.Action TestScript::lambdaDelegate IL_0007: call instance void TestScript::TakeDelegate(class [System.Core]System.Action) IL_000c: ret } // end of method TestScript::TestLambda .method private hidebysig instance void TestAnonymousMethod () cil managed { // Method begins at RVA 0x216d // Code size 13 (0xd) .maxstack 8 IL_0000: nop IL_0001: ldarg.0 IL_0002: ldsfld class [System.Core]System.Action TestScript::anonymousMethodDelegate IL_0007: call instance void TestScript::TakeDelegate(class [System.Core]System.Action) IL_000c: ret } // end of method TestScript::TestAnonymousMethod }
Now all four of these functions is implemented the same. They simply pass their static field to TakeDelegate
without any null
check. Unfortunately, the compiler leaves in its own cache fields so there’s duplication now. So you have a tradeoff: do you want duplicate fields or duplicate null
checks?
With this strategy you can get control over the garbage that’s created when you use delegates. It’s really easy to cache the delegates as fields and the GC win can be quite large. So keep this in mind next time you’re passing a callback!
Let me know in the comments how you deal with garbage creation and delegates.
#1 by Valerie on November 13th, 2017 ·
Jackson how did you get the profiler window to show your TestScript.Start() data? When I run profiler, I don’t see any of my scripts, just a bunch of Unity data Behaviors, Instatiations, etc.
#2 by jackson on November 13th, 2017 ·
The profiler GUI has changed since this article was written. Now you’ll find your scripts nested inside Unity’s functions. For example,
Update.ScriptRunBehaviourUpdate
will containBehaviourUpdate
which then contains yourMonoBehaviour.Update
functions.#3 by JJ on January 27th, 2018 ·
Thanks for the useful blog!
#4 by NC on August 2nd, 2019 ·
Thanks, this has been super useful
#5 by NC on August 2nd, 2019 ·
What if Lambda or AnonymousMethod need to use local variables only defined in the function where they’re called?
#6 by jackson on August 2nd, 2019 ·
That’s allowed in C#. The class created for the delegate will have fields matching the local variables it accesses. When the class is instantiated, the local variables are copied to those fields. All of the local variable accesses from inside the delegate are then rewritten to be accesses of those fields. For more on closures, check out this more recent article.
#7 by NC on August 6th, 2019 ·
Thanks for your reply. Here is a little more information about my question. Let’s say I used to do this :
Now that I read your article, I realize why I get cpu spikes at the first frame due to garbage creation and I really want to avoid it! So I add a static cached Action field for my delegate :
I hope you understand my problem better. I’d like to not leave my current scope (within the TestLambda function) and be able to pass myVar to my delegate. However I’m not aware of passing parameters to delegate. Maybe there is another solution?
Thanks a lot
#8 by jackson on August 10th, 2019 ·
You can still declare local variables inside lambdas:
You can also take parameters in lambdas:
#9 by Maciek on November 22nd, 2021 ·
Hi, thank you for this article. It’s very informative. :)
I have one question: is this predicate also garbage safe?
#10 by jackson on November 23rd, 2021 ·
Assuming
_tagToFind
is a field of some class, the delegate will become a closure that stores a reference to that class and require garbage collection.#11 by Ramburglar on January 4th, 2022 ·
I tried your example and when passing a static method to a method that takes a delegate, eg:
Unity’s profiler shows that there is a GC allocation. Has there been a change in how the compiler works in this regard in recent versions of Unity? I’m on 2020.3.25f1 (LTS).
#12 by Trenton on April 16th, 2022 ·
I’m seeing the same thing on 2021.3.0 and 2019.4.18. Not sure how far back it goes.
Even stranger, if you wrap the static function call in a lambda, it _will_ statically cache it and only allocate the first time (similar to above).
I’m annoyed the compiler doesn’t optimize that but maybe there’s a reason I’m not seeing (I’m no expert). I would love some more insight on this.
By the way, if anyone’s interested in how C# 9.0’s static lambdas affect all this, from what I can tell all it’s doing is preventing closures. Meaning, it’s exactly the same as using a lambda without a closure:
Precaching the delegates can be nice, but personally, I’ll just be doing static lambdas most of the time now. Worrying about if-else branching seems extreme even for me as a chronic premature optimizer.
#13 by Beevik on June 20th, 2024 ·
So I did a binary search on Unity versions, and it appears to have stopped caching delegates in the transition to Unity 2019.2.0f1.