📜 ⬆️ ⬇️

IL2CPP: method calls

This is the fourth article in the series on IL2CPP. In it, we’ll talk about how il2cpp.exe generates C ++ code for method calls in managed code.



In particular, we consider six types of calls:
')

We will focus on the actions of the generated C ++ code, as well as the costs associated with each type of call. As I already spoke, the presented code for certain will change in the next 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 the version of Unity 5.0.1p4 on Windows to build the project under WebGL. At the same time, I will turn on the Development Player option and set the value to Full for Enable Exceptions. To analyze the different types of method calls, I will use the modified script from the previous article, starting with the interface and the 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 specify the methods we are interested in, as well as the obligatory Start method (empty in our case):

 [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 a tag file with Ctags to make it easier to navigate through the code.

Direct calls


It is easier to call a method and, as you can see, the fastest is 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 just calls the free function specified in C ++ code. As we said in the 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. Here is the generated code for the CallStaticMethodDirectly method:

 [cpp] Important_StaticMethod_m3(NULL /*static, unused*/, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_StaticMethod_m3_MethodInfo); [/cpp] 

It can be said that it is less expensive to call a static method, since we do not need to create and initialize an object instance. But the method call itself is the same. The only difference is that for the first argument of static functions, IL2CPP always passes the value NULL. Given that the difference between calls to static methods and instance methods is so small, in the framework of this article we will identify them.

Calls through the compile time delegate


An indirect call through a delegate has its own specifics. First I will clarify what I mean by the delegate of the compile time - 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 method being called is not actually part of the generated code. The VirtFuncInvoker1 <int32_t, String_t *> :: Invoke method is located in the GeneratedVirtualInvokers.h file generated by the il2cpp.exe utility based on using virtual functions that return a value (VirtFuncInvokerN) and do not return it (VirtActionInvokerN), where N is the number of arguments. This is how the Invoke method looks like:

 [cpp] template <typename R, typename T1> 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 call to GetVirtualInvokeData looks for a virtual method in the vtable table generated from the managed code, and then calls this method.

Why we did not use the variable templates C ++ 11 to implement the methods of VirtFuncInvokerN? Everything indicates that in this case they would have come in handy. However, to work with C ++ code generated by il2cpp.exe, we need C ++ compilers, which do not support all aspects of C ++ 11 yet. Therefore, we decided that creating a separate branch of the generated code for the compilers would only complicate the process and did not.

But why is this a virtual method call? Don't we call the instance method in C # code? Don't forget that we do this through the C # delegate. Take another look at the generated code. The called method is passed through the MethodInfo * argument (method metadata) - ImportantMethodDelegate_Invoke_m5_MethodInfo. Find a method called ImportantMethodDelegate_Invoke_m5 in the generated code, and you will see that the call goes to the managed Invoke method of type ImportantMethodDelegate. This is a virtual method, which means 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 switched from a single call to a free C ++ function to several calls, including a table search. However, calling a method through a delegate is much more expensive than directly. By the way, in the process of reviewing 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 such 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 invoked via the InterfaceFuncInvoker1 :: Invoke function in the GeneratedInterfaceInvokers.h file. Like VirtFuncInvoker1, the InterfaceFuncInvoker1 class searches the vtable table using the il2cpp :: vm :: Runtime :: GetInterfaceInvokeData function in libil2cpp.

Why do calls via the interface method and calls via the virtual method use different APIs in libil2cpp? The call to the InterfaceFuncInvoker1 :: Invoke function passes not only the method being called and its arguments, but also the interface (in this case, L_1). For each type, a vtable table is stored so that the interface methods are fixed at offset. Therefore, il2cpp.exe must provide an interface to determine which method to call. In the end, we can say that calls through the virtual method and calls through the interface are equally expensive in IL2CPP.

Calls through the runtime delegate


You can also create a delegate at runtime 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 turns out to be 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 function VirtFuncInvoker1 :: Invoke - just like the compile time delegate. Thus, the runtime delegate requires not only one more function call, but also an additional search on the vtable table.

Challenges through reflection


It is not surprising that the most costly type of method call is through reflection. Here is the generated code for the CallViaReflection method:

 [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 runtime delegate, we need to create an array for the method arguments. Then we call the virtual method MethodBase :: Invoke - the MethodBase_Invoke_m24 function, which, in turn, calls another virtual function. And only then the required method call is made.

Conclusion


Although it doesn’t compare to profiling, parsing the generated C ++ code allows you to better understand the costs associated with a particular method call. For example, calling methods via the delegate of the execution time and through reflection is not worth it. To improve performance, measure costs early on, preferably with profilers.

We are continuing to work on optimizing the code generated by il2cpp.exe, so it is likely that in the next versions of Unity, the types of calls listed 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.

Source: https://habr.com/ru/post/310614/


All Articles