Componentes internos de IL2CPP: Llamadas de método

Esta es la cuarta publicación del blog de la serie IL2CPP Internals . En esta publicación, veremos cómo il2cpp.exe genera código C++ para llamadas a métodos en código administrado. En concreto, investigaremos seis tipos diferentes de llamadas a métodos:
- Llamadas directas a métodos de instancia y estáticos
- Llamadas a través de un delegado en tiempo de compilación
- Llamadas a través de un método virtual
- Llamadas a través de un método de interfaz
- Llamadas a través de un delegado en tiempo de ejecución
- Llamadas vía reflexión
En cada caso, nos centraremos en lo que hace el código C++ generado y, específicamente, en cuánto costarán esas instrucciones.
Al igual que con todas las publicaciones de esta serie, exploraremos el código que está sujeto a cambios y, de hecho, es probable que cambie en una versión más nueva de Unity. Sin embargo, los conceptos deberían seguir siendo los mismos. Por favor, tome todo lo discutido en esta serie como detalles de implementación. ¡Nos gusta exponer y discutir detalles como este cuando es posible!
Configuración
Usaré la versión 5.0.1p4 de Unity . Ejecutaré el editor en Windows y compilaré para la plataforma WebGL. Estoy compilando con la opción “Reproductor de desarrollo” habilitada y la opción “Habilitar excepciones” establecida en un valor “Completo”.
Lo construiré con un solo archivo de script, modificado desde la última publicación para que podamos ver los diferentes tipos de llamadas a métodos. El script comienza con una definición de interfaz y clase:
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; }
}Luego tenemos un campo constante y un tipo delegado, ambos utilizados más adelante en el código:
private const string question = "What is the answer to the ultimate question of life, the universe, and everything?";
private delegate int ImportantMethodDelegate(string question);Finalmente, estos son los métodos que nos interesa explorar (más el obligatorio método Start, que no tiene contenido aquí):
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 () {}Con todo esto definido, comencemos. Recuerde que el código C++ generado se ubicará en el directorio Temp\StagingArea\Data\il2cppOutput del proyecto (siempre que el editor permanezca abierto). Y no olvides generar Ctags en el código generado, para ayudar a navegarlo.
Llamar a un método directamente
La forma más sencilla (y rápida, como veremos) de llamar a un método es llamarlo directamente. Aquí está el código generado para el método CallDirectly:
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);La última línea es la llamada al método real. Tenga en cuenta que no hace nada especial, solo llama a una función libre definida en el código C++. Recuerde que en la publicación anterior sobre el código generado, il2cpp.exe genera todos los métodos como funciones libres de C++. El backend de scripting IL2CPP no utiliza funciones miembro de C++ ni funciones virtuales para ningún código generado. De ello se deduce entonces que llamar a un método estático directory debería ser similar. Aquí está el código generado por el método CallStaticMethodDirectly:
Important_StaticMethod_m3(NULL /*static, unused*/, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_StaticMethod_m3_MethodInfo);Podríamos decir que hay menos sobrecarga al llamar a un método estático, ya que no necesitamos crear e inicializar una instancia de objeto. Sin embargo, la llamada al método en sí es exactamente la misma, una llamada a una función libre de C++. La única diferencia aquí es que el primer argumento siempre se pasa con un valor NULL.
Dado que la diferencia entre las llamadas a métodos estáticos y de instancia es mínima, en el resto de esta publicación nos centraremos solo en los métodos de instancia, pero la información se aplica también a los métodos estáticos.
Llamar a un método a través de un delegado en tiempo de compilación
¿Qué sucede con una llamada a un método un poco más exótico, como una llamada indirecta a través de delegado? Primero veremos lo que llamaré un delegado de tiempo de compilación, lo que significa que sabemos en tiempo de compilación qué método se llamará en qué instancia de objeto. El código para este tipo de llamada está en el método CallViaDelegate. En el código generado se ve así:
// 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);He añadido algunos comentarios para indicar las diferentes partes del código generado.
Tenga en cuenta que el método real llamado aquí no es parte del código generado. El método VirtFuncInvoker1<int32_t, String_t*>::Invoke se encuentra en el archivo GeneratedVirtualInvokers.h. Este archivo es generado por il2cpp.exe, pero no proviene de ningún código IL. En cambio, il2cpp.exe crea este archivo basándose en el uso de funciones virtuales que devuelven un valor (VirtFuncInvokerN) y aquellas que no lo hacen (VirtActionInvokerN), donde N es el número de argumentos del método.
El método Invoke aquí se ve así:
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);
}
};La llamada a libil2cpp GetVirtualInvokeData busca un método virtual en la estructura vtable generada en función del código administrado y luego realiza una llamada a ese método.
¿Por qué no usamosplantillas variádicasde C++11para implementar estosVirtFuncInvokerN?¿métodos? Esta parece una situación que pide a gritos plantillas variádicas, y de hecho lo es. Sin embargo, el código C++ generado por il2cpp.exe tiene que funcionar con algunos compiladores de C++ que aún no admiten todas las características de C++ 11, incluidas las plantillas variádicas. En este caso al menos, no pensamos que valiera la pena la complejidad adicional de bifurcar el código generado para los compiladores de C++11.
Pero ¿por qué se trata de una llamada a un método virtual? ¿No estamos llamando a un método de instancia en el código C#? Recordemos que estamos llamando al método de instancia a través de un delegado de C#. Mire nuevamente el código generado arriba. El método real que vamos a llamar se pasa a través del argumento MethodInfo* (metadatos del método): ImportantMethodDelegate_Invoke_m5_MethodInfo. Si buscamos el método llamado "ImportantMethodDelegate_Invoke_m5" en el código generado, vemos que la llamada es en realidad al método Invoke administrado en el tipo ImportantMethodDelegate. Este es un método virtual, por lo que necesitamos hacer una llamada virtual. Es esta función ImportantMethodDelegate_Invoke_m5 la que realmente realizará la llamada al método llamado Method en el código C#.
Vaya, eso sí que fue un bocado. Al realizar lo que parece un cambio simple en el código C#, ahora pasamos de una única llamada a una función libre de C++ a múltiples llamadas a funciones, además de una búsqueda en una tabla. Llamar a un método a través de un delegado es significativamente más costoso que llamar al mismo método directamente.
Tenga en cuenta que en el proceso de analizar una llamada a un método delegado, también vimos cómo funciona una llamada a través de un método virtual.
Llamar a un método a través de una interfaz
También es posible llamar a un método en C# a través de una interfaz. Esta llamada se implementa mediante il2cpp.exe de manera similar a una llamada a un método virtual:
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);Tenga en cuenta que la llamada al método real aquí se realiza a través de la función InterfaceFuncInvoker1::Invoke, que se encuentra en el archivo GeneratedInterfaceInvokers.h. Al igual que la clase VirtFuncInvoker1, la clase InterfaceFuncInvoker1 realiza una búsqueda en una vtable a través de la función il2cpp::vm::Runtime::GetInterfaceInvokeData en libil2cpp.
¿Por qué una llamada a un método de interfaz necesita utilizar una API diferente en libil2cpp que una llamada a un método virtual? Tenga en cuenta que la llamada a InterfaceFuncInvoker1::Invoke pasa no solo el método a llamar y sus argumentos, sino también la interfaz para llamar a ese método (L_1 en este caso). La tabla virtual de cada tipo se almacena de modo que los métodos de interfaz se escriban en un desplazamiento específico. Por lo tanto, il2cpp.exe debe proporcionar la interfaz para determinar qué método llamar.
La conclusión aquí es que llamar a un método virtual y llamar a un método a través de una interfaz tienen efectivamente la misma sobrecarga en IL2CPP.
Llamar a un método a través de un delegado de tiempo de ejecución
Otra forma de utilizar un delegado es crearlo en tiempo de ejecución a través del método Delegate.CreateDelegate. Este enfoque es similar a un delegado en tiempo de compilación, excepto que puede modificarse en tiempo de ejecución de algunas maneras más. Pagamos esa flexibilidad con una llamada de función adicional. Aquí está el código generado:
// 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);Este delegado requiere una buena cantidad de código para su creación e inicialización. Pero la llamada al método en sí también tiene aún más sobrecarga. Primero necesitamos crear una matriz para almacenar los argumentos del método, luego llamar al método DynamicInvoke en la instancia Delegate. Si seguimos ese método en el código generado, podemos ver que llama a la función VirtFuncInvoker1::Invoke, tal como lo hace el delegado de tiempo de compilación. Por lo tanto, este delegado requiere una llamada de función más que el delegado de tiempo de compilación, además de dos búsquedas en una vtable, en lugar de solo una.
Llamar a un método mediante reflexión
La forma más costosa de llamar a un método es, como era de esperar, mediante la reflexión. Veamos el código generado para el método CallViaReflection:
// 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);Al igual que en el caso del delegado de tiempo de ejecución, necesitamos dedicar algún tiempo a crear una matriz para los argumentos del método. Luego realizamos una llamada al método virtual MethodBase::Invoke (la función MethodBase_Invoke_m24). ¡Esta función a su vez invoca otra función virtual, antes de que finalmente lleguemos a la llamada al método real!
Conclusión
Si bien esto no reemplaza la creación de perfiles y la medición reales, podemos obtener una idea de la sobrecarga de cualquier invocación de método al observar cómo se usa el código C++ generado para diferentes tipos de llamadas a métodos. En concreto, está claro que queremos evitar llamadas a través de delegados de tiempo de ejecución y reflexión, si es posible. Como siempre, el mejor consejo para realizar mejoras de rendimiento es medir de forma temprana y frecuente con herramientas de creación de perfiles.
Siempre estamos buscando formas de optimizar el código generado por il2cpp.exe, por lo que es probable que estas llamadas a métodos se vean diferentes en una versión posterior de Unity.
La próxima vez profundizaremos en las implementaciones de métodos y veremos cómo compartimos la implementación de métodos genéricos para minimizar el código generado y el tamaño del ejecutable.
