IL2CPP Interna: Generische Implementierung der gemeinsamen Nutzung
Dies ist der fünfte Beitrag in der Serie IL2CPP Internals.
Im letzten Beitrag haben wir uns angesehen, wie Methoden im C++-Code aufgerufen werden, der für das IL2CPP-Skript-Backend generiert wird. In diesem Beitrag werden wir untersuchen, wie sie implementiert werden. Insbesondere werden wir versuchen, eines der wichtigsten Merkmale des mit IL2CPP erzeugten Codes besser zu verstehen - die generische gemeinsame Nutzung. Die gemeinsame Nutzung von Generika ermöglicht es vielen generischen Methoden, eine gemeinsame Implementierung zu nutzen. Dies führt zu einer erheblichen Verringerung der Größe der ausführbaren Dateien für das IL2CPP-Skript-Backend.
Beachten Sie, dass die generische Freigabe keine neue Idee ist. Sowohl Mono als auch .Net-Laufzeitsysteme verwenden ebenfalls die generische Freigabe. Ursprünglich hat IL2CPP keine generische Freigabe durchgeführt. Jüngste Verbesserungen haben es noch robuster und nützlicher gemacht. Da il2cpp.exe C++-Code erzeugt, können wir sehen, wo die Methodenimplementierungen gemeinsam genutzt werden.
Wir werden untersuchen, wie generische Methodenimplementierungen für Referenztypen und Werttypen gemeinsam genutzt werden (oder auch nicht). Wir werden auch untersuchen, wie sich allgemeine Parametereinschränkungen auf die allgemeine gemeinsame Nutzung auswirken.
Denken Sie daran, dass alles, was in dieser Serie besprochen wird, Implementierungsdetails sind. Die hier behandelten Themen und Codes werden sich in Zukunft wahrscheinlich ändern. Wenn es möglich ist, stellen wir solche Details aber gerne heraus und diskutieren sie!
Was ist generische Freigabe?
Stellen Sie sich vor, Sie schreiben die Implementierung für die Klasse List<T> in C#. Hängt diese Implementierung von dem Typ T ab? Könnten Sie die gleiche Implementierung der Methode Add für List<string> und List<object> verwenden? Wie wäre es mit List<DateTime>?
Die Stärke der Generika liegt darin, dass diese C#-Implementierungen gemeinsam genutzt werden können und die generische Klasse List<T> für jedes beliebige T funktioniert. Aber was passiert, wenn List von C# in etwas Ausführbares übersetzt wird, z. B. Assembler-Code (wie bei Mono) oder C++-Code (wie bei IL2CPP)? Können wir die Implementierung der Add-Methode trotzdem gemeinsam nutzen?
Ja, wir können sie meistens teilen. Wie wir in diesem Beitrag feststellen werden, hängt die Möglichkeit, die Implementierung einer generischen Methode gemeinsam zu nutzen, fast ausschließlich von der Größe des Typs T ab. Wenn T ein beliebiger Referenztyp ist (wie String oder Objekt), dann hat er immer die Größe eines Zeigers. Wenn T ein Wertetyp ist (wie int oder DateTime), kann seine Größe variieren, und die Dinge werden etwas komplexer. Je mehr Methodenimplementierungen gemeinsam genutzt werden können, desto kleiner ist der resultierende ausführbare Code.
Mark Probst, der Entwickler, der die generische Freigabe in Mono implementiert hat, hat eine ausgezeichnete Serie von Beiträgen darüber verfasst, wie Mono die generische Freigabe durchführt. Wir werden hier nicht allzu sehr in die Tiefe gehen, was die generische Freigabe betrifft. Stattdessen werden wir sehen, wie und wann IL2CPP eine generische Freigabe durchführt. Wir hoffen, dass diese Informationen Ihnen helfen, die ausführbare Größe Ihres Projekts besser zu analysieren und zu verstehen.
Was wird von IL2CPP geteilt?
Derzeit teilt IL2CPP generische Methodenimplementierungen für einen generischen Typ SomeGenericType<T>, wenn T ist:
- Jeder Referenztyp (z.B. String, Objekt oder eine benutzerdefinierte Klasse)
- Jeder Integer- oder Enum-Typ
IL2CPP teilt keine generischen Methodenimplementierungen, wenn T ein Wertetyp ist, da die Größe jedes Wertetyps unterschiedlich ist (basierend auf der Größe seiner Felder).
In der Praxis bedeutet dies, dass das Hinzufügen einer neuen Verwendung von SomeGenericType<T>, wobei T ein Referenztyp ist, eine minimale Auswirkung auf die Größe der ausführbaren Datei hat. Wenn T jedoch ein Wertetyp ist, wirkt sich dies auf die Größe der ausführbaren Datei aus. Dieses Verhalten ist sowohl für das Mono- als auch für das IL2CPP-Skript-Backend gleich. Wenn Sie mehr wissen wollen, lesen Sie weiter, es ist Zeit, sich mit den Details der Implementierung zu beschäftigen!
Die Einrichtung
Ich werde Unity 5.0.2p1 unter Windows verwenden und für die WebGL-Plattform entwickeln. Ich habe die Option "Development Player" in den Build-Einstellungen aktiviert und die Option "Enable Exceptions" ist auf den Wert "None" eingestellt. Der Skriptcode für diesen Beitrag beginnt mit einer Treibermethode zur Erstellung von Instanzen der generischen Typen, die wir untersuchen werden:
public void DemonstrateGenericSharing() {
var usesAString = new GenericType<string>();
var usesAClass = new GenericType<AnyClass>();
var usesAValueType = new GenericType<DateTime>();
var interfaceConstrainedType = new InterfaceConstrainedGenericType<ExperimentWithInterface>();
}
Als nächstes definieren wir die Typen, die in dieser Methode verwendet werden:
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();
}
}
Und der gesamte Code ist in einer Klasse namens HelloWorld verschachtelt, die von MonoBehaviour abgeleitet ist.
Wenn Sie sich die Befehlszeile von il2cpp.exe ansehen, stellen Sie fest, dass sie nicht die Option --enable-generic-sharing enthält, wie im ersten Beitrag dieser Serie beschrieben. Der generische Austausch findet jedoch weiterhin statt. Dies ist nicht mehr optional und geschieht jetzt in allen Fällen.
Generische Freigabe für Referenztypen
Wir beginnen mit dem am häufigsten vorkommenden generischen Sharing-Fall: Referenztypen. Da alle Referenztypen in verwaltetem Code von System.Object abgeleitet sind, sind alle Referenztypen im generierten C++-Code vom Typ Object_t abgeleitet. Alle Referenztypen können dann in C++-Code mit dem Typ Object_t* als Platzhalter dargestellt werden. Warum das wichtig ist, werden wir gleich sehen.
Lassen Sie uns nach der generierten Version der Methode DemonstrateGenericSharing suchen. In meinem Projekt heißt es HelloWorld_DemonstrateGenericSharing_m4. Wir suchen nach den Methodendefinitionen für die vier Methoden in der Klasse GenericType. Mit Ctags können wir zur Methodendeklaration für den GenericType<string> Konstruktor, GenericType_1__ctor_m8, springen. Beachten Sie, dass diese Methodendeklaration eigentlich eine #define-Anweisung ist, die die Methode einer anderen Methode, GenericType_1__ctor_m10447_gshared, zuordnet.
Lassen Sie uns zurückspringen und die Methodendeklarationen für den Typ GenericType<AnyClass> finden. Wenn wir zur Deklaration des Konstruktors GenericType_1__ctor_m9 springen, sehen wir, dass es sich ebenfalls um eine #define-Anweisung handelt, die derselben Funktion zugeordnet ist, GenericType_1__ctor_m10447_gshared!
Wenn wir zur Definition von GenericType_1__ctor_m10447_gshared springen, können wir dem Codekommentar zur Methodendefinition entnehmen, dass diese Methode dem verwalteten Methodennamen HelloWorld/GenericType`1<System.Object>::.ctor() entspricht. Dies ist der Konstruktor für den Typ GenericType<Object>. Dieser Typ wird als vollständig gemeinsam genutzter Typ bezeichnet. Das bedeutet, dass bei einem Typ GenericType<T> für jedes T, das ein Referenztyp ist, die Implementierung aller Methoden diese Version verwenden wird, wobei T ein Objekt ist.
Schauen Sie im generierten Code direkt unter den Konstruktor und Sie sollten den C++-Code für die Methode UsesGenericParameter sehen:
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;
}
}
An beiden Stellen, an denen der generische Parameter T verwendet wird (der Rückgabetyp und der Typ des einzelnen verwalteten Arguments), verwendet der generierte Code den Typ Object_t*. Da alle Referenztypen im generierten Code durch Object_t* dargestellt werden können, können wir diese einzelne Methodenimplementierung für jedes T, das ein Referenztyp ist, aufrufen.
Im zweiten Blogbeitrag dieser Serie (über generierten Code) haben wir erwähnt, dass alle Methodendefinitionen in C++ freie Funktionen sind. Das Dienstprogramm il2cpp.exe erzeugt keine überschriebenen Methoden in C# mit C++-Vererbung. il2cpp.exe verwendet jedoch die C++-Vererbung für Typen. Wenn wir im generierten Code nach der Zeichenfolge "AnyClass_t" suchen, finden wir die C++-Darstellung des C#-Typs AnyClass:
struct AnyClass_t1 : public Object_t
{
};
Da AnyClass_t1 von Object_t abgeleitet ist, können wir ohne Probleme einen Zeiger auf AnyClass_t1 als Argument an die Funktion GenericType_1_UsesGenericParameter_m10449_gshared übergeben.
Aber was ist mit dem Rückgabewert? Wir können keinen Zeiger auf eine Basisklasse zurückgeben, wenn ein Zeiger auf eine abgeleitete Klasse erwartet wird, richtig? Schauen Sie sich die Deklaration der Methode GenericType<AnyClass>::UsesGenericParameter an:
#define GenericType_1_UsesGenericParameter_m10452(__this, ___value, method) (( AnyClass_t1 * (*) (GenericType_1_t6 *, AnyClass_t1 *, MethodInfo*))GenericType_1_UsesGenericParameter_m10449_gshared)(__this, ___value, method)
Der generierte Code castet den Rückgabewert (Typ Object_t*) in den abgeleiteten Typ AnyClass_t1*. Hier lügt IL2CPP also den C++-Compiler an, um das C++-Typsystem zu umgehen. Da der C#-Compiler bereits durchgesetzt hat, dass kein Code in UsesGenericParameter irgendetwas Unvernünftiges mit dem Typ T macht, kann IL2CPP den C++-Compiler hier getrost anlügen.
Generische Freigabe mit Beschränkungen
Angenommen, wir möchten zulassen, dass einige Methoden auf einem Objekt des Typs T aufgerufen werden? Wird die Verwendung von Object_t* dies nicht verhindern, da wir nicht viele Methoden für System.Object haben? Ja, das ist richtig. Aber zuerst müssen wir diese Idee dem C#-Compiler mit Hilfe von generischen Constraints mitteilen.
Schauen Sie sich im Skriptcode für diesen Beitrag noch einmal den Typ InterfaceConstrainedGenericType an. Dieser generische Typ verwendet eine Where-Klausel, die vorschreibt, dass der Typ T von einer bestimmten Schnittstelle, AnswerFinderInterface, abgeleitet sein muss. Dadurch kann die Methode ComputeAnswer aufgerufen werden. Erinnern Sie sich an den vorherigen Blog-Beitrag über Methodenaufrufe, dass Aufrufe von Schnittstellenmethoden einen Lookup in einer vtable-Struktur erfordern. Da die FindTheAnswer-Methode einen direkten Funktionsaufruf auf die eingeschränkte Instanz des Typs T ausführt, kann der C++-Code immer noch die vollständig freigegebene Methodenimplementierung verwenden, wobei der Typ T durch Object_t* repräsentiert wird.
Wenn wir bei der Implementierung der Funktion HelloWorld_DemonstrateGenericSharing_m4 beginnen und dann zur Definition der Funktion InterfaceConstrainedGenericType_1__ctor_m11 springen, können wir sehen, dass diese Methode wieder ein #define ist und auf die Funktion InterfaceConstrainedGenericType_1__ctor_m10456_gshared abgebildet wird. Wenn wir direkt unter dieser Funktion nach der Implementierung der Funktion InterfaceConstrainedGenericType_1_FindTheAnswer_m10458_gshared suchen, können wir sehen, dass dies tatsächlich die vollständig freigegebene Version der Funktion ist, die ein Object_t*-Argument annimmt. Sie ruft die Funktion InterfaceFuncInvoker0::Invoke auf, um den Aufruf der verwalteten Methode ComputeAnswer tatsächlich durchzuführen.
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;
}
}
Dies alles hängt im generierten C++-Code zusammen, da IL2CPP alle verwalteten Schnittstellen wie System.Object behandelt. Dies ist eine nützliche Faustregel, die Ihnen hilft, den von il2cpp.exe erzeugten Code auch in anderen Fällen zu verstehen.
Constraints mit einer Basisklasse
Zusätzlich zu den Schnittstellen-Constraints erlaubt C#, dass Constraints eine Basisklasse sein können. IL2CPP behandelt nicht alle Basisklassen wie System.Object. Wie also funktioniert die generische Freigabe für Basisklasseneinschränkungen?
Da Basisklassen immer Referenztypen sind, verwendet IL2CPP die vollständig freigegebene Version der generischen Methoden für diese Typen. Jeder Code, der ein Feld verwenden oder eine Methode des eingeschränkten Typs aufrufen muss, führt in C++ einen Cast in den richtigen Typ durch. Auch hier verlassen wir uns darauf, dass der C#-Compiler die generische Einschränkung korrekt durchsetzt, und wir belügen den C++-Compiler bezüglich des Typs.
Generische gemeinsame Nutzung mit Werttypen
Kehren wir nun zur Funktion HelloWorld_DemonstrateGenericSharing_m4 zurück und sehen wir uns die Implementierung für GenericType<DateTime> an. Der Typ DateTime ist ein Wertetyp, so dass GenericType<DateTime> nicht gemeinsam genutzt wird. Wir können zur Deklaration des Konstruktors für diesen Typ springen, GenericType_1__ctor_m10. Hier sehen wir ein #define, wie in den anderen Fällen, aber das #define entspricht der Funktion GenericType_1__ctor_m10_gshared, die spezifisch für die Klasse GenericType<DateTime> ist und von keiner anderen Klasse verwendet wird.
Konzeptionelle Überlegungen zum generischen Teilen
Die Umsetzung der generischen Freigabe kann schwer zu verstehen und nachzuvollziehen sein. Der Problemraum selbst ist voll von pathologischen Fällen (z.B. das seltsam wiederkehrende Schablonenmuster). Es kann helfen, über ein paar Konzepte nachzudenken:
- Jede Methodenimplementierung für einen generischen Typ wird gemeinsam genutzt
- Einige generische Typen teilen nur Methodenimplementierungen mit sich selbst (z.B. generische Typen mit einem generischen Parameter vom Typ Wert, GenericType oben)
- Generische Typen mit einem generischen Parameter vom Typ Referenz sind vollständig gemeinsam genutzt - sie verwenden immer die Implementierung mit System.Object für alle Typparameter.
- Generische Typen mit zwei oder mehr Typparametern können teilweise gemeinsam genutzt werden, wenn mindestens einer dieser Typparameter ein Referenztyp ist.
Das Dienstprogramm il2cpp.exe generiert immer die vollständigen Implementierungen der gemeinsam genutzten Methoden für jeden generischen Typ. Es erzeugt andere Methodenimplementierungen nur dann, wenn sie verwendet werden.
Gemeinsame Nutzung von generischen Methoden
Genauso wie Methodenimplementierungen für generische Typen gemeinsam genutzt werden können, gilt dies auch für Methodenimplementierungen für generische Methoden. Beachten Sie im ursprünglichen Skriptcode, dass die Methode UsesDifferentGenericParameter einen anderen Typparameter als die Klasse GenericType verwendet. Als wir uns die Implementierungen der gemeinsamen Methoden für die Klasse GenericType angesehen haben, haben wir die Methode UsesDifferentGenericParameter nicht gesehen. Wenn ich im generierten Code nach "UsesDifferentGenericParameter" suche, sehe ich, dass sich die Implementierung dieser Methode in der Datei GenericMethods0.cpp befindet:
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;
}
}
Beachten Sie, dass dies die vollständig freigegebene Version der Methodenimplementierung ist, die den Typ Object_t* akzeptiert. Obwohl sich diese Methode in einem generischen Typ befindet, wäre das Verhalten bei einer generischen Methode in einem nicht-generischen Typ dasselbe. il2cpp.exe versucht, immer so wenig Code wie möglich für Methodenimplementierungen mit generischen Parametern zu erzeugen.
Fazit
Die generische Freigabe ist eine der wichtigsten Verbesserungen des IL2CPP-Skript-Backends seit seiner ersten Veröffentlichung. Sie ermöglicht es, den generierten C++-Code so klein wie möglich zu halten und Methodenimplementierungen zu teilen, wenn sie sich im Verhalten nicht unterscheiden. Da wir die Größe der Binärdateien weiter verringern wollen, werden wir uns bemühen, mehr Gelegenheiten zu nutzen, um Methoden gemeinsam zu implementieren.
Im nächsten Beitrag werden wir uns ansehen, wie p/invoke-Wrapper generiert werden und wie Typen von verwaltetem zu nativem Code umgewandelt werden. Wir werden in der Lage sein, die Kosten für das Marshaling verschiedener Typen zu sehen und Probleme mit dem Marshaling-Code zu beheben.