Internos do IL2CPP: Chamadas de método

Esta é a quarta postagem do blog na série IL2CPP Internals. Nesta postagem, veremos como o il2cpp.exe gera código C++ para chamadas de método em código gerenciado. Especificamente, investigaremos seis tipos diferentes de chamadas de método:
- Chamadas diretas em métodos estáticos e de instância
- Chamadas por meio de um delegado em tempo de compilação
- Chamadas por meio de um método virtual
- Chamadas por meio de um método de interface
- Chamadas por meio de um delegado em tempo de execução
- Chamadas via reflexão
Em cada caso, vamos nos concentrar no que o código C++ gerado está fazendo e, especificamente, em quanto essas instruções custarão.
Como em todas as publicações desta série, exploraremos códigos que estão sujeitos a alterações e que, na verdade, provavelmente mudarão em uma versão mais recente do Unity. Entretanto, os conceitos devem permanecer os mesmos. Considere tudo o que foi discutido nesta série como detalhes de implementação. No entanto, gostamos de expor e discutir detalhes como esse quando é possível!
Configuração
Usarei a versão 5.0.1p4 do Unity. Executarei o editor no Windows e criarei para a plataforma WebGL. Estou construindo com a opção "Development Player" ativada e a opção "Enable Exceptions" definida com o valor "Full".
Vou construir com um único arquivo de script, modificado a partir da última postagem, para que possamos ver os diferentes tipos de chamadas de método. O script começa com uma definição de interface e de classe:
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; }
}Em seguida, temos um campo constante e um tipo de delegado, ambos usados posteriormente no 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);Por fim, esses são os métodos que temos interesse em explorar (mais o método obrigatório Start, que não tem conteúdo aqui):
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 () {}Com tudo isso definido, vamos começar. Lembre-se de que o código C++ gerado estará localizado no diretório Temp\StagingArea\Data\il2cppOutput do projeto (enquanto o editor permanecer aberto). E não se esqueça de gerar Ctags no código gerado, para ajudar a navegar nele.
Chamada direta de um método
A maneira mais simples (e mais rápida, como veremos) de chamar um método é chamá-lo diretamente. Aqui está o código gerado para o 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);A última linha é a chamada real do método. Observe que ele não faz nada de especial, apenas chama uma função free definida no código C++. Lembre-se de que, na postagem anterior sobre o código gerado, o il2cpp.exe gera todos os métodos como funções livres do C++. O backend de script IL2CPP não usa funções de membro C++ ou funções virtuais para nenhum código gerado. Portanto, chamar um diretório de método estático deve ser semelhante. Aqui está o código gerado do método CallStaticMethodDirectly:
Important_StaticMethod_m3(NULL /*static, unused*/, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_StaticMethod_m3_MethodInfo);Poderíamos dizer que há menos sobrecarga ao chamar um método estático, pois não precisamos criar e inicializar uma instância de objeto. No entanto, a chamada do método em si é exatamente a mesma, uma chamada para uma função livre do C++. A única diferença aqui é que o primeiro argumento é sempre passado com o valor NULL.
Como a diferença entre as chamadas para métodos estáticos e de instância é mínima, vamos nos concentrar apenas nos métodos de instância no restante desta postagem, mas as informações também se aplicam aos métodos estáticos.
Chamada de um método por meio de um delegado em tempo de compilação
O que acontece com uma chamada de método um pouco mais exótica, como uma chamada indireta por meio de um delegado? Primeiro, veremos o que chamarei de delegado em tempo de compilação, o que significa que sabemos em tempo de compilação qual método será chamado em qual instância de objeto. O código para esse tipo de chamada está no método CallViaDelegate. O código gerado tem a seguinte aparência:
// 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);Acrescentei alguns comentários para indicar as diferentes partes do código gerado.
Observe que o método real chamado aqui não faz parte do código gerado. O método VirtFuncInvoker1<int32_t, String_t*>::Invoke está localizado no arquivo GeneratedVirtualInvokers.h. Esse arquivo é gerado pelo il2cpp.exe, mas não vem de nenhum código IL. Em vez disso, o il2cpp.exe cria esse arquivo com base no uso de funções virtuais que retornam um valor (VirtFuncInvokerN) e aquelas que não retornam (VirtActionInvokerN), em que N é o número de argumentos do método.
O método Invoke aqui tem a seguinte aparência:
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);
}
};A chamada para libil2cpp GetVirtualInvokeData procura um método virtual na estrutura vtable gerada com base no código gerenciado e, em seguida, faz uma chamada para esse método.
Por que não usamos C++11 modelos variádicos para implementar esses métodos VirtFuncInvokerN ? Essa parece ser uma situação que implora por modelos variados, e de fato é. Entretanto, o código C++ gerado pelo il2cpp.exe precisa funcionar com alguns compiladores C++ que ainda não oferecem suporte a todos os recursos do C++ 11, inclusive modelos variádicos. Pelo menos nesse caso, não achamos que a bifurcação do código gerado para compiladores C++11 valesse a complexidade adicional.
Mas por que essa é uma chamada de método virtual? Não estamos chamando um método de instância no código C#? Lembre-se de que estamos chamando o método de instância por meio de um delegado do C#. Veja novamente o código gerado acima. O método real que vamos chamar é passado por meio do argumento MethodInfo* (metadados do método): ImportantMethodDelegate_Invoke_m5_MethodInfo. Se procurarmos o método chamado "ImportantMethodDelegate_Invoke_m5" no código gerado, veremos que a chamada é, na verdade, para o método Invoke gerenciado no tipo ImportantMethodDelegate. Esse é um método virtual, portanto, precisamos fazer uma chamada virtual. É essa função ImportantMethodDelegate_Invoke_m5 que realmente fará a chamada para o método chamado Method no código C#.
Uau, isso certamente foi de encher a boca. Ao fazer o que parece ser uma simples alteração no código C#, passamos de uma única chamada a uma função livre do C++ para várias chamadas de função, além de uma pesquisa de tabela. Chamar um método por meio de um delegado é significativamente mais caro do que chamar o mesmo método diretamente.
Observe que, no processo de análise de uma chamada de método delegado, também vimos como funciona uma chamada por meio de um método virtual.
Chamada de um método por meio de uma interface
Também é possível chamar um método em C# por meio de uma interface. Essa chamada é implementada pelo il2cpp.exe de forma semelhante a uma chamada de 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);Observe que a chamada real do método aqui é feita por meio da função InterfaceFuncInvoker1::Invoke, que está no arquivo GeneratedInterfaceInvokers.h. Assim como a classe VirtFuncInvoker1, a classe InterfaceFuncInvoker1faz uma pesquisa em uma vtable por meio da função il2cpp::vm::Runtime::GetInterfaceInvokeData em libil2cpp.
Por que uma chamada de método de interface precisa usar uma API diferente na libil2cpp em relação a uma chamada de método virtual? Observe que a chamada para InterfaceFuncInvoker1::Invoke está passando não apenas o método a ser chamado e seus argumentos, mas também a interface na qual esse método será chamado (L_1, nesse caso). A vtable de cada tipo é armazenada de modo que os métodos de interface sejam gravados em um deslocamento específico. Portanto, o il2cpp.exe precisa fornecer a interface para determinar o método a ser chamado.
O ponto principal aqui é que chamar um método virtual e chamar um método por meio de uma interface tem efetivamente a mesma sobrecarga no IL2CPP.
Chamada de um método por meio de um delegado em tempo de execução
Outra forma de usar um delegado é criá-lo em tempo de execução por meio do método Delegate.CreateDelegate. Essa abordagem é semelhante a um delegado em tempo de compilação, exceto pelo fato de que pode ser modificada em tempo de execução de mais algumas maneiras. Pagamos por essa flexibilidade com uma chamada de função adicional. Aqui está o código gerado:
// 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);Esse delegado requer uma boa quantidade de código para criação e inicialização. Mas a chamada do método em si também tem ainda mais sobrecarga. Primeiro, precisamos criar uma matriz para armazenar os argumentos do método e, em seguida, chamar o método DynamicInvoke na instância do Delegate. Se seguirmos esse método no código gerado, poderemos ver que ele chama a função VirtFuncInvoker1::Invoke, exatamente como o delegado em tempo de compilação faz. Portanto, esse delegado requer mais uma chamada de função do que o delegado em tempo de compilação, além de duas pesquisas em uma vtable, em vez de apenas uma.
Chamada de um método por meio de reflexão
A maneira mais cara de chamar um método é, não surpreendentemente, por meio de reflexão. Vejamos o código gerado para o 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);Como no caso do delegado de tempo de execução, precisamos dedicar algum tempo à criação de uma matriz para os argumentos do método. Em seguida, fazemos uma chamada de método virtual para MethodBase::Invoke (a função MethodBase_Invoke_m24). Essa função, por sua vez, invoca outra função virtual, antes de finalmente chegarmos à chamada do método real!
Conclusão
Embora isso não substitua a criação e a medição reais de perfis, podemos obter algumas informações sobre a sobrecarga de uma determinada invocação de método observando como o código C++ gerado é usado para diferentes tipos de chamadas de método. Especificamente, está claro que queremos evitar chamadas por meio de delegados em tempo de execução e reflexão, se possível. Como sempre, o melhor conselho para melhorar o desempenho é medir com antecedência e frequência com ferramentas de criação de perfil.
Estamos sempre procurando maneiras de otimizar o código gerado pelo il2cpp.exe, portanto, é provável que essas chamadas de método sejam diferentes em uma versão posterior do Unity.
Na próxima vez, vamos nos aprofundar nas implementações de métodos e ver como compartilhamos a implementação de métodos genéricos para minimizar o código gerado e o tamanho do executável.
