Hinweis
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, sich anzumelden oder das Verzeichnis zu wechseln.
Für den Zugriff auf diese Seite ist eine Autorisierung erforderlich. Sie können versuchen, das Verzeichnis zu wechseln.
Was jeder Programmierer über Compileroptimierungen wissen sollte, Teil 2
Willkommen beim zweiten Teil meiner Reihe zu Compileroptimierungen. Im ersten Artikel (msdn.microsoft.com/magazine/dn904673) habe ich die Inlineersetzung von Funktionen, Loop Unrolling, schleifeninvariante Codeverschiebung, automatische Vektorisierung und COMDAT-Optimierungen erörtert. In diesem zweiten Artikel werde ich zwei weitere Optimierungen untersuchen: Registerzuweisung und Anweisungskoordination (Instruction Scheduling). Wie immer, werde ich mich auf den Visual C++-Compiler konzentrieren und kurz die Funktionsweise in Microsoft .NET Framework erläutern. Zum Kompilieren des Codes verwende ich Visual Studio 2013. Lassen Sie uns beginnen.
Registerzuweisung
Unter Registerzuweisung ist das Zuordnen einer Gruppe von Variablen zu den verfügbaren Registern zu verstehen, damit sie nicht im Arbeitsspeicher zugeordnet werden müssen. Dieser Prozess erfolgt normalerweise auf der Ebene einer gesamten Funktion. Doch insbesondere, wenn die Codegenerierung zur Verknüpfungszeit (LTCG, Link-Time Code Generation) aktiviert ist, kann der Prozess funktionsübergreifend erfolgen, was möglicherweise zu einer effizienteren Zuordnung führt. (In diesem Abschnitt sind alle Variablen automatisch [solche, deren Lebensdauer syntaktisch bestimmt wird], sofern nicht anders angegeben.)
Die Registerzuweisung ist eine besonders wichtige Optimierung. Um sie zu verstehen, wollen wir uns ansehen, was erforderlich ist, um auf die verschiedenen Arbeitsspeicherebenen zuzugreifen. Der Zugriff auf ein Register dauert kürzer als ein Prozessorzyklus. Der Zugriff auf den Cache erfolgt ein wenig langsamer ist und dauert einige bis zu Dutzenden von Zyklen. Der Zugriff auf den (Remote-)DRAM-Arbeitsspeicher erfolgt sogar noch langsamer. Zum Schluss ist der Zugriff auf die Festplatte überaus langsam und kann Millionen von Zyklen dauern! Außerdem erhöht ein Speicherzugriff den Datenverkehr zu freigegebenen Caches und zum Hauptspeicher. Mithilfe der Registerzuweisung wird die Anzahl der Speicherzugriffe reduziert, indem die verfügbaren Register so umfassend wie möglich genutzt werden.
Der Compiler versucht, jeder Variablen ein Register zuzuordnen, im Idealfall bis alle Anweisungen im Zusammenhang mit dieser Variablen ausgeführt sind. Ist dies nicht möglich, was aus Gründen, die ich in Kürze erörtern werde, üblich ist, müssen ein oder mehrere Variablen in den Arbeitsspeicher überlaufen, sodass sie häufig geladen und gespeichert werden müssen. "Registerdruck" bezieht sich auf die Anzahl der Register, die aufgrund der Nichtverfügbarkeit von Registern in den Speicher übergelaufen sind. Je größer der Registerdruck, desto mehr Speicherzugriffe, die nicht nur das Programm selbst, sondern das gesamte System ausbremsen können.
Moderne x86-Prozessoren bieten die folgenden Register, die von Compilern zugeordnet werden können: acht 32-Bit-Allzweckregister, acht 80-Bit-Gleitkommaregister und acht 128-Bit-Vektorregister. Alle x64-Prozessoren bieten 16 64-Bit-Allzweckregister, acht 80-Bit-Gleitkommaregister und mindestens 16 Vektorregister – jedes mit einer Breite von mindestens 128 Bit. Moderne 32-Bit-ARM-Prozessoren bieten 15 32-Bit-Allzweckregister und 32 64-Bit-Gleitkommaregister. Alle 64-Bit-ARM-Prozessoren bieten 31 64-Bit-Allzweckregister, 32 128-Bit-Gleitkommaregister und 16 128-Bit-Vektorregister (NEON). Alle stehen für die Registerzuweisung zur Verfügung (und können auch zur Liste der Register hinzugefügt werden, die von der Grafikkarte geboten wird). Wenn eine lokale Variable keinem verfügbaren Register zugeordnet werden kann, muss sie dem Stapel zugeordnet werden. Dies geschieht aus verschiedenen Gründen in fast jeder Funktion, was ich zeigen werde. Sehen Sie sich das Programm in Abbildung 1 an. Das Programm hat eigentlich keine bedeutende Aufgabe, dient aber als ein gutes Beispiel zur Veranschaulichung der Registerzuweisung.
Abbildung 1: Beispielprogramm für die Registerzuweisung
#include <stdio.h>
int main() {
int n = 0, m;
scanf_s("%d", &m);
for (int i = 0; i < m; ++i){
n += i;
}
for (int j = 0; j < m; ++j){
n += j;
}
printf("%d", n);
return 0;
}
Bevor der Compiler die verfügbaren Register Variablen zuordnet, analysiert er zunächst die Verwendung aller deklarierten Variablen innerhalb der Funktion (oder funktionsübergreifend bei Verwendung von "/LTCG"), um zu bestimmen, welche Gruppen von Variablen gleichzeitig aktiv sind, und schätzt, wie oft auf jede Variable zugegriffen wird. Zwei Variablen aus unterschiedlichen Gruppen können demselben Register zugeordnet werden. Wenn es für einige Variablen der gleichen Gruppe keine geeigneten Register gibt, müssen diese Variablen überlaufen. Der Compiler versucht, die Variablen mit den wenigsten Zugriffen für das Überlaufen auszuwählen, um die Gesamtanzahl der Speicherzugriffe möglichst zu minimieren. Das ist der Grundgedanke. Es gibt jedoch viele Sonderfälle, in denen es möglich ist, eine bessere Zuweisung zu finden. Moderne Compiler sind zu einer guten, aber leider nicht zur optimalen Zuweisung fähig. Doch für Sterbliche ist es sehr, sehr schwer, eine bessere zu finden.
Vor diesem Hintergrund kompiliere ich das Programm in Abbildung 1 mit aktivierten Optimierungen, und prüfe, wie der Compiler lokale Variablen zu Registern zuweist. Es gibt vier zuzuweisende Variablen: n, m, i und j. Ziel soll in diesem Fall die x86-Plattform sein. Durch Untersuchen des generierten Assemblycodes (/FA) stelle ich fest, dass die Variable "n" dem Register "ESI", die Variable "m" "ECX" und "i" und "j" EAX zugeordnet wurden. Beachten Sie, wie der Compiler EAX für zwei Variablen clever wiederverwendet, da sich deren Lebensdauer nicht überschneidet. Beachten Sie außerdem, dass der Compiler für "m" einen Platz im Stapel reserviert hat, da dessen Adresse verwendet wird. Auf der x64-Plattform werden die Variablen wie folgt zugewiesen: "n" dem Register "EDI", "m" dem Register "EDX", "i" dem Register "EAX" und j" dem Register "EBX". Aus irgendeinem Grund hat der Compiler "i" und "j" dieses Mal nicht dem gleichen Register zugewiesen.
War dies eine optimale Zuweisung? Nein. Das Problem ist die Verwendung von "ESI" und "EDI". Diese Register sind vom Aufgerufenen gespeicherte Register, was bedeutet, dass die aufgerufene Funktion sicherstellen muss, dass die Werte dieser Register beim Ausgang dieselben wie beim Eingang sind. Daher musste der Compiler eine Anweisung am Eingang der Funktion zum Übergeben von ESI/EDI an den Stapel und eine weitere Anweisung beim Ausgang zu ihrem Entfernen aus dem Stapel ausgeben. Der Compiler hätte dies auf beiden Plattformen mithilfe eines vom Aufrufer gespeicherten Registers wie z. B. EDX vermeiden können. Diese Lücken im Registerzuweisungsalgorithmus können mitunter mithilfe der Inlineersetzung von Funktionen geschlossen werden. Viele andere Optimierungen können den Code so rendern, dass er für eine effizientere Registerzuweisung empfänglich wird, z. B. die Beseitigung von totem Code und gängiger Unterausdrücke sowie Anweisungskoordination.
Es ist tatsächlich üblich, dass Variablen voneinander abweichende Lebensdauern haben, weshalb die Zuweisung desselben Registers zu allen sehr ökonomisch ist. Aber was geschieht, wenn Sie nicht mehr genügend Register haben, um alle zu berücksichtigen? Sie müssen sie überlaufen lassen. Das können Sie jedoch auf intelligente Weise erreichen. Sie lassen Sie alle an den gleichen Speicherort im Stapel überlaufen. Diese Optimierung wird als Stack Packing bezeichnet und von Visual C++ unterstützt. Stack Packing reduziert die Größe des Stapelrahmens und kann die Trefferquote im Datencache erhöhen, was zu einer besseren Leistung führt.
Doch leider sind die Dinge nicht so einfach. Aus theoretischer Sicht kann eine (nahezu) optimale Registerzuweisung erreicht werden. In der Praxis gibt es jedoch viele Gründe, warum dies ggf. nicht möglich ist:
- Die verfügbaren Register für die (zuvor erwähnten) x86- und x64-Plattformen und andere moderne Plattformen (z. B. ARM) können nicht beliebig verwendet werden. Es gibt komplexe Einschränkungen. Für jede Anweisung gelten Einschränkungen dahingehend, welche Register als Operanden verwendet werden können. Wenn Sie eine Anweisung verwenden möchten, müssen Sie daher die zulässigen Register verwenden, um den erforderlichen Operanden an sie zu übergeben. Außerdem werden die Ergebnisse einiger Anweisungen in vordefinierten Registern gespeichert, deren Werte von den Anweisungen als flüchtig angenommen werden. Möglicherweise gibt es eine andere Sequenz von Anweisungen, die die gleiche Berechnung durchführen, Ihnen jedoch eine effizientere Registerzuweisung ermöglichen. Die Probleme der Anweisungsauswahl und -koordination sowie der Registerzuweisung sind leider furchtbar verwoben.
- Nicht alle Variablen haben primitive Typen. Es ist nicht ungewöhnlich, dass automatische Strukturen und Arrays vorhanden sind. Diese Variablen können nicht direkt für die Registerzuweisung berücksichtigt werden. Sie können jedoch Registern gesondert zugeordnet werden. Aktuelle Compiler sind noch nicht so gut.
- Die Aufrufkonvention der Funktion erzwingt eine feste Zuordnung für einige Argumente, während andere als ungeeignet für die Zuweisung ungeachtet der Verfügbarkeit von Registern eingestuft werden. Mehr zu dieser Problematik weiter unten. Außerdem machen die Konzepte der vom Aufrufer und vom Aufgerufenen gespeicherten Register die Dinge komplizierter.
- Wenn die Adresse einer Variablen verwendet wird, wird die Variable besser an einem Ort gespeichert, der über eine Adresse verfügt. Ein Register hat keine Adresse, weshalb es im Arbeitsspeicher gespeichert werden muss, ob es verfügbar ist oder nicht.
All dies mag für Sie den Anschein erwecken, dass aktuelle Compiler bei der Registerzuweisung wenig überzeugend sind. Sie sind jedoch einigermaßen gut darin und werden besser, aber nur sehr langsam. Können Sie sich außerdem das Schreiben von Assemblycode vorstellen, während Sie an all dies denken?
Sie können dem Compiler helfen, eine potenziell bessere Zuweisung zu finden, indem Sie für x86-Architekturen "/LTCG" aktivieren. Wenn Sie den Compilerschalter "/GL" angeben, enthalten die generierten OBJ-Dateien CIL-Code (C Intermediate Language) anstelle von Assemblycode. Funktionsaufrufkonventionen sind nicht in den CIL-Code integriert. Wenn eine bestimmte Funktion nicht für den Export aus der ausführbaren Ausgabedatei definiert ist, kann der Compiler zum Verbessern der Leistung gegen die Aufrufkonvention verstoßen. Dies ist möglich, da er alle Aufrufpositionen der Funktion bestimmen kann. Visual C++ nutzt dies aus, indem alle Argumente der Funktion für die Registerzuweisung unabhängig von der Aufrufkonvention in Frage kommen können. Auch wenn die Registerzuweisung nicht verbessert werden kann, versucht der Compiler, Parameter für eine ökonomischere Ausrichtung neu anzuordnen und sogar nicht verwendete Parameter zu entfernen. Ohne den Schalter "/GL" enthalten die resultierenden OBJ-Dateien Binärcode, in dem Aufrufkonventionen bereits berücksichtigt wurden. Wenn eine OBJ-Datei einer Assembly eine Aufrufposition für eine Funktion in einer OBJ-Datei für CIL hat oder die Adresse der Funktion an einer beliebigen Stelle verwendet wird oder virtuell ist, kann der Compiler seine Aufrufkonvention nicht mehr optimieren. Ohne "/LTCG" weisen alle Funktionen und Methoden standardmäßig eine externe Verknüpfung auf, weshalb der Compiler diese Technik nicht anwenden kann. Wenn jedoch eine Funktion in einer OBJ-Datei explizit mit interner Verknüpfung definiert wurde, kann der Compiler diese Technik darauf anwenden, allerdings nur innerhalb einer OBJ-Datei. Diese Technik, die in der Dokumentation als benutzerdefinierte Aufrufkonvention bezeichnet wird, ist wichtig bei x86-Architekturen, da die Standardaufrufkonvention "__cdecl" nicht effizient ist. Dagegen ist die "__fastcall"-Aufrufkonvention für die x64-Architektur sehr effizient, da die ersten vier Argumente über Register übergeben werden. Aus diesem Grund kommt die benutzerdefinierte Aufrufkonvention nur für die x86-Zielplattform zum Einsatz.
Beachten Sie, dass selbst wenn "/LTCG" aktiviert ist, nicht gegen die Aufrufkonvention einer exportierten Funktion oder Methode verstoßen werden kann, da es für den Compiler genau wie in allen zuvor genannten Fällen unmöglich ist, alle Aufrufpositionen zu bestimmen.
Die Effektivität der Registerzuweisung hängt von der Genauigkeit der geschätzten Anzahl von Zugriffen auf die Variablen ab. Die meisten Funktionen enthalten bedingte Anweisungen, die die Genauigkeit dieser Schätzungen gefährden. Eine profilgesteuerte Optimierung kann verwendet werden, um diese Schätzungen zu optimieren.
Wenn "/LTCG" aktiviert und die Zielplattform x64 ist, führt der Compiler die interprozedurale Registerzuweisung aus. Dies bedeutet, dass die innerhalb einer Funktionskette deklarierten Variablen geprüft werden und versucht wird, eine bessere Zuweisung abhängig von den Einschränkungen zu finden, die durch den Code in jeder Funktion erzwungen werden. Andernfalls führt der Compiler eine globale Registerzuweisung aus, bei der jede Funktion ("global" hier bedeutet hier die gesamte Funktion) separat verarbeitet wird.
C und C++ bieten das Schlüsselwort "register", mit dessen Hilfe der Programmierer dem Compiler einen Hinweis zu den Variablen geben kann, die in Registern gespeichert werden sollen. Schon in der ersten Version von C wurde dieses Schlüsselwort eingeführt. Seinerzeit (um 1972) war es nützlich, da niemand wusste, wie eine Registerzuweisung effektiv erfolgen konnte. (Ein von IBM in den späten 60-er Jahren entwickelter FORTRAN IV-Compiler für die S/360-Reihe konnte dagegen bereits eine einfache Registerzuweisung durchführen. Die meisten S/360-Modelle boten 16 32-Bit-Allzweckregister und vier 64-Bit-Gleitkommaregister!) Wie bei zahlreichen anderen Features von C erleichtert das Schlüsselwort "register" auch das Schreiben von C-Compilern. Fast ein Jahrzehnt später wurde C++ entwickelt und bot das Schlüsselwort "register", da C als eine Teilmenge von C++ angesehen wurde. (Leider gibt es viele feine Unterschiede.) Seit den frühen 80-ern wurden viele effektiv Registerzuweisungsalgorithmen implementiert, weshalb das Vorhandensein des Schlüsselworts bis zum heutigen Tag für Verwirrung sorgt. Die meisten Produktionssprachen, die seitdem entwickelt wurden, bieten keine solches Schlüsselwort (z. B. C# und Visual Basic). Dieses Schlüsselwort gilt seit C++11 als veraltet, aber nicht in C11, der neuesten Version von C. Dieses Schlüsselwort darf nur für das Schreiben von Benchmarks verwendet werden. Der Visual C++-Compiler berücksichtigt dieses Schlüsselwort, sofern möglich. C lässt nicht das Verwenden der Adresse einer Registervariablen zu. C++ hingegen schon, doch dann muss der Compiler die Variable an einem adressierbaren Speicherort statt in einem Register speichern, wodurch gegen die manuell angegebene Speicherklasse verstoßen wird.
Wenn auf die CLR abgezielt wird, muss der Compiler CIL-Code (Common Intermediate Language) ausgeben, der einen Kellerautomaten modelliert. In diesem Fall führt der Compiler keine Registerzuweisung durch (wenn jedoch ein Teil des ausgegebenen Codes systemeigen ist, erfolgt die Registerzuweisung auf jeden Fall) und verschiebt diese bis zur Laufzeit für die Durchführung durch den JIT-Compiler (Just-in-Time) (oder das Visual C++-Back-End bei einer systemeigenen .NET-Kompilierung). RyuJIT, der JIT-Compiler im Funktionsumfang ab .NET Framework 4.5.1, implementiert einen recht zweckmäßigen Algorithmus für die Registerzuweisung.
Anweisungskoordination (Instruction Scheduling)
Die Registerzuweisung und Anweisungskoordination gehören zu den letzten Optimierungen, die vom Compiler ausgeführt werden, bevor er die Binärdatei ausgibt.
Alle bis auf die einfachsten Anweisungen werden in mehreren Phasen ausgeführt, wobei jede Phase von einer bestimmten Einheit des Prozessors verarbeitet wird. Um allen diese Einheiten so umfassend wie möglich zu nutzen, ruft der Prozessor mehrere Anweisungen in Pipelineform auf, sodass verschiedene Anweisungen in verschiedenen Phasen gleichzeitig ausgeführt werden. Dies kann die Leistung erheblich verbessern. Wenn eine dieser Anweisungen aus irgendeinem Grund nicht zur Ausführung bereit ist, wird jedoch die gesamte Pipeline blockiert. Dies ist aus vielen Gründen möglich: warten, bis eine andere Anweisung für ihr Ergebnis ein Commit ausgeführt hat, warten, bis Daten aus dem Arbeitsspeicher oder von der Festplatte übertragen werden, oder warten, bis ein E/A-Vorgang abgeschlossen ist.
Die Anweisungskoordination ist eine Technik zum Bewältigen dieses Problems. Es gibt zwei Arten von Anweisungskoordination:
- Compilerbasiert: Der Compiler analysiert die Anweisungen einer Funktion, um diejenigen zu ermitteln, die die Pipeline blockieren könnten. Anschließend versucht er, eine andere Reihenfolge der Anweisungen zu finden, um die Kosten der erwarteten Blockierungen zu minimieren und gleichzeitig den ordnungsgemäßen Zustand des Programms beizubehalten. Dies wird als die Neuanordnung von Anweisungen bezeichnet.
- Hardwarebasiert: Die meisten modernen x86-, x64- und ARM-Prozessoren sind in der Lage, den Strom von Anweisungen (genauer gesagt Mikrooperationen) vorauszusehen und diejenigen Anweisungen auszuführen, deren Operanden und erforderliche Funktionseinheit zur Ausführung zur Verfügung stehen. Dies wird als OoOE- (Out-of-Order oder 3OE) bzw. dynamische Ausführung bezeichnet. Das Ergebnis ist, dass das Programm in einer Reihenfolge ausgeführt wird, die sich von der ursprünglichen unterscheidet.
Es gibt weitere Gründe, die bewirken können, dass der Compiler bestimmte Anweisungen neu anordnet. Der Compiler kann z. B. geschachtelte Schleifen neu anordnen, damit der Code eine bessere Positionierung von Verweisen aufweist (diese Optimierung wird als Schleifenaustausch bezeichnet). Ein weiteres Beispiel ist das Reduzieren der Kosten des Registerüberlaufs, indem Anweisungen, die denselben aus dem Arbeitsspeicher geladenen Wert verwenden, aufeinanderfolgend eingerichtet werden, damit der Wert nur einmal geladen wird. Noch ein weiteres Beispiel ist das Reduzieren von Daten- und Anweisungscachefehlern.
Als Programmierer müssen Sie nicht wissen, wie ein Compiler oder Prozessor die Anweisungskoordination durchführt. Allerdings sollten Ihnen die potenziellen Auswirkungen dieser Technik und der Umgang damit bekannt sein.
Wenngleich bei der Anweisungskoordination die Richtigkeit der meisten Programme beibehalten wird, können einige willkürliche und überraschende Ergebnisse die Folge sein. Abbildung 2 zeigt ein Beispiel, bei dem die Anweisungskoordination bewirkt, dass der Compiler falschen Code ausgibt. Um dies zu sehen, kompilieren Sie das Programm als C-Code (/TC) im Releasemodus. Sie können als Zielplattform x86 oder x64 festlegen. Da Sie den resultierenden Assemblycode untersuchen werden, geben Sie "/FA" an, damit der Compiler eine Assemblyauflistung ausgibt.
Abbildung 2: Beispielprogramm für die Anweisungskoordination
#include <stdio.h>
#include <time.h>
__declspec(noinline) int compute(){
/* Some code here */
return 0;
}
int main() {
time_t t0 = clock();
/* Target location */
int result = compute();
time_t t1 = clock(); /* Function call to be moved */
printf("Result (%d) computed in %lld ticks.", result, t1 - t0);
return 0;
}
In diesem Programm möchte ich die Ausführungsdauer der Berechnungsfunktion messen. Zu diesem Zweck umschließe ich normalerweise den Aufruf der Funktion durch Aufrufe einer Zeitberechnungsfunktion wie z. B. "clock". Durch Berechnen des Unterschieds der Werte von "clock" erhalte ich dann eine Schätzung der Zeit, die von der Funktion zur Ausführung benötigt wurde. Beachten Sie, dass der Zweck dieses Codes nicht ist, Ihnen die beste Methode zum Messen der Leistung eines Teil des Codes zu zeigen, sondern Ihnen die Gefahren der Anweisungskoordination zu veranschaulichen.
Da es sich um C-Code handelt und das Programm sehr einfach ist, lässt sich der resultierende Assemblycode leicht verstehen. Bei Untersuchen des Assemblycodes mit Fokus auf den Aufrufanweisungen werden Sie feststellen, dass der zweite Aufruf der "clock"-Funktion dem Aufruf der "compute"-Funktion (die an den "Zielort" verschoben wurde) vorangestellt ist, wodurch die Messung völlig falsch ist.
Beachten Sie, dass diese Neuordnung nicht gegen die Mindestanforderungen des Standards für konforme Implementierungen verstößt und deshalb gültig ist.
Aber warum sollte der Compiler dies tun? Der Compiler ging davon aus, dass der zweite Aufruf von "clock" nicht vom Aufruf von "compute" abhängt (aus Compilersicht beeinflussen sich diese Funktionen einander überhaupt nicht). Nach dem ersten Aufruf von "clock" ist es außerdem wahrscheinlich, dass der Anweisungscache einige der Anweisungen dieser Funktion enthält und dass der Datencache einige der Daten enthält, die von diesen Anweisungen benötigt werden. Das Aufrufen von "compute" kann bewirken, dass diese Anweisungen und Daten überschrieben werden, weshalb der Compiler den Code entsprechend neu angeordnet hat.
Der Visual C++-Compiler bietet keinen Schalter zur Deaktivierung der Anweisungskoordination, während alle anderen Optimierungen aktiviert bleiben. Darüber hinaus kann dieses Problem aufgrund der dynamischen Ausführung auftreten, wenn die "compute"-Funktion inline ersetzt wurde. Abhängig davon, wie die Ausführung der "compute"-Funktion erfolgt und wie weit ein Prozessor voraussehen kann, entschließt sich ein 3OE-Prozessor ggf., mit der Ausführung des zweiten Aufrufs von "clock" zu beginnen, bevor die "compute"-Funktion abgeschlossen ist. Wie beim Compiler lässt die Mehrheit der Prozessoren nicht das Deaktivieren der dynamischen Ausführung zu. Fairerweise sollte erwähnt werden, dass es jedoch sehr unwahrscheinlich ist, dass dieses Problem aufgrund der dynamischen Ausführung auftritt. Wie könnten Sie feststellen, dass es trotzdem aufgetreten ist?
Der Visual C++-Compiler ist tatsächlich sehr umsichtig, wenn Sie diese Optimierung durchführen. Er ist so umsichtig, weil es gibt viele Aspekte gibt, die ihn hindern, eine Anweisung neu anzuordnen (z. B. eine Aufrufanweisung). Ich habe die folgenden Situationen bemerkt, die den Compiler veranlasst haben, den Aufruf der "clock"-Funktion nicht an eine bestimmte Position (die Zielposition) zu verschieben:
- Aufrufen einer importierten Funktion aus einer der Funktionen, die zwischen der Position des Funktionsaufrufs und der Zielposition aufgerufen werden. Wie dieser Code zeigt, bewirkt das Aufrufen einer importierten Funktion aus der "compute"-Funktion, dass der Compiler den zweiten Aufruf von "clock" nicht verschiebt:
__declspec(noinline) int compute(){
int x;
scanf_s("%d", &x); /* Calling an imported function */
return x;
}
- Aufrufen einer importierten Funktion zwischen dem Aufruf von "compute" und dem zweite Aufruf von "clock":
int main() {
time_t t0 = clock();
int result = compute();
printf("%d", result); /* Calling an imported function */
time_t t1 = clock();
printf("Result (%d) computed in %lld.", result, t1 - t0);
return 0;
}
- Zugreifen auf eine beliebige globale oder statische Variable aus einer der Funktionen, die zwischen der Position des Funktionsaufrufs und der Zielposition aufgerufen werden. Dies gilt unabhängig davon, ob die Variable gelesen oder geschrieben wird. Das folgende Beispiel zeigt, dass das Zugreifen auf eine globale Variable aus der "compute"-Funktion dazu führt, dass der Compiler den zweiten Aufruf von "clock" nicht verschiebt:
int x = 0;
__declspec(noinline) int compute(){
return x;
}
- t1 als flüchtig markieren.
Es gibt andere Situationen, die verhindern, dass der Compiler Anweisungen neu anordnet. Grund ist die "As-If-Regel" von C++, die besagt, dass der Compiler ein Programm, das keine nicht definierten Operationen enthält, wie gewünscht gestalten kann, solange garantiert ist, dass das erkennbare Verhalten gleich bleibt. Visual C++ hält sich nicht nur an diese Regel, sondern ist außerdem viel konservativer, um die Dauer der Kompilierung des Codes zu verkürzen. Eine importierte Funktion kann Nebenwirkungen verursachen. E/A-Funktionen in Bibliotheken und der Zugriff auf flüchtige Variablen können auch Nebenwirkungen verursachen.
"Volatile", "Restrict" und "/favor"
Das Angeben einer Variablen mit dem Schlüsselwort "volatile" kann sich auf die Registerzuweisung und Neuanordnung von Anweisungen auswirken. Erstens wird die Variable keinem Register zugeordnet. (Die meisten Anweisungen erfordern, dass einige ihrer Operanden in Registern gespeichert werden. Dies bedeutet, dass die Variable in ein Register geladen wird, jedoch nur, um einige der Anweisungen auszuführen, die die Variable verwenden.) Das heißt, dass das Lesen der oder Schreiben in die Variable immer einen Zugriff auf den Speicher verursacht. Zweitens weist das Schreiben in eine flüchtige Variable Release-Semantik auf, was bedeutet, dass alle Speicherzugriffe, die syntaktisch vor dem Schreiben in diese Variable erfolgen, davor auftreten. Drittens weist das Lesen einer flüchtigen Variablen Acquire-Semantik auf, was bedeutet, dass alle Speicherzugriffe, die syntaktisch nach dem Lesen dieser Variable erfolgen, danach auftreten. Dabei gibt es jedoch einen Haken: Diese Neuordnungsgarantien werden nur geboten, wenn der Schalter "/volatile:ms" angegeben wird. Im Gegensatz dazu weist der Schalter "/volatile:iso" den Compiler an, den Sprachstandard zu befolgen, der mittels dieses Schlüsselworts keine solchen Garantien bietet. Für ARM ist "/volatile:iso" standardmäßig aktiviert. Bei anderen Architekturen ist "/volatile:ms" Standard. Vor C ++11 war der Schalter "/volatile:ms" nützlich, da der Standard für Multithreadprogramme nichts zu bieten hatte. Doch ab C11/C++11 ist der Code bei Verwenden von "/volatile:ms" nicht mehr portabel, weshalb davon ausdrücklich abgeraten wird. Sie sollten stattdessen atomare Elemente verwenden. Interessant ist der Hinweis, dass wenn Ihr Programm unter "/volatile:iso" ordnungsgemäß funktioniert, es auch unter "/volatile:ms" ordnungsgemäß funktioniert. Noch wichtiger ist allerdings, dass wenn Ihr Programm unter "/volatile:ms" ordnungsgemäß funktioniert, es unter "/volatile:iso" ggf. nicht ordnungsgemäß funktioniert, weil das erstgenannte stärkere Garantien als das letztgenannte gibt.
Der Schalter "/volatile:ms" implementiert "Acquire" und "Release"-Semantik. Es reicht nicht aus, diese zur Kompilierzeit beizubehalten. Der Compiler kann (abhängig von der Zielplattform) ggf. zusätzliche Anweisungen (z. B. "mfence" und "xchg") ausgeben, um einen 3OE.Prozessor anzuweisen, diese Semantik beibehalten, während der Code ausgeführt wird. Aus diesem Grund verschlechtern flüchtige Variablen die Leistung nicht nur, weil die Variablen nicht in Registern zwischengespeichert werden, sondern auch aufgrund der zusätzlichen Anweisungen, die ausgegeben werden.
Die Semantik des "volatile"-Schlüsselworts gemäß der C#-Sprachspezifikation ähnelt derjenigen, die vom Visual C++-Compiler mit angegebenem Schalter "/volatile:ms" geboten wird. Es gibt jedoch einen Unterschied. Das Schlüsselwort "volatile" in C# implementiert Semantik vom Typ "Sequentially Consistent (SC) Acq/Rel", während "volatile" in C/C++- unter "/volatile:ms" reine Acq/Rel-Semantik implementiert. Denken Sie daran, dass "volatile" in C/C++ unter "/volatile:iso" über keine Acq/Rel-Semantik verfügt. Die Details würden den Rahmen dieses Artikels sprengen. Speicherbarrieren hindern den Compiler im Allgemeinen daran, zahlreiche Optimierungen über sie hinweg durchzuführen.
Sehr wichtig zu verstehen ist, dass wenn der Compiler solche Garantieren nicht von vornherein geboten hat, vom Prozessor gegebene entsprechende Garantien automatisch ungültig sind.
Das Schlüsselwort "__restrict" (oder "restrict") wirkt sich auch auf die Effektivität der Registerzuweisung und Anweisungskoordination aus. Im Gegensatz zu "volatile kann "restrict" diese Optimierungen jedoch deutlich verbessern. Eine in einem Bereich mit diesem Schlüsselwort markierte Zeigervariable gibt an, dass es keine andere Variable gibt, die auf das gleiche Objekt zeigt, außerhalb des Bereichs erstellt wurde und verwendet wird, um es zu ändern. Dieses Schlüsselwort kann auch dem Compiler ermöglichen, zahlreiche Optimierungen auf Zeiger anzuwenden, unter zuverlässiger Einbeziehung der automatischen Vektorisierung und von Schleifenoptimierungen, und die Größe des generierten Codes zu verringern. Sie können sich das Schlüsselwort "restrict" als streng geheime Hightech-, Anti-Anti-Optimierungswaffe vorstellen. Es verdient eigentlich einen eigenen Artikel, weshalb es hier nicht erörtert wird.
Wenn eine Variable mit sowohl "volatile" als auch "__restrict" gekennzeichnet ist, hat das Schlüsselwort "volatile" Vorrang bei Entscheidungen darüber, wie der Code optimiert werden soll. Der Compiler kann "restrict" sogar völlig ignorieren, muss "volatile" jedoch berücksichtigen.
Der Schalter "/favor" kann dem Compiler ermöglichen, eine Anweisungskoordination durchzuführen, die für die angegebene Architektur optimiert ist. Der Schalter kann auch die Größe des generierten Codes verringern, da der Compiler möglicherweise die Möglichkeit hat, keine Anweisungen auszugeben, die prüfen, ob ein bestimmtes Feature vom Prozessor unterstützt wird. Dies führt wiederum zu einer verbesserten Trefferquote im Datencache und einer besseren Leistung. Die Standardeinstellung ist "/favor:blend", was zu Code mit guter Leistung für x86- und x64-Prozessoren von Intel Corp. und AMD führt.
Zusammenfassung
Ich habe zwei wichtige vom Visual C++-Compiler durchgeführten Optimierungen erörtert: Registerzuweisung und Anweisungskoordination.
Die Registerzuweisung ist die wichtigste vom Compiler ausgeführte Optimierung, da der Zugriff auf ein Register viel schneller als sogar auf den Cache ist. Die Anweisungskoordination ist ebenfalls wichtig. Aktuelle Prozessoren müssen jedoch über enorme Fähigkeiten zur dynamischen Ausführung verfügen, wodurch die Anweisungskoordination weniger bedeutend aus zuvor ist. Der Compiler kann allerdings alle Anweisungen in einer Funktion unabhängig von ihrer Größe erkennen, während ein Prozessor nur eine begrenzte Anzahl von Anweisungen erkennen kann. Darüber hinaus verbraucht OoOE-Hardware ziemlich viel Strom, da sie immer so lange arbeitet, wie der Kern arbeitet. Ferner implementieren x86- und c64-Prozessoren ein Speichermodell, das stärker als das C11/C++11-Speichermodell ist und eine bestimmte Neuanordnung von Anweisungen verhindert, die die Leistung verbessern könnte. Aus diesem Grund ist die compilerbasierte Anweisungskoordination weiterhin äußerst wichtig für Geräte mit begrenzter Leistung.
Mehrere Schlüsselwörter und Compilerschalter können die Leistung entweder negativ oder positiv beeinträchtigen. Sie sollten sie also entsprechend nutzen, um sicherzustellen, dass Ihr Code so schnell wie möglich ausgeführt wird und die gewünschten Ergebnisse liefert. Es gibt noch viele weitere Optimierungen zu erörtern – bleiben Sie dran!
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 für die Durchsicht dieses Artikels: Jim Hogg (Microsoft Visual C++-Team)