The Effects of Useless Code
There are a lot of ways to write C# code that has no effect. One common way is to initialize class fields to their default values: public int Value = 0;
. Today we’ll go over five types of useless code and see what effect it has on the actual machine code that the CPU executes. Do IL2CPP and the C++ compiler always do the right thing? Let’s find out!
Setting fields to their default values
All fields of a class are initially set to their default
value: 0
, false
, null
, 0.0
, etc. Still, it’s really common to see programmers explicitly set the initial value to the same default
value. For example, consider this class:
public class MyClass { public int ImplicitlyDefault; public int SetToDefaultInConstructor; public int SetToDefaultInline = 0; public int SetToNonDefaultInConstructor; public int SetToNonDefaultInline = 123; public MyClass() { SetToDefaultInConstructor = 0; SetToNonDefaultInConstructor = 123; } }
Here we have five fields initialized in different ways:
ImplicitlyDefault
: initialized by the runtime system to thedefault
valueSetToDefaultInConstructor
: initialized to thedefault
value in the constructorSetToDefaultInline
: initialized to thedefault
value inlineSetToNonDefaultInConstructor
: initialized to a non-default
value in the constructorSetToNonDefaultInline
: initialized to a non-default
value inline
Let’s see what IL2CPP generates for the MyClass
constructor:
extern "C" IL2CPP_METHOD_ATTR void MyClass__ctor_m2144872410 (MyClass_t3388352440 * __this, const RuntimeMethod* method) { { __this->set_SetToNonDefaultInline_4(((int32_t)123)); Object__ctor_m297566312(__this, /*hidden argument*/NULL); __this->set_SetToDefaultInConstructor_1(0); __this->set_SetToNonDefaultInConstructor_3(((int32_t)123)); return; } }
This constructor comes in three stages. First, the inline field initializers are run. In this case we see SetToNonDefaultInline
is set to 123
but we don’t see SetToDefaultInline
set to 0
. Second, the base class (System.Object
) constructor is run. Finally, the constructor’s body is run to set SetToDefaultInConstructor
to 0
and SetToNonDefaultInConstructor
to 123
.
So far we’ve seen that IL2CPP didn’t generate any C++ code for the inline setting of a field to its default
value, but it did generate C++ code for our setting of a field to its default
value in the constructor. Let’s see if that translates into machine code by looking at the assembly output of the C++ compiler. Here’s what Xcode 9.4.1 generates for an iOS ARM64 release build: (cleaned up and annotated by Jackson)
stp x20, x19, [sp, #-32]! stp x29, x30, [sp, #16] add x29, sp, #16 mov x19, x0 mov w20, #123 str w20, [x19, #32] ; SetToNonDefaultInConstructor = 123 mov x1, #0 bl _Object__ctor_m297566312 ; Call System.Object's constructor str wzr, [x19, #20] ; SetToDefaultInConstructor = 0 str w20, [x19, #28] ; SetToNonDefaultInConstructor = 123 ldp x29, x30, [sp, #16] ldp x20, x19, [sp], #32 ret
The C++ compiler still generated machine code to redundantly and unnecessarily set a field to its default
value.
Conclusion: Setting fields to their default
value has no effect when done inline but wastes CPU when done in a constructor’s body.
Using the f
suffix with doubles
Real numbers can be specified as either double
or float
literals depending on whether the f
suffix is used. 123.456
is a double
and 123.456f
is a float
. Since float
is much more common in games than double
, adding the f
suffix is reflexive for some programmers. What happens when accidentally using the f
suffix with a double
variable? Let’s try!
public static class TestClass { public static double FloatToDouble() { return 123.456f; } public static float FloatToFloat() { return 123.456f; } }
Here we have one function that returns a float
literal (123.456f
) for a double
return type and another function that returns the same float
literal for a float
return type. Let’s see what IL2CPP generates:
extern "C" IL2CPP_METHOD_ATTR double TestClass_FloatToDouble_m2539055131 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method) { { return (123.45600128173828); } } extern "C" IL2CPP_METHOD_ATTR float TestClass_FloatToFloat_m2528796140 (RuntimeObject * __this /* static, unused */, const RuntimeMethod* method) { { return (123.456f); } }
The generated C++ shows that the float
literal in FloatToDouble
has been converted to a double literal: 123.45600128173828
. Meanwhile, the float
literal in FloatToFloat
is preserved exactly as it was in C#: 123.456f
.
Conclusion: Floating point constants with the f
suffix are converted at build time to double
constants when used as a double
.
Empty overrides
A virtual
method can be overridden by a derived class using the override
keyword. Sometimes programmers or their IDEs will generate an override
method that simply calls the base class’ method that’s being overridden. Here’s how that looks:
public class BaseClass { public virtual void EmptyMethod() { } } public class DerivedClass : BaseClass { public override void EmptyMethod() { base.EmptyMethod(); } } public static class TestClass { public static void CallBaseEmptyMethod(BaseClass bc) { bc.EmptyMethod(); } public static void CallDerivedEmptyMethod(DerivedClass dc) { dc.EmptyMethod(); } }
Neither CallBaseEmptyMethod
nor CallDerivedEmptyMethod
do anything regardless of the parameter type they’re passed. Let’s see if IL2CPP can figure this out and generate C++ that doesn’t make any method calls:
extern "C" IL2CPP_METHOD_ATTR void TestClass_CallBaseEmptyMethod_m3638386401 (RuntimeObject * __this /* static, unused */, BaseClass_t296860279 * ___bc0, const RuntimeMethod* method) { { BaseClass_t296860279 * L_0 = ___bc0; NullCheck(L_0); VirtActionInvoker0::Invoke(4 /* System.Void BaseClass::EmptyMethod() */, L_0); return; } } extern "C" IL2CPP_METHOD_ATTR void TestClass_CallDerivedEmptyMethod_m657137151 (RuntimeObject * __this /* static, unused */, DerivedClass_t2882573829 * ___dc0, const RuntimeMethod* method) { { DerivedClass_t2882573829 * L_0 = ___dc0; NullCheck(L_0); VirtActionInvoker0::Invoke(4 /* System.Void BaseClass::EmptyMethod() */, L_0); return; } }
IL2CPP still generated the virtual method calls for CallBaseEmptyMethod
and CallDerivedEmptyMethod
, so let’s check if the C++ compiler can figure out that these calls do nothing and remove them:
; CallBaseEmptyMethod stp x20, x19, [sp, #-32]! stp x29, x30, [sp, #16] add x29, sp, #16 mov x19, x1 cbnz x19, LBB10_2 mov x0, #0 bl __ZN6il2cpp2vm9Exception27RaiseNullReferenceExceptionEP19Il2CppSequencePoint LBB10_2: ldr x8, [x19] ldp x2, x1, [x8, #368] mov x0, x19 ldp x29, x30, [sp, #16] ldp x20, x19, [sp], #32 br x2 ; bc.EmptyMethod(); ; CallDerivedEmptyMethod stp x20, x19, [sp, #-32]! stp x29, x30, [sp, #16] add x29, sp, #16 mov x19, x1 cbnz x19, LBB11_2 mov x0, #0 bl __ZN6il2cpp2vm9Exception27RaiseNullReferenceExceptionEP19Il2CppSequencePoint LBB11_2: ldr x8, [x19] ldp x2, x1, [x8, #368] mov x0, x19 ldp x29, x30, [sp, #16] ldp x20, x19, [sp], #32 br x2 ; dc.EmptyMethod();
The virtual method calls in both functions made it all the way to the machine code that the CPU executes. Now let’s see what the EmptyMethod
methods look like:
extern "C" IL2CPP_METHOD_ATTR void BaseClass_EmptyMethod_m1600043815 (BaseClass_t296860279 * __this, const RuntimeMethod* method) { { return; } } extern "C" IL2CPP_METHOD_ATTR void DerivedClass_EmptyMethod_m1036491442 (DerivedClass_t2882573829 * __this, const RuntimeMethod* method) { { BaseClass_EmptyMethod_m1600043815(__this, /*hidden argument*/NULL); return; } }
BaseClass.EmptyMethod
is literally empty and DerivedClass.EmptyMethod
is a non-virtual call to BaseClass.EmptyMethod
. Now let’s see what these turn into when compiled:
; BaseClass.EmptyMethod ret ; DerivedClass.EmptyMethod ret
Here the C++ compiler was able to generate two completely empty functions. Even the call from DerivedClass.EmptyMethod
to BaseClass.EmptyMethod
was removed, which is good because it did nothing.
Conclusion: Calling an empty virtual method or one that only calls the base method it overrides still results in a virtual method call. The generated functions for the virtual and override methods are empty.
Redundant type checks
C# provides at least three ways to check types at runtime:
(T)x
: Check ifx
is typeT
, cast if so, throw an exception otherwisex as T
: Check ifx
is typeT
, cast if so,null
otherwisex is T
: Check ifx
is typeT
,true
if so,false
otherwise
Sometimes programmers will perform redundant checks like this:
public static class TestClass { public static DerivedClass RedundantTypeCheck(BaseClass bc, DerivedClass or) { if (bc is DerivedClass) { return (DerivedClass)bc; } return or; } }
This code performs the x is T
check and then the (T)x
check. Here’s the equivalent code that uses just x as T
:
public static class TestClass { public static DerivedClass NoRedundantTypeCheck(BaseClass bc, DerivedClass or) { DerivedClass dc = bc as DerivedClass; if (dc != null) { return dc; } return or; } }
Let’s look at the IL2CPP output for these methods:
extern "C" IL2CPP_METHOD_ATTR DerivedClass_t2882573829 * TestClass_RedundantTypeCheck_m2601560077 (RuntimeObject * __this /* static, unused */, BaseClass_t296860279 * ___bc0, DerivedClass_t2882573829 * ___or1, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestClass_RedundantTypeCheck_m2601560077_MetadataUsageId); s_Il2CppMethodInitialized = true; } { BaseClass_t296860279 * L_0 = ___bc0; if (!((DerivedClass_t2882573829 *)IsInstClass((RuntimeObject*)L_0, DerivedClass_t2882573829_il2cpp_TypeInfo_var))) { goto IL_0012; } } { BaseClass_t296860279 * L_1 = ___bc0; return ((DerivedClass_t2882573829 *)CastclassClass((RuntimeObject*)L_1, DerivedClass_t2882573829_il2cpp_TypeInfo_var)); } IL_0012: { DerivedClass_t2882573829 * L_2 = ___or1; return L_2; } } extern "C" IL2CPP_METHOD_ATTR DerivedClass_t2882573829 * TestClass_NoRedundantTypeCheck_m592734819 (RuntimeObject * __this /* static, unused */, BaseClass_t296860279 * ___bc0, DerivedClass_t2882573829 * ___or1, const RuntimeMethod* method) { static bool s_Il2CppMethodInitialized; if (!s_Il2CppMethodInitialized) { il2cpp_codegen_initialize_method (TestClass_NoRedundantTypeCheck_m592734819_MetadataUsageId); s_Il2CppMethodInitialized = true; } DerivedClass_t2882573829 * V_0 = NULL; { BaseClass_t296860279 * L_0 = ___bc0; V_0 = ((DerivedClass_t2882573829 *)IsInstClass((RuntimeObject*)L_0, DerivedClass_t2882573829_il2cpp_TypeInfo_var)); DerivedClass_t2882573829 * L_1 = V_0; if (!L_1) { goto IL_000f; } } { DerivedClass_t2882573829 * L_2 = V_0; return L_2; } IL_000f: { DerivedClass_t2882573829 * L_3 = ___or1; return L_3; } }
The generated C++ for RedundantTypeCheck
calls IsInstClass
to perform the x is T
check and then CastclassClass
to perform the (T)x
check and cast. NoRedundantTypeCheck
calls the same IsInstClass
to perform the x as T
check and then simply checks for null.
At this point the IL2CPP-generated C++ code still has the redundant type checking, so let’s look at the C++ compiler’s output to see how the machine code looks:
; RedundantTypeCheck stp x22, x21, [sp, #-48]! stp x20, x19, [sp, #16] stp x29, x30, [sp, #32] add x29, sp, #32 mov x19, x2 mov x20, x1 adrp x21, __ZZ40TestClass_RedundantTypeCheck_m2601560077E25s_Il2CppMethodInitialized@PAGE ldrb w8, [x21, __ZZ40TestClass_RedundantTypeCheck_m2601560077E25s_Il2CppMethodInitialized@PAGEOFF] tbnz w8, #0, LBB14_2 adrp x8, _TestClass_RedundantTypeCheck_m2601560077_MetadataUsageId@GOTPAGE ldr x8, [x8, _TestClass_RedundantTypeCheck_m2601560077_MetadataUsageId@GOTPAGEOFF] ldr w0, [x8] bl __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj orr w8, wzr, #0x1 strb w8, [x21, __ZZ40TestClass_RedundantTypeCheck_m2601560077E25s_Il2CppMethodInitialized@PAGEOFF] LBB14_2: cbz x20, LBB14_4 adrp x8, _DerivedClass_t2882573829_il2cpp_TypeInfo_var@GOTPAGE ldr x8, [x8, _DerivedClass_t2882573829_il2cpp_TypeInfo_var@GOTPAGEOFF] ldr x8, [x8] ldr x9, [x20] ldrb w11, [x9, #292] ldrb w10, [x8, #292] cmp w11, w10 b.hs LBB14_5 LBB14_4: mov x0, x19 b LBB14_6 LBB14_5: ldr x9, [x9, #200] add x9, x9, x10, lsl #3 ldur x9, [x9, #-8] cmp x9, x8 csel x0, x20, x19, eq LBB14_6: ldp x29, x30, [sp, #32] ldp x20, x19, [sp, #16] ldp x22, x21, [sp], #48 ret ; NoRedundantTypeCheck stp x22, x21, [sp, #-48]! stp x20, x19, [sp, #16] stp x29, x30, [sp, #32] add x29, sp, #32 mov x19, x2 mov x20, x1 adrp x21, __ZZ41TestClass_NoRedundantTypeCheck_m592734819E25s_Il2CppMethodInitialized@PAGE ldrb w8, [x21, __ZZ41TestClass_NoRedundantTypeCheck_m592734819E25s_Il2CppMethodInitialized@PAGEOFF] tbnz w8, #0, LBB16_2 adrp x8, _TestClass_NoRedundantTypeCheck_m592734819_MetadataUsageId@GOTPAGE ldr x8, [x8, _TestClass_NoRedundantTypeCheck_m592734819_MetadataUsageId@GOTPAGEOFF] ldr w0, [x8] bl __ZN6il2cpp2vm13MetadataCache24InitializeMethodMetadataEj orr w8, wzr, #0x1 strb w8, [x21, __ZZ41TestClass_NoRedundantTypeCheck_m592734819E25s_Il2CppMethodInitialized@PAGEOFF] LBB16_2: cbz x20, LBB16_4 adrp x8, _DerivedClass_t2882573829_il2cpp_TypeInfo_var@GOTPAGE ldr x8, [x8, _DerivedClass_t2882573829_il2cpp_TypeInfo_var@GOTPAGEOFF] ldr x8, [x8] ldr x9, [x20] ldrb w11, [x9, #292] ldrb w10, [x8, #292] cmp w11, w10 b.hs LBB16_5 LBB16_4: mov x8, #0 b LBB16_6 LBB16_5: ldr x9, [x9, #200] add x9, x9, x10, lsl #3 ldur x9, [x9, #-8] cmp x9, x8 csel x8, x20, xzr, eq LBB16_6: cmp x8, #0 csel x0, x19, x8, eq ldp x29, x30, [sp, #32] ldp x20, x19, [sp, #16] ldp x22, x21, [sp], #48 ret
The only difference is that the NoRedundantTypeCheck
version has two additional instructions toward the end:
cmp x8, #0 csel x0, x19, x8, eq
Not only has the C++ compiler removed the redundant type check, but it’s actually generated slightly more efficient code for the redundant check.
Conclusion: Redundant type checks don’t generate any additional code and can actually generate even more efficient code than without the redundancy.
Immediately overwriting local variables
Similarly to initializing a field to its default value, sometimes C# programmers will initialize a local variable and then immediately overwrite it. This means the initialization has no effect since the value of the variable isn’t read before it’s overwritten. Here’s an example:
public static class TestClass { public static BaseClass ImmediateOverwrite( BaseClass a, BaseClass b, bool choice) { BaseClass ret = null; if (choice) { ret = a; } else { ret = b; } return ret; } }
So let’s see if IL2CPP still generates the initialization of ret = null
:
extern "C" IL2CPP_METHOD_ATTR BaseClass_t296860279 * TestClass_ImmediateOverwrite_m2257400232 (RuntimeObject * __this /* static, unused */, BaseClass_t296860279 * ___a0, BaseClass_t296860279 * ___b1, bool ___choice2, const RuntimeMethod* method) { BaseClass_t296860279 * V_0 = NULL; { V_0 = (BaseClass_t296860279 *)NULL; bool L_0 = ___choice2; if (!L_0) { goto IL_000f; } } { BaseClass_t296860279 * L_1 = ___a0; V_0 = L_1; goto IL_0011; } IL_000f: { BaseClass_t296860279 * L_2 = ___b1; V_0 = L_2; } IL_0011: { BaseClass_t296860279 * L_3 = V_0; return L_3; } }
The key line here is V_0 = (BaseClass_t296860279 *)NULL
where ret
is initialized to NULL
. Now let’s check the assembly to see if the C++ compiler strips out the initialization:
cmp w3, #0 csel x0, x1, x2, ne ret
The C++ compiler removed the initialization, remaining only the remaining if
which it turned into the assembly version of a ternary operator.
Conclusion: No code is generated for initializing a variable if it isn’t read before being overwritten.
Final thoughts
We’ve seen today that useless C# code can result in a wide range of machine code effects. In cases like calling an empty virtual method, expensive code will still end up executing. In other cases like an inline field initializer that assigns the default
value, there is no effect at all. At the other end of the spectrum, redundant type checks can actually result in faster code. It’s important to check both the C++ output of IL2CPP and the machine code that the C++ compiler generates for any performance-critical code. You might be surprised what you find!