Three Surprises I Encountered While Reading IL2CPP Output
We code in C#, but that’s just a starting point. Our C# code is compiled to DLLs and then converted into C++ where it’s compiled again to machine code. The good news is that this isn’t a black box! I’ve recently been reading through the C++ code that IL2CPP outputs and learning quite a lot. Today’s article is about some of the surprises that I encountered and how you can change your C# code to avoid some nasty pitfalls.
Static Variables
Say we write a static functions that use a static variable. Here’s one for basic circle geometry:
static class CircleFunctions { private static readonly float Pi = 3.14f; public static float Area(float radius) { return Pi * radius * radius; } }
Notice that Pi
is a static variable, not a constant. Normally you’d make it a constant, but for this example we’ll use a static variable. There are plenty of times you can’t use const
anyhow.
Now let’s look at the C++ code that IL2CPP generates for Area
. I’ve added inline comments and some spacing to it explaining what’s going on.
extern "C" float CircleFunctions_Area_m4188038 (Il2CppObject * __this /* static, unused */, float ___radius0, const MethodInfo* method) { // A static variable scoped to just this function // This is used to do one-time initialization for the method static bool s_Il2CppMethodInitialized; // This "if" gets hit every time you call the function if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (CircleFunctions_Area_m4188038_MetadataUsageId); s_Il2CppMethodInitialized = true; } { // This macro expands to this: // do { // if((klass)->has_cctor && !(klass)->cctor_finished) // il2cpp::vm::Runtime::ClassInit ((klass)); // } while (0) // This happens every time you call the function IL2CPP_RUNTIME_CLASS_INIT(CircleFunctions_t532702825_il2cpp_TypeInfo_var); // Access Pi float L_0 = ((CircleFunctions_t532702825_StaticFields*)CircleFunctions_t532702825_il2cpp_TypeInfo_var->static_fields)->get_Pi_0(); // Actually do the work float L_1 = ___radius0; float L_2 = ___radius0; return ((float)((float)((float)((float)L_0*(float)L_1))*(float)L_2)); } }
This was supposed to be a simple function, but turned into something quite complex. A lot of overhead was added by IL2CPP. Now let’s look at a function that calls this one:
static void TestStaticFunctionUsingStaticVariable() { float area = CircleFunctions.Area(3.0f); }
Here’s the C++ from IL2CPP:
extern "C" void TestScript_TestStaticFunctionUsingStaticVariable_m953893846 (Il2CppObject * __this /* static, unused */, const MethodInfo* method) { // More static bool for one-time initialization static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestScript_TestStaticFunctionUsingStaticVariable_m953893846_MetadataUsageId); s_Il2CppMethodInitialized = true; } float V_0 = 0.0f; { // More class init (same macro as above) IL2CPP_RUNTIME_CLASS_INIT(CircleFunctions_t532702825_il2cpp_TypeInfo_var); // Actual work: float L_0 = CircleFunctions_Area_m4188038(NULL /*static, unused*/, (3.0f), /*hidden argument*/NULL); V_0 = L_0; return; } }
So even the callers of this function need to pay the price for the static variable. Ouch.
Now let’s look at what happens if you avoid the static variable. In this case it’s easy to just use const
:
static class CircleFunctionsConst { private const float Pi = 3.14f; public static float Area(float radius) { return Pi * radius * radius; } }
And here’s the C++:
extern "C" float CircleFunctionsConst_Area_m2838794717 (Il2CppObject * __this /* static, unused */, float ___radius0, const MethodInfo* method) { { float L_0 = ___radius0; float L_1 = ___radius0; return ((float)((float)((float)((float)(3.14f)*(float)L_0))*(float)L_1)); } }
The overhead is gone! Only the actual work remains. So how about the caller?
static void TestStaticFunctionNotUsingStaticVariable() { float area = CircleFunctionsConst.Area(3.0f); }
extern "C" void TestScript_TestStaticFunctionNotUsingStaticVariable_m2848480467 (Il2CppObject * __this /* static, unused */, const MethodInfo* method) { float V_0 = 0.0f; { float L_0 = CircleFunctionsConst_Area_m2838794717(NULL /*static, unused*/, (3.0f), /*hidden argument*/NULL); V_0 = L_0; return; } }
All that nasty overhead has completely vanished.
Recommendation: consider using constants and parameters instead of static variables.
Struct Initialization
Now let’s make a simple little struct:
struct MyVector3 { public float X; public float Y; public float Z; public MyVector3(float x, float y, float z) { X = x; Y = y; Z = z; } }
And let’s initialize it with a default constructor then setting its fields:
static void TestDefaultStructConstructor() { MyVector3 vec = new MyVector3(); vec.X = 1; vec.Y = 2; vec.Z = 3; }
Here’s how this looks in C++:
extern "C" void TestScript_TestDefaultStructConstructor_m1260349596 (Il2CppObject * __this /* static, unused */, const MethodInfo* method) { // The static variable overhead is back! static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestScript_TestDefaultStructConstructor_m1260349596_MetadataUsageId); s_Il2CppMethodInitialized = true; } MyVector3_t770449606 V_0; // The struct is defaulted to all zeroes memset(&V_0, 0, sizeof(V_0)); { // Initobj looks like this: // inline void Initobj(Il2CppClass* type, void* data) // { // if (type->valuetype) // memset(data, 0, type->instance_size - sizeof(Il2CppObject)); // else // *static_cast<Il2CppObject**>(data) = NULL; // } // There's an overhead of one more "if", even though we know this is a struct // Then the struct is cleared to zero _again_ Initobj (MyVector3_t770449606_il2cpp_TypeInfo_var, (&V_0)); // Set the fields. These "set_*" functions are trivial passthroughs. (&V_0)->set_X_0((1.0f)); (&V_0)->set_Y_1((2.0f)); (&V_0)->set_Z_2((3.0f)); return; } }
Why did that static overhead come back? Can we get rid of it? Let’s try an object initializer syntax:
static void TestStructInitializer() { MyVector3 vec = new MyVector3 { X = 1, Y = 2, Z = 3 }; }
extern "C" void TestScript_TestStructInitializer_m3484430381 (Il2CppObject * __this /* static, unused */, const MethodInfo* method) { // Same static variable overhead static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestScript_TestStructInitializer_m3484430381_MetadataUsageId); s_Il2CppMethodInitialized = true; } // Clear the struct to zero MyVector3_t770449606 V_0; memset(&V_0, 0, sizeof(V_0)); // Make another struct? Why? // Also, clear it to zero. MyVector3_t770449606 V_1; memset(&V_1, 0, sizeof(V_1)); { // Clear one of the structs to zero _again_ Initobj (MyVector3_t770449606_il2cpp_TypeInfo_var, (&V_1)); // Set all the fields (&V_1)->set_X_0((1.0f)); (&V_1)->set_Y_1((2.0f)); (&V_1)->set_Z_2((3.0f)); // Copy one struct to another struct MyVector3_t770449606 L_0 = V_1; V_0 = L_0; return; } }
The field initializer added even more overhead! Now there are two structs, a copy, and three unnecessary clears to zero on top of the static variable initialization code.
OK, let’s try a custom constructor:
static void TestCustomStructConstructor() { MyVector3 vec = new MyVector3(1, 2, 3); }
extern "C" void TestScript_TestCustomStructConstructor_m3485483736 (Il2CppObject * __this /* static, unused */, const MethodInfo* method) { MyVector3_t770449606 V_0; memset(&V_0, 0, sizeof(V_0)); { MyVector3__ctor_m3460461338((&V_0), (1.0f), (2.0f), (3.0f), /*hidden argument*/NULL); return; } } extern "C" void MyVector3__ctor_m3460461338 (MyVector3_t770449606 * __this, float ___x0, float ___y1, float ___z2, const MethodInfo* method) { { float L_0 = ___x0; __this->set_X_0(L_0); float L_1 = ___y1; __this->set_Y_1(L_1); float L_2 = ___z2; __this->set_Z_2(L_2); return; } }
The custom constructor got rid of all the static variable overhead and the extra struct. It’s all replaced with a function (the constructor) which sets the fields. Unfortunately, even though the constructor is required by the C# language to set all fields and not access them before setting them IL2CPP has still generated a memset
call before the constructor is called to clear the struct to zero. That’s the lowest overhead we’re going to get though.
Recommendation: consider using custom constructors instead of default constructors and object initializers.
Class Overhead
Finally, let’s make a class version of the above struct:
class MyVector3Class { public float X; public float Y; public float Z; public MyVector3Class(float x, float y, float z) { X = x; Y = y; Z = z; } }
Here’s how it looks in the C++ IL2CPP generates:
struct MyVector3Class_t1350799278 : public Il2CppObject { public: // System.Single MyVector3Class::X float ___X_0; // System.Single MyVector3Class::Y float ___Y_1; // System.Single MyVector3Class::Z float ___Z_2; public: inline static int32_t get_offset_of_X_0() { return static_cast<int32_t>(offsetof(MyVector3Class_t1350799278, ___X_0)); } inline float get_X_0() const { return ___X_0; } inline float* get_address_of_X_0() { return &___X_0; } inline void set_X_0(float value) { ___X_0 = value; } inline static int32_t get_offset_of_Y_1() { return static_cast<int32_t>(offsetof(MyVector3Class_t1350799278, ___Y_1)); } inline float get_Y_1() const { return ___Y_1; } inline float* get_address_of_Y_1() { return &___Y_1; } inline void set_Y_1(float value) { ___Y_1 = value; } inline static int32_t get_offset_of_Z_2() { return static_cast<int32_t>(offsetof(MyVector3Class_t1350799278, ___Z_2)); } inline float get_Z_2() const { return ___Z_2; } inline float* get_address_of_Z_2() { return &___Z_2; } inline void set_Z_2(float value) { ___Z_2 = value; } };
There’s a lot of boilerplate “get” and “set” functions, but the class is mostly as we’d expect. It has the three float
fields and it derives from System.Object
(a.k.a. object
) which is the default when you don’t explicitly declare a base class. That’s known as Il2CppObject
, so let’s take a look at that:
struct Il2CppObject { Il2CppClass *klass; MonitorData *monitor; };
This means that the size of an instance of our class isn’t just the three float
variables but also the size of two pointers. On 64-bit platforms that’s an additional 16 bytes of required storage. So instead of needing 24 bytes for an instance of our vector we actually need 40, an increase of 66%. This is a fixed overhead so it won’t matter as much for larger classes, but definitely something to be aware of for smaller ones that you have a lot of.
To compare, let’s look at the C++ for the struct version:
struct MyVector3_t770449606 { public: // System.Single MyVector3::X float ___X_0; // System.Single MyVector3::Y float ___Y_1; // System.Single MyVector3::Z float ___Z_2; public: inline static int32_t get_offset_of_X_0() { return static_cast<int32_t>(offsetof(MyVector3_t770449606, ___X_0)); } inline float get_X_0() const { return ___X_0; } inline float* get_address_of_X_0() { return &___X_0; } inline void set_X_0(float value) { ___X_0 = value; } inline static int32_t get_offset_of_Y_1() { return static_cast<int32_t>(offsetof(MyVector3_t770449606, ___Y_1)); } inline float get_Y_1() const { return ___Y_1; } inline float* get_address_of_Y_1() { return &___Y_1; } inline void set_Y_1(float value) { ___Y_1 = value; } inline static int32_t get_offset_of_Z_2() { return static_cast<int32_t>(offsetof(MyVector3_t770449606, ___Z_2)); } inline float get_Z_2() const { return ___Z_2; } inline float* get_address_of_Z_2() { return &___Z_2; } inline void set_Z_2(float value) { ___Z_2 = value; } };
This version is almost identical, except that it doesn’t come with the overhead of those two pointers.
Recommendation: consider using structs instead of classes when you need to save per-instance memory.
Conclusion
The C++ code generated by IL2CPP is full of surprises. Take some time to check it out for known hotspots in your game. You can simply search for “MyClass::MyFunction” and easily find the C++ equivalent of your C# code. You might be surprised what you find!
#1 by VVEthan on August 14th, 2017 ·
Super helpful article. Looking through IL2CPP output can be difficult even to those comfortable with C++, and it’s very hard to know if a Unity+Mono “best practice” has negative implications when using the same code base on Unity+IL2CPP outputs.
#2 by Josh Peterson on October 17th, 2017 ·
Thanks for digging into the C++ code generated by IL2CPP, this is a good analysis. I wanted to point out that this additional overhead is not specific to IL2CPP. In fact, this is exactly the same overhead any .NET runtime needs to have.
For example, the calls to
IL2CPP_RUNTIME_CLASS_INIT
are required in order to ensure that the static constructor for the type is executed at the right time.You have properly pointed out that using some different techniques in C#, like using
const
instead ofstatic
where possible, and using astruct
instead of aclass
can lead to more efficient code. Thanks for writing this article!#3 by Yorn on June 9th, 2019 ·
Very useful information! Thank you! That makes me wonder… does this apply to regular classes with static fields? If a regular class has its instances call its own static field is there the same overhead? Its seems to me that if the static constructor is triggered first-thing in the regular class constructor, this means that instances might not (?) need that boiler plate b/c the static initialization has already guaranteed to have happened.
#4 by jackson on June 11th, 2019 ·
Yes, this applies to non-
static
classes as well.#5 by Yorn on June 9th, 2019 ·
Also not sure if you saw this but your work was featured in the Speed Demon Unity talk: https://www.youtube.com/watch?v=apct9_tsBdA
#6 by Christopher U. on May 20th, 2022 ·
FYI, it seems at least some of the issues mentioned in the `Struct Initialization` section have been fixed on latest Unity versions (2020.3+)