Ich arbeite mit VS 2010 und habe, um einen Überblick über die Optimierungsmöglichkeiten zu kriegen, mal meine Kompilate mit den Quelltexten verglichen …
Zuerst muss ich sagen: Der Optimizer ist gut. Er leistet stellenweise so gute Arbeit, dass ich mit meinen Assembler-Basiskenntnissen nicht mehr nachvollziehen kann, was in bestimmten Codeabschnitten vorgeht (was auch der Grund ist, warum ich mich jetzt hier erkundigen möchte).
Dann gibt es aber auch Stellen, an denen – meiner Meinung nach – offensichtliche Optimierungsmöglichkeiten schlicht ignoriert werden. Das sind vor allem: Aussagenlogik mit integralen Typen, Exception-Overhead und vftables in abstrakten Klassen.
Aussagenlogik mit integralen Typen:
Mir stieß schon in dem Thread über den möglichst schnellen Point-in-Rect-Test bitter auf, dass der Compiler alle Ausdrücke behandelt, als ob bei ihrer Auswertung Side-Effects aufträten – auch, wenn es sich nur um Ints handelt. Ein Beispiel: Testen, ob einer von drei Zeigern nicht nullptr ist.
// 1)
if((nullptr != p1) || (nullptr != p2) || (nullptr != p3))
// Kompiliert zu sechs Befehlen, davon drei bedingte Sprünge
// 2)
if((nullptr != p1) | (nullptr != p2) | (nullptr != p3))
// Kompiliert zu zwölf Befehlen, davon ein bedingter Sprung
// 3)
if(uintptr_t(p1) | uintptr_t(p2) | uintptr_t(p3))
// Kompiliert zu vier Befehlen, davon ein bedingter Sprung
Von einem Compiler, in dem >15 Jahre C-/C++-Erfahrung steckt, würde ich auf höchster Optimierungsstufe zumindest die Leistung von 2) und 3) erwarten, auch, wenn mein Quelltext wie 1) aussieht. Ist das wirklich so kompliziert oder haben die Gründe, das nicht zu tun?
Exception-Overhead:
Der Compiler behandelt jedes throw mit inline. Wenn man mal darüber nachdenkt, ist das doch wahnwitzig: throw impliziert doch, dass der Codeabschnitt bestenfalls nie benutzt werden wird und dass die Performance des umliegenden Codes wichtiger ist, sonst würde man return verwenden. Außerdem ist das Schmeißen einer Exception von sich aus teuer wie nichts, aber trotzdem optimiert es der Compiler, als ginge es um Leben und Tod. Beispiel:
// Üblich:
class CException : public ::std::exception {
public:
CException() throw() { }
virtual char const * what() const throw() { return "dummy"; }
};
if(!SomeCrucialCondition)
throw CException; // Kompiliert zu sechs Befehlen, bei komplexeren Exception-Klassen auch gern zu elf oder >20.
// Dem gegenüber:
class CException : public ::std::exception {
protected:
CException() throw() { }
public:
virtual char const * what() const throw() { return "dummy"; }
__declspec(noinline) __declspec(noreturn) static void Throw() { throw CException(); }
};
if(!SomeCrucialCondition)
CException::Throw(); // Kompiliert zu *einem* Befehl
Die zweite Version ist, wenn eine Exception auftritt, einen Aufruf länger (ich spreche hier extra nicht von „schneller“ oder „langsamer“, weil ich es nicht gebencht habe) als die erste. Wenn allerdings keine Exception auftritt, was ja die Regel sein sollte, spart man fünf Sechstel des Exception-Overheads. Allein das Auswechseln des throws hat mein Kompilat fast zehn Prozent kleiner gemacht! (Noch krasser wird der Unterschied, wenn man Fehlermeldungen mit auslagert.)
Wenn der Compiler schon sonst nicht weiß, welcher Pfad bevorzugt behandelt werden muss: throw ist doch der Wink mit dem Zaunpfahl schlechthin, dass dem Pfad Geschwindigkeit egal ist. Der Compiler müsste mit Leichtigkeit erkennen können, dass derselbe throw-Code hunderte Male an unkritischen Stellen im Code vorkommt, es mit Leichtigkeit auslagern können und COMDAT-Folding würde dann den Rest erledigen. Auch hier wieder: Ist das Absicht? (Unter x64 sieht die Situation glücklicherweise besser aus, wenn auch bloß architekturbedingt.)
vftables in abstrakten Klassen: __declspec(novtable) erlaubt dem Compiler, den vftable einer abstrakten Klasse wegzulassen – da abstrakte Klassen ja sowieso nicht instanziiert werden können. Nette kleine Optimierung, die wieder einen unwesentlichen bis beachtlichen Overhead aus dem Kompilat treibt – je nachdem, wie viele oder wie wenige abstrakte Klassen man benutzt. Warum macht der Compiler das nicht automatisch? Gibt es überhaupt einen Fall, in dem der vftable einer abstrakten Klasse zur Laufzeit erreichbar sein muss?
Ich hoffe, ihr könnt mich erleuchten …
Gruß, Ky
Re: Is it just me, or …
Verfasst: 09.04.2010, 18:18
von Tobiking
Krishty hat geschrieben:Aussagenlogik mit integralen Typen:
Mir stieß schon in dem Thread über den möglichst schnellen Point-in-Rect-Test bitter auf, dass der Compiler alle Ausdrücke behandelt, als ob bei ihrer Auswertung Side-Effects aufträten – auch, wenn es sich nur um Ints handelt. Ein Beispiel: Testen, ob einer von drei Zeigern nicht nullptr ist.
// 1)
if((nullptr != p1) || (nullptr != p2) || (nullptr != p3))
// Kompiliert zu sechs Befehlen, davon drei bedingte Sprünge
// 2)
if((nullptr != p1) | (nullptr != p2) | (nullptr != p3))
// Kompiliert zu zwölf Befehlen, davon ein bedingter Sprung
// 3)
if(uintptr_t(p1) | uintptr_t(p2) | uintptr_t(p3))
// Kompiliert zu vier Befehlen, davon ein bedingter Sprung
Von einem Compiler, in dem >15 Jahre C-/C++-Erfahrung steckt, würde ich auf höchster Optimierungsstufe zumindest die Leistung von 2) und 3) erwarten, auch, wenn mein Quelltext wie 1) aussieht. Ist das wirklich so kompliziert oder haben die Gründe, das nicht zu tun?
Ich denke der Compiler kann an der Stelle nicht erkennen ob Variante 1) oder 2) besser ist und hält sich daher strikt an das was der Programmierer ihm sagt. Variante 1) hat zwar sechs Befehle, aber wenn die erste Aussage wahr ist, werden nur zwei Befehle ausgeführt. Ist die erste Aussage also oft erfüllt, ist diese Lösung besser. Sind allerdings die ersten beiden Aussagen oft nicht erfüllt, kommt es auf die branch prediction des Prozessors an wie schlecht diese Lösung ist.
Den Unterschied bei 2) und 3) seh ich gerade nicht. Ich habe auch grad kein VS da zum testen, sondern nur einen gcc. Der erstellt bei beiden zwar unterschiedlichen Code, allerdings nach dem gleichen Prinzip und kein sichtbarer Unterschied im Aufwand.
Re: Is it just me, or …
Verfasst: 09.04.2010, 19:00
von Krishty
Tobiking hat geschrieben:Ich denke der Compiler kann an der Stelle nicht erkennen ob Variante 1) oder 2) besser ist und hält sich daher strikt an das was der Programmierer ihm sagt. Variante 1) hat zwar sechs Befehle, aber wenn die erste Aussage wahr ist, werden nur zwei Befehle ausgeführt. Ist die erste Aussage also oft erfüllt, ist diese Lösung besser. Sind allerdings die ersten beiden Aussagen oft nicht erfüllt, kommt es auf die branch prediction des Prozessors an wie schlecht diese Lösung ist.
Der Knackpunkt ist gerade, dass es – selbst, wenn Aussage 1 oft wahr ist – auf die Branch-Prediction des Prozessors ankommt und nicht generell besser ist, denn es ist ja nicht gesagt, dass sich der Prozessor immer automatisch für den Weg aus dem Vergleich heraus entscheidet. Nur kommt es bei 1) eben dreimal so stark auf die Branch-Prediction an wie bei 2) und 3). Das „ich weiß nicht, was am besten ist, also mache ich einfach garnichts“-Argument vermute ich bei VC++ auch, finde es aber komisch, dass die Compiler bei so fundamentalen Situationen ratlos sind.
Tobiking hat geschrieben:Den Unterschied bei 2) und 3) seh ich gerade nicht. Ich habe auch grad kein VS da zum testen, sondern nur einen gcc. Der erstellt bei beiden zwar unterschiedlichen Code, allerdings nach dem gleichen Prinzip und kein sichtbarer Unterschied im Aufwand.
Das Verhalten zu 2) und 3) ist auf jeden Fall komisch. Wenn dem Compiler bekannt ist das nullptr 0 ist sollte der gleiche Code herauskommen.
Ich habe meinen Test mit gcc mal ausgebaut, so dass ich nun auch vollständige Optimierung aktivieren konnte. Das Ergebnis ist bei allen Fällen der gleiche Code. Die Vorgehensweise entspricht immer dem Fall 1. Das würde heißen der gcc erkennt schon mal das die Integer keine Seiteneffekte haben können. Interessant ist dabei vielleicht noch die Reihenfolge:
Test Aussage 1
Falls nicht erfüllt Springe zu Test 2
Rumpf
Springe ans Ende
Test 2
Falls erfüllt Sprung zu Rumpf
Test 3
Falls erfüllt Sprung zu Rumpf
Laut Wikipedia werden bei der branch prediction Sprünge nach hinten meistens gewählt, weil diese auf Schleifen hindeuten. Das sieht allgemein so aus als wenn der gcc sehr optimistisch ist, und davon ausgeht das die Bedingung erfüllt wird. Ob das optimal ist, bleibt aber eine andere Frage.
Re: Is it just me, or …
Verfasst: 10.04.2010, 00:55
von Krishty
Tobiking hat geschrieben:Ich habe meinen Test mit gcc mal ausgebaut, so dass ich nun auch vollständige Optimierung aktivieren konnte. Das Ergebnis ist bei allen Fällen der gleiche Code. Die Vorgehensweise entspricht immer dem Fall 1. […] Ob das optimal ist, bleibt aber eine andere Frage.
Bemerkenswert – VCpp scheint da also tatsächlich einen Knacks zu haben …
Ob der GCC-Code in diesem Beispiel optimal ist, ist eigentlich zweitrangig – wenn der Compiler nicht in jedem Fall optimalen Code produziert, ich aber weiß, dass er gewisse Kenntnisse über Logik und Wirkungen hat, dann ist mir das mehr geheuer als wenn er Optimierungen einfach generell sein lässt.
Auf jeden Fall danke für den Test, ich habe hier keinen GCC zur Verfügung.
Re: Is it just me, or …
Verfasst: 10.04.2010, 10:46
von eXile
Ich wäre von meiner Seite aus noch sehr an einem Vergleich der Kompilierungsoptionen /O1 (Größe minimieren) und /O2 (Geschwindigkeit maximieren) interessiert!
Re: Is it just me, or …
Verfasst: 10.04.2010, 15:35
von Krishty
Keine Wirkung.
(ist ein real-life-Beispiel, synthetische Benchmarks auf höchster Optimierungsstufe sind zu anstrengend)
Wie vertragen sich denn novtable und default-Implementierungen von pure-virtual functions?
Also z.b.:
a.h
class A {
~A() = 0;
};
a.cpp
A:~A() {}
Wenn das eintragen der vtable wirklich mal ein Performance-Problem wird, dann wuesste ich andere Wege, das zu umgehen....
PS: Da habe ich eine Frage formuliert ohne auf das urspruengliche Problem hinzuweisen....ein automatisches Erkennen von "novtable" funktioniert nur bei einem globalen Optimierungsvorgang....sonst weiss der Compiler nicht, ob nicht irgendwo so eine heimtueckische "ich bin die pure-virtual Implementierung" herumliegt (und benutzt wird). Und ich mag mir nicht vorstellen, was passiert, wenn die ganze vtable fuer diese Basisklasse im Orkus gelandet ist. Lange Reder, kurzer Sinn: Eine Automatik waere wohl schwierig und gefaehrlich, mit der declspec-Variante weiss man ja, wen man verantwortlich machen muss.
Der Sinn dieses Konstrukts liegt wohl woanders: Ich kann z.B. inplace-Konstruktoren aufrufen, welche eine vorher eingetragene vtable nicht (mehr) ueberschreiben.
Re: Is it just me, or …
Verfasst: 11.04.2010, 21:43
von Krishty
Ich wusste garnicht, dass man abstrakte Funktionen später noch definieren kann (und kann die Frage ergo auch nicht beantworten) … scheinbar, um auch bei implementierten Funktionen abgeleitete Klassen zu zwingen, die Funktionen zu überladen? Wieder was gelernt.
Mit deiner (nachgetragenen) Antwort kann ich was anfangen, danke :) Aber afaik hat Visual C++ einen solchen globalen Optimierungsvorgang doch mit Link-Time Code Generation implementiert, oder? Also stammt das novtable-Keyword noch aus alten Zeiten und die Automatik ist einfach nicht implementiert, weil sie bei zuviel Aufwand zuwenig Gewinn abwerfen würde.
Re: Is it just me, or …
Verfasst: 12.04.2010, 08:10
von Jörg
'novtable' ist wirklich alt (eingefuehrt mit Visual Studio 5, um etwas Overhead bei ATL&COM zu sparen), LTCG gab es damals noch nicht bei MS.
Re: Is it just me, or …
Verfasst: 14.05.2010, 15:41
von Krishty
Ich möchte hier nochmal anmerken, dass Visual C++ es versäumt Funktionen zu inlinen, die nur einmal referenziert werden. Man kann ordentlich nachoptimieren, wenn man diese Funktionen von Hand mit __forceinline dekoriert – bei mir sind gerade wieder über 3 KiB .text rausgeflogen, nachdem ich fünf Funktionen ge-force-inline-d habe … normalerweise fällt die Ersparnis geringer aus, aber manchmal trifft man eben einen Punkt, an dem der Optimizer den Code dadurch komplett umkrempeln kann.
void Foo(
char const * A, char const * B,
char const * C, char const * D,
char const * E
) {
// Möglichst viele Funktionsaufrufe, damit die Funktion für konventionelles Inlining zu lang wird.
::std::cout << A << ' ' << B << ' ' << C << ' ' << D << ' ' << E << ::std::endl;
}
int main() {
// Verhindern, dass die Parameter wegoptimiert werden.
static char const * Text[] = { "Bloss", "ein", "kleiner", "Text", "am Rande" };
Foo(Text[0], Text[1], Text[2], Text[3], Text[4]);
return 0;
}
Das kompiliert zu 69 Befehlen (59 in der Funktion, zehn beim Aufruf). Dekoriert man Foo() mit __forceinline, sind es nurnoch 47 beim Aufruf – also über 30 % Ersparnis. GCC implementiert das übrigens schon; für VC habe ich mal eine Feature-Request abgeschickt.
Ich könnte das hier langsam zu einem VC-Optimierung-Nachhilfethread ausbauen :)
Re: Is it just me, or …
Verfasst: 21.05.2010, 13:23
von Krishty
Leute, lasst leere Destruktoren (~MyClass(){}) weg. VC scheint erhebliche Probleme damit zu haben, die wegzuoptimieren … u.a. landen leere Destruktoren in der atexit-Liste, falls Objekte des Typs static deklariert sind. Der Hammer ist aber, dass der Compiler erst anfängt, SSE zu benutzen, wenn bei beteiligten Typen keine D’toren definiert sind …
… also, wenn ihr im D’tor nichts zu sagen habt, löscht das Ding sofort, denn VC wird es wahrscheinlich nicht wegoptimieren.
Re: Is it just me, or …
Verfasst: 21.05.2010, 14:28
von jgl
Vielen Dank für die Info. :)
Wie kamst Du auf die Erkenntnis?
Re: Is it just me, or …
Verfasst: 21.05.2010, 14:38
von Krishty
Mein Code wurde kleiner, nachdem ich ein paar leere D’toren gelöscht habe, da war ich schon skeptisch. Als ich dann die CRT nachprogrammiert habe, fiel mir auf, dass leere D’toren in der atexit-Liste auftauchten (eine Liste aller initialisierter statischer Instanzen des Programms, die die Runtime führt, damit deren Destruktoren beim Programmende aufgerufen werden) – da war ich dann alarmiert und habe die entsprechenden D’toren gelöscht. An dem Punkt wusste ich, dass der Compiler zumindest Probleme mit leeren D’toren hat …
… aber nachdem ich alle leeren D’toren gelöscht hatte ist mein Code größenmäßig geradezu explodiert. Dank Jörg kam ich auf die Spur von einigen meiner Funktionen, die plötzlich komplett mit SSE arbeiteten statt mit der FPU – da wurde es mir dann zuviel und ich habe einen Bug-Report gepostet … mal sehen, ob sich was tut, schließlich ist man auch manchmal gezwungen, leere D’toren anzugeben, bspw. wenn sie nicht public sein sollen oder eine spezielle Exception-specification brauchen. (Das Beispiel im Report kompiliert zu 45 Befehlen ohne D’tor, zu 94 mit leerem D’tor.)
Re: Is it just me, or …
Verfasst: 21.05.2010, 14:53
von jgl
Gilt das auch für VS2005 und VS2008?
Re: Is it just me, or …
Verfasst: 21.05.2010, 14:55
von Krishty
Habe die beiden nicht mehr installiert … probier es aus und poste hier :)
Re: Is it just me, or …
Verfasst: 21.05.2010, 14:58
von jgl
Würde ich gerne machen, aber wie weis ich nicht ;)
// Compile with /Ox /Ob2, link with /LTCG
#include <iostream>
struct Vector {
float A, B, C, D;
Vector(float A, float B, float C, float D)
: A(A), B(B), C(C), D(D) { }
Vector operator + (Vector const & Summand) {
return Vector(A + Summand.A, B + Summand.B, C + Summand.C, D + Summand.D);
}
~Vector() { }
};
int main() {
float A, B, C, D;
::std::cin >> A >> B >> C >> D;
Vector const Result = Vector(A, B, C, D) + Vector(0.0f, 1.0f, 2.0f, 3.0f);
::std::cout << Result.A << Result.B << Result.C << Result.D;
return 0;
}
Release-Build (Full Optimization, Whole Program Optimization, Use Link-Time Code Generation), mit Strg+F10 in die Zeile mit dem ::std::cin springen, Rechtsklick -> View Disassembly. Wenn ein call Vector::operator + drinsteht, ist der Bug mit hoher Wahrscheinlichkeit auch dort vorhanden. Dann Debugging abbrechen, den Destruktor auskommentieren, neu builden, wieder in die Zeile springen, wieder Disassembly anschauen. Wenn die Addition komplett geinlined wurde und der Code insgesamt kürzer ist, haben wir einen Treffer. Oder, falls du mit Disassembly nicht viel anfangen kannst, den Assembler-Code beide Male rauskopieren und hier posten.
Re: Is it just me, or …
Verfasst: 21.05.2010, 15:18
von jgl
Okay, also so wie Du beschrieben hast (alle Compiler- und Linkereinstellung so gemacht), Code kopiert.
Also:
1) VS2005: dort taucht ein "call Vector::operator+ (401000h) " auf.
Ich poste mal das ganzen Disassembly
Und nun habe ich den leeren Destructor rausgeschmissen und es steht auch kein " call Vector::operator+" mehr.
Kommt mir auch kürzer vor :)
Also demnach existiert dieses Problem schon seit VS2005...
Re: Is it just me, or …
Verfasst: 21.05.2010, 15:23
von Krishty
Super. Damit wäre das reproduziert :) Falls du bei MSDN Connect bist, kannst du das Ticket ja bestätigen/uppen.
Bei MS konnte der Bug zuerst nicht reproduziert werden. Ich musste nochmal ran und habe dann herausgefunden, dass Exception-Handling aktiviert sein muss, damit das Verhalten auftritt …
… weiter vorne im Thread habe ich mich ja schon darüber aufgeregt, dass Funktionen, die try-Blöcke enthalten, nicht geinlined werden können. Aus dieser Zeit – besser gesagt aus der Beschreibung der Warnung C4717 – hatte ich noch im Hinterkopf, dass Funktionen nicht geinlined werden können, wenn ihre Rückgabewerte unwindable sind – also im Fall einer Exception ein Destruktor aufgerufen werden müsste.
Ich schätze die Situation so ein, dass der Compiler in dem Code-Beispiel Vector als unwindable klassifiziert, sobald man einen Destruktor – wenn auch nur einen leeren – definiert, und es deshalb ablehnt, Vector::operator + () zu inlinen.
Das alles wäre nicht merkwürdig, wenn der Compiler nicht normalerweise großartige Arbeit bei solchen Klassifizierungen leisten würde. Dass all die Mechanismen, die sonst bestimmen ob eine Funktion wegoptimiert werden kann und ob sie Exception-Handling benötigt, bei einem manuell definierten Destruktor versagen ist vielleicht keinen Bug-Report mehr wert, aber zumindest einen Verbesserungsvorschlag. Jetzt warte ich aber erstmal ab, was das VC-Team dazu sagt, vielleicht bin ich ja auch auf dem Holzweg.
Und wir sehen: Ohne __forceinline wird der Code geinlined und die überflüssigen Konstruktoren werden verworfen …
Re: Is it just me, or …
Verfasst: 05.06.2010, 17:46
von Krishty
Interessant … was es nicht alles gibt. Ich schätze, der Initializer des Arrays wird nur aus bestimmten Stellen rausoptimiert … ich werde direkt mal prüfen, ob ich irgendwo uninitialisierte Arrays habe.
Re: Is it just me, or …
Verfasst: 12.06.2010, 22:57
von Krishty
Hmmm, der Compiler scheint auch nicht daraufhin zu optimieren, dass durch Datentypgrenzen bestimmte Vergleiche konstant sind:
Hier wird der Compiler beide Vergleiche durchführen, obwohl der zweite Vergleich durch das char-Intervall von [-128, 127] immer true liefert.
Immer, wenn eine Bereichsgrenze auf 0, 127, 32767 oder 2^31-1 liegt, kann man durch den richtigen Datentypen einen Vergleich sparen. Leider muss man das scheinbar manuell machen. Oder gibt es einen guten Grund, warum der Compiler das nicht optimiert?
Re: Is it just me, or …
Verfasst: 13.06.2010, 09:57
von TGGC
Krishty hat geschrieben:Hmmm, der Compiler scheint auch nicht daraufhin zu optimieren, dass durch Datentypgrenzen bestimmte Vergleiche konstant sind:
Hier wird der Compiler beide Vergleiche durchführen, obwohl der zweite Vergleich durch das char-Intervall von [-128, 127] immer true liefert.
Immer, wenn eine Bereichsgrenze auf 0, 127, 32767 oder 2^31-1 liegt, kann man durch den richtigen Datentypen einen Vergleich sparen. Leider muss man das scheinbar manuell machen. Oder gibt es einen guten Grund, warum der Compiler das nicht optimiert?
Nein, der zweite Vergleich ist nicht immer wahr, sondern nur wenn x >= 127 ist. f'`8k
TGGC: Du hast die richtung des Vergleichs umgedreht.
Krishty: Mal ne ganz platte Vermutung: Es kommt extrem selten vor dass jemand einen integralwert mit einer konstanten an der Grenze des Wertebereichs vergleicht und lohnt sich daher nicht.
Re: Is it just me, or …
Verfasst: 13.06.2010, 10:42
von Schrompf
Nein, TGGC hat schon recht: "127 >= x" bedeutet eben "größer oder gleich". Und char kann nunmal einen Wert von 127 annehmen. Die Bedingung ist also nicht zwangsweise falsch.
Re: Is it just me, or …
Verfasst: 13.06.2010, 10:52
von Alexander Kornrumpf
Thomas:
Krishty: 127 >= x oder auch x <=127
TGGC: x >= 127
ist schon ein Unterschied.
P.S. Genau deswegen ist konstanten nach vorne schreiben unituitiv.
P.P.S. Was soll überhaupt das dämliche = dort? 128 > x hätte es doch auch getan. Vielleicht kapiert der compiler das sogar besser.
Re: Is it just me, or …
Verfasst: 13.06.2010, 11:28
von Schrompf
Oh stimmt, hab mich von der verworrenen Schreibweise verwirren lassen.