Was jeder Programmierer über Compiler-Optimierung wissen sollte

Ursprünglicher Autor: Hadi Brais
  • Übersetzung
Hochrangige Programmiersprachen enthalten viele abstrakte Programmierkonstrukte wie Funktionen, bedingte Anweisungen und Schleifen - sie machen uns überraschend produktiv. Einer der Nachteile beim Schreiben von Code in einer höheren Programmiersprache ist jedoch die potenzielle signifikante Verringerung der Programmgeschwindigkeit. Daher versuchen Compiler, den Code automatisch zu optimieren und die Arbeitsgeschwindigkeit zu erhöhen. Heutzutage ist die Optimierungslogik sehr komplex geworden: Compiler transformieren Schleifen, bedingte Ausdrücke und rekursive Funktionen; ganze Codeblöcke löschen. Sie optimieren den Code für die Prozessorarchitektur, um ihn wirklich schnell und kompakt zu gestalten. Und das ist sehr cool, da es besser ist, sich auf das Schreiben von lesbarem Code zu konzentrieren, als manuelle Optimierungen vorzunehmen, die schwer zu verstehen und zu warten sind. Außerdem, Manuelle Optimierungen können den Compiler daran hindern, zusätzliche und effizientere automatische Optimierungen durchzuführen. Anstatt Optimierungen von Hand zu schreiben, sollten Sie sich auf Architekturdesign und effiziente Algorithmen konzentrieren, einschließlich Parallelität und Verwendung von Bibliotheksfunktionen.

Dieser Artikel befasst sich mit Visual C ++ - Compileroptimierungen. Ich werde die wichtigsten Optimierungstechniken und -lösungen diskutieren, die der Compiler anwenden muss, um sie korrekt anzuwenden. Mein Ziel ist es nicht, Ihnen zu erklären, wie Sie den Code manuell optimieren, sondern zu zeigen, warum Sie dem Compiler vertrauen sollten, um Ihren Code selbst zu optimieren. Bei diesem Artikel handelt es sich keineswegs um eine Beschreibung aller Optimierungen, die der Visual C ++ - Compiler vornimmt. Er zeigt nur die wirklich wichtigen, die Sie kennen sollten. Es gibt andere wichtige Optimierungen, die der Compiler nicht ausführen kann. Ersetzen Sie beispielsweise einen ineffizienten Algorithmus durch einen effektiven oder ändern Sie die Ausrichtung der Datenstruktur. Wir werden solche Optimierungen in diesem Artikel nicht diskutieren.

Definieren von Compiler-Optimierungen

Bei der Optimierung wird ein Codeteil in ein anderes umgewandelt, das funktional dem Original entspricht, um eine oder mehrere seiner Eigenschaften zu verbessern, wobei die Geschwindigkeit und Größe des Codes am wichtigsten sind. Weitere Merkmale sind der Energieverbrauch pro Codeausführung und die Kompilierungszeit (sowie die JIT-Kompilierungszeit, wenn der resultierende Code JIT verwendet).

Compiler werden ständig verbessert, ihre Ansätze werden verbessert. Trotz der Tatsache, dass sie nicht perfekt sind, ist es oft immer noch der richtige Ansatz, Optimierungen auf niedriger Ebene dem Compiler zu überlassen, als sie manuell auszuführen.

Es gibt vier Möglichkeiten, wie der Compiler Optimierungen effizienter durchführen kann:
  1. Schreiben Sie lesbaren Code, der leicht zu pflegen ist. Stellen Sie sich die verschiedenen OOP-Funktionen von Visual C ++ nicht als den schlimmsten Feind der Leistung vor. Die neueste Version von Visual C ++ wird in der Lage sein, den OOP-Aufwand auf ein Minimum zu reduzieren und sie manchmal sogar vollständig zu beseitigen.
  2. Verwenden Sie Compiler-Direktiven. Weisen Sie den Compiler beispielsweise an, eine Funktionsaufrufkonvention zu verwenden, die schneller als die Standardkonvention ist.
  3. Verwenden Sie die im Compiler integrierten Funktionen. Dies sind spezielle Funktionen, deren Implementierung automatisch vom Compiler bereitgestellt wird. Denken Sie daran, dass der Compiler über umfassende Kenntnisse verfügt, wie eine Sequenz von Maschinenanweisungen effizient angeordnet werden kann, damit der Code auf der angegebenen Softwarearchitektur so schnell wie möglich ausgeführt wird. Derzeit unterstützt Microsoft .NET Framework keine integrierten Funktionen, sodass verwaltete Sprachen diese nicht verwenden können. Visual C ++ bietet jedoch umfangreiche Unterstützung für solche Funktionen. Vergessen Sie jedoch nicht, dass ihre Verwendung die Leistung des Codes zwar verbessert, aber die Lesbarkeit und Portabilität beeinträchtigt.
  4. Verwenden Sie die profilgesteuerte Optimierung (PGO). Dank dieser Technologie weiß der Compiler mehr darüber, wie sich der Code während des Betriebs verhält, und optimiert ihn entsprechend.


Der Zweck dieses Artikels ist es, Ihnen zu zeigen, warum Sie dem Compiler vertrauen können, Optimierungen durchzuführen, die für ineffizienten, aber lesbaren Code gelten (erste Methode). Ich werde auch einen kurzen Überblick über profilgesteuerte Optimierungen geben und einige Compiler-Direktiven erwähnen, mit denen Sie einen Teil Ihres Quellcodes verbessern können.

Es gibt viele Techniken für Compiler-Optimierungen, die von einfachen Transformationen wie Faltungskonstanten bis zu komplexen wie Befehlsplanung reichen. In diesem Artikel beschränken wir uns auf die wichtigsten Optimierungen, die die Leistung Ihres Codes erheblich verbessern (um eine zweistellige Prozentzahl) und seine Größe durch Ersetzen von Funktionen (Inlining von Funktionen), COMDAT-Optimierungen und Schleifenoptimierungen verringern können. Ich werde die ersten beiden Ansätze im nächsten Abschnitt diskutieren und dann zeigen, wie Sie die Leistung von Optimierungen in Visual C ++ steuern können. Abschließend werde ich kurz auf die Optimierungen eingehen, die in .NET Framework verwendet werden. Im gesamten Artikel werde ich Visual Studio 2013 für alle Beispiele verwenden.

Link-Time-Code-Generierung

Die Link-Time Code Generation (LTCG) -Codegenerierung ist eine Technik zum Durchführen von Optimierungen über das gesamte Programm (Gesamtprogrammoptimierungen, WPO) für C / C ++ - Code. Der C / C ++ - Compiler verarbeitet jede Quellcodedatei einzeln und gibt die entsprechende Objektdatei aus. Mit anderen Worten, der Compiler kann nur eine einzelne Datei optimieren, anstatt das gesamte Programm zu optimieren. Einige wichtige Optimierungen können jedoch nur für das gesamte Programm gelten. Sie können diese Optimierungen nur beim Verknüpfen und nicht beim Kompilieren verwenden, da der Linker das Programm vollständig versteht.

Wenn LTCG aktiviert ist (Flag /GL), cl.exeruft der Compiler-Treiber ( ) nur das Front-End ( c1.dlloder c1xx.dll) auf und verschiebt das Back-End (c2.dll) bis zur Verlinkung. Die resultierenden Objektdateien enthalten C Intermediate Language (CIL), keinen Maschinencode. Dann wird der Linker ( link.exe) aufgerufen. Er sieht, dass die Objektdateien CIL-Code enthalten, und ruft das Back-End auf, das wiederum WPO ausführt und binäre Objektdateien generiert, damit der Linker sie miteinander verbinden und eine ausführbare Datei bilden kann.

Das Front-End führt auch einige Optimierungen durch (z. B. Faltkonstanten), unabhängig davon, ob Optimierungen aktiviert oder deaktiviert sind. Alle wichtigen Optimierungen werden jedoch vom Backend durchgeführt und können mit Hilfe von Kompilierungsschlüsseln gesteuert werden.

Mit LTCG kann das Back-End viele Optimierungen aggressiv durchführen (mithilfe von Compilerschlüsseln /GLzusammen mit /O1oder /O2und /Gwsowie Linkerschlüsseln)/OPT:REFund /OPT:ICF). In diesem Artikel werde ich nur Inlining- und COMDAT-Optimierungen behandeln. Eine vollständige Liste der LTCG-Optimierungen finden Sie in der Dokumentation. Es ist nützlich zu wissen, dass der Linker LTCG für native, nativ verwaltete und rein verwaltete Objektdateien sowie für sichere verwaltete Objektdateien und safe.net-Module ausführen kann.

Ich werde mit einem Programm aus zwei Quellcode-Dateien ( source1.cund source2.c) und einer Header-Datei ( source2.h) arbeiten. Dateien source1.cund source2.cgezeigt in der Liste unten, und die Header - Datei Prototypen aller Funktionen enthält source2.c, so einfach , dass es dazu führen , ich weiß nicht.

// source1.c
#include  // 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));
}

// source2.c
#include  // sqrt.
#include  // 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.centhält zwei Funktionen: eine Funktion square, die das Quadrat einer ganzen Zahl berechnet, und die Hauptfunktion des Programms main. Die Hauptfunktion ruft die Quadratfunktion und alle Funktionen source2.caußer auf isPrime. Die Datei source2.centhält 5 Funktionen: cubeErhöhen einer Ganzzahl auf die dritte Potenz, sumBerechnen der Summe von Ganzzahlen von 1 zu einer bestimmten Zahl, sumOfCubesBerechnen der Summe von Ganzzahlwürfeln von 1 zu einer bestimmten Zahl, isPrimeÜberprüfen der Einfachheit der Zahl, getPrimeErhalten einer Primzahl mit einer bestimmten Zahl. Ich habe die Fehlerbehandlung übersprungen, da sie in diesem Artikel nicht von Interesse ist.

Der Code ist sehr einfach, aber nützlich. Wir haben mehrere Funktionen, die einfache Berechnungen durchführen, einige von ihnen enthalten Schleifen. FunktiongetPrimeist die komplexeste, da sie eine Schleife enthält while, in der sie eine Funktion aufruft isPrime, die auch eine Schleife enthält. Ich werde diesen Code verwenden, um eine der wichtigen Funktionen zu demonstrieren, die Compiler-Optimierungen und einige zusätzliche Optimierungen beinhalten.

Betrachten Sie das Ergebnis des Compilers unter drei verschiedenen Konfigurationen. Wenn Sie sich selbst mit dem Beispiel befassen, benötigen Sie eine Assembler-Ausgabedatei (die mit dem Compiler-Schlüssel abgerufen wird /FA[s]) und eine Map-Datei (die mit dem Linker-Schlüssel abgerufen wird /MAP), um die durchgeführten COMDAT-Optimierungen zu untersuchen (der Linker meldet sie, wenn Sie die Schlüssel /verbose:icfund aktivieren /verbose:ref). . Stellen Sie sicher, dass alle Schlüssel korrekt sind, und lesen Sie den Artikel weiter. Ich werde den C-Compiler verwenden (/TC), damit der generierte Code leichter zu erlernen ist, aber alles, was im Artikel angegeben ist, gilt auch für C ++ - Code.

Konfiguration debuggen

Die Debug-Konfiguration wird hauptsächlich verwendet, weil alle Back-End-Optimierungen deaktiviert sind, wenn Sie einen Schlüssel /Odohne Schlüssel angeben /GL. In dieser Konfiguration enthalten die resultierenden Objektdateien Binärcode, der genau mit dem Quellcode übereinstimmt. Sie können die resultierenden Assembler-Ausgabedateien und die Zuordnungsdatei untersuchen, um dies zu überprüfen. Die Konfiguration entspricht der Debug-Konfiguration in Visual Studio.

Release-Konfiguration für die Generierung von Kompilierungszeitcodes

Diese Konfiguration ist ähnlich der Konfiguration der Veröffentlichung (in dem die angegebene Taste /O1, /O2oder /Ox), aber nicht den Schlüssel enthalten /GL. In dieser Konfiguration enthalten die resultierenden Objektdateien optimierten Binärcode, die Optimierung der Ebene des gesamten Programms wird jedoch nicht durchgeführt.

Wenn Sie sich die generierte Baugruppenlistendatei ansehen source1.c, werden Sie feststellen, dass zwei wichtige Optimierungen durchgeführt wurden. Der erste Aufruf der Funktion square, square(n)wurde von dem berechneten Wert während der Kompilierung ersetzt. Wie ist das passiert? Der Compiler bemerkte, dass der Funktionskörper klein ist, und entschied sich, dessen Inhalt durch einen Aufruf zu ersetzen. Dann stellte der Compiler fest, dass in der Wertberechnung eine lokale Variable vorhanden istnmit einem bekannten Anfangswert, der sich zwischen der Anfangszuweisung und dem Funktionsaufruf nicht geändert hat. So kam er zu dem Schluss, dass es sicher ist, den Wert der Multiplikationsoperation zu berechnen und das Ergebnis zu ersetzen ( 25). Die zweite Anruf - Funktion square, square(m)wurde ebenfalls zainlaynen, t. E. Functions Körper wurde für den Anruf ersetzt. Da der Wert der Variablen m zum Zeitpunkt der Kompilierung unbekannt ist, konnte der Compiler den Wert des Ausdrucks nicht im Voraus berechnen.

Wenden wir uns nun der Assembly-Listing-Datei zu source2.c. Sie ist viel interessanter. Der Funktionsaufruf cubein der Funktion sumOfCubeswar inline. Dies wiederum ermöglichte es dem Compiler, eine Schleifenoptimierung durchzuführen (mehr dazu im Abschnitt "Schleifenoptimierung"). In FunktionisPrimeSSE2 Anweisungen wurden für die Umrechnung verwenden intin doubledem Aufruf sqrtund die Umwandlung von doublein intder Vorbereitung des Ergebnisses sqrt. Tatsächlich meldete sie sqrtsich einmal vor Beginn des Zyklus freiwillig. Beachten Sie, dass der Schlüssel /archdem Compiler mitteilt, dass x86 standardmäßig SSE2 verwendet (die meisten x86-Prozessoren und x86-64-Prozessoren unterstützen SSE2).

Release-Konfiguration für Link-Time-Code-Generierung

Diese Konfiguration ist identisch mit der Release-Konfiguration in Visiual Studio: Optimierungen sind enthalten und der Compilerschlüssel /GList angegeben (Sie können auch explizit /O1oder angeben /O2). Daher weisen wir den Compiler an, Objektdateien mit CIL-Code anstelle von Assembly-Objektdateien zu generieren. Dies bedeutet, dass der Linker das Back-End des Compilers aufruft, um WPO wie oben beschrieben auszuführen. Wir werden nun mehrere WPOs diskutieren, um die enormen Vorteile von LTCG aufzuzeigen. Die generierten Assembly-Code-Listen für diese Konfiguration sind online verfügbar.

Während das Inlining von Funktionen aktiviert ist (ein Schlüssel /Ob, der aktiviert ist, wenn Sie Optimierungen aktiviert haben ), ermöglicht der Schlüssel /GLdem Compiler, Funktionen, die in anderen Dateien definiert sind, unabhängig vom Schlüssel inline zu schreiben /Gy(wir werden es etwas später besprechen). Linker-Schlüssel/LTCGOptional und betrifft nur den Linker.

Wenn Sie sich die Assembly-Auflistungsdatei ansehen source1.c, werden Sie möglicherweise feststellen, dass alle Funktionen außer scanf_sInline aufgerufen wurden. Als Ergebnis kann der Compiler eine Berechnungsfunktionen ausführen cube, sumund sumOfCubes. Nur die Funktion wurde isPrimenicht eingebunden. Wenn Sie jedoch manuell zainlaynili getPrime, ist es immer noch der Compiler durchführen würde inline getPrimein main.

Wie Sie sehen, sind Inlining-Funktionen nicht nur wichtig, weil Funktionsaufrufe optimiert sind, sondern auch, weil der Compiler viele zusätzliche Optimierungen vornehmen kann. Inlining erhöht normalerweise die Leistung, indem die Größe des Codes erhöht wird. Übermäßiger Gebrauch dieser Optimierung führt zu einem Phänomen namens Code Bloat. Daher berechnet der Compiler bei jedem Aufruf der Funktion die Kosten und den Nutzen und entscheidet dann, ob die Funktion eingebunden werden soll.

Aufgrund der Bedeutung von Inlining bietet der Visual C ++ - Compiler eine hervorragende Unterstützung dafür. Sie können den Compiler anweisen, niemals eine Reihe von Funktionen mit einer Direktive inline zu setzen auto_inline. Sie können dem Compiler auch die angegebenen Funktionen oder Methoden mitteilen__declspec(noinline). Sie können die Funktion auch mit einem Schlüsselwort kennzeichnen inlineund dem Compiler raten, Inline auszuführen (obwohl der Compiler diesen Rat möglicherweise ignoriert, wenn er ihn für schlecht hält). Das Schlüsselwort inlineist seit der ersten Version von C ++ verfügbar und wurde in C99 veröffentlicht. Sie können das __inlineMicrosoft-Compiler- Schlüsselwort sowohl für C als auch für C ++ verwenden. Dies ist praktisch, wenn Sie ältere Versionen von C verwenden möchten, die dieses Schlüsselwort nicht unterstützen. Das Schlüsselwort __forceinline(für C und C ++) zwingt den Compiler, die Funktion, wenn möglich, immer inline zu setzen. Und last but not least können Sie den Compiler anweisen, eine rekursive Funktion mit einer angegebenen oder unbestimmten Tiefe bereitzustellen, indem Sie die Direktive als Inlining verwendeninline_recursion. Beachten Sie, dass der Compiler derzeit nicht in der Lage ist, Inlining am Ort des Funktionsaufrufs und nicht am Ort seiner Deklaration zu steuern.

Der Schlüssel /Ob0deaktiviert Inlining vollständig, was beim Debuggen hilfreich ist (dieser Schlüssel funktioniert in der Debug-Konfiguration in Visual Studio). Der Schlüssel /Ob1teilt den Compiler mit, dass als Kandidat für inlining sollte nur die Funktionen in Betracht gezogen werden , die durch eine gekennzeichnet ist inline, __inline, __forceinline. Der Schlüssel /Ob2funktioniert nur, wenn er angegeben wurde, /O[1|2|x]und weist den Compiler an, alle Funktionen für das Inlining zu berücksichtigen. Meiner Meinung nach ist der einzige Grund für die Verwendung von Schlüsselwörtern inlineund __inline- Steuerung für einen Schlüssel inlining /Ob1.

Der Compiler kann eine Funktion nicht immer inline schreiben. Beispiel: Während eines virtuellen Aufrufs einer virtuellen Funktion: Eine Funktion kann nicht eingebunden werden, da der Compiler nicht genau weiß, welche Funktion aufgerufen wird. Ein weiteres Beispiel: Eine Funktion wird über einen Zeiger auf eine Funktion aufgerufen, anstatt über ihren Namen. Sie sollten versuchen, solche Situationen zu vermeiden, damit Inlining möglich ist. Eine vollständige Liste aller dieser Bedingungen finden Sie auf MSDN.

Function Inlining ist nicht die einzige Optimierung, die auf Programmebene angewendet werden kann. Die meisten Optimierungen funktionieren auf dieser Ebene am effektivsten. Im weiteren Verlauf dieses Artikels werde ich eine bestimmte Optimierungsklasse mit der Bezeichnung COMDAT-Optimierungen erläutern.

Standardmäßig wird beim Kompilieren des Moduls der gesamte Code in einem einzigen Abschnitt der resultierenden Objektdatei gespeichert. Der Linker funktioniert auf Abschnittsebene: Er kann Abschnitte löschen, sie verbinden oder neu anordnen. Dies verhindert, dass er drei sehr wichtige Optimierungen vornimmt (zweistellige Prozentzahl), die dazu beitragen, die Größe der ausführbaren Datei zu verringern und die Leistung zu steigern. Der erste entfernt nicht verwendete Funktionen und globale Variablen. Die zweite reduziert identische Funktionen und globale Konstanten. Die dritte ordnet Funktionen und globale Variablen neu an, sodass die Übergänge zwischen physischen Speicherfragmenten zur Laufzeit kürzer sind.

Um diese Linker-Optimierungen zu aktivieren, sollten Sie den Compiler auffordern, Funktionen und Variablen mithilfe von Compilerschlüsseln in separate Abschnitte zu packen/Gy(Verknüpfung der Funktionsebene) und /Gw(Optimierung der globalen Daten). Diese Abschnitte werden als COMDATs bezeichnet. Sie können eine bestimmte globale Variable auch markieren, indem __declspec( selectany)Sie den Compiler anweisen, die Variable in COMDAT zu packen. Mit dem Linker-Schlüssel können /OPT:REFSie außerdem nicht verwendete Funktionen und globale Variablen entfernen. Der Schlüssel /OPT:ICFhilft dabei, identische Funktionen und globale Konstanten zu reduzieren (ICF ist Identical COMDAT Folding). Der Schlüssel /ORDERveranlasst den Linker, COMDATs in den resultierenden Bildern in einer bestimmten Reihenfolge zu platzieren. Beachten Sie, dass für alle Linker-Optimierungen kein Schlüssel erforderlich ist /GL. Die Tasten /OPT:REFund /OPT:ICFsollten während des Debuggens aus offensichtlichen Gründen ausgeschaltet sein.

Sie sollten nach Möglichkeit LTCG verwenden. Der einzige Grund für den Abbruch von LTCG besteht darin, dass Sie die resultierenden Objektdateien und Bibliotheksdateien verteilen möchten. Erinnern Sie sich, dass sie einen CIL-Code anstelle eines Maschinencodes enthalten. CIL-Code kann nur vom Compiler und Linker derselben Version verwendet werden, mit der sie generiert wurden. Dies ist eine erhebliche Einschränkung, da Entwickler dieselbe Version des Compilers verwenden müssen, um Ihre Dateien zu verwenden. In diesem Fall sollten Sie stattdessen die Codegenerierung verwenden, wenn Sie nicht für jede Version des Compilers eine separate Version der Objektdateien verteilen möchten. Zusätzlich zum Versionslimit sind die Objektdateien um ein Vielfaches größer als die entsprechenden Assembler-Objektdateien. Jedoch

Loop-Optimierung

Der Visual C ++ - Compiler unterstützt verschiedene Arten von Schleifenoptimierungen, wir werden jedoch nur drei behandeln: Schleifenentrollung, automatische Vektorisierung und schleifeninvariante Codebewegung. Wenn Sie den Code von source1.cso ändern , dass sumOfCubesm anstelle von n übergeben wird, kann der Compiler den Wert der Parameter nicht berechnen. Sie müssen die Funktion kompilieren, damit sie für jedes Argument verwendet werden kann. Die resultierende Funktion wird gut optimiert, weshalb sie groß sein wird, was bedeutet, dass der Compiler sie nicht einfügt.

Wenn Sie den Code mit dem Schlüssel kopieren /O1, werden keine Optimierungen sumOfCubesangewendet. Schlüsselkompilierung/O2wird Geschwindigkeitsoptimierung geben. In diesem Fall nimmt die Codegröße erheblich zu, sumOfCubesda die Schleife innerhalb der Funktion abgewickelt und vektorisiert wird. Es ist sehr wichtig zu verstehen, dass eine Vektorisierung ohne Inlining der Cube-Funktion nicht möglich ist. Darüber hinaus ist das Abwickeln des Zyklus auch ohne Inlining nicht so effektiv. Eine vereinfachte grafische Darstellung des endgültigen Codes ist in der folgenden Abbildung dargestellt (diese Grafik gilt sowohl für x86 als auch für x86-64).



In diesem Diagramm kennzeichnet eine grüne Raute einen Eintrittspunkt, und rote Rechtecke kennzeichnen einen Austrittspunkt. Blaue Raute stellen bedingte Anweisungen dar, die ausgeführt werden, wenn die Funktion ausgeführt wird.sumOfCubes. Wenn SSE4 unterstützt wird und x größer oder gleich 8 ist, werden mit SSE4-Befehlen 4 Multiplikationen gleichzeitig ausgeführt. Der Vorgang des Durchführens derselben Operation für mehrere Variablen wird als Vektorisierung bezeichnet. Der Compiler wickelt diese Schleife auch zweimal ab. Dies bedeutet, dass der Körper der Schleife bei jeder Iteration zweimal wiederholt wird. Infolgedessen wird die Durchführung von acht Multiplikationsoperationen in einer Iteration erfolgen. Bei xweniger als 8 wird der Code ohne Optimierungen zum Ausführen der Funktion verwendet. Beachten Sie, dass der Compiler anstelle von einem drei Exit-Punkte einfügt, wodurch die Anzahl der Übergänge verringert wird.

Das Abwickeln von Zyklen erfolgt durch mehrmaliges Wiederholen des Zykluskörpers innerhalb einer Iteration eines neuen (abgewickelten) Zyklus. Dies erhöht die Produktivität, da die Vorgänge des Zyklus selbst weniger häufig ausgeführt werden. Zusätzlich ermöglicht dies dem Compiler, zusätzliche Optimierungen (z. B. Vektorisierung) durchzuführen. Der Nachteil des Abwickelns von Schleifen ist die Zunahme der Codemenge und der Belastung der Register. Trotzdem kann eine solche Optimierung je nach Zyklus die Produktivität um einen zweistelligen Prozentsatz steigern.

Im Gegensatz zu x86-Prozessoren unterstützen alle x86-64-Prozessoren SSE2. Darüber hinaus können Sie die AVX / AVX2-Anweisungen für die neuesten x86-64-Modelle von Intel- und AMD-Prozessoren mit einem Schlüssel nutzen /arch. Indem Sie angeben /arch:AVX2, weisen Sie den Compiler an, auch die FMA- und BMI-Anweisungen zu verwenden.

Derzeit können Sie mit dem Visual C ++ - Compiler das Abwickeln von Schleifen nicht steuern. Sie können dies jedoch mithilfe der __forceinlineDirektive loopmit der Option beeinflussen no_vector(letztere deaktiviert die automatische Vektorisierung der angegebenen Zyklen).

Wenn Sie sich den generierten Assembler-Code ansehen, stellen Sie möglicherweise fest, dass zusätzliche Optimierungen darauf angewendet werden können. Der Compiler hat dennoch hervorragende Arbeit geleistet, und es ist nicht erforderlich, viel mehr Zeit mit der Analyse zu verbringen, um kleinere Optimierungen anzuwenden.

Die Funktion ist someOfCubesnicht die einzige, deren Schleife abgewickelt wurde. Wenn Sie den Code ändern und ihn stattdessen man die Funktion übergeben , kann der Compiler seinen Wert nicht berechnen und muss den Code generieren. Die Schleife wird zweimal abgewickelt.sumn

Abschließend betrachten wir eine Optimierung wie das Entfernen von Zyklusinvarianten. Schauen Sie sich den folgenden Code an:

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, die wir vorgenommen haben, ist das Hinzufügen einer zusätzlichen Variablen, die bei jeder Iteration erhöht und am Ende auf der Konsole angezeigt wird. Es ist leicht zu erkennen, dass dieser Code einfach optimiert werden kann, indem die inkrementierte Variable aus der Schleife verschoben wird. Weisen Sie ihr einfach einen Wert zu x. Diese Optimierung wird als schleifeninvariante Codebewegung bezeichnet. Das Wort "invariant" zeigt, dass diese Technik anwendbar ist, wenn ein Teil des Codes unabhängig von Ausdrücken ist, die eine Schleifenvariable enthalten.

Aber hier ist der Haken: Wenn Sie diese Optimierung manuell anwenden, kann der resultierende Code unter bestimmten Bedingungen an Leistung verlieren. Kannst du sagen warum? Stellen Sie sich eine Variable vorxnicht positiv. In diesem Fall wird die Schleife nicht ausgeführt, und in der nicht optimierten Version countbleibt die Variable unberührt. Die manuell optimierte Version führt eine unnötige Zuweisung von x zu count durch, die außerhalb der Schleife ausgeführt wird! Darüber hinaus erhält die xVariable , wenn sie negativ ist, countden falschen Wert. Sowohl Menschen als auch Compiler sind ähnlichen Fallen ausgesetzt. Glücklicherweise ist der Visual C ++ - Compiler schlau genug, dies herauszufinden und die Schleifenbedingung vor der Zuweisung zu testen, um die Leistung für alle möglichen Werte zu verbessern x.

Abschließend möchte ich sagen, dass Sie, wenn Sie kein Compiler und kein Experte für Compileroptimierungen sind, solche Transformationen Ihres Codes vermeiden sollten, die den Eindruck erwecken, dass er schneller funktioniert. Halten Sie Ihre Hände sauber und vertrauen Sie darauf, dass der Compiler Ihren Code optimiert.

Optimierungskontrolle

Zusätzlich zu den Tasten O1, /O2, /Ox, können Sie die spezifische Funktion mit der Richtlinie steuern optimize:

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

In diesem Beispiel kann die Optimierungsliste leer sein oder kann einen oder mehr Werte des Satzes enthalten: g, s, t, y. Diese entsprechen den Tasten /Og, /Os, /Ot, /Oy.

Eine leere Liste mit einem Parameter offdeaktiviert alle Optimierungen, unabhängig von den Compilerschlüsseln. Eine leere Liste mit einem Parameter onwendet die obigen Compilerschlüssel an.

Mit dem Schlüssel /Ogkönnen Sie globale Optimierungen durchführen, die nur innerhalb der optimierten Funktion und nicht in den aufrufenden Funktionen durchgeführt werden können. Wenn diese Option LTCGaktiviert ist, /Ogkönnen Sie WPO ausführen.

RichtlinieoptimizeSehr nützlich in Fällen, in denen verschiedene Funktionen auf unterschiedliche Weise optimiert werden sollen: einige in Bezug auf die belegte Größe und andere in Bezug auf die Geschwindigkeit. Wenn Sie jedoch wirklich Kontrolle über Optimierungen dieser Ebene haben möchten, sollten Sie sich mit profilgesteuerten Optimierungen (PGOs) befassen, bei denen es sich um Codeoptimierungen handelt, die ein Profil verwenden, in dem Verhaltensinformationen gespeichert sind, die während der Ausführung der instrumentellen Version des Codes aufgezeichnet wurden. Der Compiler verwendet das Profil, um bessere Lösungen bei der Codeoptimierung bereitzustellen. Visual Studio führt spezielle Tools ein, um diese Technik sowohl auf systemeigenen als auch auf verwalteten Code anzuwenden.

Optimierungen in .NET

In .NET gibt es keinen Linker, der am Kompilierungsmodell beteiligt wäre. Stattdessen gibt es einen Quell-Compiler (C # -Compiler) und einen JIT-Compiler. Der Quellcode wird nur geringfügig optimiert. Auf dieser Ebene werden beispielsweise keine Inlining-Funktionen oder Schleifenoptimierungen angezeigt. Stattdessen werden Optimierungsdaten auf der JIT-Kompilierungsebene ausgeführt. Der JIT-Compiler vor .NET 4.5 hat SIMD nicht unterstützt. Der JIT-Compiler von .NET 4.5.1 (RyuJIT genannt) unterstützt jedoch SIMD.

Was ist der Unterschied zwischen RyuJIT und Visual C ++ hinsichtlich der Optimierungsmöglichkeiten? Da RyuJIT zur Laufzeit ausgeführt wird, können Optimierungen durchgeführt werden, die in Visual C ++ nicht möglich sind. Zum Beispiel kann er direkt zur Laufzeit verstehen, dass ein Ausdruck in einer bedingten Anweisung niemals einen Wert annimmttruein der aktuell ausgeführten Version der Anwendung, und wenden Sie dann die entsprechende Optimierung an. RyuJIT kann auch Kenntnisse über die zur Laufzeit verwendete Prozessorarchitektur verwenden. Wenn der Prozessor beispielsweise SSE4.1 unterstützt, verwendet der JIT-Compiler nur SSE4.1-Anweisungen, um die Funktion zu implementierensubOfCubesDadurch wird der generierte Code kompakter. Sie müssen jedoch verstehen, dass RyuJIT nicht viel Zeit für die Optimierung aufwenden kann, da sich die Zeit der JIT-Kompilierung auf die Leistung der Anwendung auswirkt. Der Visual C ++ - Compiler kann jedoch viel Zeit damit verbringen, den Code zu analysieren, um Optimierungsoptionen zur Verbesserung der endgültigen ausführbaren Datei zu finden. Mit der großartigen neuen Microsoft-Technologie .NET Native können Sie verwalteten Code mithilfe von Visual C ++ - Optimierungen in eigenständige ausführbare Dateien kompilieren. Derzeit unterstützt diese Technologie nur Anwendungen aus dem Windows Store.

Bisher ist die Möglichkeit zur Steuerung von Optimierungen von verwaltetem Code eingeschränkt. C # - und Visual Basic-Compiler ermöglichen nur das Aktivieren oder Deaktivieren von Optimierungen mithilfe des Schlüssels/optimize. Um JIT-Optimierungen zu steuern, können Sie das Attribut System.Runtime.Compiler­Services.MethodImplmit der Option von auf die gewünschte Methode anwenden MethodImplOptions. Die Option NoOptimizationdeaktiviert Optimierungen, die Option NoInliningverbietet die Inline-Methode und die Option AggressiveInlining(verfügbar seit .NET 4.5) gibt dem JIT-Compiler die Empfehlung, diese Methode inline zu setzen.

Zusammenfassung

Alle in diesem Artikel beschriebenen Optimierungsmethoden können die Leistung Ihres Codes um einen zweistelligen Prozentsatz erheblich verbessern. Alle von ihnen werden im Visual C ++ - Compiler unterstützt. Was diese Techniken wirklich wichtig macht, ist, dass der Compiler nach dem Anwenden andere Optimierungen vornehmen kann. Dieser Artikel enthält keine vollständige Beschreibung aller Optimierungen, die Visual C ++ durchführt. Ich hoffe jedoch, dass Sie den Compiler jetzt zu schätzen wissen. Visual C ++ kann viel, viel mehr. Wir werden in Teil 2 ausführlicher darauf eingehen.

Jetzt auch beliebt: