Compiler

Was jeder Programmierer über Compileroptimierungen wissen sollte

Hadi Brais

Laden Sie die Codebeispiele herunter

Höhere Programmiersprachen stellen zahlreiche abstrakte Programmierkonstrukte zur Verfügung, z. B. Funktionen, Bedingungsanweisungen und Schleifen, die zu erstaunlicher Produktivität verhelfen. Ein Nachteil des Schreibens von Code in einer höheren Programmiersprache ist jedoch die potenziell erhebliche Leistungsverringerung. Idealerweise sollten Sie verständlichen Code schreiben, der gut gepflegt werden kann – ohne die Leistung zu beeinträchtigen. Aus diesem Grund versuchen Compiler, den Code automatisch zu optimieren, um seine Leistung zu verbessern, und sie führen diese Aufgabe zurzeit recht differenziert aus. Sie können Schleifen, Bedingungsanweisungen und rekursive Funktionen transformieren, ganze Codeblöcke beseitigen und die Ziel-ISA (Instruction Set Architecture) nutzen, um den Code schnell und kompakt zu gestalten. Es es wesentlich empfehlenswerter, sich auf das Schreiben von verständlichem Code zu konzentrieren, als manuelle Optimierungen vorzunehmen, die als Ergebnis zu kryptischem Code führen, der nur schlecht gepflegt werden kann. Wenn Sie den Code manuell optimieren, kann dies sogar verhindern, dass der Compiler zusätzliche oder effizientere Optimierungen vornimmt.

Anstatt den Code manuell zu optimieren, sollten Sie Aspekte Ihres Entwurfs überdenken, z. B. die Verwendung schnellerer Algorithmen, die Integration von Parallelität auf Threadebene und die Verwendung frameworkspezifischer Funktionen (z. B. die Verwendung von Bewegungskonstruktoren).

Dieser Artikel dreht sich um Visual C++-Compileroptimierungen. Ich stelle die wichtigsten Optimierungstechniken vor und zeige die Entscheidungen auf, die ein Compiler treffen muss, um diese anzuwenden. Meine Absicht besteht nicht darin, Ihnen zu zeigen, wie Sie den Code manuell optimieren, sondern darin, Sie zu überzeugen, warum Sie dem Compiler bezüglich der Optimierung des Codes trauen können. Dieser Artikel stellt unter keinen Umständen eine Untersuchung der Optimierungen dar, die vom Visual C++-Compiler ausgeführt werden. Er zeigt jedoch die Optimierungen, die Sie wirklich kennen sollten, sowie die Kommunikation mit dem Compiler, damit sie angewendet werden.

Es gibt weitere wichtige Optimierungen, die zurzeit jenseits der Möglichkeiten jedes Compilers liegen – z. B. das Ersetzen eines ineffizienten Algorithmus durch einen effizienten Algorithmus oder das Ändern des Layouts einer Datenstruktur, um ihre Lokalität zu verbessern. Solche Optimierungen sprengen jedoch den Rahmen dieses Artikels.

Definieren von Compileroptimierungen

Eine Optimierung besteht im Transformieren eines Codeabschnitts in einen anderen, funktional gleichwertigen Codeabschnitt, um mindestens eines seiner Merkmale zu optimieren. Die beiden wichtigsten Merkmale sind die Geschwindigkeit und die Größe des Codes. Weitere Merkmale sind z. B. der Aufwand, der für die Ausführung des Codes erforderlich ist, die zum Kompilieren des Codes erforderliche Zeit und (wenn der Code JIT-Kompilierung (Just-in-Time) erfordert) der Zeitaufwand für die JIT-Kompilierung des Codes.

Compiler werden hinsichtlich der Techniken, die sie zum Optimieren des Codes verwenden, ständig verbessert. Sie sind jedoch nicht perfekt. Dennoch ist es nicht empfehlenswert, Zeit für die manuelle Optimierung eines Programms aufzuwenden. Es ist im Allgemeinen wesentlich sinnvoller, bestimmte Funktionen zu verwenden, die vom Compiler bereitgestellt werden, und den Compiler den Code optimieren zu lassen. 

Sie können den Compiler auf vierfache Weise beim effizienteren Optimieren Ihres Codes unterstützen:

  1. Schreiben Sie verständlichen Code, der gut zu pflegen ist. Sehen Sie die objektorientierten Funktionen von Visual C++ nicht als Feinde der Leistung an. Die aktuellste Version von Visual C++ kann solchen Mehraufwand auf ein Minimum beschränken und manchmal sogar völlig beseitigen.
  2. Verwenden Sie Compilerdirektiven. Weisen Sie den Compiler z. B. an, eine Funktionsaufrufkonvention zu verwenden, die schneller als die Standardkonvention ist.
  3. Verwenden Sie compilerinterne Funktionen. Eine systeminterne Funktion ist eine besondere Funktion, deren Implementierung automatisch durch den Compiler bereitgestellt wird. Der Compiler kennt die Funktion genau und ersetzt den Funktionsaufruf durch eine ausgesprochen effiziente Anweisungssequenz, die die Ziel-ISA nutzt. Zurzeit unterstützt Microsoft .NET Framework keine systeminternen Funktionen. Sie werden daher von keiner der verwalteten Sprachen unterstützt. Visual C++ weist jedoch umfangreiche Unterstützung für diese Funktion auf. Beachten Sie, dass systeminterne Funktionen zwar die Leistung des Codes verbessern können, jedoch auch seine Lesbarkeit und Portierbarkeit verringern.
  4. Verwenden Sie profilgesteuerte Optimierung (Profile-Guided Optimization, PGO). Mit dieser Technik weiß der Compiler besser, wie sich der Code zur Laufzeit verhalten wird, und er kann entsprechende Optimierungen vornehmen.

Dieser Artikel soll Ihnen zeigen, warum Sie dem Compiler vertrauen können, indem die Optimierungen gezeigt werden, die für ineffizienten, jedoch verständlichen Code (unter Verwendung der ersten Methode) ausgeführt werden. Außerdem erhalten Sie eine kurze Einführung in profilgesteuerte Optimierung, und ich stelle einige der Compilerdirektiven vor, mit denen Sie die Feinabstimmung einiger Codeteile vornehmen können.

Es stehen zahlreiche Compileroptimierungstechniken von einfachen Transformationen (z. B. Konstantenfaltung) bis hin zu extremen Transformationen (z. B. Instruction Scheduling) zur Verfügung. In diesem Artikel werden nur einige der wichtigsten Optimierungen behandelt – die Optimierungen, die die Leistung erheblich verbessern können (um einen zweistelligen Prozentsatz) und die Codegröße verringern: Inlineersetzung von Funktionen, COMDAT-Optimierungen und Schleifenoptimierungen. Die ersten beiden Möglichkeiten werden im nächsten Abschnitt erläutert. Dann zeige ich Ihnen, wie Sie die durch Visual C++ ausgeführten Optimierungen steuern können. Schließlich werfen wir noch einen kurzen Blick auf Optimierungen in .NET Framework. In diesem Artikel verwende ich ausschließlich Visual Studio 2013 zum Erstellen des Codes.

Codegenerierung zur Verknüpfungszeit

Codegenerierung zur Verknüpfungszeit (Link-Time Code Generation, LTCG) ist eine Technik zum Ausführen vollständiger Programmoptimierungen (Whole Program Optimizations, WPO) für C/C++-Code. Der C/C++-Compiler kompiliert jede Quelldatei separat und generiert die entsprechende Objektdatei. Dies bedeutet, dass der Compiler Optimierungen nur auf eine einzelne Quelldatei und nicht auf das gesamte Programm anwenden kann. Einige wichtige Optimierungen können jedoch nur im Hinblick auf das gesamte Programm ausgeführt werden. Sie können diese Optimierungen zur Verknüpfungszeit und nicht zur Kompilierungszeit anwenden, weil der Linker über eine vollständige Ansicht des Programms verfügt.

Wenn LTCG aktiviert ist (durch Angeben des Compilerschalters „/GL“), ruft der Compilertreiber („cl.exe“) nur das Front-End des Compilers („c1.dll“ oder „c1xx.dll“) auf und schiebt die Aufgaben des Back-Ends („c2.dll“) bis zur Verknüpfungszeit auf. Die sich ergebenden Objektdateien enthalten CIL-Code (C Inter­mediate Language) und keinen computerabhängigen Assemblycode. Wenn dann der Linker („link.exe“) aufgerufen wird, erkennt dieser, dass die Objektdateien CIL-Code enthalten, und er ruft das Back-End des Compilers auf, der seinerseits WPO ausführt, die Binärobjektdateien erstellt und die Rückgabe an den Linker ausführt, um alle Objektdateien zusammenzuführen und die ausführbare Datei zu generieren.

Das Front-End führt sogar unabhängig davon, ob Optimierungen aktiviert oder deaktiviert sind, einige Optimierungen aus, z. B. Konstantenfaltung. Alle wichtigen Optimierungen werden jedoch vom Back-End des Compilers ausgeführt und können mithilfe von Compilerschaltern gesteuert werden.

LTCG ermöglicht dem Back-End die aggressive Ausführung von Optimierungen (durch Angeben von „/GL“ zusammen mit den Compilerschaltern „/O1“ oder „/O2“ und „/Gw“ und den Linkerschaltern „/OPT:REF“ und „/OPT:ICF“). In diesem Artikel behandele ich nur die Inlineersetzung von Funktionen und COMDAT-Optimierungen. Eine vollständige Liste der LTCG-Optimierungen finden Sie in der Dokumentation. Beachten Sie, dass der Linker LTCG für systemeigene Objektdateien, gemischte systemeigene/verwaltete Objektdateien, reine verwaltete Objektdateien, sicher verwaltete Objektdateien und sichere .netmodules ausführen kann.

Ich werde ein Programm erstellen, das aus zwei Quelldateien („source1.c“ und „source2.c“) und einer Headerdatei („source2.h“) besteht. Die Dateien „source1.c“ und „source2.c“ werden in Abbildung 1 bzw. Abbildung 2 gezeigt. Die Headerdatei, die die Prototypen aller Funktionen in „source2.c“ enthält, ist recht einfach. Ich zeige sie daher hier nicht.

Abbildung 1 – Die Datei „source1.c“

#include <stdio.h> // scanf_s and printf.
#include "Source2.h"
int square(int x) { return x*x; }
main() {
  int n = 5, m;
  scanf_s("%d", &m);
  printf("The square of %d is %d.", n, square(n));
  printf("The square of %d is %d.", m, square(m));
  printf("The cube of %d is %d.", n, cube(n));
  printf("The sum of %d is %d.", n, sum(n));
  printf("The sum of cubes of %d is %d.", n, sumOfCubes(n));
  printf("The %dth prime number is %d.", n, getPrime(n));
}

Abbildung 2 – Die Datei „source2.c“

#include <math.h> // sqrt.
#include <stdbool.h> // bool, true and false.
#include "Source2.h"
int cube(int x) { return x*x*x; }
int sum(int x) {
  int result = 0;
  for (int i = 1; i <= x; ++i) result += i;
  return result;
}
int sumOfCubes(int x) {
  int result = 0;
  for (int i = 1; i <= x; ++i) result += cube(i);
  return result;
}
static
bool isPrime(int x) {
  for (int i = 2; i <= (int)sqrt(x); ++i) {
    if (x % i == 0) return false;
  }
  return true;
}
int getPrime(int x) {
  int count = 0;
  int candidate = 2;
  while (count != x) {
    if (isPrime(candidate))
      ++count;
  }
  return candidate;
}

Die Datei „source1.c“ enthält zwei Funktionen: die square-Funktion, die einen Integerwert annimmt und sein Quadrat zurückgibt, und die Hauptfunktion des Programms. Die Hauptfunktion ruft die square-Funktion und alle Funktionen von „source2.c“ mit Ausnahme von „isPrime“ auf. Die Datei „source2.c“ enthält fünf Funktionen: Die cube-Funktion gibt den Cube eines angegebenen Integerwerts zurück, die sum-Funktion gibt die Summe aller Integerwerte von 1 bis zu einem angegebenen Integerwert zurück, die sumOfcubes-Funktion gibt die Summe der Cubes aller Integerwerte von 1 bis zu einem angegebenen Integerwert zurück, die isPrime-Funktion ermittelt, ob ein angegebener Integerwert eine Primzahl ist, und die getPrime-Funktion gibt die xte Primzahl zurück. Ich habe die Fehlerprüfung nicht berücksichtigt, weil sie in diesem Artikel nicht von Interesse ist.

Der Code ist einfach und gleichzeitig nützlich. Es sind mehrere Funktionen vorhanden, die einfache Berechnungen ausführen, da diese für Schleifen in einigen Fällen erforderlich sind. Die getPrime-Funktion ist die komplexeste Funktion, weil sie eine while-Schleife enthält und in dieser Schleife die isPrime-Funktion aufruft, die ebenfalls eine Schleife enthält. Ich verwende diesen Code, um eine der wichtigsten Compileroptimierungen zu zeigen, nämlich die Inlineersetzung von Funktionen, sowie einige weitere Optimierungen.

Ich habe den Code unter drei verschiedenen Konfigurationen erstellt und die Ergebnisse untersucht, um zu ermitteln, wie er vom Compiler transformiert wurde. Wenn Sie dieses Beispiel nachvollziehen möchten, benötigen Sie die Assemblerausgabedatei (wird mit dem Compilerschalter „/FA[s]“ generiert), um den sich ergebenden Assemblycode untersuchen zu können, sowie die Zuordnungsdatei (wird mit dem Linkerschalter „/MAP“ generiert), um die COMDAT-Optimierungen zu ermitteln, die ausgeführt wurden (der Linker kann dies auch aufzeichnen, wenn Sie die Schalter „/verbose:icf“ und „/verbose:ref“ verwenden). Stellen Sie also sicher, dass diese Schalter in allen folgenden Konfigurationen angegeben werden, die ich beschreibe. Außerdem verwenden ich den C-Compiler („/TC“), damit der generierte Code einfacher untersucht werden kann. Alle diesbezüglichen Aussagen gelten jedoch auch für C++-Code.

Die Debugkonfiguration

Die Debugkonfiguration wird hauptsächlich verwendet, weil alle Back-End-Optimierungen deaktiviert sind, wenn Sie den Compilerschalter „/Od“ angeben, ohne den Schalter „/GL“ anzugeben. Wenn Sie den Code mit dieser Konfiguration erstellen, enthalten die sich ergebenden Objektdateien Binärcode, der genau dem Quellcode entspricht. Sie können die sich ergebenden Assemblerausgabedateien und die Zuordnungsdatei untersuchen, um diese Aussage zu bestätigen. Diese Konfiguration entspricht der Debugkonfiguration von Visual Studio.

Die Releasekonfiguration „Codegenerierung zur Kompilierungszeit“

Diese Konfiguration ähnelt der Releasekonfiguration, in der Optimierungen (durch Angeben der Compilerschalter „/O1“, „/O2“ oder „/Ox“) ohne Angabe des Compilerschalters /GL aktiviert sind. In dieser Konfiguration enthalten die sich ergebenden Objektdateien optimierten Binärcode. Es werden jedoch keine Optimierungen auf der Ebene des Gesamtprogramms ausgeführt.

Wenn Sie die generierte Assemblyauflistungsdatei von „source1.c“ untersuchen, werden Sie feststellen, dass zwei Optimierungen ausgeführt wurden. Der erste Aufruf der square-Funktion („square(n)“) in Abbildung 1 wurde durch Auswerten der Berechnung zur Kompilierungszeit vollständig entfernt. Warum ist dies so? Der Compiler hat ermittelt, dass die square-Funktion klein ist und daher inline ersetzt werden sollte. Nach der Inlineersetzung hat der Compiler ermittelt, dass der Wert der lokalen Variablen „n“ bekannt ist und sich zwischen der Zuweisungsanweisung und dem Funktionsaufruf nicht ändert. Daraus wurde geschlossen, dass es sicher ist, die Multiplikation auszuführen und das Ergebnis (25) zu ersetzen. In der zweiten Optimierung wurde der zweite Aufruf der square-Funktion („square(m)“) ebenfalls inline ersetzt. Da der Wert von „m“ zur Kompilierungszeit jedoch nicht bekannt ist, kann der Compiler die Berechnung nicht auswerten. Daher wird der eigentliche Code ausgegeben.

Jetzt untersuche die Assemblyauflistungsdatei von „source2.c“, die wesentlich interessanter ist. Der Aufruf der cube-Funktion in „sumOfCubes“ wurde inline ersetzt. Dieser Vorgang versetzt seinerseits den Compiler in die Lage, erhebliche Optimierungen für die Schleife auszuführen (dies wird im Abschnitt „Schleifenoptimierungen“ beschrieben). Außerdem wird der SSE2-Befehlssatz in der isPrime-Funktion verwendet, um die Konvertierung aus „int“ in „double“ auszuführen, wenn die sqrt-Funktion aufgerufen wird, und auch die Konvertierung aus „double“ in „int“ auszuführen, wenn die Rückgabe von „sqrt“ erfolgt. „sqrt“ wird nur ein Mal aufgerufen, bevor die Schleife gestartet wird. Beachten Sie, dass kein Schalter „/arch“ für den Compiler angegeben wird. Der x86-Compiler verwendet standardmäßig SSE2. Die meisten bereitgestellten x86-Prozessoren sowie alle x86-64-Prozessoren unterstützen SSE2.

Die Releasekonfiguration „Codegenerierung zur Verknüpfungszeit“

Die LTCG-Releasekonfiguration ist mit der Releasekonfiguration in Visual Studio identisch. In dieser Konfiguration sind Optimierungen aktiviert, und der Compilerschalter „/GL“ wird angegeben. Dieser Schalter wird implizit angegeben, wenn „/O1“ oder „/O2“ verwendet wird. Er informiert den Compiler, CIL-Objektdateien anstelle von Assemblyobjektdateien auszugeben. Auf diese Weise ruft der Linker das Back-End des Compilers auf, um WPO wie zuvor beschrieben auszuführen. Im Folgenden beschreibe ich verschiedene WPO-Optimierungen, um die gewaltigen Vorteile von LTCG aufzuzeigen. Die Assemblycodeauflistungen, die mit dieser Konfiguration generiert werden, sind online verfügbar.

Solange die Inlineersetzung von Funktionen aktiviert ist („/Ob“ – diese Option wird immer aktiviert, wenn Sie Optimierungen anfordern), ermöglicht der Schalter „/GL“ dem Compiler die Inlineersetzung von Funktionen, die in anderen Übersetzungseinheiten definiert sind. Dies erfolgt unabhängig davon, ob der Schalter „/Gy“ (weiter unten beschrieben) angegeben wird. Der Linkerschalter „/LTCG“ ist optional und stellt nur Anleitungen für den Linker zur Verfügung.

Wenn Sie die Assemblyauflistungsdatei von „source1.c“ untersuchen, werden Sie feststellen, dass alle Funktionsaufrufe mit Ausnahme von „scanf_s“ inline ersetzt wurden. Aus diesem Grund konnte der Compiler die Berechnungen für „cube“, „sum“ und „sumOfCubes“ ausführen. Nur die Funktion „isPrime“ wurde nicht inline ersetzt. Wenn diese jedoch manuell in „getPrime“ inline ersetzt wurde, würde der Compiler „getPrime“ in der Hauptfunktion noch immer inline ersetzen.

Wie Sie erkennen können, ist die Inlineersetzung von Funktionen nicht nur wichtig, weil ein Funktionsaufruf entfernt und somit optimiert wird, sondern auch, weil sie den Compiler in die Lage versetzt, als Ergebnis zahlreiche weitere Optimierungen auszuführen. Durch die Inlineersetzung von Funktionen wird normalerweise die Leistung verbessert. Allerdings nimmt auch die Codegröße zu. Wenn diese Optimierung zu häufig verwendet wird, kommt es zu einem Phänomen, das als „Codeaufblähung“ bezeichnet wird. An jeder Aufrufposition führt der Compiler eine Kosten-Nutzen-Analyse aus und entscheidet dann, ob eine Inlineersetzung der Funktion erfolgen soll.

Aufgrund der Wichtigkeit der Inlineersetzung von Funktionen stellt der Visual C++-Compiler wesentlich umfangreichere Unterstützung als die Standardvorgaben bezüglich der Inlinesteuerung zur Verfügung. Sie können den Compiler anweisen, niemals eine Inlineersetzung eines Bereichs von Funktionen auszuführen, indem Sie das Pragma „auto_inline“ verwenden. Sie können den Compiler anweisen, niemals eine Inlineersetzung einer bestimmten Funktion auszuführen, indem Sie die Markierung „__declspec(noinline)“ verwenden. Sie können eine Funktion mit dem Schlüselwort „inline“ markieren, um einen Hinweis für den Compiler bereitzustellen, dass er die Inlineersetzung einer Funktion ausführen soll (der Compiler kann diesen Hinweis jedoch ignorieren, wenn die Inlineersetzung unter dem Strich einen Verlust darstellt). Das Schlüsselwort „inline“ ist seit der ersten Version von C++ verfügbar – es wurde in C99 eingeführt. Sie können das Microsoft-spezifische Schlüsselwort „__inline“ in C- und C++-Code verwenden. Dies ist sinnvoll, wenn Sie eine alte Version von C verwenden, die dieses Schlüsselwort nicht unterstützt. Außerdem können Sie das Schlüsselwort „__forceinline“ (C und C++) verwenden, um den Compiler zu zwingen, immer Inlineersetzung von Funktionen auszuführen, wenn dies möglich ist. Schließlich können Sie den Compiler auch noch anweisen, für eine rekursive Funktion einen Anamorphismus („unfold“) bis zu einer bestimmten oder einer unbestimmten Tiefe auszuführen, indem Sie eine Inlineersetzung mithilfe des Pragmas „inline_recursion“ verwenden. Beachten Sie, dass der Compiler zurzeit keine Funktionen bereitstellt, die das Steuern der Inlineersetzung an der Aufrufposition anstatt bei der Funktionsdefinition ermöglichen.

Der Schalter „/Ob0“ deaktiviert Inlineersetzung vollständig und wird standardmäßig wirksam. Sie sollten diesen Schalter beim Debuggen verwenden (er wird in der Visual Studio-Debugkonfiguration automatisch angegeben). Der Schalter „/Ob1“ weist den Compiler an, nur Funktionen für die Inlineersetzung in Betracht zu ziehen, die mit „inline“, „__inline“ oder „__forceinline“ markiert sind. Der Schalter „/Ob2“, der wirksam wird, wenn „/O[1|2|x]“ angegeben wird, weist den Compiler an, beliebige Funktionen für die Inlineersetzung in Betracht zu ziehen. Meiner Meinung nach besteht der einzige Grund für die Verwendung der Schlüsselwörter „inline“ oder „__inline“ im Steuern der Inlineersetzung mit dem Schalter „/Ob1“.

Unter bestimmten Umständen kann der Compiler keine Inlineersetzung einer Funktion vornehmen. Dies ist beispielsweise beim virtuellen Aufrufen einer virtuellen Funktion der Fall. Für die Funktion kann keine Inlineersetzung ausgeführt werden, weil der Compiler ggf. nicht weiß, welche Funktion aufgerufen wird. Ein weiteres Beispiel ist der Aufruf einer Funktion über einen Zeiger auf die Funktion anstatt über ihren Namen. Sie sollten solche Umstände nach Möglichkeit vermeiden, um Inlineersetzung zu ermöglichen. Eine vollständige Liste solcher Bedingungen finden Sie in der MSDN-Dokumentation.

Inlineersetzung für Funktionen ist nicht die einzige Optimierung, die effektiver ist, wenn sie auf der Ebene des gesamten Programms angewendet wird. In der Tat werden die meisten Optimierungen auf dieser Ebene effektiver. Im weiteren Verlauf werde ich in diesem Abschnitt eine besondere Klasse solcher Optimierungen vorstellen, die als COMDAT-Optimierungen bezeichnet werden.

Beim Kompilieren einer Übersetzungseinheit wird standardmäßig der gesamte Code in einem Abschnitt in der sich ergebenden Objektdatei gespeichert. Der Linker arbeitet auf der Abschnittsebene: Dies bedeutet, dass er Abschnitte entfernen, kombinieren und neu anordnen kann. Dies verhindert, dass der Linker drei Optimierungen ausführen kann, die die Größe der ausführbaren Datei erheblich (zweistelliger Prozentsatz) verringern und ihre Leistung steigern können. Die erste Optimierung besteht im Beseitigen von Funktionen, auf die nicht verwiesen wird, und globalen Variablen. Die zweite Optimierung besteht im Falten identischer Funktionen und konstanter globaler Variablen. Die dritte Optimierung besteht im Neuanordnen von Funktionen und globalen Variablen, damit die Funktionen, die unter den gleichen Ausführungspfad fallen, und die Variablen, auf die zusammen zugegriffen wird, im Arbeitsspeicher physisch näher beeinander liegen, um die Lokalität zu erhöhen.

Damit diese Linkeroptimierungen aktiviert werden, können Sie den Compiler anweisen, Funktionen und Variablen in separaten Abschnitten zu platzieren, indem Sie den Compilerschalter „/Gy“ (Verknüpfung auf Funktionsebene) bzw. „/Gw“ (globale Datenoptimierung) angeben. Solche Abschnitte werden als COMDATs bezeichnet. Sie können auch eine bestimmte globale Datenvariable mit „__declspec(selectany)“ markieren, um den Compiler anzuweisen, die Variable in einem COMDAT zu platzieren. Wenn Sie dann die Linkeroption „/OPT:REF“ angeben, entfernt der Linker Funktionen und globale Variablen, auf die nicht verwiesen wird. Wenn Sie auch die Linkeroption „/OPT:REF“ angeben, faltet der Linker identische Funktionen und globale konstante Variablen. (ICF ist die Abkürzung für „Identical COMDAT Folding“.) Mit dem Linkerschalter „/ORDER“ können Sie den Linker anweisen, COMDATs im sich ergebenden Image in einer bestimmten Reihenfolge zu platzieren. Beachten Sie, dass alle diese Optimierungen Linkeroptimierungen sind und nicht den Compilerschalter „/GL“ erfordern. Die Schalter „/OPT:REF“ und „/OPT:ICF“ sollten aus offensichtlichen Gründen beim Debuggen deaktiviert werden.

LTCG sollte nach Möglichkeit immer verwendet werden. LTCG sollte nur dann nicht verwendet werden, wenn Sie die sich ergebenden Objekt- und Bibliothekdateien verteilen möchten. Denken Sie daran, dass diese Dateien CIL-Code und keinen Assemblycode enthalten. CIL-Code kann nur von einem Compiler/Linker verarbeitet werden, der die gleiche Version wie der Produktionscompiler/-linker aufweist. Dies kann die Verwendbarkeit der Objektdateien erheblich einschränken, weil Entwickler über die gleiche Version des Compilers verfügen müssen, um diese Dateien verwenden zu können. In diesem Fall sollten Sie stattdessen Codegenerierung zur Kompilierzeit verwenden, wenn Sie nicht bereit sind, die Objektdateien für jede Compilerversion zu verteilen. Zusätzlich zur eingeschränkten Verwendbarkeit sind diese Objektdateien um ein Vielfaches größer als die entsprechenden Assemblerobjektdateien. Denken Sie jedoch auch an den großen Vorteil von CIL-Objektdateien, der in der Aktivierung von WPO liegt.

Schleifenoptimierungen

Der Visual C++-Compiler unterstützt mehrere Schleifenoptimierungen. Ich werde jedoch nur drei von ihnen vorstellen: Loop Unrolling, automatische Vektorisierung und schleifeninvariante Codeverschiebung. Wenn Sie den Code in Abbildung 1 so ändern, dass „m“ anstelle von „n“ an „sumOfCubes“ übergeben wird, kann der Compiler den Wert des Parameters nicht ermitteln und muss daher die Funktion kompilieren, um Argumente zu verarbeiten. Die sich ergebende Funktion ist hochgradig optimiert, und sie ist recht groß. Der Compiler führt daher keine Inlineersetzung aus.

Wenn der Code mit dem Schalter „/O1“ kompiliert wird, ergibt sich Assemblycode, der hinsichtlich der Größe optimiert ist. In diesem Fall werden keine Optimierungen für die sumOfCubes-Funktion ausgeführt. Wenn der Code mit dem Schalter „/O2“ kompiliert wird, ergibt sich Code, der hinsichtlich der Geschwindigkeit optimiert ist. Der Code wird erheblich größer, jedoch auch erheblich schneller sein, weil die Schleife innerhalb von „sumOfCubes“ entrollt und vektorisiert wurde. Sie müssen unbedingt verstehen, dass ohne Inlineersetzung der cube-Funktion keine Vektorisierung möglich wäre. Außerdem wäre Loop Unrolling ohne Inlineersetzung nicht so effektiv. Eine vereinfachte grafische Darstellung des sich ergebenden Assemblycodes wird in Abbildung 3 gezeigt. Das Flussdiagramm ist für x86- und x86-64-Architekturen identisch.

Steuerungsflussdiagramm für „sumOfCubes“
Abbildung 3 – Steuerungsflussdiagramm für „sumOfCubes“

In Abbildung 3 ist die grüne Raute der Eintrittspunkt, und die roten Rechtecke stellen die Austrittspunkte dar. Die blauen Rauten stellen Bedingungen dar, die als Teil der sumOfCubes-Funktion zur Laufzeit ausgeführt werden. Wenn SSE4 vom Prozessor unterstützt wird und „x“ größer oder gleich acht ist, werden SSE4-Anweisungen zum gleichzeitigen Ausführen von vier Multiplikationen verwendet. Der Prozess der gleichzeitigen Ausführung des gleichen Vorgangs für mehrere Werte wird als Vektorisierung bezeichnet. Außerdem entrollt der Compiler die Schleife zwei Mal. Dies bedeutet, dass der Textkörper der Schleife in jeder Iteration zwei Mal wiederholt wird. In der Kombination ergibt sich, dass acht Multiplikationen für jede Iteration ausgeführt werden. Wenn „x“ kleiner als acht wird, werden traditionelle Anweisungen verwendet, um die restlichen Berechnungen auszuführen. Beachten Sie, dass der Compiler drei Austrittspunkte ausgegeben hat, die separate Epiloge in der Funktion anstelle nur eines Epilogs enthalten. Auf diese Weise wird die Anzahl der Sprünge verringert.

Als „Loop Unrolling“ wird der Vorgang bezeichnet, bei dem der Schleifenkörper in der Schleife wiederholt wird, damit mehrere Iterationen der Schleife in einer Iteration der nicht entrollten Schleife ausgeführt werden. Der Grund, warum dies die Leistung verbessert, besteht darin, dass die Schleifensteuerungsanweisungen weniger häufig ausgeführt werden. Wichtiger ist vielleicht noch, dass der Compiler ggf. in der Lage ist, zahlreiche weitere Optimierungen auszuführen, z. B. Vektorisierung. Der Nachteil von Loop Unrolling besteht in der Zunahme der Codegröße und des Registerdrucks. Abhängig vom Schleifenkörper wird die Leistung jedoch ggf. um einen zweistelligen Prozentsatz verbessert.

Im Gegensatz zu x86-Prozessoren unterstützen alle x86-64-Prozessoren SSE2. Außerdem können Sie die AVX/AVX2-Befehlssätze der aktuellen x86-64-Mikroarchitekturen von Intel und AMD nutzen, indem Sie den Schalter „/arch“ angeben. Wenn „/arch:AVX2“ angegeben wird, kann der Compiler außerdem auch die FMA- und BMI-Befehlssätze verwenden.

Zurzeit ermöglicht Ihnen der Visual C++-Compiler keine Steuerung von Loop Unrolling. Sie können diese Technik jedoch emulieren, indem Sie Vorlagen zusammen mit dem Schlüsselwort „__ forceinline“ verwenden. Sie können die automatische Vektorisierung für eine bestimmte Schleife mithilfe des Schleifenpragmas ohne die Option „no_vector“ deaktivieren.

Wenn Sie den generierten Assemblycode untersuchen, bemerken Sie beim näheren Hinsehen, dass der Code noch ein wenig mehr optimiert werden kann. Der Compiler hat jedoch bereits hervorragende Arbeit geleistet und wendet keine weitere Zeit für die Analyse des Codes und das Anwenden kleinerer Optimierungen auf.

„someOfCubes“ ist nicht die einzige Funktion, deren Schleife entrollt wurde. Wenn Sie den Code so ändern, dass „m“ anstelle von „n“ an die sum-Funktion übergeben wird, kann der Compiler die Funktion nicht auswerten und muss daher ihren Code ausgeben. In diesem Fall wird die Schleife zwei Mal entrollt.

Die letzte Optimierung, die ich behandele, ist die schleifeninvariante Codeverschiebung. Betrachten Sie das folgende Codefragment:

int sum(int x) {
  int result = 0;
  int count = 0;
  for (int i = 1; i <= x; ++i) {
    ++count;
    result += i;
  }
  printf("%d", count);
  return result;
}

Die einzige Änderung besteht hier darin, dass eine zusätzliche Variable vorhanden ist, die in jeder Iteration inkrementiert und dann ausgegeben wird. Es ist unschwer zu erkennen, dass dieser Code durch Verschieben des Inkrements der Variablen „count“ aus der Schleife optimiert werden kann. Dies bedeutet, dass „x“ einfach der Variablen „count“ zugewiesen werden kann. Diese Optimierung wird als „schleifeninvariante Codeverschiebung“ bezeichnet. Der schleifeninvariante Teil gibt klar an, dass diese Technik nur funktioniert, wenn der Code nicht von Ausdrücken im Schleifenheader abhängt.

Die Sache hat den folgenden Haken: Wenn Sie diese Optimierung manuell anwenden, tritt für den sich ergebenden Code unter bestimmten Bedingungen ggf. eine verschlechterte Leistung auf. Können Sie erkennen, warum dies so ist? Überlegen Sie, was geschieht, wenn „x“ kein positiver Wert ist. Die Schleife wird niemals ausgeführt. Dies bedeutet, dass in der nicht optimierten Version die Variable „count“ nicht verarbeitet wird. In der manuell optimierten Version erfolgt jedoch eine unnötige Zuweisung von „x“ zu „count“, die außerhalb der Schleife ausgeführt wird! Wenn „x“ negativ war, enthält „count“ außerdem den falschen Wert. Menschen wie auch Compiler sind anfällig für solche Fallstricke. Glücklicherweise ist der Visual C++-Compiler intelligent genug, um dies zu erkennen, indem die Bedingung der Schleife vor der Zuweisung ausgegeben wird. Dies führt zu einer verbesserten Leistung für alle Werte von „x“.

Zusammenfassend lässt sich sagen, dass Sie das Ausführen manueller Transformationen Ihres Codes vermeiden sollten, nur um ihn schneller aussehen zu lassen, wenn Sie kein Experte für Compiler bzw. Compileroptimierungen sind. Lassen Sie die Finger davon, und vertrauen Sie dem Compiler bei der Optimierung Ihres Codes.

Steuern von Optimierungen

Die Compilerschalter „/O1“, „/O2“ und „/Ox“ können zum Steuern von Optimierungen für bestimmte Funktionen verwendet werden. Sie können jedoch auch das Optimierungspragma wie im folgenden Beispiel gezeigt einsetzen:

#pragma optimize( "[optimization-list]", {on | off} )

Die Optimierungsliste kann leer sein oder mindestens einen der folgenden Werte enthalten: „g“, „s“, „t“ und „y“. Diese entsprechen den Compilerschaltern „/Og“, „/Os“, „/Ot“ bzw. „/Oy“.

Eine leere Liste mit dem Parameter „off“ bewirkt, dass alle diese Optimierungen unabhängig von den Compilerschaltern deaktiviert werden, die angegeben wurden. Eine leere Liste mit dem Parameter „on“ bewirkt, dass die angegebenen Compilerschalter wirksam werden.

Der Schalter „/Og“ ermöglicht globale Optimierungen. Diese können durch alleiniges Untersuchen der Funktion, die optimiert werden soll (nicht der Funktionen, die sie aufruft), ausgeführt werden. Wenn LTCG aktiviert ist, aktiviert „/Og“ WPO.

Das Optimierungspragma eignet sich gut, wenn Sie verschiedene Funktionen auf unterschiedliche Weise optimieren möchten – einige hinsichtlich der Größe und andere hinsichtlich der Geschwindigkeit. Wenn Sie jedoch wirklich diesen Grad von Kontrolle wünschen, sollten Sie profilgesteuerte Optimierung (Profile-Guided Optimization, PGO) in Betracht ziehen. Dabei wird der Code mithilfe eines Profils optimiert, das Verhaltensinformationen enthält, die während der Ausführung einer instrumentierten Version des Codes aufgezeichnet wurden. Der Compiler verwendet das Profil, um bessere Entscheidungen hinsichtlich der Optimierung des Codes treffen zu können. Visual Studio stellt die erforderlichen Tools zum Anwenden dieser Technik auf systemeigenen und verwalteten Code zur Verfügung.

Optimierungen in .NET

Am .NET-Kompilierungsmodell ist kein Linker beteiligt. Es stehen jedoch ein Quellcodecompiler (C#-Compiler) und ein JIT-Compiler zur Verfügung. Der Quellcodecompiler führt nur kleinere Optimierungen aus. Er führt z. B. keine Inlineersertzung für Funktionen und keine Schleifenoptimierungen aus. Diese Optimierungen werden stattdessen vom JIT-Compiler übernommen. Der JIT-Compiler, der im Lieferumfang aller Versionen von .NET Framework bis zur Version 4.5 enthalten ist, unterstützt keine SIMD-Anweisungen. Der JIT-Compiler, der im Lieferumfang von .NET Framework 4.5.1 oder höher enthalten ist (namens RyuJIT), unterstützt SIMD.

Welche Unterschiede bestehen zwischen RyuJIT und Visual C++ hinsichtlich der Optimierungsfunktionen? Da RyuJIT die Verarbeitung zur Laufzeit ausführt, kann der Compiler Optimierungen ausführen, die in Visual C++ nicht möglich sind. RyuJIT kann zur Laufzeit z. B. ggf. ermitteln, dass die Bedingung einer if-Anweisung in dieser bestimmten Ausführung der Anwendung niemals „true“ ist und daher entfernt und der Code auf dieser Weise optimiert werden kann. RyuJIT kann auch die Funktionen des Prozessors nutzen, mit dem der Compiler ausgeführt wird. Wenn der Prozessor SSE4.1 unterstützt, gibt der JIT-Compiler z. B. nur SSE4.1-Anweisungen für die sumOfcubes-Funktion aus und führt so dazu, dass kompakterer Code generiert wird. Er kann jedoch nicht viel Zeit für die Optimierung des Codes aufwenden, weil sich der für die JIT-Kompilierung anfallende Zeitaufwand negativ auf die Leistung der Anwendung auswirkt. Andererseits kann der Visual C++-Compiler viel mehr Zeit dafür aufwenden, weitere Optimierungsmöglichkeiten zu ermitteln und diese zu nutzen. Eine beeindruckende neue Technologie von Microsoft namens .NET Native ermöglicht Ihnen das Kompilieren verwalteten Codes in eigenständige ausführbare Dateien, die mithilfe des Visual C++-Back-Ends optimiert werden. Zurzeit unterstützt diese Technologie nur Windows Store-Apps.

Die Möglichkeiten, verwaltete Codeoptimierungen zu steuern, ist zurzeit eingeschränkt. Die C#- und Visual Basic-Compiler bieten nur die Möglichkeit, Optimierungen mithilfe des Schalters „/optimize“ zu aktivieren bzw. zu deaktivieren. Zum Steuern von JIT-Optimiierungen können Sie das Attribut „System.Runtime.Compiler­Services.MethodImpl“ auf eine Methode mit einer Option aus „MethodImplOptions“ anwenden. Die Option „NoOptimization“ deaktiviert Optimierungen, die Option „NoInlining“ verhindert, dass für die Methode Inlineersetzung vorgenommen wird, und die Option „AggressiveInlining“ (.NET 4.5) stellt dem JIT-Compiler eine Empfehlung (mehr als nur einen Hinweis) zur Inlineersetzung der Methode zur Verfügung.

Zusammenfassung

Alle in diesem Artikel behandelten Optimierungstechniken können die Leistung Ihres Codes erheblich um einen zweistelligen Prozentsatz verbessern, und alle werden vom Visual C++-Compiler unterstützt. Diese Techniken sind wichtig, da bei ihrer Anwendung der Compiler in die Lage versetzt wird, weitere Optimierungen auszuführen. Dieser Artikel stellt unter keinen Umständen eine umfassende Untersuchung der Compileroptimierungen dar, die von Visual C++ ausgeführt werden. Ich hoffe jedoch, dass Sie einen Eindruck von den Fähigkeiten des Compilers erhalten haben. Visual C++ kann noch mehr – viel mehr –, also freuen Sie sich auf Teil 2.


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 der CLR und CRT. 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 von Microsoft für die Durchsicht dieses Artikels: Jim Hogg