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!