IL2CPP: Method Calls
- Transfer

In particular, we consider six types of calls:
- direct calls to instance methods and static methods;
- calls through the compile-time delegate;
- calls through the virtual method;
- calls through an interface method;
- Calls through the runtime delegate
- challenges through reflection.
We will pay attention to the actions of the generated C ++ code, as well as to the costs associated with each type of call. As I said, the code presented is likely to change in future versions of Unity. But the basic principles will remain unchanged.
Previous articles in the series:
» Introduction to IL2CPP.
» IL2CPP: a tour of the generated code.
» IL2CPP: tips for debugging generated code.
Preparation for work
I will use Unity version 5.0.1p4 on Windows to build a project under WebGL. In doing so, I will enable the Development Player option and set the Full value to Enable Exceptions. To analyze the various types of method calls, I will use the modified script from the previous article, starting with the interface and class definition:
[csharp]
interface Interface {
int MethodOnInterface(string question);
}
class Important : Interface {
public int Method(string question) { return 42; }
public int MethodOnInterface(string question) { return 42; }
public static int StaticMethod(string question) { return 42; }
}
[/csharp]
They are followed by a constant field and a delegate type:
[csharp]
private const string question = "What is the answer to the ultimate question of life, the universe, and everything?";
private delegate int ImportantMethodDelegate(string question);
[/csharp]
Finally, we indicate the methods of interest to us, as well as the required Start method (in our case, empty):
[csharp]
private void CallDirectly() {
var important = ImportantFactory();
important.Method(question);
}
private void CallStaticMethodDirectly() {
Important.StaticMethod(question);
}
private void CallViaDelegate() {
var important = ImportantFactory();
ImportantMethodDelegate indirect = important.Method;
indirect(question);
}
private void CallViaRuntimeDelegate() {
var important = ImportantFactory();
var runtimeDelegate = Delegate.CreateDelegate(typeof (ImportantMethodDelegate), important, "Method");
runtimeDelegate.DynamicInvoke(question);
}
private void CallViaInterface() {
Interface importantViaInterface = new Important();
importantViaInterface.MethodOnInterface(question);
}
private void CallViaReflection() {
var important = ImportantFactory();
var methodInfo = typeof(Important).GetMethod("Method");
methodInfo.Invoke(important, new object[] {question});
}
private static Important ImportantFactory() {
var important = new Important();
return important;
}
void Start () {}
[/csharp]
So, everything is ready. Note that while the editor is open, the generated C ++ code is located in the Temp \ StagingArea \ Data \ il2cppOutput directory. And don't forget to generate the tag file with Ctags to make code navigation easier.
Direct calls
Calling a method is simpler and, as you can see, the fastest way directly. Here is the generated code for the CallDirectly method:
[cpp]
Important_t1 * L_0 = HelloWorld_ImportantFactory_m15(NULL /*static, unused*/, /*hidden argument*/&HelloWorld_ImportantFactory_m15_MethodInfo);
V_0 = L_0;
Important_t1 * L_1 = V_0;
NullCheck(L_1);
Important_Method_m1(L_1, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_Method_m1_MethodInfo);
[/cpp]
The last line is the method call. Note that it only calls the free function defined in the C ++ code. As we said in a previous article, IL2CPP does not use member functions or virtual functions, but generates all methods as free C ++ functions. Similarly, a direct call to the static method works. This is what the generated code for the CallStaticMethodDirectly method looks like:
[cpp]
Important_StaticMethod_m3(NULL /*static, unused*/, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_StaticMethod_m3_MethodInfo);
[/cpp]
We can say that calling a static method is less costly since we do not need to create and initialize an instance of the object. But the method call itself is one and the same. The only difference is that IL2CPP always passes NULL for the first argument of static functions. Given that the difference between calls to static methods and instance methods is so small, in this article we will identify them.
Calls through the compile-time delegate
An indirect call through a delegate has its own specifics. First, I’ll clarify what I mean by the compile time delegate - already at compile time we know which method is called from which instance of the object. The code for this type is in the CallViaDelegate method. In the generated code, it looks like this:
[cpp]
// 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;
Important_t1 * L_1 = V_0;
// Create the delegate.
IntPtr_t L_2 = { &Important_Method_m1_MethodInfo };
ImportantMethodDelegate_t4 * L_3 = (ImportantMethodDelegate_t4 *)il2cpp_codegen_object_new (InitializedTypeInfo(&ImportantMethodDelegate_t4_il2cpp_TypeInfo));
ImportantMethodDelegate__ctor_m4(L_3, L_1, L_2, /*hidden argument*/&ImportantMethodDelegate__ctor_m4_MethodInfo);
V_1 = L_3;
ImportantMethodDelegate_t4 * L_4 = V_1;
// Call the method
NullCheck(L_4);
VirtFuncInvoker1< int32_t, String_t* >::Invoke(&ImportantMethodDelegate_Invoke_m5_MethodInfo, L_4, (String_t*) &_stringLiteral1);
[/cpp]
Note that the called method is not actually part of the generated code. VirtFuncInvoker1 Method
[cpp]
template
struct VirtFuncInvoker1
{
typedef R (*Func)(void*, T1, MethodInfo*);
static inline R Invoke (MethodInfo* method, void* obj, T1 p1)
{
VirtualInvokeData data = il2cpp::vm::Runtime::GetVirtualInvokeData (method, obj);
return ((Func)data.methodInfo->method)(data.target, p1, data.methodInfo);
}
};
[/cpp]
The GetVirtualInvokeData call looks for a virtual method in the vtable generated from the managed code, and then calls this method.
Why didn’t we use C ++ 11 variable templates to implement VirtFuncInvokerN methods? Everything indicates that in this case they would have come in handy. However, to work with the C ++ code generated by il2cpp.exe, we need C ++ compilers, which do not yet support all aspects of C ++ 11. Therefore, we decided that creating a separate branch of the generated code for compilers would only complicate the process, and did not do this.
But why is this a virtual method call? Aren't we calling the instance method in C # code? Do not forget that we do this through the C # delegate. Take another look at the generated code. The called method is passed through the argument MethodInfo * (method metadata) - ImportantMethodDelegate_Invoke_m5_MethodInfo. Find the method called ImportantMethodDelegate_Invoke_m5 in the generated code, and you will see that the call goes to the Invoke managed method of type ImportantMethodDelegate. This is a virtual method, and so we have to make a virtual call: the ImportantMethodDelegate_Invoke_m5 function will call a method called Method in C # code.
So, due to a small change in the C # code, we moved from a single call to a free C ++ function to several calls, including a table search. However, invoking a method through a delegate is much more expensive than directly. By the way, in the process of considering this type of call, we also talked about how calls work through the virtual method.
Calls through the interface
You can also call a method in C # through the interface. Il2cpp.exe makes these calls by analogy with calls to virtual methods:
[cpp]
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);
[/cpp]
Note that the method is called through the function InterfaceFuncInvoker1 :: Invoke in the GeneratedInterfaceInvokers.h file. Like VirtFuncInvoker1, the InterfaceFuncInvoker1 class searches the vtable using the il2cpp :: vm :: Runtime :: GetInterfaceInvokeData function in libil2cpp.
Why are calls through an interface method and calls through a virtual method using different APIs in libil2cpp? The call to the function InterfaceFuncInvoker1 :: Invoke passes not only the called method and its arguments, but also the interface (in this case L_1). A vtable is stored for each type, so that the interface methods are fixed at offset. Therefore, il2cpp.exe must provide an interface to determine which method to call. The bottom line is that calls through the virtual method and calls through the interface are equally costly in IL2CPP.
Calls through the runtime delegate
You can also create a delegate at run time using the Delegate.CreateDelegate method. This is similar to creating a delegate at compile time, but requires calling another function. The generated code looks like this:
[cpp]
// 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);
[/cpp]
Creating and initializing such a delegate requires a lot more code. And the method call itself is more costly. First, you need to create an array for the method arguments. Then, call the DynamicInvoke method from the Delegate instance. Note that this method calls the VirtFuncInvoker1 :: Invoke function - just like the compile-time delegate. Thus, the run-time delegate requires not only another function call, but also an additional search on the vtable.
Challenges Through Reflection
Not surprisingly, the most expensive type of method invocation is through reflection. This is how the generated code for the CallViaReflection method looks like:
[cpp]
// 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);
[/cpp]
As with the run-time delegate, we need to create an array for the method arguments. Then we make a call to the virtual MethodBase :: Invoke method, the MethodBase_Invoke_m24 function, which, in turn, calls another virtual function. And only then is the required method call made.
Conclusion
Although this does not compare with profiling, parsing the generated C ++ code allows you to better understand the costs associated with a particular method call. For example, calling methods through a run-time delegate and through reflection is better off. To increase productivity, measure costs early on, preferably with profilers.
We continue to work on optimizing the code generated by il2cpp.exe, so it is likely that in the next versions of Unity the listed types of calls will look different. In the next article, we will talk about the implementation of universal methods and ways to reduce the size of the executable file and the generated code.