IL2CPP Internals: Implementação genérica de compartilhamento

Esta é a quinta postagem da série IL2CPP Internals.
Na última postagem, vimos como os métodos são chamados no código C++ gerado para o backend de script IL2CPP. Nesta postagem, exploraremos como eles são implementados. Especificamente, tentaremos entender melhor um dos recursos mais importantes do código gerado com o IL2CPP: o compartilhamento genérico. O compartilhamento genérico permite que muitos métodos genéricos compartilhem uma implementação comum. Isso leva a reduções significativas no tamanho do executável para o backend de script IL2CPP.
Observe que o compartilhamento genérico não é uma ideia nova, pois os tempos de execução do Mono e do .Net também usam o compartilhamento genérico. Inicialmente, o IL2CPP não realizava o compartilhamento genérico. Melhorias recentes o tornaram ainda mais robusto e benéfico. Como o il2cpp.exe gera código C++, podemos ver onde as implementações de métodos são compartilhadas.
Exploraremos como as implementações de métodos genéricos são compartilhadas (ou não) para tipos de referência e tipos de valor. Também investigaremos como as restrições de parâmetros genéricos afetam o compartilhamento genérico.
Lembre-se de que tudo o que foi discutido nesta série são detalhes de implementação. Os tópicos e o código discutidos aqui provavelmente mudarão no futuro. No entanto, gostamos de expor e discutir detalhes como esse quando é possível!
O que é compartilhamento genérico?
Imagine que o senhor esteja escrevendo a implementação da classe List<T> em C#. Essa implementação dependeria do tipo de T? O senhor poderia usar a mesma implementação do método Add para List<string> e List<object>? O que o senhor acha de List<DateTime>?
Na verdade, o poder dos genéricos é justamente o fato de que essas implementações em C# podem ser compartilhadas, e a classe genérica List<T> funcionará para qualquer T. Mas o que acontece quando List é traduzida de C# para algo executável, como código assembly (como Mono faz) ou código C++ (como IL2CPP faz)? Ainda podemos compartilhar a implementação do método Add?
Sim, podemos compartilhá-lo na maioria das vezes. Como descobriremos neste post, a capacidade de compartilhar a implementação de um método genérico depende quase inteiramente do tamanho desse tipo T. Se T for qualquer tipo de referência (como string ou objeto), ele sempre terá o tamanho de um ponteiro. Se T for um tipo de valor (como int ou DateTime), seu tamanho poderá variar e as coisas ficarão um pouco mais complexas. Quanto mais implementações de métodos puderem ser compartilhadas, menor será o código executável resultante.
Mark Probst, o desenvolvedor que implementou o compartilhamento genérico no Mono, tem uma excelente série de posts sobre como o Mono realiza o compartilhamento genérico. Não vamos nos aprofundar muito sobre o compartilhamento genérico aqui. Em vez disso, veremos como e quando o IL2CPP realiza o compartilhamento genérico. Esperamos que essas informações ajudem o senhor a analisar e entender melhor o tamanho executável do seu projeto.
O que é compartilhado pelo IL2CPP?
Atualmente, o IL2CPP compartilha implementações de métodos genéricos para um tipo genérico SomeGenericType<T> quando T é:
- Qualquer tipo de referência (por exemplo, string, objeto ou qualquer classe definida pelo usuário)
- Qualquer tipo de número inteiro ou enum
O IL2CPP não compartilha implementações de métodos genéricos quando T é um tipo de valor porque o tamanho de cada tipo de valor será diferente (com base no tamanho de seus campos).
Na prática, isso significa que adicionar um novo uso de SomeGenericType<T>, em que T é um tipo de referência, terá um impacto mínimo no tamanho do executável. No entanto, se T for um tipo de valor, o tamanho do executável será afetado. Esse comportamento é o mesmo para os back-ends de script do Mono e do IL2CPP. Se o senhor quiser saber mais, continue lendo, pois chegou a hora de conhecer alguns detalhes da implementação!
A configuração
Estarei usando o Unity 5.0.2p1 no Windows e criando para a plataforma WebGL. Ativei a opção "Development Player" nas configurações de compilação e a opção "Enable Exceptions" (Ativar exceções) está definida com o valor "None" (Nenhum). O código do script para esta postagem começa com um método de driver para criar instâncias dos tipos genéricos que investigaremos:
public void DemonstrateGenericSharing() {
var usesAString = new GenericType<string>();
var usesAClass = new GenericType<AnyClass>();
var usesAValueType = new GenericType<DateTime>();
var interfaceConstrainedType = new InterfaceConstrainedGenericType<ExperimentWithInterface>();
}Em seguida, definimos os tipos usados nesse método:
class GenericType<T> {
public T UsesGenericParameter(T value) {
return value;
}
public void DoesNotUseGenericParameter() {}
public U UsesDifferentGenericParameter<U>(U value) {
return value;
}
}
class AnyClass {}
interface AnswerFinderInterface {
int ComputeAnswer();
}
class ExperimentWithInterface : AnswerFinderInterface {
public int ComputeAnswer() {
return 42;
}
}
class InterfaceConstrainedGenericType<T> where T : AnswerFinderInterface {
public int FindTheAnswer(T experiment) {
return experiment.ComputeAnswer();
}
}E todo o código está aninhado em uma classe chamada HelloWorld derivada de MonoBehaviour.
Se o senhor visualizar a linha de comando do il2cpp.exe, observe que ela não contém a opção --enable-generic-sharing, conforme descrito na primeira postagem desta série. No entanto, o compartilhamento genérico ainda está ocorrendo. Isso não é mais opcional e acontece em todos os casos agora.
Compartilhamento genérico para tipos de referência
Começaremos analisando o caso de compartilhamento genérico que ocorre com mais frequência: tipos de referência. Como todos os tipos de referência no código gerenciado derivam de System.Object, todos os tipos de referência no código C++ gerado derivam do tipo Object_t. Todos os tipos de referência podem ser representados no código C++ usando o tipo Object_t* como espaço reservado. Veremos por que isso é importante daqui a pouco.
Vamos procurar a versão gerada do método DemonstrateGenericSharing. No meu projeto, ele se chama HelloWorld_DemonstrateGenericSharing_m4. Estamos procurando as definições de método para os quatro métodos da classe GenericType. Usando Ctags, podemos pular para a declaração do método do construtor GenericType<string>, GenericType_1__ctor_m8. Observe que essa declaração de método é, na verdade, uma declaração #define, que mapeia o método para outro método, GenericType_1__ctor_m10447_gshared.
Vamos voltar, voltar e encontrar as declarações de método para o tipo GenericType<AnyClass>. Se pularmos para a declaração do construtor, GenericType_1__ctor_m9, veremos que ele também é uma declaração #define, mapeada para a mesma função, GenericType_1__ctor_m10447_gshared!
Se pularmos para a definição de GenericType_1__ctor_m10447_gshared, poderemos ver no comentário de código sobre a definição do método que esse método corresponde ao nome do método gerenciado HelloWorld/GenericType`1<System.Object>::.ctor(). Este é o construtor do tipo GenericType<object>. Esse tipo é chamado de tipo totalmente compartilhado, o que significa que, dado um tipo GenericType<T>, para qualquer T que seja um tipo de referência, a implementação de todos os métodos usará essa versão, em que T é um objeto.
Olhe logo abaixo do construtor no código gerado e o senhor verá o código C++ para o método UsesGenericParameter:
extern "C" Object_t * GenericType_1_UsesGenericParameter_m10449_gshared (GenericType_1_t2159 * __this, Object_t * ___value, MethodInfo* method)
{
{
Object_t * L_0 = ___value;
return L_0;
}
}Nos dois lugares em que o parâmetro genérico T é usado (o tipo de retorno e o tipo do único argumento gerenciado), o código gerado usa o tipo Object_t*. Como todos os tipos de referência podem ser representados no código gerado por Object_t*, podemos chamar essa implementação de método único para qualquer T que seja um tipo de referência.
Na segunda postagem do blog desta série (sobre código gerado), mencionamos que todas as definições de métodos são funções livres em C++. O utilitário il2cpp.exe não gera métodos substituídos em C# usando a herança C++. No entanto, o il2cpp.exe usa a herança C++ para os tipos. Se pesquisarmos no código gerado a string "AnyClass_t", poderemos encontrar a representação C++ do tipo AnyClass do C#:
struct AnyClass_t1 : public Object_t
{
};Como AnyClass_t1 deriva de Object_t, podemos passar um ponteiro para AnyClass_t1 como argumento para a função GenericType_1_UsesGenericParameter_m10449_gshared sem problemas.
Mas e o valor de retorno? Não podemos retornar um ponteiro para uma classe base onde se espera um ponteiro para uma classe derivada, certo? Dê uma olhada na declaração do método GenericType<AnyClass>::UsesGenericParameter:
#define GenericType_1_UsesGenericParameter_m10452(__this, ___value, method) (( AnyClass_t1 * (*) (GenericType_1_t6 *, AnyClass_t1 *, MethodInfo*))GenericType_1_UsesGenericParameter_m10449_gshared)(__this, ___value, method)O código gerado está, na verdade, convertendo o valor de retorno (tipo Object_t*) para o tipo derivado AnyClass_t1*. Portanto, aqui o IL2CPP está mentindo para o compilador C++ para evitar o sistema de tipos C++. Como o compilador C# já impôs que nenhum código em UsesGenericParameter faz algo irracional com o tipo T, então o IL2CPP pode mentir para o compilador C++ aqui.
Compartilhamento genérico com restrições
Suponha que queiramos permitir que alguns métodos sejam chamados em um objeto do tipo T? O uso de Object_t* não evitará isso, já que não temos muitos métodos em System.Object? Sim, isso está correto. Mas primeiro precisamos expressar essa ideia para o compilador C# usando restrições genéricas.
Dê uma olhada novamente no código do script desta postagem no tipo chamado InterfaceConstrainedGenericType. Esse tipo genérico usa uma cláusula where para exigir que o tipo T seja derivado de uma determinada interface, AnswerFinderInterface. Isso permite que o método ComputeAnswer seja chamado. Lembre-se de que, na postagem anterior do blog sobre invocação de métodos, as chamadas de métodos de interface exigem uma pesquisa em uma estrutura vtable. Como o método FindTheAnswer fará uma chamada de função direta na instância restrita do tipo T, o código C++ ainda pode usar a implementação do método totalmente compartilhado, com o tipo T representado por Object_t*.
Se começarmos com a implementação da função HelloWorld_DemonstrateGenericSharing_m4 e depois pularmos para a definição da função InterfaceConstrainedGenericType_1__ctor_m11, veremos que esse método é novamente um #define, mapeando para a função InterfaceConstrainedGenericType_1__ctor_m10456_gshared. Se olharmos logo abaixo dessa função para a implementação da função InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared, veremos que, de fato, essa é a versão totalmente compartilhada da função, recebendo um argumento Object_t*. Ele chama a função InterfaceFuncInvoker0::Invoke para realmente fazer a chamada para o método gerenciado ComputeAnswer.
extern "C" int32_t InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared (InterfaceConstrainedGenericType_1_t2160 * __this, Object_t * ___experiment, MethodInfo* method)
{
static bool s_Il2CppMethodIntialized;
if (!s_Il2CppMethodIntialized)
{
AnswerFinderInterface_t11_il2cpp_TypeInfo_var = il2cpp_codegen_class_from_type(&AnswerFinderInterface_t11_0_0_0);
s_Il2CppMethodIntialized = true;
}
{
int32_t L_0 = (int32_t)InterfaceFuncInvoker0<int32_t>::Invoke(0 /* System.Int32 HelloWorld/AnswerFinderInterface::ComputeAnswer() */, AnswerFinderInterface_t11_il2cpp_TypeInfo_var, (Object_t *)(*(&amp;___experiment)));
return L_0;
}
}Tudo isso se encaixa no código C++ gerado porque o IL2CPP trata todas as interfaces gerenciadas como System.Object. Essa é uma regra prática útil para ajudar a entender o código gerado pelo il2cpp.exe em outros casos também.
Restrições com uma classe base
Além das restrições de interface, o C# permite que as restrições sejam uma classe base. O IL2CPP não trata todas as classes de base como System.Object, portanto, como o compartilhamento genérico funciona para restrições de classe de base?
Como as classes de base são sempre tipos de referência, o IL2CPP usa a versão totalmente compartilhada dos métodos genéricos para esses tipos. Qualquer código que precise usar um campo ou chamar um método no tipo restrito executa uma conversão em C++ para o tipo adequado. Novamente, aqui contamos com o compilador C# para impor corretamente a restrição genérica e mentimos para o compilador C++ sobre o tipo.
Compartilhamento genérico com tipos de valor
Vamos voltar à função HelloWorld_DemonstrateGenericSharing_m4 e ver a implementação de GenericType<DateTime>. O tipo DateTime é um tipo de valor, portanto, o GenericType<DateTime> não é compartilhado. Podemos pular para a declaração do construtor desse tipo, GenericType_1__ctor_m10. Lá vemos um #define, como nos outros casos, mas o #define mapeia para a função GenericType_1__ctor_m10_gshared, que é específica da classe GenericType<DateTime> e não é usada por nenhuma outra classe.
Pensando conceitualmente no compartilhamento genérico
A implementação do compartilhamento genérico pode ser difícil de entender e acompanhar. O próprio espaço do problema está repleto de casos patológicos (por exemplo, o padrão de modelo curiosamente recorrente). Pode ser útil pensar em alguns conceitos:
- Toda implementação de método em um tipo genérico é compartilhada
- Alguns tipos genéricos compartilham apenas implementações de métodos com eles mesmos (por exemplo, tipos genéricos com um parâmetro genérico de tipo de valor, GenericType acima)
- Os tipos genéricos com um parâmetro genérico de tipo de referência são totalmente compartilhados - eles sempre usam a implementação com System.Object para todos os parâmetros de tipo.
- Os tipos genéricos com dois ou mais parâmetros de tipo podem ser parcialmente compartilhados se pelo menos um desses parâmetros de tipo for um tipo de referência.
O utilitário il2cpp.exe sempre gera as implementações de métodos totalmente compartilhados para qualquer tipo genérico. Ele gera outras implementações de método somente quando elas são usadas.
Compartilhamento de métodos genéricos
Assim como as implementações de métodos em tipos genéricos podem ser compartilhadas, o mesmo ocorre com a implementação de métodos para métodos genéricos. No código do script original, observe que o método UsesDifferentGenericParameter usa um parâmetro de tipo diferente da classe GenericType. Quando analisamos as implementações de métodos compartilhados para a classe GenericType, não vimos o método UsesDifferentGenericParameter. Se eu pesquisar no código gerado por "UsesDifferentGenericParameter", verei que a implementação desse método está no arquivo GenericMethods0.cpp:
extern "C" Object_t * GenericType_1_UsesDifferentGenericParameter_TisObject_t_m15243_gshared (GenericType_1_t2159 * __this, Object_t * ___value, MethodInfo* method)
{
{
Object_t * L_0 = ___value;
return L_0;
}
}Observe que essa é a versão totalmente compartilhada da implementação do método, aceitando o tipo Object_t*. Embora esse método esteja em um tipo genérico, o comportamento também seria o mesmo para um método genérico em um tipo não genérico. Na verdade, o il2cpp.exe tenta sempre gerar o menor código possível para implementações de métodos que envolvem parâmetros genéricos.
Conclusão
O compartilhamento genérico foi um dos aprimoramentos mais importantes do backend de scripts do IL2CPP desde seu lançamento inicial. Ele permite que o código C++ gerado seja o menor possível, compartilhando implementações de métodos em que não diferem em comportamento. Como pretendemos continuar a diminuir o tamanho dos binários, trabalharemos para aproveitar mais oportunidades de compartilhar implementações de métodos.
Na próxima postagem, exploraremos como os wrappers p/invoke são gerados e como os tipos são transferidos do código gerenciado para o nativo. Poderemos ver o custo do marshaling de vários tipos e depurar problemas com o código de marshaling.
