IL2CPP Interna: Methodenaufrufe

Dies ist der vierte Blog-Beitrag in der Serie IL2CPP Internals. In diesem Beitrag werden wir uns ansehen, wie il2cpp.exe C++-Code für Methodenaufrufe in verwaltetem Code generiert. Konkret werden wir sechs verschiedene Arten von Methodenaufrufen untersuchen:
- Direkte Aufrufe von Instanz- und statischen Methoden
- Aufrufe über einen Kompilierzeit-Delegaten
- Anrufe über eine virtuelle Methode
- Anrufe über eine Schnittstellenmethode
- Anrufe über einen Laufzeitdelegaten
- Anrufe über Reflexion
In jedem Fall werden wir uns darauf konzentrieren, was der generierte C++-Code tut und insbesondere, wie viel diese Anweisungen kosten werden.
Wie bei allen Beiträgen in dieser Serie werden wir uns mit Code beschäftigen, der sich ändern kann und sich in einer neueren Version von Unity wahrscheinlich auch ändern wird. Die Konzepte sollten jedoch dieselben bleiben. Bitte nehmen Sie alles, was in dieser Serie besprochen wird, als Implementierungsdetails. Wenn es möglich ist, stellen wir solche Details aber gerne heraus und diskutieren sie!
Einrichtung
Ich werde Unity Version 5.0.1p4 verwenden. Ich werde den Editor unter Windows ausführen und für die WebGL-Plattform erstellen. Ich habe die Option "Development Player" aktiviert und die Option "Enable Exceptions" auf den Wert "Full" gesetzt.
Ich baue mit einer einzigen Skriptdatei, die gegenüber dem letzten Beitrag geändert wurde, damit wir die verschiedenen Arten von Methodenaufrufen sehen können. Das Skript beginnt mit der Definition einer Schnittstelle und einer Klasse:
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; }
}Dann haben wir ein konstantes Feld und einen Delegatentyp, die beide später im Code verwendet werden:
private const string question = "What is the answer to the ultimate question of life, the universe, and everything?";
private delegate int ImportantMethodDelegate(string question);Dies sind schließlich die Methoden, die wir erforschen möchten (plus die obligatorische Methode Start, die hier keinen Inhalt hat):
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 () {}Wenn das alles definiert ist, können wir loslegen. Erinnern Sie sich, dass sich der generierte C++-Code im Verzeichnis Temp\StagingArea\Data\il2cppOutput im Projekt befindet (solange der Editor geöffnet bleibt). Und vergessen Sie nicht, den generierten Code mit Ctags zu versehen, um die Navigation zu erleichtern.
Direktes Aufrufen einer Methode
Der einfachste (und schnellste, wie wir sehen werden) Weg, eine Methode aufzurufen, ist, sie direkt aufzurufen. Hier ist der generierte Code für die Methode 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);Die letzte Zeile ist der eigentliche Methodenaufruf. Beachten Sie, dass es nichts Besonderes tut, sondern lediglich eine im C++-Code definierte Free-Funktion aufruft. Erinnern Sie sich an den früheren Beitrag über generierten Code, dass il2cpp.exe alle Methoden als freie C++-Funktionen generiert. Das IL2CPP-Skript-Backend verwendet keine C++-Mitgliedsfunktionen oder virtuellen Funktionen für generierten Code. Daraus folgt, dass der Aufruf eines statischen Methodenverzeichnisses ähnlich sein sollte. Hier ist der generierte Code der Methode CallStaticMethodDirectly:
Important_StaticMethod_m3(NULL /*static, unused*/, (String_t*) &_stringLiteral1, /*hidden argument*/&Important_StaticMethod_m3_MethodInfo);Man könnte sagen, dass es weniger Aufwand bedeutet, eine statische Methode aufzurufen, da wir keine Objektinstanz erstellen und initialisieren müssen. Der Methodenaufruf selbst ist jedoch genau derselbe, ein Aufruf einer freien C++-Funktion. Der einzige Unterschied besteht darin, dass das erste Argument immer mit einem Wert von NULL übergeben wird.
Da der Unterschied zwischen Aufrufen von statischen und Instanzmethoden so minimal ist, konzentrieren wir uns in diesem Beitrag nur auf Instanzmethoden, aber die Informationen gelten auch für statische Methoden.
Aufrufen einer Methode über einen Kompilierdelegaten
Was passiert bei einem etwas exotischeren Methodenaufruf, wie einem indirekten Aufruf über einen Delegaten? Zunächst sehen wir uns einen Delegaten an, den ich als Kompilierzeit-Delegaten bezeichne. Das bedeutet, dass wir zur Kompilierzeit wissen, welche Methode für welche Objektinstanz aufgerufen wird. Der Code für diese Art von Aufruf befindet sich in der Methode CallViaDelegate. Im generierten Code sieht es so aus:
// 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);Ich habe ein paar Kommentare hinzugefügt, um die verschiedenen Teile des generierten Codes zu kennzeichnen.
Beachten Sie, dass die eigentliche Methode, die hier aufgerufen wird, nicht Teil des generierten Codes ist. Die Methode VirtFuncInvoker1<int32_t, String_t*>::Invoke befindet sich in der Datei GeneratedVirtualInvokers.h. Diese Datei wird von il2cpp.exe generiert, aber sie stammt nicht aus einem IL-Code. Stattdessen erstellt il2cpp.exe diese Datei auf der Grundlage der Verwendung von virtuellen Funktionen, die einen Wert zurückgeben (VirtFuncInvokerN) und solchen, die dies nicht tun (VirtActionInvokerN), wobei N die Anzahl der Argumente für die Methode ist.
Die Invoke-Methode sieht hier wie folgt aus:
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);
}
};Der Aufruf von libil2cpp GetVirtualInvokeData sucht eine virtuelle Methode in der vtable-Struktur, die auf der Grundlage des verwalteten Codes generiert wurde, und ruft dann diese Methode auf.
Warum verwenden wir nicht C++11 variadische Vorlagen um diese VirtFuncInvokerN Methoden zu implementieren? Dies sieht aus wie eine Situation, die nach variablen Vorlagen schreit, und das ist sie auch. Allerdings muss der von il2cpp.exe generierte C++-Code mit einigen C++-Compilern funktionieren, die noch nicht alle Funktionen von C++ 11 unterstützen, einschließlich variadischer Vorlagen. Zumindest in diesem Fall waren wir nicht der Meinung, dass das Forking des generierten Codes für C++11-Compiler die zusätzliche Komplexität wert war.
Aber warum ist dies ein Aufruf einer virtuellen Methode? Rufen wir im C#-Code nicht eine Instanzmethode auf? Sie erinnern sich, dass wir die Instanzmethode über einen C#-Delegaten aufrufen. Sehen Sie sich noch einmal den oben generierten Code an. Die eigentliche Methode, die wir aufrufen wollen, wird über das Argument MethodInfo* (Methoden-Metadaten) übergeben: ImportantMethodDelegate_Invoke_m5_MethodInfo. Wenn wir im generierten Code nach der Methode mit dem Namen "ImportantMethodDelegate_Invoke_m5" suchen, sehen wir, dass der Aufruf tatsächlich die verwaltete Invoke-Methode für den Typ ImportantMethodDelegate ist. Dies ist eine virtuelle Methode, also müssen wir einen virtuellen Aufruf machen. Es ist diese Funktion ImportantMethodDelegate_Invoke_m5, die den Aufruf der Methode namens Method im C#-Code durchführt.
Wow, das war wirklich ein gefundenes Fressen für Sie. Durch eine scheinbar einfache Änderung des C#-Codes sind wir nun von einem einzigen Aufruf einer freien C++-Funktion zu mehreren Funktionsaufrufen plus einer Tabellensuche übergegangen. Der Aufruf einer Methode über einen Delegaten ist wesentlich kostspieliger als der direkte Aufruf der gleichen Methode.
Beachten Sie, dass wir bei der Betrachtung des Aufrufs einer Delegatenmethode auch gesehen haben, wie ein Aufruf über eine virtuelle Methode funktioniert.
Aufrufen einer Methode über eine Schnittstelle
Es ist auch möglich, eine Methode in C# über eine Schnittstelle aufzurufen. Dieser Aufruf wird von il2cpp.exe ähnlich wie ein Aufruf einer virtuellen Methode implementiert:
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);Beachten Sie, dass der eigentliche Methodenaufruf hier über die Funktion InterfaceFuncInvoker1::Invoke erfolgt, die sich in der Datei GeneratedInterfaceInvokers.h befindet. Wie die Klasse VirtFuncInvoker1führt die Klasse InterfaceFuncInvoker1eine Suche in einer vtable über die Funktion il2cpp::vm::Runtime::GetInterfaceInvokeData in libil2cpp durch.
Warum muss der Aufruf einer Schnittstellenmethode in libil2cpp eine andere API verwenden als der Aufruf einer virtuellen Methode? Beachten Sie, dass der Aufruf von InterfaceFuncInvoker1::Invoke nicht nur die aufzurufende Methode und ihre Argumente übergibt, sondern auch die Schnittstelle, über die diese Methode aufgerufen werden soll (in diesem Fall L_1). Die vtable für jeden Typ wird so gespeichert, dass die Schnittstellenmethoden an einem bestimmten Offset geschrieben werden. Daher muss il2cpp.exe die Schnittstelle zur Verfügung stellen, um zu bestimmen, welche Methode aufgerufen werden soll.
Die Quintessenz ist, dass der Aufruf einer virtuellen Methode und der Aufruf einer Methode über eine Schnittstelle in IL2CPP praktisch den gleichen Overhead haben.
Aufrufen einer Methode über einen Laufzeitdelegaten
Eine andere Möglichkeit, einen Delegaten zu verwenden, besteht darin, ihn zur Laufzeit mit der Methode Delegate.CreateDelegate zu erstellen. Dieser Ansatz ähnelt einem Kompilierdelegat, mit der Ausnahme, dass er zur Laufzeit auf einige weitere Arten geändert werden kann. Wir bezahlen für diese Flexibilität mit einem zusätzlichen Funktionsaufruf. Hier ist der generierte Code:
// 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);Dieser Delegat erfordert ein gutes Stück Code für die Erstellung und Initialisierung. Aber auch der Methodenaufruf selbst verursacht noch mehr Overhead. Zunächst müssen wir ein Array für die Methodenargumente erstellen und dann die DynamicInvoke-Methode für die Delegate-Instanz aufrufen. Wenn wir diese Methode im generierten Code verfolgen, können wir sehen, dass sie die Funktion VirtFuncInvoker1::Invoke aufruft, genau wie der Kompilierdelegat. Dieser Delegat erfordert also einen Funktionsaufruf mehr als der Delegat zur Kompilierzeit und außerdem zwei Abfragen in einer vtable statt nur einer.
Aufrufen einer Methode über Reflexion
Der kostspieligste Weg, eine Methode aufzurufen, ist, wenig überraschend, die Reflexion. Schauen wir uns den generierten Code für die Methode CallViaReflection an:
// 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);Wie im Fall des Laufzeitdelegaten müssen wir einige Zeit darauf verwenden, ein Array für die Argumente der Methode zu erstellen. Dann machen wir einen virtuellen Methodenaufruf an MethodBase::Invoke (die Funktion MethodBase_Invoke_m24). Diese Funktion ruft wiederum eine andere virtuelle Funktion auf, bevor wir schließlich zum eigentlichen Methodenaufruf kommen!
Fazit
Dies ist zwar kein Ersatz für tatsächliches Profiling und Messungen, aber wir können einen Einblick in den Overhead eines bestimmten Methodenaufrufs erhalten, indem wir uns ansehen, wie der generierte C++-Code für verschiedene Arten von Methodenaufrufen verwendet wird. Es ist klar, dass wir Aufrufe über Laufzeitdelegierte und Reflexion möglichst vermeiden wollen. Wie immer ist der beste Rat für Leistungsverbesserungen, früh und oft mit Profiling-Tools zu messen.
Wir sind immer auf der Suche nach Möglichkeiten, den von il2cpp.exe generierten Code zu optimieren. Es ist also wahrscheinlich, dass diese Methodenaufrufe in einer späteren Version von Unity anders aussehen werden.
Nächstes Mal werden wir uns eingehender mit Methodenimplementierungen befassen und sehen, wie wir die Implementierung generischer Methoden gemeinsam nutzen, um den generierten Code und die Größe der ausführbaren Datei zu minimieren.
