September 2015

Band 30, Nummer 9

Compileroptimierungen – Systemeigene profilgesteuerte Optimierung von Code

Von Hadi Brais | September 2015

Oftmals trifft ein Compiler falsche Optimierungsentscheidungen, die die Leistung bei der Ausführung des Codes nicht wirklich verbessern oder sie schlimmstenfalls sogar verschlechtern. Die in den ersten beiden Artikeln beschriebenen Optimierungen sind entscheidend für die Leistung Ihrer Anwendung.

In diesem Artikel wird eine wichtige Technik mit dem Namen "Profilgesteuerte Optimierung (PGO)" vorgestellt, mit der das Compiler-Back-End Code effizienter optimieren kann. Bei Experimenten wurden Leistungssteigerungen von 5-35 % erreicht. Darüber hinaus beeinträchtigt diese Technik bei sorgfältigem Einsatz auf keinen Fall die Leistung Ihres Codes.

Dieser Artikel baut auf die ersten beiden Teilen (msdn.microsoft.com/magazine/dn904673 und msdn.microsoft.com/magazine/dn973015) auf. Wenn Ihnen das Konzept der profilgesteuerten Optimierung nicht vertraut ist, empfehle ich Ihnen zunächst die Lektüre des Visual C++-Teamblogbeitrags unter bit.ly/1fJn1DI.

Einführung in die profilgesteuerte Optimierung

Eine der wichtigsten Optimierungen, die ein Compiler durchführt, ist das Inlining von Funktionen. In der Standardeinstellung führt der Visual C++-Compiler das Inlining einer Funktion aus, solange der Aufrufer nicht zu groß wird. Viele Funktionsaufrufe werden erweitert, was jedoch nur hilfreich ist, wenn der Aufruf häufig erfolgt. Andernfalls vergrößert sich bloß der Code, wodurch Speicherplatz im Anweisungs- und einheitlichen Cache verschwendet wird und sich der Arbeitssatz der App vergrößert. Doch woher weiß der Compiler, ob der Aufruf häufig erfolgt? Das hängt letztendlich von den Argumenten ab, die an die Funktion übergeben werden.

Bei den meisten Optimierungen fehlt zuverlässige Heuristik, die zum Treffen richtiger Entscheidungen erforderlich ist. Ich habe viele Fälle von schlechter Registerzuweisung gesehen, die zu beträchtlichen Leistungseinbußen geführt haben. Beim Kompilieren des Codes ist alles, was Sie tun können, zu hoffen, dass alle Leistungssteigerungen und -verschlechterungen aller Optimierungen letztlich zu einem positiven Gesamtergebnis, d. h. zu mehr Tempo führen. Dies ist fast immer der Fall, kann aber zu einer übermäßig großen ausführbaren Datei führen.

Schön wäre es, wenn es solche Nebenwirkungen nicht gäbe. Wenn Sie dem Compiler mitteilen könnten, wie sich der Code zur Laufzeit verhält, könnte er den Code besser optimieren. Der Prozess des Aufzeichnens von Informationen zum Programmverhalten zur Laufzeit wird Profilerstellung genannt, und die generierten Informationen werden als Profil bezeichnet. Sie können ein oder mehrere Profile für den Compiler bereitstellen, der diese verwendet, um seine Optimierungen zu steuern. Darum geht es letztlich bei der programmgesteuerten Optimierung (PGO).

Sie können diese Technik in systemeigenem und verwaltetem Code verwenden. Jedoch unterscheiden sich die Tools, weshalb ich hier nur auf die systemeigene PGO eingehen werde und die verwaltete PGO in einem anderen Artikel behandeln werde. Im Rest dieses Abschnitts wird beschrieben, wie die profilgesteuerte Optimierung auf eine Anwendung angewendet wird.

Profilgesteuerte Optimierung ist eine ausgezeichnete Technik. Doch wie alles andere hat sie auch Nachteile. Sie erfordert einiges an Zeit (abhängig von der Größe der App) und Aufwand. Zum Glück, wie Sie später sehen werden, bietet Microsoft Tools, mit denen die benötigte Zeit zum Anwenden der PGO auf eine App wesentlich verkürzt werden kann. Es gibt drei Phasen zum Anwenden der PGO auf eine Anwendung: der Instrumentationsbuild, das Training und der PGO-Build.

Der Instrumentationsbuild

Es gibt mehrere Methoden, ein Profil eines ausgeführten Programms zu erstellen. Der Visual C++-Compiler verwendet die statische Binärinstrumentation, die die genauesten Profile generiert, aber länger dauert. Mithilfe der Instrumentation fügt der Compiler eine kleine Anzahl von Maschinenbefehlen an Stellen von Interesse in allen Funktionen Ihres Codes ein (siehe Abbildung 1). Diese Befehle werden aufgezeichnet, wenn der dazugehörige Teil des Codes ausgeführt wurde, und fügen diese Informationen dem generierten Profil hinzu.

Der Instrumentationsbuild einer App mit profilgesteuerter Optimierung
Abbildung 1: Der Instrumentationsbuild einer App mit profilgesteuerter Optimierung

Es gibt mehrere Schritte zum Erstellen eines Builds einer instrumentierten Version einer App. Zuerst müssen Sie alle Quellcodedateien mit dem Schalter "/GL" kompilieren, um die vollständige Programmoptimierung (Whole Program Optimization, WPO) zu aktivieren. Die WPO ist erforderlich, um das Programm zu instrumentieren (was technisch nicht erforderlich ist, aber dazu beiträgt, das erstellte Profil nützlicher zu machen). Es werden nur die Dateien instrumentiert, die mit "/GL" kompiliert wurden.

Damit die nächste Phase so reibungslos wie möglich abläuft, vermeiden Sie Compilerschalter, die zu zusätzlichem Code führen. Deaktivieren Sie beispielsweise das Inlining von Funktionen (/Ob0). Deaktivieren Sie außerdem Sicherheits- (/GS-) und Laufzeitüberprüfungen (no/RTC). Sie sollten also nicht die Standardmodi "Release" und "Debug" von Visual Studio verwenden. Optimieren Sie für Dateien, die nicht mit "/GL" kompiliert wurden, die Geschwindigkeit (/O2). Geben Sie für instrumentierten Code mindestens "/Og" an.

Verknüpfen Sie anschließend die generierten Objektdateien und erforderlichen statischen Bibliotheken mit dem Schalter "/LTCG:PGI". Dadurch führt der Linker drei Aufgaben aus. Er weist das Compiler-Back-End an, den Code zu instrumentieren und eine PGO-Datenbankdatei (PGD) zu generieren. Diese wird in der dritten Phase verwendet, um alle Profile zu speichern. Zu diesem Zeitpunkt enthält die PGD-Datei keine Profile. Sie enthält nur Informationen zum Bestimmen der verwendeten Objektdateien, um zum Zeitpunkt der Nutzung der PGD-Datei zu erkennen, ob sie sich geändert haben. Standardmäßig übernimmt die PGD-Datei den Namen der ausführbaren Datei. Sie können auch über den optionalen Linkerschalter "/PGD" einen PGD-Dateinamen angeben. Die dritte Aufgabe ist die Verknüpfung der Importbibliothek "pgort.lib". Die ausführbare Ausgabedatei ist abhängig von der PGO-Laufzeit-DLL "pgortXXX.dll", wobei XXX die Version von Visual Studio ist.

Das Ergebnis dieser Phase ist eine mit Instrumentationscode aufgeblähte ausführbare Datei (EXE oder DLL) und eine leere PGD-Datei, die in der dritten Phase aufgefüllt und genutzt werden soll. Eine instrumentierte statische Bibliothek ist nur möglich, wenn diese Bibliothek mit einem zu instrumentierenden Projekt verknüpft ist. Darüber hinaus muss die gleiche Version des Compilers alle CIL-OBJ-Dateien erzeugen, weil andernfalls der Linker einen Fehler zurückgibt.

Überprüfungen der Profilerstellung

Bevor Sie mit der nächsten Phase fortfahren, möchte ich den Code ansprechen, den der Compiler zum Erstellen eines Profils des Codes einfügt. Auf diese Weise können Sie den Verarbeitungsaufwand abschätzen, der Ihrem Programm hinzugefügt wird, und die Informationen verstehen, die zur Laufzeit gesammelt werden.

Um ein Profil zu erfassen, fügt der Compiler eine Anzahl von Überprüfungen in jede Funktion ein, die mit "/GL" kompiliert wurde. Eine Überprüfung ist eine kleine Folge von Anweisungen (zwei bis vier Anweisungen) bestehend aus mehreren Pushanweisungen und einer Aufrufanweisung für einen Überprüfungshandler am Ende. Bei Bedarf wird eine Überprüfung von zwei Funktionsaufrufen zum Speichern und Wiederherstellen aller XMM-Register umschlossen. Es gibt drei Arten von Überprüfungen:

  • Überprüfungen der Anzahl: Dies ist der am häufigsten verwendete Typ von Überprüfung. Hierbei wird die Häufigkeit gezählt, mit der ein Codeblock ausgeführt wird, und zwar durch Erhöhen eines Zählers bei jeder Ausführung. Diese Überprüfung hat hinsichtlich Größe und Geschwindigkeit den niedrigsten Aufwand. Jeder Zähler hat unter x64 eine Größe von 8 Bytes und unter x86 von 4 Bytes.
  • Eingangsüberprüfung: Der Compiler fügt eine Eingangsüberprüfung am Anfang jeder Funktion hinzu. Dieser Test dient anzuweisen, die andere Überprüfungen in derselben Funktion anzuweisen, die mit der jeweiligen Funktion verknüpften Zähler zu verwenden. Dies ist erforderlich, da Überprüfungshandler von Überprüfungen funktionsübergreifend gemeinsam genutzt werden. Durch die Eingangsüberprüfung der "main"-Funktion wird die PGO-Laufzeit initialisiert. Eine Eingangsüberprüfung ist auch eine Zählüberprüfung. Dies ist die langsamste Überprüfung.
  • Wertüberprüfungen: Diese Überprüfungen werden vor allen virtuellen Funktionsaufrufen und "switch"-Anweisungen eingefügt und zum Aufzeichnen eines Histogramms von Werten verwendet. Eine Wertüberprüfung ist auch eine Zählüberprüfung, da sie die Häufigkeit zählt, mit der ein Wert vorhanden ist. Diese Überprüfung ist am größten.

Eine Funktion wird von keiner Überprüfung instrumentiert, wenn sie nur einen Basisblock (eine Abfolge von Anweisungen mit einem Eingang und Ausgang) aufweist. In der Tat wird sie trotz des Schalters "/Ob0" inline gesetzt. Neben der Wertüberprüfung bewirkt jede "switch"-Anweisung, dass der Compiler einen konstanten COMDAT-Abschnitt erstellt, der ihn beschreibt. Die Größe dieses Abschnitts entspricht ungefähr der Anzahl der Vorkommen multipliziert mit der Größe der Variablen, die den Schalter steuert.

Jede Überprüfung endet mit einem Aufruf des Überprüfungshandlers. Die Eingangsüberprüfung der "main"-Funktion erstellt einen Vektor (8 Bytes für x64 und 4 Bytes für x86) von Zeigern von Überprüfungshandlern, wobei jeder Eingang auf einen anderen Überprüfungshandler zeigt. In den meisten Fällen gibt es nur wenige Überprüfungshandler. Überprüfungen werden in jeder Funktion an den folgenden Stellen eingefügt:

  • Eine Eingangsüberprüfung am Eingang der Funktion
  • Eine Zählüberprüfung in jedem Basisblock, der mit einem Aufruf oder einer "ret"-Anweisung endet
  • Eine Wertüberprüfung direkt vor jeder "switch"-Anweisung
  • Eine Wertüberprüfung direkt vor jedem virtuellen Funktionsaufruf

So wird die Menge an Speicheroverhead des instrumentierten Programms durch die Anzahl der Überprüfungen, die Anzahl der Vorkommen in allen "switch"-Anweisungen, die Anzahl der "switch"-Anweisungen und die Anzahl der virtuellen Funktionsaufrufe bestimmt.

Alle Überprüfungshandler erhöhen an einem bestimmten Punkt einen Zähler um 1, um die Ausführung des entsprechenden Codeblocks aufzuzeichnen. Der Compiler verwendet die ADD-Anweisung, um einen 4-Byte-Zähler um 1 zu erhöhen, und unter x64 die ADC-Anweisung, um das "carry"-Kennzeichen den hohen 4 Bytes des Zählers hinzufügen. Diese Anweisungen sind nicht threadsicher. Dies bedeutet, dass alle Überprüfungen standardmäßig nicht threadsicher sind. Wenn mindestens eine der Funktionen von mehreren Threads gleichzeitig ausgeführt werden kann, sind die Ergebnisse nicht zuverlässig. In diesem Fall können Sie den Linkerschalter "/pogosafemode" verwenden. Dieser bewirkt, dass der Compiler LOCK als Präfix vor diese Anweisungen setzt, wodurch alle Überprüfungen threadsicher werden. Dadurch werden sie allerdings auch langsamer. Leider kann dieses Feature nicht selektiv angewendet werden.

Wenn Ihre Anwendung aus mehreren Projekten besteht, deren Ausgabe entweder eine EXE- oder DLL-Datei für die profilgesteuerte Optimierung ist, müssen Sie den Prozess für jedes wiederholen.

Die Trainingsphase

Nach der ersten Phase verfügen Sie über eine instrumentierte Version der ausführbaren Datei und eine PGD-Datei. In der zweiten Phase erfolgt das Training, bei dem die ausführbare Datei eine oder mehrere Profile zum Speichern in einer separaten PGC-Datei (PGO Count) generiert. Sie verwenden diese Dateien in der dritten Phase zum Optimieren des Codes.

Dies ist die wichtigste Phase, da Profilgenauigkeit entscheidend für den Erfolg des gesamten Prozesses ist. Damit ein Profil nützlich ist, muss es ein allgemeines Verwendungsszenario des Programms widerspiegeln. Der Compiler optimiert das Programm, vorausgesetzt die getesteter Szenarien sind allgemein. Wenn dies nicht der Fall war, weist das Programm ggf. in der Praxis eine schlechtere Leistung auf. Ein anhand eines allgemeinen Nutzungsszenarios generiertes Profil hilft dem Compiler beim Bestimmen der langsamsten Pfade zur Optimierung der Geschwindigkeit und der schnellsten Pfade zur Optimierung der Größe (siehe Abbildung 2).

Die Trainingsphase beim Erstellen einer PGO-App
Abbildung 2: Die Trainingsphase beim Erstellen einer PGO-App

Die Komplexität dieser Phase hängt von der Anzahl der Nutzungsszenarien und der Art des Programms ab. Das Training ist einfach, wenn die Anwendung keine Benutzereingaben erfordert. Bei zahlreichen Nutzungsszenarien ist das sequenzielle Generieren eines Profils für jedes Szenario möglicherweise nicht der schnellste Weg.

Beim komplexen Trainingsszenario in Abbildung 2 ist "pgosweep.exe" ein Befehlszeilentool, mit dem Sie den Inhalt des Profils steuern können, das von der PGO-Laufzeit verwaltet wird, wenn es ausgeführt wird. Sie können mehrere Instanzen des Programms erzeugen und Verwendungsszenarien gleichzeitig anwenden.

Stellen Sie sich vor, Sie haben zwei Instanzen, die in den Prozessen X und Y ausgeführt werden.Rufen Sie, wenn ein Szenario vor dem Start für Prozess X steht, "pgosweep" auf, und übergeben Sie es an die Prozess-ID und den Schalter "/onlyzero". Dies bewirkt, dass die PGO-Laufzeit den Teil des speicherinternen Profils nur für diesen Prozess löscht. Ohne die Prozess-ID wird das gesamte PGC-Profil gelöscht. Dann kann das Szenario gestartet werden. Sie können das Nutzungsszenario 2 für Prozess Y auf ähnliche Weise auslösen.

Die PGC-Datei wird generiert, wenn alle ausgeführten Instanzen des Programms beendet sind. Wenn das Programm allerdings eine lange Startzeit hat und Sie es nicht für jedes Szenario ausführen möchten, können Sie die Laufzeit zwingen, ein Profil zu generieren und das speicherinterne Profil zu löschen, um es für ein anderes Szenario im selben Lauf vorzubereiten. Führen Sie hierzu "pgosweep.exe" aus, und übergeben Sie die Prozess-ID, den Namen der ausführbaren Datei und den Namen der PGC-Datei.

Standardmäßig wird die PGC-Datei im gleichen Verzeichnis wie die ausführbare Datei generiert. Sie können dies mit der Umgebungsvariablen VCPROFILE_PATH ändern, die festgelegt werden muss, bevor Sie die erste Instanz des Programms ausführen.

Ich habe die Daten und den Anweisungsmehraufwand beim Instrumentieren von Code angesprochen. In den meisten Fällen kann dieser Mehraufwand bewältigt werden. Die Arbeitsspeicherbelegung der PGO-Laufzeit überschreitet standardmäßig nicht einen bestimmten Schwellenwert. Wenn sich herausstellt, dass mehr Arbeitsspeicher erforderlich ist, tritt ein Fehler auf. In diesem Fall können Sie die Umgebungsvariable VCPROFILE_ALLOC_SCALE nutzen, um diesen Schwellenwert zu erhöhen.

Der PGO-Build

Nachdem Sie alle gängigen Nutzungsszenarien ausgeführt haben, verfügen Sie über eine Reihe von PGC-Dateien, die Sie verwenden können, um die optimierte Version des Programms zu erstellen. Sie können PGC-Dateien verwerfen, die Sie nicht verwenden möchten.

Der erste Schritt beim Erstellen der PGO-Version ist das Zusammenführen aller PGC-Dateien mit einem Befehlszeilenprogramm namens "pgomgr.exe". Sie können hiermit auch eine PGD-Datei bearbeiten. Um die beiden PGC-Dateien in der in der ersten Phase generierten PGD-Datei zusammenführen, führen Sie "pgomgr" aus und übergeben den Schalter "/merge" an die PGD-Datei. Dadurch werden alle PGC-Dateien im aktuellen Verzeichnis, deren Namen mit dem Namen der angegebenen PGD-Datei übereinstimmen, zusammengeführt, gefolgt von "!#" und einer Zahl. Der Compiler und der Linker können die resultierende PGD-Datei verwenden, um den Code zu optimieren.

Mit dem Tool "pgomgr" können Sie ein allgemeineres und wichtigeres Nutzungsszenario erfassen. Übergeben Sie hierzu den entsprechenden PGC-Dateinamen und den Schalter "/merge:n". "n" ist eine positive ganze Zahl, die die Anzahl der Kopien der PGC-Datei angibt, die in die PGD-Datei eingeschlossen werden sollen. "n" ist standardmäßig 1. Diese Multiplizität bewirkt, dass ein bestimmtes Profil die Optimierungen zu seinem Vorteil beeinflusst.

Der zweite Schritt ist das Ausführen des Linkers, wobei der gleichen Satz von Objektdateien wie in Phase 1 übergeben wird. Verwenden Sie dieses Mal den Schalter "/LTCG:PGO". Der Linker sucht nach einer PGD-Datei mit einem Namen, der dem der ausführbaren Datei im aktuellen Verzeichnis entspricht. Der Linker stellt sicher, dass sich die CIL-OBJ-Dateien seit dem Generieren der PGD-Datei in Phase 1 nicht geändert haben, und übergibt diese dann an den Compiler zur Optimierung des Codes. Dieser Prozess wird in Abbildung 3 dargestellt. Sie können den Linkerschalter "/PGD" verwenden, um explizit eine PGD-Datei anzugeben. Vergessen Sie nicht, das Inlining von Funktionen für diese Phase zu aktivieren.

Der PGO-Build in Phase 3
Abbildung 3: Der PGO-Build in Phase 3

Die meisten Compiler- und Linkeroptimierungen werden profilgesteuert. Das Ergebnis dieser Phase ist eine hinsichtlich Größe und Geschwindigkeit überaus optimierte ausführbare Datei. Es ist jetzt eine gute Idee, die Leistungszuwächse zu messen.

Verwalten der Codebasis

Wenn Sie beliebige Änderungen an den Eingabedateien vornehmen, die an den Linker mit dem Schalter "/LTCG:PGI" übergeben werden, weigert sich der Linker, die PGD-Datei zu verwenden, wenn "/LTCG:PGO" angegeben ist. Der Grund ist, dass solche Änderungen den Nutzen der PGD-Datei erheblich beeinträchtigen können.

Eine Möglichkeit ist, die drei zuvor beschriebenen Phasen zu wiederholen, um eine weitere kompatible PGD-Datei zu erstellen. Wenn die Änderungen allerdings geringfügig waren (z. B. Hinzufügen einer kleinen Anzahl von Funktionen, weniger häufiges oder häufigeres Aufrufen einer Funktion oder vielleicht Hinzufügen eines Features, dass nicht so oft verwendet wird), dann ist es praktisch, den ganzen Prozess zu wiederholen. In diesem Fall können Sie den Schalter "/LTCG:PGU" anstelle des Schalters "/LTCG:PGO" verwenden. Dieser weist den Linker an, Kompatibilitätprüfungen für die PGD-Datei zu überspringen.

Diese kleinen Änderungen sammeln sich mit der Zeit an. Sie werden letztlich einen Punkt erreichen, an dem es von Vorteil ist, die Anwendung erneut zu instrumentieren. Sie können ermitteln, wann Sie diesen Punkt erreicht haben, indem Sie sich die Compilerausgabe ansehen, wenn Sie den PGO-Build für den Code erstellen. Sie erfahren, wie viel der Codebasis die PGD-Datei abdeckt. Wenn die Profilabdeckung auf unter 80 % fällt (siehe Abbildung 4), ist es eine gute Idee, den Code erneut zu instrumentieren. Dieser Prozentsatz ist jedoch stark abhängig von der Art der Anwendung.

Die PGO in Aktion

Die PGO steuert Optimierungen, die vom Compiler und Linker genutzt werden. Ich verwende den Simulator NBody, um einige ihrer Vorteile zu veranschaulichen. Sie können diese Anwendung unter bit.ly/1gpEaCY herunterladen. Sie müssen auch das DirectX SDK unter bit.ly/1LQnKge herunterladen und installieren, um die Anwendung zu kompilieren.

Zunächst kompiliere ich die Anwendung im Releasemodus, um sie mit der PGO-Version zu vergleichen. Um die PGO-Version der Anwendung zu erstellen, können Sie das Menüelement "Profilgesteuerte Optimierung" im Menü "Build" von Visual Studio verwenden.

Außerdem sollten Sie die Assemblerausgabe mit dem Compilerschalter "/FA[c]" aktivieren (verwenden Sie für diese Demo nicht "/FA[c]s"). Bei dieser einfachen Anwendung reicht es aus, die instrumentierte App einmal zu trainieren, um eine PGC-Datei zu generieren und zur Optimierung der App zu verwenden. Auf diese Weise erhalten Sie zwei ausführbare Dateien: eine blind optimierte und eine zweite PGO-optimierte. Stellen Sie sicher, dass Sie auf die endgültige PGD-Datei zugreifen können, da Sie sie später benötigen.

Wenn Sie nun beide ausführbare Dateien nacheinander ausführen und die erreichten GFLOP-Werte vergleichen, erkennen Sie, dass beide eine ähnliche Leistung erzielt haben. Offenbar war das Anwenden der PGO auf die App Zeitverschwendung. Bei näherer Untersuchung stellt sich heraus, dass die Größe der App von 531 KB (für die blind optimierte App) auf 472 KB (für die PGO-basierte Anwendung), d. h. um 11 %, reduziert wurde. Das Anwenden der PGO auf diese App hat also bewirkt, dass sie bei gleicher Leistung verkleinert wurde. Warum ist dies so?

Sehen Sie sich die aus 200 Zeilen bestehende "DXUTParseCommandLine"-Funktion in der Datei "DXUT/Core/DXUT. CPP" an. Bei Betrachten des generierten Assemblycodes des Releasebuilds erkennen Sie, dass die Größe des Binärcodes ungefähr 2700 Bytes ist. Auf der anderen Seite ist die Größe des Binärcodes im PGO-Build höher als 1650 Bytes. Sie können die Ursache dieses Unterschieds in der Assemblyanweisung finden, die die Bedingung der folgenden Schleife überprüft:

for( int iArg = iArgStart; iArg < nNumArgs; iArg++ ) { ... }

Der blind optimierte Build hat den folgenden Code generiert:

0x044 jge block1
; Fall-through code executed when iArg < nNumArgs
; Lots of code in between
0x362 block1:
; iArg >= nNumArgs
; Lots of other code

Der PGO-Build hat hingegen den folgenden Code erzeugt:

0x043 jl   block1
; taken 0(0%), not-taken 1(100%)
block2:
; Fall-through code executed when iArg >= nNumArgs
0x05f ret  0
; Scenario dead code below
0x000 block1:
; Lots of other code executed when iArg < nNumArgs

Viele Benutzer bevorzugen das Angeben von Parametern auf der grafischen Benutzeroberfläche, anstatt sie über die Befehlszeile zu übergeben. Daher ist das übliche Szenario hier entsprechend den Profilinformationen, dass die Schleife nie durchlaufen wird. Ohne ein Profil kann der Compiler dies unmöglich wissen. Deshalb setzt er nun alles daran, den Code in der Schleife zu optimieren. Dabei werden viele Funktionen erweitert, was zu einer sinnlosen Codeaufblähung führt. Beim PGO-Build haben Sie dem Compiler ein Profil bereitgestellt, laut dem die Schleife nie ausgeführt wurde. Aus diesem Grund wusste der Compiler, dass es zwecklos ist, ein Inlining für Funktionen vorzunehmen, die im Hauptteil der Schleife aufgerufen werden.

Anhand der Assemblycodeausschnitte lässt sich noch ein weiterer interessanter Unterschied erkennen. In der blind optimierten ausführbaren Datei befindet sich die Verzweigung, die selten ausgeführt wird, im Fall-Through-Pfad der bedingten Anweisung. Die Verzweigung, die fast immer ausgeführt wird, befindet sich 800 Bytes von der bedingten Anweisung entfernt. Dadurch misslingt nicht nur das Vorhersagen der Prozessorverzweigung, sondern wird auch garantiert ein Anweisungscachefehler verursacht.

Der PGO-Build hat diese beide Probleme durch Austauschen der Positionen der Verzweigungen vermieden. Tatsächlich wurde die selten ausgeführte Verzweigung in einen getrennten Abschnitt der ausführbaren Datei verschoben, wodurch die Lokalität des Arbeitsatzes verbessert wurde. Diese Optimierung wird als Abtrennung von "totem" Code bezeichnet. Dies wäre ohne ein Profil unmöglich gewesen. Nur selten aufgerufene Funktionen, z. B. kleine Unterschiede im Binärcode, können deutliche Leistungsunterschiede verursachen.

Beim Erstellen des PGO-Codes zeigt Ihnen der Compiler, wie viele Funktionen aller instrumentierten Funktionen hinsichtlich Geschwindigkeit kompiliert wurden. Der Compiler zeigt Ihnen dies auch in den Ausgabefenstern von Visual Studio. Nicht mehr als 10 % der Funktionen würden in der Regel hinsichtlich Geschwindigkeit (denken Sie an aggressives Inlining) kompiliert werden, während die übrigen hinsichtlichGröße kompiliert werden (stellen Sie sich teilweises oder kein Inlining vor).

Betrachten wir mit "DXUTStaticWndProc" eine etwas interessantere Funktion, die in derselben Datei definiert ist. Die Funktionen, die die Struktur steuern, sehen folgendermaßen aus:

if (condition1) { /* Lots of code */ }
if (condition2) { /* Lots of code */ }
if (condition3) { /* Lots of code */ }
switch (variable) { /* Many cases with lots of code in each */ }
if-else statement
return

Der blind optimierte Code gibt jeden Codeblock in der gleichen Reihenfolge wie im Quellcode aus. Der Code im PGO-Build wurde allerdings anhand der Ausführungshäufigkeit jedes Blocks und der Uhrzeit der Ausführung jedes Blocks clever neu angeordnet. Die ersten beiden Bedingungen wurden nur selten ausgeführt, weshalb sich die entsprechenden Codeblöcke nun zur Verbesserung der Cache- und Arbeitsspeicherauslastung in einem getrennten Abschnitt befinden. Außerdem sind die Funktionen, die als zum langsamsten Pfad gehörig erkannt wurden (z. B. DXUTIsWindowed), jetzt inline:

if (condition1) { goto dead-code-section }
if (condition2) { goto dead-code-section }
if (condition3) { /* Lots of code */ }
{/* Frequently executed cases pulled outside the switch statement */}
if-else statement
return
switch(variable) { /* The rest of cases */ }

Die meisten Optimierungen profitieren von einem zuverlässigen Profil und andere können nun ausgeführt werden. Auch wenn die PGO nicht zu einer deutlichen Leistungssteigerung führt, wird aber mit Sicherheit die Größe der erstellten ausführbaren Dateien und ihr Verarbeitungsaufwand für das Speichersystem verringert.

PGO-Datenbanken

Die Vorteile des PGD-Profils gehen bei Weitem über die Optimierungen des Compilers hinaus. Während Sie "pgomgr.exe" verwenden können, um mehrere PGC-Dateien zusammenführen, dient diese Datei auch einem anderen Zweck. Sie bietet drei Schalter, mit denen Sie den Inhalt der PGD-Datei anzeigen können, um das Verhalten Ihres Codes in Bezug auf die getesteten Szenarien besser zu verstehen. Die erste Schalter, /summary, weist das Tool an, eine Zusammenfassung des Inhalts der PGD-Datei in Textform auszugeben. Der zweite Schalter, /detail, weist das Tool zusammen mit dem ersten Schalter an, eine detaillierte Beschreibung des Profils in Textform auszugeben. Die letzte Option, /unique, weist das Tool an, die Funktionsnamen in nicht ergänzter Form anzuzeigen (besonders nützlich für C++-Codebasen).

Programmgesteuerte Steuerung

Es gibt noch ein weiteres erwähnenswertes Merkmal. Die Headerdatei "pgobootrun.h" deklariert eine Funktion mit dem Namen "PgoAutoSweep". Sie können diese Funktion aufrufen, um programmgesteuert eine PGC-Datei zu generieren und das Profil im Arbeitsspeicher als Vorbereitung auf die nächste PGC-Datei zu löschen. Die Funktion akzeptiert ein Argument vom Typ "char*", das auf den Namen der PGC-Datei verweist. Um diese Funktion zu verwenden, müssen Sie eine Verknüpfung mit der statischen Bibliothek "pgobootrun.lib" herstellen. Derzeit ist dies die einzige programmgesteuerte Unterstützung im Zusammenhang mit der PGO.

Zusammenfassung

Die profilgesteuerte Optimierung (PGO) ist eine Optimierungstechnik, die dem Compiler und Linker hilft, bessere Entscheidungen zur Optimierung durch einen Verweis auf ein zuverlässiges Profil zu treffen, immer wenn hinsichtlich Größe oder Geschwindigkeit eine Abwägung nötig ist. Visual Studio bietet visuellen Zugriff auf dieses Verfahren über das Menü "Build" oder im Kontextmenü des Projekts.

Sie erhalten jedoch eine umfangreichere Palette von Features mithilfe des PGO-Plug-Ins, das Sie unter bit.ly/1Ntg4Be herunterladen können. Dies ist auch unter bit.ly/1RLjPDi gut dokumentiert. Wenn Sie sich an die Schwellenwertabdeckung in Abbildung 4 erinnern, ist dies die einfachste Möglichkeit zur Optimierung mithilfe des Plug-Ins, was in der Dokumentation beschrieben ist. Wenn Sie jedoch lieber mit Befehlszeilentools arbeiten, finden Sie im Artikel unter bit.ly/1QYT5nO zahlreiche Beispiele. Wenn Sie über eine systemeigene Codebasis verfügen, empfiehlt es sich, dieses Verfahren nun einmal auszuprobieren. Wenn Sie dies tun, lassen Sie mich gerne wissen, wie es sich auf die Größe und Geschwindigkeit der Anwendung ausgewirkt hat.

Wartungszyklus der PGO-Codebasis
Abbildung 4: Wartungszyklus der PGO-Codebasis

Weitere Ressourcen

Weitere Informationen zur profilgesteuerten Optimierung von Datenbanken finden Sie im Blogbeitrag von Hadi Brais unter bit.ly/1KBcffQ.


Hadi Brais ist Doktorand am Indian Institute of Technology Delhi (IITD). 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 und Compilerframeworks. 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: Ankit Asthana