IL2CPP-Optimierungen: Devirtualisierung

JOSH PETERSON / UNITY TECHNOLOGIESSenior Software Engineer
Jul 26, 2016|5 Min.
IL2CPP-Optimierungen: Devirtualisierung
Diese Website wurde aus praktischen Gründen für Sie maschinell übersetzt. Die Richtigkeit und Zuverlässigkeit des übersetzten Inhalts kann von uns nicht gewährleistet werden. Sollten Sie Zweifel an der Richtigkeit des übersetzten Inhalts haben, schauen Sie sich bitte die offizielle englische Version der Website an.

Das Skripting Virtual Machine Team von Unity ist immer auf der Suche nach Möglichkeiten, Ihren Code schneller laufen zu lassen. Dies ist der erste Beitrag in einer dreiteiligen Miniserie über einige Mikro-Optimierungen, die der IL2CPP AOT-Compiler durchführt, und wie Sie diese nutzen können. Zwar wird nichts davon dazu führen, dass der Code zwei- oder dreimal so schnell läuft, aber diese kleinen Optimierungen können in wichtigen Teilen eines Spiels helfen, und wir hoffen, dass sie Ihnen einen Einblick in die Ausführung Ihres Codes geben.

Devirtualisierung

Man kann es nicht anders sagen, virtuelle Methodenaufrufe sind immer teurer als direkte Methodenaufrufe. Wir haben an einigen Leistungsverbesserungen in der libil2cpp-Laufzeitbibliothek gearbeitet, um den Overhead von Aufrufen virtueller Methoden zu reduzieren (mehr dazu im nächsten Beitrag), aber sie erfordern immer noch eine Art von Laufzeit-Lookup. Der Compiler kann nicht wissen, welche Methode zur Laufzeit aufgerufen werden wird - oder doch?

Devirtualisierung ist eine gängige Compiler-Optimierungstaktik, die einen virtuellen Methodenaufruf in einen direkten Methodenaufruf umwandelt. Ein Compiler kann diese Taktik anwenden, wenn er zur Kompilierzeit genau nachweisen kann, welche Methode tatsächlich aufgerufen wird. Leider ist es oft schwierig, diese Tatsache zu beweisen, da der Compiler nicht immer die gesamte Codebasis sieht. Aber wenn es möglich ist, können virtuelle Methodenaufrufe dadurch viel schneller erfolgen.

Das kanonische Beispiel

Als junger Entwickler lernte ich über virtuelle Methoden anhand eines ziemlich konstruierten Tierbeispiels. Dieser Code könnte auch Ihnen bekannt vorkommen:

public abstract class Animal {
  public abstract string Speak();
}

public class Cow : Animal {
   public override string Speak() {
       return "Moo";
   }
}

public class Pig : Animal {
    public override string Speak() {
        return "Oink";
   }
}

In Unity (Version 5.3.5) können wir diese Klassen dann verwenden, um eine kleine Farm zu erstellen:

public class Farm: MonoBehaviour {
   void Start () {
       Animal[] animals = new Animal[] {new Cow(), new Pig()};
       foreach (var animal in animals)
           Debug.LogFormat("Some animal says '{0}'", animal.Speak());

       var cow = new Cow();
       Debug.LogFormat("The cow says '{0}'", cow.Speak());
   }
}

Hier ist jeder Aufruf von Speak ein Aufruf einer virtuellen Methode. Mal sehen, ob wir IL2CPP davon überzeugen können, einige dieser Methodenaufrufe zu devirtualisieren, um ihre Leistung zu verbessern.

Der generierte C++ Code ist gar nicht so schlecht

Eine der Funktionen von IL2CPP, die mir gefällt, ist, dass es C++-Code anstelle von Assembler-Code erzeugt. Sicherlich sieht dieser Code nicht wie C++-Code aus, den Sie von Hand schreiben würden, aber er ist viel einfacher zu verstehen als Assembler. Sehen wir uns den generierten Code für den Körper der for each-Schleife an:

// Set up a local variable to point to the animal array
AnimalU5BU5D_t2837741914* L_5 = V_2;
int32_t L_6 = V_3;
int32_t L_7 = L_6;

// Get the current animal from the array
V_1 = ((L_5)->GetAt(static_cast<il2cpp_array_size_t>(L_7)));
Animal_t3277885659 * L_9 = V_1;

// Call the Speak method
String_t* L_10 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Animal::Speak() */, L_9);

Ich habe einen Teil des generierten Codes entfernt, um die Dinge zu vereinfachen. Sehen Sie diesen hässlichen Aufruf von Invoke? Es wird die richtige virtuelle Methode in der vtable suchen und sie dann aufrufen. Dieser vtable-Lookup wird langsamer sein als ein direkter Funktionsaufruf, aber das ist verständlich. Das Tier könnte eine Kuh oder ein Schwein oder eine andere abgeleitete Art sein.

Sehen wir uns den generierten Code für den zweiten Aufruf von Debug.LogFormat an, der eher einem direkten Methodenaufruf entspricht:

// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;

// Call the Speak method
String_t* L_17 = VirtFuncInvoker0< String_t* >::Invoke(4 /* System.String AssemblyCSharp.Cow::Speak() */, L_16);

Selbst in diesem Fall rufen wir immer noch eine virtuelle Methode auf! IL2CPP ist ziemlich konservativ bei Optimierungen und zieht es vor, in den meisten Fällen die Korrektheit zu gewährleisten. Da die Analyse des gesamten Programms nicht ausreicht, um sicher zu sein, dass es sich um einen direkten Aufruf handelt, entscheidet sich das Programm für den sichereren (und langsameren) Aufruf einer virtuellen Methode.

Nehmen wir an, wir wissen, dass es auf unserer Farm keine anderen Arten von Kühen gibt, so dass keine Art jemals von Kuh abstammen wird. Wenn wir dieses Wissen für den Compiler explizit machen, können wir ein besseres Ergebnis erzielen. Ändern wir die Klasse so, dass sie wie folgt definiert wird:

public sealed class Cow : Animal {
   public override string Speak() {
       return "Moo";
   }
}

Das Schlüsselwort sealed teilt dem Compiler mit, dass niemand von Cow ableiten kann (sealed kann auch direkt bei der Methode Speak verwendet werden). Jetzt kann IL2CPP einen direkten Methodenaufruf durchführen:

// Create a new cow
Cow_t1312235562 * L_14 = (Cow_t1312235562 *)il2cpp_codegen_object_new(Cow_t1312235562_il2cpp_TypeInfo_var);
Cow__ctor_m2285919473(L_14, /*hidden argument*/NULL);
V_4 = L_14;
Cow_t1312235562 * L_16 = V_4;

// Look ma, no virtual call!
String_t* L_17 = Cow_Speak_m1607867742(L_16, /*hidden argument*/NULL);

Der Aufruf von Speak wird hier nicht unnötig langsam sein, da wir den Compiler sehr explizit darauf hingewiesen haben und ihm erlaubt haben, mit Zuversicht zu optimieren.

Diese Art der Optimierung wird Ihr Spiel nicht unglaublich schnell machen, aber es ist eine gute Praxis, alle Annahmen, die Sie über den Code haben, im Code auszudrücken, sowohl für zukünftige menschliche Leser dieses Codes als auch für Compiler. Wenn Sie mit IL2CPP kompilieren, sollten Sie sich den generierten C++-Code in Ihrem Projekt ansehen, um herauszufinden, was Sie sonst noch finden könnten!

Das nächste Mal werden wir besprechen, warum virtuelle Methodenaufrufe teuer sind und was wir tun, um sie schneller zu machen.