Calling a method via an interface
It’s also possible to call a method in C# via an interface. This call is implemented by il2cpp.exe similar to a virtual method call:
Important_t1 * L_0 = (Important_t1 *)il2cpp_codegen_object_new (InitializedTypeInfo(&Important_t1_il2cpp_TypeInfo));
Important__ctor_m0(L_0, /*hidden argument*/&Important__ctor_m0_MethodInfo);
V_0 = L_0;
Object_t * L_1 = V_0;
NullCheck(L_1);
InterfaceFuncInvoker1< int32_t, String_t* >::Invoke(&Interface_MethodOnInterface_m22_MethodInfo, L_1, (String_t*) &_stringLiteral1);
Note the actual method call here is done via the InterfaceFuncInvoker1::Invoke function, which is in the GeneratedInterfaceInvokers.h
file. Like the VirtFuncInvoker1 class the InterfaceFuncInvoker1 class does a lookup in a vtable via the
il2cpp::vm::Runtime::GetInterfaceInvokeData function in libil2cpp.
Why does an interface method call need to use a different API in libil2cpp from a virtual method call? Note that the call to
InterfaceFuncInvoker1::Invoke is passing not only the method to call and its arguments, but also the interface to call that
method on (L_1 in this case). The vtable for each type is stored so that interface methods are written at a specific offset.
Therefore, il2cpp.exe needs to provide the interface in order to determine which method to call.
The bottom line here is that calling a virtual method and calling a method via an interface have effectively the same overhead in IL2CPP.
Calling a method via a run-time delegate
Another way to use a delegate is to create it at runtime via the Delegate.CreateDelegate method. This approach is similar to
a compile-time delegate, except that it be modified at runtime in a few more ways. We pay for that flexibility with an additional
function call. Here is the generated code:
// Get the object instance used to call the method.
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
// Create the delegate.
IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo));
Type_t * L_1 = Type_GetTypeFromHandle_m19(NULL /*static, unused*/, LoadTypeToken(&ImportantMethodDelegate_t4_0_0_0), /*hidden argument*/&Type_GetTypeFromHandle_m19_MethodInfo);
Important_t1 * L_2 = V_0;
Delegate_t12 * L_3 = Delegate_CreateDelegate_m20(NULL /*static, unused*/, L_1, L_2, (String_t*) &_stringLiteral2, /*hidden argument*/&Delegate_CreateDelegate_m20_MethodInfo);
V_1 = L_3;
Delegate_t12 * L_4 = V_1;
// Call the method
ObjectU5BU5D_t9* L_5 = ((ObjectU5BU5D_t9*)SZArrayNew(ObjectU5BU5D_t9_il2cpp_TypeInfo_var, 1));
NullCheck(L_5);
IL2CPP_ARRAY_BOUNDS_CHECK(L_5, 0);
ArrayElementTypeCheck (L_5, (String_t*) &_stringLiteral1);
*((Object_t **)(Object_t **)SZArrayLdElema(L_5, 0)) = (Object_t *)(String_t*) &_stringLiteral1;
NullCheck(L_4);
Delegate_DynamicInvoke_m21(L_4, L_5, /*hidden argument*/&Delegate_DynamicInvoke_m21_MethodInfo);
This delegate requires a good bit of code for creation and initialization. But the method call itself has even more overhead, too.
First we need to create an array to hold the method arguments, then call the DynamicInvoke method on the Delegate instance.
If we follow that method in the generated code, we can see that it calls the VirtFuncInvoker1::Invoke function,
just as the compile-time delegate does. So this delegate requires one more function call then the compile-time delegate does,
plus two lookups in a vtable, instead of just one.
Calling a method via reflection
The most costly way to call a method is, not surprisingly, via reflection. Let’s look at the generated code for the
CallViaReflection method:
// Get the object instance used to call the method.
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
// Get the method metadata from the type via reflection.
IL2CPP_RUNTIME_CLASS_INIT(InitializedTypeInfo(&Type_t_il2cpp_TypeInfo));
Type_t * L_1 = Type_GetTypeFromHandle_m19(NULL /*static, unused*/, LoadTypeToken(&Important_t1_0_0_0), /*hidden argument*/&Type_GetTypeFromHandle_m19_MethodInfo);
NullCheck(L_1);
MethodInfo_t * L_2 = (MethodInfo_t *)VirtFuncInvoker1< MethodInfo_t *, String_t* >::Invoke(&Type_GetMethod_m23_MethodInfo, L_1, (String_t*) &_stringLiteral2);
V_1 = L_2;
MethodInfo_t * L_3 = V_1;
// Call the method.
Important_t1 * L_4 = V_0;
ObjectU5BU5D_t9* L_5 = ((ObjectU5BU5D_t9*)SZArrayNew(ObjectU5BU5D_t9_il2cpp_TypeInfo_var, 1));
NullCheck(L_5);
IL2CPP_ARRAY_BOUNDS_CHECK(L_5, 0);
ArrayElementTypeCheck (L_5, (String_t*) &_stringLiteral1);
*((Object_t **)(Object_t **)SZArrayLdElema(L_5, 0)) = (Object_t *)(String_t*) &_stringLiteral1;
NullCheck(L_3);
VirtFuncInvoker2< Object_t *, Object_t *, ObjectU5BU5D_t9* >::Invoke(&MethodBase_Invoke_m24_MethodInfo, L_3, L_4, L_5);
As in the case of the runtime delegate, we need to spend some time creating an array for the arguments to the method.
Then we make a virtual method call to MethodBase::Invoke (the MethodBase_Invoke_m24 function). This function in turn invokes
another virtual function, before we finally get to the actual method call!
Conclusion
While this is no substitute for actual profiling and measurement, we can get some insight about the overhead of any given method invocation by looking at how the generated C++ code is used for different types of method calls. Specifically, it is clear that we want to avoid calls via run-time delegates and reflection, if at all possible. As always, the best advice about making performance improvements is to measure early and often with profiling tools.
We’re always looking for ways to optimize the code generated by il2cpp.exe, so it is likely that these method calls will look different in a later version of Unity.
Next time we’ll delve deeper in to method implementations and see how we share the implementation of generic methods to minimize generated code and executable size.