IL2CPP Internals : Mise en œuvre du partage générique

Ceci est le cinquième article de la série IL2CPP Internals.
Dans le dernier article, nous avons examiné comment les méthodes sont appelées dans le code C++ généré pour le backend de script IL2CPP. Dans ce billet, nous allons voir comment ils sont mis en œuvre. Plus précisément, nous essaierons de mieux comprendre l'une des caractéristiques les plus importantes du code généré avec IL2CPP - le partage générique. Le partage générique permet à de nombreuses méthodes génériques de partager une implémentation commune. Cela permet de réduire considérablement la taille de l'exécutable pour le backend de script IL2CPP.
Notez que le partage générique n'est pas une idée nouvelle, les moteurs d'exécution Mono et .Net utilisent également le partage générique. Au départ, l'IL2CPP n'effectuait pas de partage générique. Des améliorations récentes l'ont rendu encore plus robuste et bénéfique. Comme il2cpp.exe génère du code C++, nous pouvons voir où les implémentations de méthodes sont partagées.
Nous étudierons comment les implémentations de méthodes génériques sont partagées (ou non) pour les types de référence et les types de valeur. Nous étudierons également la manière dont les contraintes liées aux paramètres génériques affectent le partage générique.
Gardez à l'esprit que tous les points abordés dans cette série sont des détails de mise en œuvre. Les thèmes et les codes abordés ici sont susceptibles de changer à l'avenir. Nous aimons exposer et discuter de ce genre de détails lorsque c'est possible !
Qu'est-ce que le partage générique ?
Imaginez que vous écriviez l'implémentation de la classe List<T> en C#. Cette mise en œuvre dépendrait-elle du type de T ? Pourriez-vous utiliser la même implémentation de la méthode Add pour List<string> et List<object> ? Pourquoi pas List<DateTime> ?
En fait, la puissance des génériques réside dans le fait que ces implémentations C# peuvent être partagées, et la classe générique List<T> fonctionnera pour n'importe quel T. Mais que se passe-t-il lorsque List est traduit de C# en quelque chose d'exécutable, comme du code assembleur (comme le fait Mono) ou du code C++ (comme le fait IL2CPP) ? Pouvons-nous encore partager l'implémentation de la méthode Add ?
Oui, nous pouvons la partager la plupart du temps. Comme nous le découvrirons dans ce billet, la possibilité de partager l'implémentation d'une méthode générique dépend presque entièrement de la taille de ce type T. Si T est un type de référence quelconque (comme une chaîne de caractères ou un objet), il aura toujours la taille d'un pointeur. Si T est un type de valeur (comme int ou DateTime), sa taille peut varier et les choses deviennent un peu plus complexes. Plus il y a d'implémentations de méthodes qui peuvent être partagées, plus le code exécutable résultant est petit.
Mark Probst, le développeur qui a mis en œuvre le partage générique Mono, a une excellente série de billets sur la façon dont Mono effectue le partage générique. Nous n'approfondirons pas ici la question du partage générique. Nous verrons plutôt comment et quand l'IL2CPP procède au partage générique. Nous espérons que ces informations vous aideront à mieux analyser et comprendre la taille exécutable de votre projet.
Qu'est-ce qui est partagé par IL2CPP ?
Actuellement, IL2CPP partage les implémentations de méthodes génériques pour un type générique SomeGenericType<T> lorsque T est :
- Tout type de référence (par exemple, chaîne de caractères, objet ou toute classe définie par l'utilisateur)
- Tout type d'entier ou d'énumération
IL2CPP ne partage pas les implémentations de méthodes génériques lorsque T est un type de valeur, car la taille de chaque type de valeur sera différente (en fonction de la taille de ses champs).
En pratique, cela signifie que l'ajout d'une nouvelle utilisation de SomeGenericType<T>, où T est un type de référence, aura un impact minimal sur la taille de l'exécutable. Toutefois, si T est un type de valeur, la taille de l'exécutable sera affectée. Ce comportement est le même pour les backends de script Mono et IL2CPP. Si vous voulez en savoir plus, lisez la suite, il est temps d'entrer dans les détails de la mise en œuvre !
La mise en place
J'utiliserai Unity 5.0.2p1 sur Windows, et je construirai pour la plateforme WebGL. J'ai activé l'option "Development Player" dans les paramètres de construction, et l'option "Enable Exceptions" est réglée sur la valeur "None". Le code du script de ce billet commence par une méthode de pilote pour créer des instances des types génériques que nous allons étudier :
public void DemonstrateGenericSharing() {
var usesAString = new GenericType<string>();
var usesAClass = new GenericType<AnyClass>();
var usesAValueType = new GenericType<DateTime>();
var interfaceConstrainedType = new InterfaceConstrainedGenericType<ExperimentWithInterface>();
}Ensuite, nous définissons les types utilisés dans cette méthode :
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();
}
}Et tout ce code est imbriqué dans une classe nommée HelloWorld dérivée de MonoBehaviour.
Si vous consultez la ligne de commande de il2cpp.exe, notez qu'elle ne contient pas l'option --enable-generic-sharing, décrite dans le premier article de cette série. Toutefois, le partage générique se poursuit. Elle n'est plus facultative et s'applique désormais dans tous les cas.
Partage générique des types de référence
Nous commencerons par examiner le cas de partage générique le plus fréquent : les types de référence. Étant donné que tous les types de référence du code géré dérivent de System.Object, tous les types de référence du code C++ généré dérivent du type Object_t. Tous les types de référence peuvent alors être représentés dans le code C++ en utilisant le type Object_t* comme substitut. Nous verrons dans un instant pourquoi cela est important.
Cherchons la version générée de la méthode DemonstrateGenericSharing. Dans mon projet, il s'appelle HelloWorld_DemonstrateGenericSharing_m4. Nous recherchons les définitions des quatre méthodes de la classe GenericType. En utilisant Ctags, nous pouvons passer à la déclaration de méthode pour le constructeur GenericType<string>, GenericType_1__ctor_m8. Notez que cette déclaration de méthode est en fait une déclaration #define, qui associe la méthode à une autre méthode, GenericType_1__ctor_m10447_gshared.
Revenons en arrière et trouvons les déclarations de méthodes pour le type GenericType<AnyClass>. Si nous passons à la déclaration du constructeur, GenericType_1__ctor_m9, nous pouvons voir qu'il s'agit également d'une déclaration #define, mappée à la même fonction, GenericType_1__ctor_m10447_gshared !
Si nous passons à la définition de GenericType_1__ctor_m10447_gshared, nous pouvons voir dans le commentaire de code sur la définition de la méthode que cette méthode correspond au nom de la méthode gérée HelloWorld/GenericType`1<System.Object>: :.ctor(). Il s'agit du constructeur du type GenericType<object>. Ce type est appelé type entièrement partagé, ce qui signifie qu'étant donné un type GenericType<T>, pour tout T qui est un type de référence, la mise en œuvre de toutes les méthodes utilisera cette version, où T est l'objet.
Regardez juste en dessous du constructeur dans le code généré, et vous devriez voir le code C++ pour la méthode 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;
}
}Aux deux endroits où le paramètre générique T est utilisé (le type de retour et le type de l'unique argument géré), le code généré utilise le type Object_t*. Étant donné que tous les types de référence peuvent être représentés dans le code généré par Object_t*, nous pouvons appeler cette implémentation de méthode unique pour tout T qui est un type de référence.
Dans le deuxième billet de cette série (sur le code généré), nous avons mentionné que toutes les définitions de méthodes sont des fonctions libres en C++. L'utilitaire il2cpp.exe ne génère pas de méthodes surchargées en C# en utilisant l'héritage C++. Cependant, il2cpp.exe utilise l'héritage C++ pour les types. Si nous recherchons la chaîne de caractères "AnyClass_t" dans le code généré, nous pouvons trouver la représentation C++ du type C# AnyClass :
struct AnyClass_t1 : public Object_t
{
};Puisque AnyClass_t1 dérive de Object_t, nous pouvons passer un pointeur sur AnyClass_t1 comme argument de la fonction GenericType_1_UsesGenericParameter_m10449_gshared sans problème.
Qu'en est-il de la valeur de retour ? Nous ne pouvons pas renvoyer un pointeur vers une classe de base là où un pointeur vers une classe dérivée est attendu, n'est-ce pas ? Examinez la déclaration de la méthode 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)Le code généré est en fait un casting de la valeur de retour (type Object_t*) vers le type dérivé AnyClass_t1*. IL2CPP ment donc au compilateur C++ pour éviter le système de type C++. Puisque le compilateur C# a déjà imposé qu'aucun code dans UsesGenericParameter ne fasse quoi que ce soit de déraisonnable avec le type T, alors IL2CPP est sûr de mentir au compilateur C++ ici.
Partage générique avec contraintes
Supposons que nous souhaitions autoriser l'appel de certaines méthodes sur un objet de type T ? L'utilisation d'Object_t* ne va-t-elle pas empêcher cela, puisque nous n'avons pas beaucoup de méthodes sur System.Object ? Oui, c'est exact. Mais nous devons d'abord exprimer cette idée au compilateur C# en utilisant des contraintes génériques.
Regardez à nouveau dans le code du script de ce billet le type appelé InterfaceConstrainedGenericType. Ce type générique utilise une clause where pour exiger que le type T soit dérivé d'une interface donnée, AnswerFinderInterface. Cela permet d'appeler la méthode ComputeAnswer. Rappelez-vous de l'article précédent sur l'invocation de méthodes que les appels sur les méthodes d'interface nécessitent une recherche dans une structure de table virtuelle. Étant donné que la méthode FindTheAnswer fera un appel de fonction direct sur l'instance contrainte du type T, le code C++ peut toujours utiliser l'implémentation de la méthode entièrement partagée, avec le type T représenté par Object_t*.
Si nous commençons par l'implémentation de la fonction HelloWorld_DemonstrateGenericSharing_m4, puis que nous passons à la définition de la fonction InterfaceConstrainedGenericType_1__ctor_m11, nous pouvons voir que cette méthode est à nouveau une #define, correspondant à la fonction InterfaceConstrainedGenericType_1__ctor_m10456_gshared. Si nous regardons juste en dessous de cette fonction l'implémentation de la fonction InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared, nous pouvons voir qu'il s'agit en effet de la version entièrement partagée de la fonction, qui prend un argument Object_t*. Il appelle la fonction InterfaceFuncInvoker0::Invoke pour effectuer l'appel à la méthode gérée 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;
}
}Tout cela se tient dans le code C++ généré car IL2CPP traite toutes les interfaces gérées comme System.Object. Il s'agit d'une règle empirique utile pour comprendre le code généré par il2cpp.exe dans d'autres cas également.
Contraintes avec une classe de base
Outre les contraintes d'interface, C# permet aux contraintes d'être une classe de base. IL2CPP ne traite pas toutes les classes de base comme System.Object, alors comment le partage générique fonctionne-t-il pour les contraintes des classes de base ?
Comme les classes de base sont toujours des types de référence, IL2CPP utilise la version entièrement partagée des méthodes génériques pour ces types. Tout code qui doit utiliser un champ ou appeler une méthode sur le type contraint effectue un cast en C++ vers le type approprié. Ici encore, nous comptons sur le compilateur C# pour appliquer correctement la contrainte générique, et nous mentons au compilateur C++ à propos du type.
Partage générique avec les types de valeurs
Revenons maintenant à la fonction HelloWorld_DemonstrateGenericSharing_m4 et examinons l'implémentation de GenericType<DateTime>. Le type DateTime est un type de valeur, donc GenericType<DateTime> n'est pas partagé. Nous pouvons passer à la déclaration du constructeur de ce type, GenericType_1__ctor_m10. Nous voyons ici une #define, comme dans les autres cas, mais la #define renvoie à la fonction GenericType_1__ctor_m10_gshared, qui est spécifique à la classe GenericType<DateTime> et n'est utilisée par aucune autre classe.
Réflexion conceptuelle sur le partage générique
La mise en œuvre du partage générique peut être difficile à comprendre et à suivre. L'espace de problèmes lui-même est truffé de cas pathologiques (par exemple, le modèle curieusement récurrent). Il peut être utile de réfléchir à quelques concepts :
- Chaque implémentation de méthode sur un type générique est partagée
- Certains types génériques ne partagent les implémentations de méthodes qu'avec eux-mêmes (par exemple, les types génériques avec un paramètre générique de type valeur, GenericType ci-dessus).
- Les types génériques avec un paramètre générique de type référence sont entièrement partagés - ils utilisent toujours l'implémentation avec System.Object pour tous les paramètres de type.
- Les types génériques avec deux paramètres de type ou plus peuvent être partiellement partagés si au moins un de ces paramètres de type est un type de référence.
L'utilitaire il2cpp.exe génère toujours les implémentations de méthodes entièrement partagées pour tout type générique. Il ne génère d'autres implémentations de méthodes que lorsqu'elles sont utilisées.
Partage des méthodes génériques
Tout comme les implémentations de méthodes sur les types génériques peuvent être partagées, il en va de même pour les implémentations de méthodes génériques. Dans le code du script original, remarquez que la méthode UsesDifferentGenericParameter utilise un paramètre de type différent de celui de la classe GenericType. Lorsque nous avons examiné les implémentations des méthodes partagées pour la classe GenericType, nous n'avons pas vu la méthode UsesDifferentGenericParameter. Si je cherche "UsesDifferentGenericParameter" dans le code généré, je vois que l'implémentation de cette méthode se trouve dans le fichier 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;
}
}Notez qu'il s'agit de la version entièrement partagée de l'implémentation de la méthode, acceptant le type Object_t*. Bien que cette méthode soit dans un type générique, le comportement serait le même pour une méthode générique dans un type non générique. En effet, il2cpp.exe tente de toujours générer le moins de code possible pour les implémentations de méthodes impliquant des paramètres génériques.
Conclusion
Le partage générique a été l'une des améliorations les plus importantes apportées au backend de script IL2CPP depuis sa sortie initiale. Il permet au code C++ généré d'être aussi petit que possible, en partageant les implémentations de méthodes lorsque leur comportement ne diffère pas. Dans le cadre de la poursuite de la réduction de la taille des binaires, nous nous efforcerons de tirer parti d'un plus grand nombre d'occasions de partager la mise en œuvre des méthodes.
Dans le prochain article, nous verrons comment les wrappers p/invoke sont générés et comment les types sont transférés du code géré au code natif. Nous serons en mesure de voir le coût du marshaling de différents types et de déboguer les problèmes liés au code de marshaling.
