In dem folgenden Beitrag möchten wir Ihnen ein paar Tipps & Tricks bei der C++-Programmierung mit auf den Weg geben.
Tipp 1: Führen Sie eine manuelle CSE (Common Subexpression Elimination) durch. Gehen Sie nicht davon aus, dass der Compiler dies tun wird. Meistens erfolgt dies nicht aufgrund von Pointer-Aliasing.
Pointer Aliasing im Allgemeinen und in C++-Mitgliederfunktionen im Besonderen ist etwas, das immer noch nicht allgemein verstanden zu werden scheint. Wenn Sie noch nie von den Begriffen Pointer-Aliasing oder Strict Aliasing Rule gehört haben, dann sollten Sie diese Begriffe googlen und sich erst einmal informieren.
Lassen Sie uns einen genaueren Blick auf den oben genannten Beispielcode werfen:
Beachten Sie, dass auch von Points zu Const Char ein Alias verwendet werden kann, da beide Typen kompatibel sind. Das bedeutet, dass der Compiler konservativ sein muss, unter der Annahme, dass ein potentielles Write to From geschrieben werden könnte, was bedeutet, dass das Ergebnis eines Aufrufs von strlen (from) bei der Schleifeniteration ein anderen Ergebnis haben könnte.
In anderen Worten: die obige Schleife hat die Komplexität O(N²) und nicht O(N).
Betrachten wir den Assembler-Code, der von Clang, GCC und MSVC generiert wird, mit vollständigen Optimierungen:
VC2017 macht dasselbe, aber Inlines den Aufruf von strlen(), was die generierte Assembly etwas länger und schwieriger zu lesen macht. Alle drei Computer werten im Wesentlichen strlen() in der inneren Schleife aus.
Aber dies liegt nicht an den Compilern. Sie können es nicht besser machen, wenn sie nach den Regeln spiegeln wollen. Beim Kompilieren von Copy() können sie nicht wissen, ob Code in einer anderen Übersetzungseinheit diese Funktion mit überlappenden Speicherbereichen aufrufen könnte.
Ja, in der Theorie könnten sie es wissen, wenn sie sich alle Aufrufseiten von Copy() ansehen, aber in der Praxis ist dies nicht etwas, woran Compiler herausragen.
Die gute Nachricht ist, dass wir dem Compiler auf zwei Arten helfen können: entweder durch Einschränkung der Zeiger oder durch die Verwendung lokaler Variablen.
Das restrict-Keyword ist seit geraumer Zeit in C verfügbar, hat sich aber (noch?) nicht in den C++-Standard eingearbeitet. Dennoch wird es von allen gängigen Compilern (_restrict oder restrict) unterstützt.
In unserem Fall gibt es eine noch einfachere Lösung, die keine nicht standardisierten Keywörter erfordert: lokale Variablen.
Mit dieser einfachen Änderung ist es unerheblich, ob es sich um einen Alias handelt oder nicht – der Code wird strlen() nur einmal auswerten. Das bedeutet natürlich, dass Sie sicher sein müssen, dass niemand Copy() mit überlappenden Memory-Bereichen aufruft.
Wenn man sich den erzeugten Assembler-Code ansieht, werden Clang und GCC die innere Schleife vollständig abrollen und optimieren. Die Dinge sind in MSVC fast gleich, abzüglich des Unrolling.
Wenn wir die Pointer zusätzlich einschränken – qualifizieren, werden sowohl Clang als auch GCC erkennen, dass wir so ziemlich nur ein memcpy() machen und folgendes erfinden:
C++ Member-Funktionen.
Da nicht alle statischen C++-Methoden einen implizierten This-Pointer tragen, kann jedes Element so ziemlich jedes Argument aliasieren (von kompatiblem Typ nach der strengen Alias-Regel).
Anbei ein Beispiel:
Auch wenn Char m_key und char*buffer so aussehen könnten, als könnten sie sich nicht gegenseitig einen Alias übertragen, vergessen Sie nicht, dass jeder Zugriff auf m_key im Wesentlichen this→m_key ist. Was sagt Ihnen Operator →?
Tatsächlich ist Char* vor aller Augen versteckt. Jedes Mal, wenn man jemanden sagen hört, dass Member in einer Schleife von Compiler sowieso in einem Register gehalten werden, solange sie nicht aufgerufen werden. Es ist eines der häufigsten Missverständnisse, die wir in den letzten Jahren immer wieder gehört haben.
Aber keine Sorge, auch in diesem Fall können wir etwas dagegen tun: Die meisten Compiler akzeptieren auch einen Restriktions-Qualifier für Methoden.
Fazit zum Tipp.
Hüten Sie sich vor Pointer-Aliasing, verwenden Sie lokale Variablen und eingeschränkte Pointer. Zudem sollten Sie von Zeit zu Zeit den erzeugten Assembler-Code überprüfen.
Tipp 2: Sie müssen nicht nach nullptr suchen, bevor Sie delete/delete[] auf einem Objekt oder Array verwenden.
Der C++-Standard schreibt vor, dass der Aufruf von delete/delete[] auf nullptr in Ordnung ist, was heutzutage von allen gängigen Compilern korrekt implementiert wird. Es gab früher Compiler, die es falsch verstanden haben, aber das liegt schon 10 Jahre her.
Heutzutage braucht man keine SAFE_DELETE-Makros mehr und wir glauben auch fest daran, dass das Setzen eines Pointers auf Null nach dem Löschen nur die Symptome verbirgt, aber nicht das zugrunde liegende Ownership-Problem beheben kann. Zudem kann SAFE_DELETE als eine Fehlbezeichnung angesehen werden.
Fazit zum Tipp.
Haben Sie klare Ownership-Regeln, verstecken Sie keine Symtome, sondern beheben Sie stattdessen Grundursachen.
Tipp 3: Verwenden Sie nicht std::vector.
Verwenden Sie es nicht. Es könnte in der Zukunft veraltet sein. Vielleicht bekommt der nächste C++-Standard ein dynamic_bitset oder Ähnliches.
Tipp 4: Erstellen Sie Ihren eigenen virtuellen Speicher std::vector replacement und verwenden Sie den 64-Bit-Adressspace. Kopieren Sie weniger & sorgen Sie für ein besseres Wachstumverhalten.
Wie Sie wahrscheinlich wissen, ist ein std::vector ein dynamisches Array, das zur Laufzeit wachsen und schrumpfen kann. Wann immer der Vektor wachsen muss (z.B. in push_back), weil sein zugewiesener Speicherblock nicht groß genug ist, um ein anderes Element aufzunehmen, muss dieser Folgendes tun:
- Weisen Sie einen neuen Speicherblock zu, der Platz für z.B. 2*N-Elemente bietet (das genaue Wachstumverhalten ist implementierungsdefiniert, aber ein Wachstum um den Faktor 2 ist sinnvoll).
- Kopieren/Verschieben vorhandener Elemente in den neuen Speicher.
- Kopieren/Verschieben des neuen Elements (z.B. in push_back) in den neuen Speicher.
- Zerstören Sie die alten Elemente.
- Geben Sie den alten Block des Speichers frei.
Dies hat mehrere Nachteile:
- Abhängig von der Art der Elemente, die der Vektor enthält, ist das Kopieren, Verschieben und Zerstören vieler dieser Elemente kein leichtes Unterfangen.
- Pointer auf bestehende Elemente werden invalidiert, so dass Sie niemals einen Pointer auf z.B. &myVector[5] speichern dürfen, falls der Vektor wachsen muss.
- Der Vektor benötigt vorübergehend Platz für 3*N (N + 2*N) Elemente, was zu Problemen außerhalb des Speichers führen kann, obwohl Sie am Ende nur 2*N Elemente verwenden werden.
- Der Verzicht auf den Aufruf von reserve/resize führt zu einer Speicherfragmentierung aufgrund mehrerer kleiner Allokationen.
Durch die Nutzung des virtuellen Speichers und der Fülle des in 64-Bit-Anwendungen verfügbaren Address Space können wir unseren eigenen vektorartigen Ersatz mit keinem dieser Nachteile entwickeln.
Zuerst werden wir den Address Space für den erwarteten schlimmsten Fall von Speicherbedarf des Vektors reservieren. Sie sollten verinnerlichen, dass jede einzelne Ihrer Vektoren/Arrays/etc. eine intrinsiche obere Grenze des benötigten Speichers hat, unabhängig davon wie groß das sein mag. Bei Konsolen könnte die obere Grenze die maximale Menge an Speicher sein, die von Ihrem Prozess zugewiesen werden kann – Sie können sowieso nie mehr als das. Realistischer ausgedrückt, kennt Ihr Code wahrscheinlich bereits erwartete Worst-Case-Szenarien für z.B. Spielobjekte, Komponenten usw., so dass wir diese stattdessen verwenden können.
Beachten Sie, dass wir in diesem Schritt nur Address Space reservieren, aber noch keine physischen Seiten übertragen.
Jetzt, wann immer unser Vektor wachsen muss, holen wir uns einfach mehr Seiten aus dem virtuellen Speichersystem des Betriebssystems in dem Address Space, den wir zuvor reserviert haben. Es ist garantiert, dass wir Speicher an dieser Adresse erhalten, solange es genügend physischen Speicher gibt, von dem wir noch allokieren können.
In shrink_to_fit können wir einfach so viele Seiten wie möglich an das Betriebssystem zurückgeben/entbinden.
Mit einem solchen Setup müssen wir beim Wachsen des Vektors nie Elemente kopieren oder verschieben. Alle vorhandenen Elemente bleiben dort, wo sie sind, so dass wir sogar Pointer auf sie speichern können, wenn wir wollen. Beachten Sie auch, dass wir nie Platz für 3*N-Elemente benötigen, sondern nur für die Anzahl der Elemente, die wir speichern werden.
Natürlich hat jede Technik auch seine Nachteile:
- Abhängig von der Seitengröße des Betriebssystems kann es vorkommen, dass Sie etwas Speicherplatz „verlieren“, da unser Vektor nur in seitengroßen Teilen wachsen kann. Betrachten Sie zum Beispiel einen Fall, in dem der Vektor wachsen muss, um Platz für ein weiteres Element zu schaffen – in diesem Fall weisen Sie eine neue Seite aus dem Betriebssystem zu, füllen sie aber nur mit einem Element, wodurch der Rest der Seite für andere Zuordnungen unbrauchbar wird.
- Das Übertragen physischer Seiten für den reservierten Address Space ist ebenfalls nicht billig, sollte aber schneller sein als das Kopieren vieler Elemente.
- Funktioniert nur bei 64-Bit-Anwendungen. Ihnen wird der Address Space in 32-Bit ziemlich schnell ausgehen.
Fazit zum Tipp:
Wir können es besser machen als eine One-Size-Fits-All Implementierung, indem wir die zugrunde liegende Hardware nutzen.
Tipp 5: Wenn Sie sich um die Leistung kümmern, überprüfen Sie die generierte Assembly. Unabhängig davon ob C++ oder Shader-Code. Sie werden überrascht sein.
Scheuen Sie sich nicht, sich den Assembly-Code anzusehen. Ja, einige der Mnemoniken – vor allem Dinge wie CVTTTPS2PI oder EIEIO mögen keinen Sinn ergeben, aber es ist nicht so schwer zu lernen, wie man eine Assembly liest. Auch wenn das manuelle Schreiben von Assembler-Code heutzutage als Programmierer in der Gaming-Industrie nicht sehr oft vorkommt, ist das eine sehr nützliche Fähigkeit, vor allem, weil Sie früher oder später diesen Once-in-a-Blue-Moon-Crash in enem Release-Kandidaten direkt vor dem Shipping debuggen müssen.
Um Assemblercode zu verstehen, sollten Sie ein gutes Verständnis der allgemeinen Computerarchitektur haben, Dinge wie Stack Frames, Aufrufkonventionen, Argument Passing und wie diese von einer Hochsprache wie C++ in Assemblercode übersetzt werden.
Sie können das alles auch lernen, indem Sie sich nur den Assemblercode ansehen, der für bestimmte C++-Konstrukte generiert wurde wie z.B. Initialisierung von Variablen, Aufruf von Funktionen, Calling Funktionen, Branches, Loops etc. Ein Gefühl dafür zu bekommen, wie C++-Code in Assemblercode kompiliert wird, ist wirklich hilfreich.
Für Ressourcen scheint dieses Tutorial für x86 ein schöner Ausgangspunkt für die Assemblierung zu sein, dieses Buch ist wirklich gut zum Erlernen der Computerarchitektur. Sie sollten den generierten Assemblercode hin und wieder überprüfen, wenn Sie sich um die Performance kümmern.
Tipp 6: Erfahren Sie, wie lange Features implementiert sind, bevor Sie sie blind verwenden oder löschen.
Dieser Tipp ähnelt Tipp 5, konzentriert sich aber mehr auf ungleiche Overhead-Sprachfunktionen wie (Capturing) Lambdas und virtuelle Funktionen.
Wenn Sie sich nicht sicher sind, nehmen Sie keine Dinge an, besonders wenn Sie z.B. an einem speicher- oder leistungskritischen Stück Code für ein 60Hz Action-Spiel arbeiten.
Natürlich bedeutet das nicht, dass Sie die Implementierung der STL Ihres Compilers auswendig kennen müssen, aber es schadet nicht, ab und zu neugierig zu sein. Sie müssen das Rad nicht immer wieder neu erfinden, aber Sie sollten schon die Grundlagen eingehend beherrschen, bevor Sie sich vertiefend mit dieser Thematik auseinandersetzen.
Tipp 7: Wrappen Sie einfache Enums in eine Struktur, um der Pollution des Namespace entgegenzuwirken z.B. Struct Mode { enum Enum { READ, WRITE }; };
Im obigen Beispiel sorgen sowohl READ als auch WRITE für eine Pollution des Namespaces. Das bedeutet, dass Sie keinen Enumerator, keinen globalen const oder irgendetwas anderes im globalen Namespace mit der Bezeichnung READ oder WRITE haben dürfen, obwohl die Enumeratoren Teil des Enum-Modus sind. Beachten Sie außerdem, wie OpenFile (READ) READ als Argument verwendet und nicht den besser lesbaren Mode::READ.
Die Lösung für diese Mängel ist ganz einfach – wrappen Sie die Enum in eine Struktur (oder einen Namespace):
Dadurch werden die oben genannten Fehler behoben und wir können die Struktur z.B. mit einer ToString-Funktion erweitern, die auch den globalen Namespace nicht belastet. Die einfachsten Lösungen sind meistens die besten.
Vielen Dank für Ihren Besuch.