März 2016

Band 31, Nummer 3

Compiler – Verwaltete profilgesteuerte Optimierung mithilfe der JIT-Kompilierung im Hintergrund

Von Hadi Brais | März 2016

Vom Compiler bewirkte Leistungsoptimierungen sind stets vorteilhaft. Das heißt, dass unabhängig davon, welcher Code zur Laufzeit ausgeführt wird, die Optimierung die Leistung verbessert. Nehmen wir z. B. „Loop Unrolling“ zum Ermöglichen der Vektorisierung. Diese Optimierung wandelt eine Schleife so um, dass anstelle der Anwendung einer einzelnen Operation im Körper der Schleife auf eine einzelne Menge von Operanden (z. B. Addieren zweier in verschiedenen Arrays gespeicherter ganzer Zahlen), dieselbe Operation auf mehrere Mengen von Operanden gleichzeitig angewendet wird (Addieren von vier Paaren ganzer Zahlen).

Andererseits gibt es überaus wichtige Optimierungen, die der Compiler heuristisch ausführt. Dies bedeutet, dass der Compiler nicht sicher weiß, ob diese Optimierungen sich tatsächlich für den Code eignen, der zur Laufzeit ausgeführt wird. Zwei der wichtigsten Optimierungen in dieser Kategorie (bzw. in allen Kategorien) sind die Registerzuweisung und Inlineersetzung von Funktionen. Sie können dem Compiler helfen, beim Vornehmen solcher Optimierungen bessere Entscheidungen zu treffen, indem Sie die App ein- oder mehrmals ausführen und ihr typische Benutzereingaben bereitstellen, während Sie gleichzeitig aufzeichnen, welcher Code ausgeführt wurde.

Die Informationen zur Ausführung der App werden in einem sog. Profil gesammelt. Der Compiler kann anschließend mithilfe dieses Profils einige seiner Optimierungen effektiver gestalten, was mitunter zu wesentlichen Beschleunigungen führt. Diese Technik wird profilgesteuerte Optimierung (PGO) genannt. Sie sollten diese Technik einsetzen, wenn Sie lesbaren, einfach verwaltbaren Code geschrieben, sinnvolle Algorithmen gewählt, die Lokalität des Datenzugriffs maximiert, die Konkurrenz um Sperren minimiert und alle möglichen Compileroptimierungen aktiviert haben, aber mit der resultierenden Leistung nicht zufrieden sind. Im Allgemeinen kann die PGO auch zum Verbessern anderer Merkmale Ihres Codes und nicht bloß der Leistung genutzt werden. Die in diesem Artikel vorgestellte Technik dient allerdings nur zum Steigern der Leistung.

Die native PGO im Microsoft Visual C++-Compiler habe ich bereits in einem vorherigen Artikel erörtert (siehe msdn.com/magazine/mt422584). Für die Leser dieses Artikels habe ich tolle Neuigkeiten. Das Verwenden verwalteter PGO ist einfacher geworden. Insbesondere das Feature, das ich in diesem Artikel vorstelle, JIT-Kompilierung im Hintergrund (auch Multi-Kern-JIT genannt), ist wesentlich einfacher zu nutzen. Dieser Artikel verlangt allerdings Vorkenntnisse. Das CLR-Team hat vor drei Jahren einen einführenden Blogbeitrag verfasst (bit.ly/1ZnIj9y). Die JIT-Kompilierung im Hintergrund wird von Microsoft .NET Framework ab Version 4.5 unterstützt.

Es gibt drei verwaltete PGO-Techniken:

  • Kompilieren des verwalteten Codes in Binärcode mit „Ngen.exe“ (ein „preJIT“ genannter Prozess) und anschließendes Ausführen von „Mpgo.exe“ zum Generieren von Profilen zum Abbilden gängiger Verwendungsszenarien, die zum Optimieren der Leistung des Binärcodes verwendet werden können. Dies ist mit der nativen PGO vergleichbar. Ich bezeichne diese Technik als statische MPGO.
  • Generieren von instrumentiertem Binärcode, der Informationen zur Laufzeit zu den Teilen der Methode aufzeichnet, die ausgeführt werden, wenn eine IL-Methode (Intermediate Language) erstmals vom JIT-Compiler verarbeitet wird. Verwenden Sie anschließend dieses speicherinterne Profil für einen erneuten Durchlauf der IL-Methode durch den JIT-Compiler, um stark optimierten Binärcode zu erzeugen. Dies ist auch mit nativer PGO vergleichbar, außer dass alle Vorgänge zur Laufzeit erfolgen. Diese Technik nenne ich dynamische MPGO.
  • Verwenden Sie die JIT-Kompilierung im Hintergrund, um den JIT-Verarbeitungsaufwand so weit wie möglich zu verbergen, indem IL-Methoden einer intelligenten JIT-Kompilierung unterzogen werden, ehe sie tatsächlich erstmals ausgeführt werden. Im Idealfall hat eine Methode vor dem ersten Aufruf den JIT-Compiler schon durchlaufen, sodass nicht abgewartet werden muss, bis der JIT-Compiler die Methode kompiliert.

Interessanterweise wurden alle diese Techniken in .NET Framework 4.5 eingeführt und werden auch von höheren Versionen unterstützt. Statische MPGO funktioniert nur mit nativen Images, die von „Ngen.exe“ generiert werden. Dagegen funktioniert die dynamische MPGO nur mit IL-Methoden. Verwenden Sie nach Möglichkeit „Ngen.exe“ zum Erzeugen nativer Images, und optimieren Sie sie mithilfe der statischen MPGO, da diese Technik wesentlich einfacher ist und zugleich für beträchtliche Beschleunigungen sorgt. Die dritte Technik, JIT-Kompilierung im Hintergrund, unterscheidet sich umfassend von den ersten beiden, da sie den JIT-Kompilierungsaufwand verringert, anstatt die Leistung des generierten Binärcodes zu verbessern. Deshalb kann sie zusammen mit den beiden anderen Techniken verwendet werden. Doch auch die alleinige Verwendung der JIT-Kompilierung im Hintergrund kann überaus vorteilhaft sein und die Leistung beim Start einer Anwendung oder in einem bestimmten Verwendungsszenario um bis 50 % verbessern. Der Schwerpunkt dieses Artikels liegt ausschließlich auf der JIT-Kompilierung im Hintergrund. Im nächste Abschnitt erörtere ich die herkömmliche JIT-Kompilierung von IL-Methoden und ihre Auswirkung auf die Leistung. Dann erläutere ich die Funktionsweise der JIT-Kompilierung im Hintergrund und ihre ordnungsgemäße Nutzung.

Herkömmliche JIT-Kompilierung

Sie haben bestimmt schon eine grundlegende Vorstellung, wie der .NET JIT-Compiler funktioniert, da es zahlreiche Artikel darüber gibt. Ich möchte jedoch noch einmal ein wenig detaillierter auf dieses Thema eingehen, bevor ich mich der JIT-Kompilierung im Hintergrund zuwende, damit Sie im nächsten Abschnitt mühelos folgen und die Funktionsweise verstehen können.

Sehen Sie sich das Beispiel in Abbildung 1 an. T0 ist der Hauptthread. Die grünen Teile des Threads geben an, dass der Thread Anwendungscode ausführt, und zwar in vollem Tempo. Lassen Sie uns annehmen, dass T0 in einer Methode ausgeführt wird, die bereits JIT kompiliert wurde (der oberste grüne Teil) und die nächste Anweisung der Aufruf der IL-Methode M0 ist. Da dies die erste Ausführung von M0 und diese Methode in IL abgebildet ist, muss sie in Binärcode kompiliert werden, den der Prozessor ausführen kann. Aus diesem Grund wird bei Ausführung der Aufrufanweisung eine Funktion aufgerufen, die JIT IL-Stub heißt. Diese Funktion ruft schließlich den JIT-Compiler zur JIT-Kompilierung des IL-Codes von M0 auf und gibt die Adresse des generierten Binärcodes zurück. Diese Verarbeitung hat nichts mit der Anwendung selbst zu tun und wird vom roten Teil von T0 dargestellt, um den Verarbeitungsaufwand darzustellen. Zum Glück wird der Arbeitsspeicherort der Adresse des JIT IL-Stubs mit der Adresse des entsprechenden Binärcodes korrigiert, damit künftige Aufrufe derselben Funktion in vollem Tempo erfolgen können.

Der Verarbeitungsaufwand für die herkömmliche JIT-Kompilierung bei Ausführung von verwaltetem Code
Abbildung 1: Der Verarbeitungsaufwand für die herkömmliche JIT-Kompilierung bei Ausführung von verwaltetem Code

Nach Rückgabe von M0 wird anderer Code ausgeführt, der bereits JIT kompiliert wurde. Danach wird die IL-Methode M1 aufgerufen. Wie bei M0 wir der JIT IL-Stub aufgerufen, der wiederum den JIT-Compiler zum Kompilieren der Methode aufruft und die Adresse des Binärcodes zurückgibt. Nach Rückgabe von M1 wird weiterer Binärcode ausgeführt, und zwei weitere Threads, T1 und T2, werden gestartet. An dieser Stelle wird es interessant:

Nach Ausführen der Methoden, die ich bereits JIT kompiliert habe, rufen T1 und T2 die IL-Methode M3 auf, die zuvor noch nicht aufgerufen wurde und deshalb JIT kompiliert werden muss. Intern führt der JIT-Compiler eine Liste aller JIT kompilierten Methoden. Es gibt eine Liste für jede AppDomain und eine für gemeinsam genutzten Code. Diese Liste ist durch eine Sperre geschützt. Außerdem ist jedes Element durch eine eigene Sperre geschützt, sodass mehrere Threads gleichzeitig sicher in die JIT-Kompilierung einbezogen werden können. In diesem Fall wendet Thread T1 die JIT-Kompilierung auf die Methode an und verschwendet Zeit mit Aufgaben, die nichts mit der Anwendung zu tun haben, während Thread T2 so lange inaktiv ist (d. h. auf Sperre wartet, da er tatsächlich nichts zu tun hat), bis der Binärcode von M3 zur Verfügung steht. Gleichzeitig wird M2 von T0 kompiliert. Wenn ein Thread mit der JIT-Kompilierung einer Methode fertig ist, ersetzt er die Adresse des JIT IL-Stubs durch die Adresse des Binärcodes, gibt die Sperre frei und führt die Methode aus. Beachten Sie, dass T2 irgendwann bloß aktiviert wird, um M3 auszuführen.

Der restliche Code, der von diesen Threads ausgeführt wird, ist in den grünen Balken in Abbildung 1 zu sehen. Das heißt, dass die Anwendung in vollem Tempo ausgeführt wird. Selbst wenn mit T3 ein neuer Thread gestartet wird, wurden alle Methoden, die dieser ausführen muss, bereits JIT kompiliert, weshalb auch der Thread schnellstmöglich ausgeführt wird. Die resultierende Leistung kommt sehr nahe an die Leistung von nativem Code heran.

Die Dauer der einzelnen dieser roten Segmente hängt ungefähr von der Dauer der JIT-Kompilierung der Methode ab, die wiederum von deren Größe und Komplexität abhängt. Möglich ist eine Spanne von einigen Mikrosekunden bis zu Dutzenden von Millisekunden (ohne die Zeit zum Laden erforderlicher Assemblys oder Module). Wenn der Start einer App beim ersten Mal die Ausführung von weniger als 100 Methoden erfordert, ist das nicht der Rede wert. Doch wenn beim ersten Start Hunderte oder Tausende von Methoden ausgeführt werden müssen, sind die Auswirkungen aller resultierenden roten Segmente signifikant. Dies gilt insbesondere, wenn die Dauer der JIT-Kompilierung einer Methode vergleichbar mit der Dauer der Ausführung der Methode ist, was zu einer Verlangsamung im zweistelligen Prozentbereich führt. Wenn eine Anwendung beim Start die Ausführung Tausender unterschiedlicher Methoden mit einer JIT-Zeit von 3 Millisekunden erfordert, dauert der gesamte Startvorgang 3 Sekunden. Das ist durchaus der Rede wert. Denn es ist nicht gut für Ihr Geschäft, weil Ihre Kunden nicht zufrieden sein werden.

Es besteht die Möglichkeit, dass die Methode von mehr als einem Thread JIT kompiliert wird. Es ist auch möglich, dass der erste Versuch der JIT-Kompilierung misslingt, aber der zweite Erfolg hat. Schließlich ist es auch möglich, dass eine bereits JIT kompilierte Methode nochmals JIT kompiliert wird. Alle diese Fälle werden jedoch nicht in diesem Artikel behandelt und müssen von Ihnen nicht berücksichtigt werden, wenn Sie die JIT-Kompilierung im Hintergrund verwenden.

JIT-Kompilierung im Hintergrund

Der im vorherigen Abschnitt angesprochene Verarbeitungsaufwand für die JIT-Kompilierung kann weder vermieden noch wesentlich verringert werden. Sie müssen IL-Methoden JIT kompilieren, um sie ausführen zu können. Sie haben allerdings die Möglichkeit, den Zeitpunkt zu ändern, an dem dieser Verarbeitungsaufwand anfällt. Anstatt darauf zu warten, dass eine IL-Methode erstmals aufgerufen wird, um sie der JIT-Kompilierung zu unterziehen, können Sie diese Methode früher JIT kompilieren. Das Ergebnis ist, dass der Binärcode zum Zeitpunkt ihres Aufrufs bereits generiert wurde. Wenn alles richtig läuft, werden sämtliche in Abbildung 1 gezeigten Threads grün angezeigt und mit vollem Tempo so ausgeführt, als würden Sie ein natives NGEN-Image oder noch besseren Code ausführen. Doch bevor Sie dort ankommen, müssen zwei Probleme bewältigt werden.

Problem 1: Wenn Sie eine Methode JIT kompilieren, bevor sie gebraucht wird, welcher Thread übernimmt diese Aufgabe? Es fällt nicht schwer zu erkennen, dass die beste Lösung dieses Problems ein dedizierter Thread ist, der im Hintergrund ausgeführt wird und Methoden so schnell wie möglich JIT kompiliert, die wahrscheinlich ausgeführt werden. Dies funktioniert allerdings nur, wenn mindestens zwei Prozessorkerne verfügbar sind (was fast immer der Fall ist), sodass der Verarbeitungsaufwand für die JIT-Kompilierung von der überlappenden Ausführung des Anwendungscodes verdeckt wird.

Problem 2: Woher wissen Sie, welche Methode als Nächstes JIT kompiliert werden soll, ehe sie das erste Mal aufgerufen wird? Bedenken Sie, dass es in jeder Methode zumeist bedingte Methodenaufrufe gibt. Deshalb können Sie nicht einfach alle Methoden der JIT-Kompilierung unterziehen, die ggf. aufgerufen werden, oder bei der Auswahl spekulieren, welche Methode als Nächstes JIT kompiliert werden soll. Es ist sehr wahrscheinlich, dass der JIT-Hintergrundthread sehr schnell hinter den Anwendungsthreads hinterherhinkt. Genau hier kommen Profile ins Spiel. Sie probieren zunächst den Start der App und gängige Verwendungsszenarien aus und zeichnen für jedes Szenario getrennt auf, welche Methoden in welcher Reihenfolge JIT kompiliert wurden. Anschließend können Sie die Anwendung zusammen mit den aufgezeichneten Profilen veröffentlichen, sodass bei ihrer Ausführung auf dem Computer des Benutzers der Verarbeitungsaufwand für die JIT-Kompilierung hinsichtlich der benötigten Zeit minimiert wird. Dieses Feature wird JIT-Kompilierung im Hintergrund genannt, das Sie mit wenig Aufwand ihrerseits nutzen können.

Im vorherigen Abschnitt wurde vorgestellt, wie der JIT-Compiler verschiedene Methoden in verschiedenen Threads parallel JIT kompilieren kann. Technisch ist die herkömmliche JIT-Kompilierung also bereits auf mehrere Prozessoren ausgelegt (Multi-Core). Es ist unglücklich und verwirrend, dass dieses Feature in der MSDN-Dokumentation als Multi-Core JIT bezeichnet wird, und zwar basierend auf der Anforderung von mindestens zwei Kernen anstatt auf seinem definierenden Merkmal. Ich verwende die Bezeichnung „JIT-Kompilierung im Hintergrund“, die ich gerne verbreitet sehen möchte. PerfView bietet eine integrierte Unterstützung dieses Features und verwendet die Bezeichnung „Background JIT“. Beachten Sie, dass die Bezeichnung „Multi-Core JIT“ der von Microsoft früh in der Entwicklung verwendete Name ist. Im Rest dieses Abschnitts erörtere ich alle erforderlichen Schritte zum Anwenden dieser Technik auf Ihre Code und wie diese das herkömmliche JIT-Kompilierungsmodell verändert. Ich zeige auch, wie Sie PerfView zum Messen des Vorteils der JIT-Kompilierung im Hintergrund in Ihren eigenen Apps verwenden.

Für die JIT-Kompilierung im Hintergrund müssen Sie die Laufzeit informieren, wo die Profile abgelegt werden sollen (eines für jedes Szenario, das eine umfassende JIT-Kompilierung auslöst). Sie müssen die Laufzeit auch informieren, welches Profil verwendet werden soll, damit das Profil gelesen wird, um zu bestimmen, welche Methoden im Hintergrundthread kompiliert werden sollen. Dies muss allerdings mit genügend Vorlauf erfolgen, ehe das dazugehörige Verwendungsszenario gestartet wird.

Rufen Sie zum Angeben, wo die Profile abgelegt werden sollen, die „System.Runtime.Profile­Optimization.SetProfileRoot“-Methode auf, die in „mscorlib.dll“ definiert wird. Diese Methode sieht wie folgt aus:

public static void SetProfileRoot(string directoryPath);

Der Zweck ihres einzigen Parameters „directoryPath“ ist das Angeben des Verzeichnisses des Ordners, in dem alle Profile gelesen bzw. in das die Profile geschrieben werden. Nur der erste Aufruf dieser Methode in derselben AppDomain hat Erfolg. Alle anderen Aufrufe werden ignoriert (derselbe Pfad kann jedoch von verschiedenen AppDomains verwendet werden). Sollte der Computer nicht mindestens zwei Kerne haben, werden außerdem Aufrufe von „SetProfileRoot“ ignoriert. Die einzige Aufgabe dieser Methode ist das Speichern des angegebenen Verzeichnisses in einer internen Variablen, sodass sie später bei Bedarf verwendet werden kann. Die Methode wird in der Regel von der ausführbaren Datei (.EXE) des Prozesses während der Initialisierung aufgerufen. Freigegebene Bibliotheken sollten sie nicht aufrufen. Sie können diese Methode jederzeit während der Ausführung der App vor dem Aufruf der „ProfileOptimization.StartProfile“-Methode aufrufen. Die andere Methode sieht wie folgt aus:

public static void StartProfile(string profile);

Wenn die App kurz vor dem Durchlaufen eines Ausführungspfads steht, dessen Leistung Sie optimieren möchten (z. B. den Start), rufen Sie diese Methode auf, und übergeben Sie an sie den Dateinamen und die Erweiterung des Profils. Wenn die Datei nicht vorhanden ist, wird ein Profil aufgezeichnet und in einer Datei mit dem angegebenen Namen im Ordner gespeichert, den Sie mit „SetProfileRoot“ angegeben haben. Dieser Prozess wird „Profilaufzeichnung“ genannt. Wenn die angegebene Datei vorhanden ist und ein gültiges Profil für die JIT-Kompilierung im Hintergrund hat, erfolgt diese in einem dedizierten Hintergrundthread für JIT-Kompilierungsmethoden, die gemäß dem Profil ausgewählt werden. Dieser Prozess wird „Profilwiedergabe“ genannt. Bei Wiedergabe des Profils wird das Verhalten der App weiter aufgezeichnet, und dasselbe Eingabeprofil wird ersetzt.

Sie können ein Profil nicht ohne Aufzeichnung wiedergeben, da dies derzeit nicht unterstützt wird. Sie können „StartProfile“ mehrmals unter Angabe verschiedener Profile für unterschiedliche Ausführungspfade aufrufen. Diese Methode hat keine Auswirkung, wenn sie vor der Initialisierung des Profilstamms mithilfe von „SetProfileRoot“ aufgerufen wird. Außerdem haben beide Methoden keine Auswirkungen, wenn das angegebene Argument wie auch immer ungültig ist. Diese Methoden lösen grundsätzlich keine Ausnahmen aus und geben keine Fehlercodes zurück, um das Verhalten von Apps nicht auf unerwünschte Weise zu stören. Beide sind wie alle anderen statischen Methoden im Framework threadsicher.

Wenn Sie beispielsweise die Leistung beim Starten verbessern möchten, rufen Sie diese beiden Methoden als ersten Schritt in der „main“-Funktion auf. Falls Sie die Leistung eines bestimmten Verwendungsszenarios verbessern möchten, rufen Sie „StartProfile“ auf, wenn vom Benutzer erwartet wird, dass er dieses Szenario auslöst, und rufen Sie „SetProfileRoot“ an beliebiger Stelle davor auf. Bedenken Sie, dass alle Vorgänge lokal in AppDomains erfolgen.

Das ist alles, was Sie für die Verwenden der JIT-Kompilierung im Hintergrund in Ihrem Code tun müssen. Das ist so einfach, dass Sie es einfach ausprobieren können, ohne viel darüber nachzudenken, ob es nützlich ist oder nicht. Wie können den Geschwindigkeitszuwachs messen, um zu bestimmen, ob die Änderung übernommen werden sollte. Bei einer Beschleunigung um mindestens 15 % sollten Sie sie übernehmen. Das bleibt Ihnen überlassen. Nun erläutere ich die Funktionsweise im Detail.

Bei jedem Aufruf von „StartProfile“ erfolgen die folgenden Aktionen im Kontext der AppDomain, in der der Code derzeit ausgeführt wird:

  1. Alle Inhalte der Datei mit dem Profil (sofern vorhanden) werden in den Arbeitsspeicher kopiert. Anschließend wird die Datei geschlossen.
  2. Wenn dies nicht der erste erfolgreiche Aufruf von „StartProfile“ ist, wird bereits ein Thread für die JIT-Kompilierung im Hintergrund ausgeführt. In diesem Fall wird dieser beendet, und einer neuer Hintergrundthread wird erstellt. Der Thread, der „StartProfile“ aufgerufen hat, wird an den Aufrufer zurückgegeben.
  3. Dieser Schritt erfolgt im Thread für die JIT-Kompilierung im Hintergrund. Das Profil wird analysiert. Die aufgezeichneten Methoden werden JIT kompiliert, und zwar in der in der sequenziellen Reihenfolge ihrer Aufzeichnung und so schnell wie möglich. Dieser Schritt bildet den Profilwiedergabeprozess.

Was den Hintergrundthread angeht, war es das. Sobald die JIT-Kompilierung aller aufgezeichneten Methoden abgeschlossen ist, wird dieser ohne Meldung beendet. Wenn bei der Analyse oder JIT-Kompilierung der Methoden etwas falsch läuft, wird der Thread ohne Meldung beendet. Wenn eine Assembly oder ein Modul, die/das nicht geladen wurde und für die JIT-Kompilierung einer Methode benötigt wird, wird diese(s) nicht geladen, weshalb die Methode nicht JIT kompiliert wird. Die JIT-Kompilierung im Hintergrund wurde so gestaltet, dass sie das Verhalten des Programms so wenig wie möglich ändert. Wenn ein Modul geladen wird, wird sein Konstruktor ausgeführt. Wenn ein Modul nicht gefunden wird, werden außerdem beim „System.Reflection.Assembly.ModuleResolve“-Ereignis registrierte Rückrufe aufgerufen. Wenn der Hintergrundthread ein Modul früher als ansonsten lädt, kann sich deshalb das Verhalten dieser Funktionen ändern. Dasselbe gilt für Rückrufe, die beim „System.AppDomain.AssemblyLoad“-Ereignis registriert sind. Da die JIT-Kompilierung im Hintergrund nicht die benötigten Module lädt, kann sie ggf. viele der aufgezeichneten Methoden nicht kompilieren, was nur zu einem bescheidenen Vorteil führt.

Sie werden sich fragen, warum Sie nicht mehr als einen Hintergrundthread für die JIT-Kompilierung weiterer Methoden erstellen sollen? Nun erstens sind diese Threads rechenintensiv, weshalb sie in Konkurrenz zu anderen App-Threads treten könnten. Zweitens führen weitere dieser Threads zu mehr Konflikten bei der Threadsynchronisierung. Drittens ist es nicht unwahrscheinlich, dass diese Methoden zwar JIT kompiliert werden, aber nie von einem App-Thread aufgerufen werden. Umgekehrt kann eine Methode erstmals aufgerufen werden, die nicht einmal im Profil aufgezeichnet ist oder bevor sie vom Multi-Core-Thread JIT kompiliert wird. Aufgrund dieser Probleme erweisen sich mehrere Hintergrundgrundthreads ggf. nicht als sehr vorteilhaft. Allerdings kann sich das CLR-Team in Zukunft anders entscheiden (insbesondere wenn die Einschränkung beim Laden von Modulen gelockert wird). Nun soll vorgestellt werden, was in den Anwendungsthreads geschieht, einschließlich im Profilaufzeichnungsprozess.

Abbildung 2 zeigt dasselbe Beispiel wie in Abbildung 1, allerdings mit aktivierter JIT-Kompilierung im Hintergrund. Das heißt, es gibt einen Hintergrundthread, der die Methoden M0, M1, M3 und M2 in dieser Reihenfolge JIT kompiliert. Beachten Sie, wie dieser Hintergrundthread in Konkurrenz zu den App-Threads T0, T1, T2 und T3 steht. Der Hintergrundthread muss alle Methoden JIT kompilieren, bevor er erstmals von einem anderen Thread aufgerufen wird, um seinen eigentlichen Zweck zu erfüllen. Die folgende Erörterung setzt voraus, dass dies bei M0, M1 und M3 der Fall ist, aber bei M2 nicht ganz.

Beispiel der Optimierung durch die JIT-Kompilierung im Hintergrund im Vergleich mit Abbildung 1
Abbildung 2: Beispiel der Optimierung durch die JIT-Kompilierung im Hintergrund im Vergleich mit Abbildung 1

Wenn M0 von T0 aufgerufen werden soll, hat der Thread für die JIT-Kompilierung im Hintergrund diesen bereits JIT kompiliert. Die Adresse der Methode wurde allerdings noch nicht korrigiert und zeigt weiter auf den JIT IL-Stub. Der Thread der JIT-Kompilierung im Hintergrund hätte sie korrigieren können, tut es aber nicht, um später bestimmen zu können, ob die Methode aufgerufen wurde oder nicht. Diese Informationen werden vom CLR-Team zum Bewerten der JIT-Kompilierung im Hintergrund verwendet. Also wird der JIT IL-Stub aufgerufen und erkennt, dass die Methode bereits im Hintergrundthread kompiliert wurde. Die einzige Aufgabe, die noch bleibt, ist das Korrigieren der Adresse und Ausführen der Methode. Beachten Sie, dass der Verarbeitungsaufwand für die JIT-Kompilierung in diesem Thread völlig wegfällt. M1 erfährt bei Aufruf in T0 dieselbe Behandlung. M3 erfährt bei Aufruf in T1 auch dieselbe Behandlung. Doch wenn M3 von T2 aufgerufen wird (siehe Abbildung 1), wird die Adresse der Methode von T1 sehr schnell korrigiert, sodass der tatsächliche Binärcode der Methode direkt aufgerufen wird. Dann wird M2 von T0 aufgerufen. Der Hintergrundthread der JIT-Kompilierung hat allerdings die JIT-Kompilierung der Methode noch nicht abgeschlossen, weshalb T0 auf die JIT-Sperre der Methode wartet. Nachdem die Methode JIT kompiliert wurde, wird T0 aktiviert und ruft sie auf.

Bislang habe ich noch nicht erklärt, wie Methoden im Profil aufgezeichnet werden. Es ist auch durchaus möglich, dass ein App-Thread eine Methode aufruft, deren JIT-Kompilierung der entsprechende Hintergrundthread noch nicht einmal begonnen hat (oder die nie JIT kompiliert wird, da sie nicht im Profil enthalten ist). Ich habe die Schritte, die in einem App-Thread erfolgen, wenn dieser eine statische oder dynamische IL-Methode aufruft, die noch nicht JIT kompiliert wurde, im folgenden Algorithmus kompiliert:

  1. Aktivieren der JIT-Listensperre der AppDomain, in der die Methode vorhanden ist.
  2. Wenn der Binärcode bereits von einem anderen App-Thread erzeugt wurde, geben Sie die JIT-Listensperre frei , und fahren Sie mit Schritt 13 fort.
  3. Hinzufügen eines neuen Elements zur Liste, das den JIT-Worker der Methode darstellt, falls nicht vorhanden. Falls vorhanden, wird dessen Verweiszähler erhöht.
  4. Freigeben der JIT-Listensperre.
  5. Aktivieren der JIT-Sperre der Methode.
  6. Wenn der Binärcode bereits von einem anderen App-Thread erzeugt wurde, fahren Sie mit Schritt 11 fort.
  7. Wenn die JIT-Kompilierung im Hintergrund die Methode nicht unterstützt, überspringen Sie diesen Schritt. Derzeit unterstützt die JIT-Kompilierung im Hintergrund nur statisch ausgegebene IL-Methoden, die in Assemblys definiert sind, die nicht mit „System.Reflection.Assembly.Load“ geladen wurden. Wenn die Methode unterstützt wird, prüfen Sie, ob sie bereits vom Hintergrundthread für die JIT-Kompilierung kompiliert wurde. Falls ja, zeichnen Sie die Methode auf, und fahren Sie mit Schritt 9 fort. Fahren Sie andernfalls mit dem nächsten Schritt fort.
  8. JIT kompilieren Sie die Methode. Der JIT-Compiler untersucht die IL der Methode, bestimmt alle erforderlichen Typen und stellt sicher, dass alle erforderlichen Assemblys geladen und alle erforderlichen Typobjekte erstellt wurden. Bei einem Fehler wird eine Ausnahme ausgelöst. Dieser Schritt bringt den größten Aufwand mit sich.
  9. Ersetzen Sie die Adresse des JIT IL-Stubs durch die Adresse des tatsächlichen Binärcodes der Methode.
  10. Wenn die Methode von einem App-Thread statt vom Hintergrundthread für die JIT-Kompilierung JIT kompiliert wurde, gibt es eine aktive Aufzeichnungsfunktion für die JIT-Kompilierung im Hintergrund, und die Methode wird von der JIT-Kompilierung im Hintergrund unterstützt. Die Methode wird in einem speicherinternen Profil aufgezeichnet. Die Reihenfolge, in der Methoden JIT kompiliert wurden, wird im Profil festgehalten. Beachten Sie, dass der generierte Binärcode nicht aufgezeichnet wird.
  11. Geben Sie die JIT-Sperre der Methode frei.
  12. Senken Sie den Verweiszähler der Methode mithilfe der Listensperre. Wenn diese null wird, wird das Element entfernt.
  13. Führen Sie die Methode aus.

Der Aufzeichnungsprozess für die JIT-Kompilierung im Hintergrund wird in den folgenden Situationen beendet:

  • Die dem Manager für die JIT-Kompilierung im Hintergrund zugeordnete AppDomain wird aus beliebigem Grund entladen.
  • „StartProfile“ wird für dieselbe AppDomain erneut aufgerufen.
  • Die Rate, mit der Methoden in App-Threads JIT kompiliert werden, wird sehr gering. Dies bedeutet, dass die App eine stabilen Zustand erreicht hat, in dem eine JIT-Kompilierung nur selten erforderlich ist. Methoden, die nach diesem Punkt JIT kompiliert werden, sind für die JIT-Kompilierung im Hintergrund nicht von Interesse.
  • Einer der Grenzwerte für die Aufzeichnung wurde erreicht. Die maximale Anzahl von Modulen ist 512. Die maximale Anzahl von Methoden ist 16.384, und die längste ununterbrochene Dauer der Aufzeichnung ist 1 Minute.

Nach Beenden des Aufzeichnungsprozesses wird das aufgezeichnete speicherinterne Profil in die angegebene Datei ausgegeben. Wenn anschließend die App das nächste Mal ausgeführt wird, wählt sie das Profil aus, das das Verhalten der App während ihrer letzten Ausführung gezeigt hat. Wie bereits erwähnt, werden Profile stets überschrieben. Wenn Sie das aktuelle Profil beibehalten möchten, müssen Sie vor dem Aufrufen von „StartProfile“ manuell eine Kopie erstellen. Ein Profil ist in der Regel nicht größer als einige Dutzend Kilobytes.

Vor dem Beenden dieses Abschnitts möchte ich auf das Auswählen von Profilstammverzeichnissen eingehen. Für Client-Apps können Sie entweder ein benutzerspezifisches oder App-bezogenes Verzeichnis angeben. Dies hängt davon ab, ob Sie verschiedene Gruppen von Profilen für unterschiedliche Benutzer oder bloß eine Profilgruppe für alle Benutzer wünschen. Für ASP.NET- und Silverlight-Apps empfiehlt sich ein App-bezogenes Verzeichnis. Ab ASP.NET 4.5 und Silverlight 4.5 ist die JIT-Kompilierung im Hintergrund standardmäßig aktiviert, und die Profile werden gemeinsam mit der App gespeichert. Die Laufzeit verhält sich so, als hätten Sie „SetProfileRoot“ und „StartProfile“ in der „main“-Methode aufgerufen, weshalb Sie zum Nutzen dieses Features nichts weiter tun müssen. Sie können aber dennoch, wie zuvor beschrieben, „StartProfile“ aufrufen. Sie können die automatische JIT-Kompilierung im Hintergrund deaktivieren, indem Sie das Flag „profileGuided­Optimizations“ in der Datei „web.config“ auf „None“ festlegen. Siehe dazu den .NET-Blogbeitrag „An Easy Solution for Improving App Launch Performance“ (bit.ly/1ZnIj9y). Dieses Flag kann mit „All“, der die JIT-Kompilierung im Hintergrund aktiviert (die Standardeinstellung), nur einen anderen Wert haben.

JIT-Kompilierung im Hintergrund in Aktion

Die JIT-Kompilierung im Hintergrund ist ein ETW-Anbieter (Event Tracing for Windows, Ereignisablaufverfolgung für Windows). Als solcher meldet dieses Feature eine Anzahl von Ereignissen, die darauf bezogen sind, den ETW-Consumern, wie z. B. Windows-Leistungsaufzeichnung und PerfView. Diese Ereignisse ermöglichen die Diagnose von Schwachstellen oder Fehlern, die bei der JIT-Kompilierung im Hintergrund aufgetreten sind. Insbesondere können Sie die Anzahl der im Hintergrundthread kompilierten Methoden und die gesamte JIT-Zeit dieser Methoden bestimmen. Sie können PerfView von bit.ly/1PpJUpv herunterladen (keine Installation erforderlich, einfach entzippen und ausführen). Ich verwende zur Veranschaulichung den folgenden einfachen Code:

class Program {
  const int OneSecond = 1000;
  static void PrintHelloWorld() {
    Console.WriteLine("Hello, World!");
  }
  static void Main() {
    ProfileOptimization.SetProfileRoot(@"C:\Users\Hadi\Desktop");
    ProfileOptimization.StartProfile("HelloWorld Profile");
    Thread.Sleep(OneSecond);
    PrintHelloWorld();
  }
}

In der „main“-Funktion werden „SetProfileRoot“ und „StartProfile“ zum Einrichten der JIT-Kompilierung im Hintergrund aufgerufen. Der Thread wird für ca. eine Sekunde in den Ruhezustand versetzt. Danach wird die Methode „PrintHelloWorld“ aufgerufen. Diese Methode ruft lediglich „Console.WriteLine“ auf und wird dann zurückgegeben. Kompilieren Sie diesen Code als ausführbare IL-Datei. Beachten Sie, dass „Console.WriteLine“ keine JIT-Kompilierung erfordert, da die Kompilierung bereits mit NGEN bei der Installation von .NET Framework auf Ihrem Computer erfolgt ist.

Verwenden Sie PerfView, um die ausführbare Datei zu starten und zu profilieren (weitere Informationen dazu finden Sie im .NET-Blogbeitrag „Improving Your App’s Performance with PerfView“ unter bit.ly/1nabIYC oder im PerfView-Tutorial auf Channel 9 unter bit.ly/23fwp6r). Sie müssen das Kontrollkästchen „Background JIT“ aktivieren (nur in .NET Framework 4.5 und 4.5.1 erforderlich), um das Erfassen von Ereignissen mit diesem Feature zu aktivieren. Warten Sie, bis PerfView beendet ist, und öffnen Sie dann die Seite „JITStats“ (siehe Abbildung 3). PerfView informiert Sie, dass der Prozess nicht die JIT-Kompilierung im Hintergrund verwendet. Der Grund ist, dass bei der ersten Ausführung ein Profil generiert werden muss.

Die Position von JITStats in PerfView
Abbildung 3: Die Position von JITStats in PerfView

Nachdem Sie ein Profil für die IT-Kompilierung im Hintergrund generiert haben, können Sie mit PerfView die ausführbare Datei starten und profilieren. Wenn Sie dieses Mal jedoch die Seite „JITStats“ öffnen, erkennen Sie, dass eine Methode, nämlich „PrintHelloWorld“, im Hintergrundthread JIT kompiliert wurde und eine Methode, nämlich „Main“, nicht. Ferner erfahren Sie, dass ca. 92 % der JIT-Kompilierungszeit für das Kompilieren aller in App-Threads vorkommenden IL-Methoden aufgewendet wurde. Der PerfView-Bericht enthält auch eine Liste aller Methoden mit JIT-Kompilierung, die IL- und Binärgröße jeder Methode, wer die Methode JIT kompiliert hat und weitere Informationen. Sie können auch mühelos auf sämtliche Informationen zu Ereignissen bei der JIT-Kompilierung im Hintergrund zugreifen. Weitere Details hierzu würden jedoch den Rahmen dieses Artikels sprengen.

Sie werden sich über den Zweck des Ruhezustands für ca. 1 Sekunde wundern. Dieser ist erforderlich, damit „PrintHelloWorld“ im Hintergrundthread JIT kompiliert werden kann. Andernfalls ist es wahrscheinlich, dass der App-Thread mit der Kompilierung der Methode vor dem Hintergrundthread beginnt. Deshalb müssen Sie „StartProfile“ früh genug aufrufen, damit der Hintergrundthread einen Vorsprung hat.

Zusammenfassung

Die JIT-Kompilierung im Hintergrund ist eine profilgesteuerte Optimierung, die ab .NET Framework 4.5 unterstützt wird. In diesem Artikel wurde nahezu alles angesprochen, was Sie zu diesem Feature wissen müssen. Ich habe sehr detailliert demonstriert, warum diese Optimierung erforderlich ist, wie sie funktioniert und ordnungsgemäß in Ihrem Code zum Einsatz kommt. Verwenden Sie dieses Feature, wenn NGEN nicht zweckmäßig oder möglich ist. Da es so einfach ist, können Sie es einfach ausprobieren, ohne viel darüber nachzudenken, ob es für Ihre App nützlich ist oder nicht. Wenn Sie mit dem Geschwindigkeitszuwachs zufrieden sind, nutzen Sie es weiter. Andernfalls können Sie es mühelos entfernen. Microsoft hat die JIT-Kompilierung im Hintergrund verwendet, um die Startleistung einiger seiner Anwendungen zu verbessern. Ich hoffe, dass auch Sie dieses Feature wirkungsvoll in Ihren Apps nutzen können, um in Szenarien mit umfassender Nutzung der JIT-Kompilierung und beim Start von Apps beträchtliche Beschleunigungen zu erzielen.


Hadi Brais* ist Doktorand am Indian Institute of Technology Delhi. Er erforscht Compileroptimierungen für die Speichertechnologie der nächsten Generation. Einen Großteil seiner Zeit verbringt er mit dem Schreiben von Code in C/C++/C# und dem Analysieren von Laufzeiten, Compilerframeworks und Computerarchitekturen. Seinen Blog finden Sie unter hadibrais.wordpress.com. Setzen Sie sich unter hadi.b@live.com mit ihm in Verbindung.*

Unser Dank gilt dem folgenden technischen Experten bei Microsoft für die Durchsicht dieses Artikels: Vance Morrison