Ich wollte hier in diese bereits ausgezeichneten Antworten eintauchen und zugeben, dass ich den hässlichen Ansatz gewählt habe, tatsächlich rückwärts an dem Antimuster zu arbeiten, polymorphen Code mit gemessenen Gewinnen in switches
oder if/else
Verzweigungen zu ändern . Aber ich habe diesen Großhandel nicht gemacht, nur für die kritischsten Pfade. Es muss nicht so schwarz und weiß sein.
Als Haftungsausschluss arbeite ich in Bereichen wie Raytracing, in denen die Richtigkeit nicht so schwer zu erreichen ist (und die oft unscharf und ohnehin angenähert ist), während Geschwindigkeit oft eine der wettbewerbsfähigsten Eigenschaften ist, die gesucht werden. Eine Verkürzung der Renderzeiten ist oft eine der häufigsten Benutzeranforderungen, da wir uns ständig am Kopf kratzen und herausfinden, wie dies für die kritischsten gemessenen Pfade erreicht werden kann.
Polymorphes Refactoring von Bedingungen
Zunächst ist zu verstehen, warum der Polymorphismus aus switch
Gründen der Wartbarkeit der bedingten Verzweigung ( oder einer Reihe von if/else
Anweisungen) vorzuziehen ist . Der Hauptvorteil hierbei ist die Erweiterbarkeit .
Mit polymorphem Code können wir unserer Codebasis einen neuen Subtyp hinzufügen, Instanzen davon zu einer polymorphen Datenstruktur hinzufügen und den gesamten vorhandenen polymorphen Code ohne weitere Änderungen weiterhin automatisch arbeiten lassen. Wenn Sie eine Menge Code in einer großen Codebasis haben, die der Form "Wenn dieser Typ 'foo' ist, tun Sie das" ähnelt , könnte es für Sie eine schreckliche Belastung sein, 50 unterschiedliche Codeabschnitte zu aktualisieren, um sie einzuführen eine neue Art von Sache, und am Ende noch ein paar fehlen.
Die Vorteile des Polymorphismus in Bezug auf die Wartbarkeit verringern sich hier natürlich, wenn Sie nur ein paar oder sogar einen Teil Ihrer Codebasis haben, die solche Typprüfungen durchführen müssen.
Optimierungsbarriere
Ich würde vorschlagen, dies nicht so sehr vom Standpunkt des Verzweigens und Pipelining aus zu betrachten und es eher vom Standpunkt des Compilerdesigns der Optimierungsbarrieren aus zu betrachten. Es gibt Möglichkeiten, die Verzweigungsvorhersage für beide Fälle zu verbessern, z. B. das Sortieren von Daten nach Untertypen (sofern diese in eine Sequenz passen).
Was sich zwischen diesen beiden Strategien stärker unterscheidet, ist die Informationsmenge, über die der Optimierer im Voraus verfügt. Ein bekannter Funktionsaufruf liefert viel mehr Informationen, ein indirekter Funktionsaufruf, der zur Kompilierzeit eine unbekannte Funktion aufruft, führt zu einer Optimierungsbarriere.
Wenn die aufgerufene Funktion bekannt ist, können Compiler die Struktur verwischen und sie auf kleinere Werte komprimieren, Aufrufe einbinden, potenziellen Aliasing-Overhead eliminieren, die Zuweisung von Befehlen / Registern verbessern und möglicherweise sogar Schleifen und andere Formen von Verzweigungen neu anordnen und so harte Verzweigungen erzeugen -codierte Miniatur-LUTs, falls zutreffend (etwas in GCC 5.3 überraschte mich kürzlich mit einer switch
Aussage, bei der anstelle einer Sprungtabelle eine hartcodierte Daten-LUT für die Ergebnisse verwendet wurde).
Einige dieser Vorteile gehen verloren, wenn wir Kompilierungszeit-Unbekannte in den Mix aufnehmen, wie im Fall eines indirekten Funktionsaufrufs, und hier kann die bedingte Verzweigung höchstwahrscheinlich einen Vorteil bieten.
Speicheroptimierung
Nehmen Sie ein Beispiel für ein Videospiel, bei dem eine Sequenz von Kreaturen wiederholt in einer engen Schleife verarbeitet wird. In einem solchen Fall könnten wir einen polymorphen Container wie diesen haben:
vector<Creature*> creatures;
Hinweis: Der Einfachheit halber habe ich unique_ptr
hier gemieden .
... wo Creature
ist ein polymorpher Basistyp. In diesem Fall besteht eine der Schwierigkeiten bei polymorphen Behältern darin, dass sie häufig Speicher für jeden Subtyp separat / einzeln zuweisen möchten (z. B .: Standardwurf operator new
für jede einzelne Kreatur verwenden).
Dadurch wird häufig die erste Priorisierung für die Optimierung (falls erforderlich) des Speichers vorgenommen und nicht für die Verzweigung. Eine Strategie besteht darin, für jeden Subtyp einen festen Allokator zu verwenden und eine zusammenhängende Darstellung zu fördern, indem in großen Teilen Speicher für jeden zugeteilten Subtyp reserviert wird. Mit einer solchen Strategie kann es auf jeden Fall hilfreich sein, diesen creatures
Container nach Subtyp (sowie nach Adresse) zu sortieren , da dies nicht nur die Verzweigungsvorhersage, sondern auch die Referenzlokalität verbessert (sodass auf mehrere Kreaturen desselben Subtyps zugegriffen werden kann) aus einer einzelnen Cache-Zeile vor der Räumung).
Partielle Devirtualisierung von Datenstrukturen und Schleifen
Nehmen wir an, Sie haben all diese Bewegungen durchlaufen und wünschen sich immer noch mehr Geschwindigkeit. Es ist erwähnenswert, dass jeder Schritt, den wir hier unternehmen, die Wartbarkeit verschlechtert und wir uns bereits in einer Phase des Metallschleifens befinden, in der die Leistung abnimmt. Es muss also eine ziemlich große Leistungsanforderung geben, wenn wir dieses Gebiet betreten, in dem wir bereit sind, die Wartbarkeit für immer kleinere Leistungssteigerungen weiter zu opfern.
Der nächste Schritt zu versuchen (und immer mit der Bereitschaft, unsere Änderungen zurückzunehmen, wenn es überhaupt nicht hilft) könnte die manuelle Devirtualisierung sein.
Tipp zur Versionskontrolle: Wenn Sie nicht viel optimierungsfähiger sind als ich, kann es sich lohnen, an dieser Stelle einen neuen Zweig zu erstellen, der bereit ist, ihn wegzuwerfen, wenn unsere Optimierungsbemühungen fehlschlagen, was sehr gut passieren kann. Für mich ist es alles nur Versuch und Irrtum, auch mit einem Profiler in der Hand.
Trotzdem müssen wir diese Denkweise nicht im großen Stil anwenden. Nehmen wir an, dieses Videospiel besteht bei weitem zum größten Teil aus menschlichen Wesen. In einem solchen Fall können wir nur menschliche Kreaturen devirtualisieren, indem wir sie herausziehen und eine separate Datenstruktur nur für sie erstellen.
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
Dies impliziert, dass alle Bereiche in unserer Codebasis, die Kreaturen verarbeiten müssen, eine separate Sonderfallschleife für menschliche Kreaturen benötigen. Damit entfällt jedoch der Aufwand für den dynamischen Versand (oder besser gesagt die Optimierungsbarriere) für den Menschen, der bei weitem der häufigste Kreaturentyp ist. Wenn diese Gebiete zahlreich sind und wir es uns leisten können, könnten wir dies tun:
vector<Human> humans; // common case
vector<Creature*> other_creatures; // additional rare-case creatures
vector<Creature*> creatures; // contains humans and other creatures
... wenn wir uns das leisten können, können die weniger kritischen Pfade so bleiben wie sie sind und einfach alle Kreaturentypen abstrakt verarbeiten. Die kritischen Pfade können humans
in einer Schleife und other_creatures
in einer zweiten Schleife verarbeitet werden.
Wir können diese Strategie nach Bedarf erweitern und auf diese Weise möglicherweise einige Verbesserungen erzielen. Es ist jedoch erwähnenswert, inwieweit wir die Wartbarkeit in diesem Prozess herabsetzen. Die Verwendung von Funktionsvorlagen kann dabei helfen, den Code sowohl für Menschen als auch für Kreaturen zu generieren, ohne die Logik manuell zu duplizieren.
Teilweise Devirtualisierung von Klassen
Etwas, das ich vor Jahren gemacht habe und das wirklich eklig war, und ich bin mir nicht einmal sicher, ob es von Vorteil ist (dies war in der C ++ 03-Ära), war die teilweise Devirtualisierung einer Klasse. In diesem Fall haben wir bereits eine Klassen-ID für jede Instanz für andere Zwecke gespeichert (auf die über einen Accessor in der Basisklasse zugegriffen wurde, der nicht virtuell war). Da haben wir etwas Analoges gemacht (meine Erinnerung ist ein wenig verschwommen):
switch (obj->type())
{
case id_common_type:
static_cast<CommonType*>(obj)->non_virtual_do_something();
break;
...
default:
obj->virtual_do_something();
break;
}
... wo virtual_do_something
implementiert wurde, um nicht-virtuelle Versionen in einer Unterklasse aufzurufen. Ich weiß, es ist grob, einen expliziten statischen Downcast zu machen, um einen Funktionsaufruf zu devirtualisieren. Ich habe keine Ahnung, wie nützlich dies jetzt ist, da ich so etwas seit Jahren nicht mehr ausprobiert habe. Mit der Einführung in datenorientiertes Design empfand ich die oben beschriebene Strategie, Datenstrukturen und Schleifen heiß / kalt aufzuteilen, als weitaus nützlicher und öffnete mehr Türen für Optimierungsstrategien (und weitaus weniger hässlich).
Großhandel Devirtualization
Ich muss zugeben, dass ich noch nie so weit gekommen bin, eine Optimierungs-Denkweise anzuwenden, daher habe ich keine Ahnung von den Vorteilen. Ich habe indirekte Funktionen im Voraus vermieden, wenn ich wusste, dass es nur einen zentralen Satz von Bedingungen geben würde (z. B. Ereignisverarbeitung mit nur einem zentralen Platz, der Ereignisse verarbeitet), aber nie mit einer polymorphen Denkweise begonnen und den gesamten Weg optimiert bis hierher.
Theoretisch könnte der unmittelbare Vorteil darin bestehen, dass ein Typ möglicherweise weniger identifiziert wird als ein virtueller Zeiger (z. B. ein einzelnes Byte, wenn Sie sich auf 256 eindeutige Typen oder weniger festlegen können) und diese Optimierungsbarrieren vollständig beseitigt werden .
In einigen Fällen kann es auch hilfreich sein, einfacher zu verwaltenden Code zu schreiben (im Vergleich zu den oben beschriebenen Beispielen für die optimierte manuelle Devirtualisierung), wenn Sie nur eine zentrale switch
Anweisung verwenden, ohne Ihre Datenstrukturen und Schleifen nach Subtyp aufteilen zu müssen, oder wenn eine Bestellung vorliegt -Abhängigkeit in diesen Fällen, in denen die Dinge in einer genauen Reihenfolge verarbeitet werden müssen (auch wenn dies dazu führt, dass wir uns überall verzweigen). Dies ist in Fällen der Fall, in denen Sie nicht zu viele Stellen haben, an denen Sie das tun müssen switch
.
Ich würde dies im Allgemeinen nicht empfehlen, selbst bei einer sehr leistungskritischen Denkweise, es sei denn, dies ist relativ einfach zu warten. "Pflegeleicht" hängt in der Regel von zwei Faktoren ab:
- Sie benötigen keine echte Erweiterbarkeit (z. B. Sie müssen sicher sein, dass Sie genau 8 Arten von Dingen zu verarbeiten haben und nie mehr).
- Der Code enthält nicht viele Stellen, an denen diese Typen überprüft werden müssen (z. B. eine zentrale Stelle).
... dennoch empfehle ich in den meisten Fällen das obige Szenario und bei Bedarf Iteration zu effizienteren Lösungen durch teilweise Devirtualisierung. Es gibt Ihnen viel mehr Freiraum, um die Anforderungen an Erweiterbarkeit und Wartbarkeit mit der Leistung in Einklang zu bringen.
Virtuelle Funktionen vs. Funktionszeiger
Um das Ganze abzurunden, bemerkte ich hier, dass es einige Diskussionen über virtuelle Funktionen vs. Funktionszeiger gab. Es ist wahr, dass das Aufrufen von virtuellen Funktionen ein wenig zusätzliche Arbeit erfordert, dies bedeutet jedoch nicht, dass sie langsamer sind. Gegenintuitiv kann es sie sogar schneller machen.
Das ist hier nicht intuitiv, da wir es gewohnt sind, die Kosten in Form von Anweisungen zu messen, ohne auf die Dynamik der Speicherhierarchie zu achten, die tendenziell einen viel bedeutenderen Einfluss hat.
Wenn wir a class
mit 20 virtuellen Funktionen vergleichen, gegenüber a, in struct
dem 20 Funktionszeiger gespeichert sind, und beide mehrfach instanziiert werden, beträgt der Arbeitsspeicher-Overhead jeder class
Instanz in diesem Fall 8 Byte für den virtuellen Zeiger auf 64-Bit-Computern, während der Arbeitsspeicher Overhead von struct
ist 160 Bytes.
Die praktischen Kosten dort können viel mehr obligatorische und nichtobligatorische Cache-Fehler mit der Tabelle der Funktionszeiger im Vergleich zur Klasse bei Verwendung virtueller Funktionen (und möglicherweise Seitenfehler bei einer ausreichend großen Eingabeskala) sein. Diese Kosten machen die zusätzliche Indizierungsarbeit für eine virtuelle Tabelle in der Regel zu kurz.
Ich habe auch ältere C-Codebasen (älter als ich) behandelt, bei denen das mehrfache Setzen solcher structs
mit Funktionszeigern gefüllten und instanziierten Codebasen zu erheblichen Leistungssteigerungen führte (über 100% ige Verbesserungen), indem sie einfach in Klassen mit virtuellen Funktionen umgewandelt wurden aufgrund der massiven Reduzierung des Speicherverbrauchs, der erhöhten Cache-Freundlichkeit usw.
Auf der anderen Seite, wenn Vergleiche mehr über Äpfel zu Äpfeln werden, habe ich auch die entgegengesetzte Denkweise der Übersetzung von einer C ++ - Denkweise für virtuelle Funktionen in eine C-artige Funktionszeiger-Denkweise als nützlich für diese Arten von Szenarien befunden:
class Functionoid
{
public:
virtual ~Functionoid() {}
virtual void operator()() = 0;
};
... in der die Klasse eine einzelne überschreibbare Funktion gespeichert hat (oder zwei, wenn wir den virtuellen Destruktor zählen). In diesen Fällen kann es auf kritischen Pfaden durchaus hilfreich sein, dies zu verwandeln:
void (*func_ptr)(void* instance_data);
... idealerweise hinter einer typsicheren Schnittstelle, um die gefährlichen Würfe zu / von zu verbergen void*
.
In den Fällen, in denen wir versucht sind, eine Klasse mit einer einzelnen virtuellen Funktion zu verwenden, kann es schnell hilfreich sein, stattdessen Funktionszeiger zu verwenden. Ein wichtiger Grund ist nicht unbedingt der reduzierte Aufwand beim Aufrufen eines Funktionszeigers. Dies liegt daran, dass wir nicht länger versucht sind, die einzelnen Funktionsbereiche auf die verstreuten Bereiche des Haufens zu verteilen, wenn wir sie zu einer dauerhaften Struktur zusammenfassen. Diese Art von Ansatz kann es einfacher machen, Heap-assoziierten Overhead und Speicherfragmentierungs-Overhead zu vermeiden, wenn die Instanzdaten beispielsweise homogen sind und nur das Verhalten variiert.
Es gibt also definitiv einige Fälle, in denen die Verwendung von Funktionszeigern hilfreich sein kann, aber ich habe es oft andersherum gefunden, wenn wir eine Reihe von Tabellen mit Funktionszeigern mit einer einzelnen vtable vergleichen, für die nur ein Zeiger pro Klasseninstanz gespeichert werden muss . Diese V-Tabelle befindet sich häufig in einer oder mehreren L1-Cache-Zeilen sowie in engen Schleifen.
Fazit
Das ist mein kleiner Dreh in diesem Thema. Ich empfehle in diesen Bereichen mit Vorsicht vorzugehen. Vertrauensmessungen, nicht Instinkt, und angesichts der Art und Weise, wie diese Optimierungen die Wartbarkeit oft verschlechtern, gehen Sie nur so weit, wie Sie es sich leisten können (und ein kluger Weg wäre, sich auf der Seite der Wartbarkeit zu irren).