Datenorientierte Denkweise
Datenorientiertes Design bedeutet nicht, SoAs überall anzuwenden. Es bedeutet einfach, Architekturen mit einem Schwerpunkt auf Datendarstellung zu entwerfen - insbesondere mit einem Schwerpunkt auf effizientem Speicherlayout und Speicherzugriff.
Das könnte möglicherweise zu SoA-Wiederholungen führen, wenn dies angemessen ist:
struct BallSoa
{
vector<float> x; // size n
vector<float> y; // size n
vector<float> z; // size n
vector<float> r; // size n
};
... dies ist häufig für die vertikale Schleifenlogik geeignet, bei der die Vektorkomponenten und der Radius eines Kugelzentrums nicht gleichzeitig verarbeitet werden (die vier Felder sind nicht gleichzeitig scharf), sondern einzeln (eine Schleife durch den Radius, weitere 3 Schleifen) durch einzelne Komponenten von Kugelzentren).
In anderen Fällen ist es möglicherweise sinnvoller, einen AoS zu verwenden, wenn auf die Felder häufig gemeinsam zugegriffen wird (wenn Ihre Loop-Logik alle Felder von Bällen durchläuft und nicht einzeln) und / oder wenn ein wahlfreier Zugriff auf einen Ball erforderlich ist:
struct BallAoS
{
float x;
float y;
float z;
float r;
};
vector<BallAoS> balls; // size n
... in anderen Fällen kann es sinnvoll sein, einen Hybrid zu verwenden, der beide Vorteile in Einklang bringt:
struct BallAoSoA
{
float x[8];
float y[8];
float z[8];
float r[8];
};
vector<BallAoSoA> balls; // size n/8
... Sie können sogar die Größe eines Balls mit Hilfe von Half-Floats auf die Hälfte komprimieren, um mehr Ballfelder in eine Cache-Zeile / Seite einzufügen.
struct BallAoSoA16
{
Float16 x2[16];
Float16 y2[16];
Float16 z2[16];
Float16 r2[16];
};
vector<BallAoSoA16> balls; // size n/16
... vielleicht wird sogar der Radius nicht annähernd so oft abgerufen wie das Kugelzentrum (vielleicht behandelt Ihre Codebasis sie oft als Punkte und nur selten als Kugeln, z. B.). In diesem Fall wenden Sie möglicherweise eine Heiß- / Kaltfeld-Aufteilungstechnik an.
struct BallAoSoA16Hot
{
Float16 x2[16];
Float16 y2[16];
Float16 z2[16];
};
vector<BallAoSoA16Hot> balls; // size n/16: hot fields
vector<Float16> ball_radiuses; // size n: cold fields
Der Schlüssel zu einem datenorientierten Entwurf besteht darin, alle diese Arten von Darstellungen zu berücksichtigen, bevor Sie Ihre Entwurfsentscheidungen treffen, und sich nicht in eine suboptimale Darstellung mit einer dahinter liegenden öffentlichen Schnittstelle zu verstricken.
Die Speicherzugriffsmuster und die zugehörigen Layouts werden hervorgehoben, wodurch sie ein wesentlich größeres Problem darstellen als gewöhnlich. In gewissem Sinne kann es sogar Abstraktionen etwas abreißen. Ich habe durch die Anwendung dieser Denkweise mehr herausgefunden, dass ich std::deque
z. B. hinsichtlich der algorithmischen Anforderungen nicht mehr so viel betrachte wie die aggregierte zusammenhängende Blockdarstellung und wie der zufällige Zugriff darauf auf Speicherebene funktioniert. Es konzentriert sich ein wenig auf Implementierungsdetails, aber Implementierungsdetails wirken sich in der Regel genauso oder mehr auf die Leistung aus als die algorithmische Komplexität, die die Skalierbarkeit beschreibt.
Vorzeitige Optimierung
Ein Großteil des vorherrschenden Fokus des datenorientierten Designs wird zumindest auf den ersten Blick als gefährlich nahe an einer vorzeitigen Optimierung erscheinen. Die Erfahrung lehrt uns oft, dass solche Mikrooptimierungen am besten im Nachhinein und mit einem Profiler in der Hand angewendet werden.
Eine wichtige Botschaft des datenorientierten Designs ist es jedoch, Raum für solche Optimierungen zu lassen. Dies kann eine datenorientierte Denkweise ermöglichen:
Datenorientiertes Design kann Raum zum Atmen lassen, um effektivere Darstellungen zu erkunden. Es geht nicht unbedingt darum, das Speicherlayout auf einmal zu perfektionieren, sondern vielmehr darum, die entsprechenden Überlegungen im Voraus zu treffen, um immer optimalere Darstellungen zu ermöglichen.
Granulares objektorientiertes Design
Viele datenorientierte Designdiskussionen werden sich mit klassischen Vorstellungen von objektorientierter Programmierung messen. Dennoch würde ich eine Sichtweise anbieten, die nicht ganz so hart ist, wie OOP vollständig zu entlassen.
Die Schwierigkeit bei objektorientiertem Design besteht darin, dass wir häufig versucht sind, Schnittstellen auf einer sehr detaillierten Ebene zu modellieren, sodass wir uns auf eine skalare, einzelne Denkweise anstatt auf eine parallele Massen-Denkweise beschränken.
Stellen Sie sich als übertriebenes Beispiel eine objektorientierte Design-Denkweise vor, die auf ein einzelnes Pixel eines Bildes angewendet wird.
class Pixel
{
public:
// Pixel operations to blend, multiply, add, blur, etc.
private:
Image* image; // back pointer to access adjacent pixels
unsigned char rgba[4];
};
Hoffentlich macht das eigentlich niemand. Um das Beispiel wirklich grob zu machen, habe ich einen Rückwärtszeiger auf das Bild gespeichert, das das Pixel enthält, damit es auf benachbarte Pixel für Bildverarbeitungsalgorithmen wie Unschärfe zugreifen kann.
Der Bild-Zurück-Zeiger fügt sofort einen grellen Overhead hinzu, aber selbst wenn wir ihn ausgeschlossen haben (indem nur die öffentliche Schnittstelle von Pixeln Operationen bereitstellt, die für ein einzelnes Pixel gelten), erhalten wir eine Klasse, die nur ein Pixel darstellt.
An einer Klasse im unmittelbaren Overhead-Sinne in einem C ++ - Kontext ist jetzt nichts mehr auszusetzen außer diesem Rückzeiger. Die Optimierung von C ++ - Compilern ist großartig, wenn es darum geht, alle von uns erstellten Strukturen auf die kleinsten zu reduzieren.
Die Schwierigkeit dabei ist, dass wir eine gekapselte Schnittstelle mit einer zu genauen Pixelebene modellieren. Daher sind wir in dieser Art von granularem Design und Daten gefangen, die möglicherweise von einer Vielzahl von Client-Abhängigkeiten mit dieser Pixel
Schnittstelle verknüpft werden.
Lösung: Beseitigen Sie die objektorientierte Struktur eines granularen Pixels und beginnen Sie mit der Modellierung Ihrer Schnittstellen auf einer gröberen Ebene, die mit einer großen Anzahl von Pixeln (auf Bildebene) zu tun hat.
Durch die Modellierung auf Bulk-Image-Ebene haben wir erheblich mehr Optimierungsspielraum. Wir können zum Beispiel große Bilder als zusammengefügte Kacheln mit 16 x 16 Pixeln darstellen, die perfekt in eine 64-Byte-Cache-Zeile passen, aber einen effizienten vertikalen Nachbarzugriff auf Pixel mit einem typisch kleinen Schritt ermöglichen (wenn wir über eine Reihe von Bildverarbeitungsalgorithmen verfügen, die müssen vertikal auf benachbarte Pixel zugreifen) als datenorientiertes Hardcore-Beispiel.
Entwerfen auf einer gröberen Ebene
Das obige Beispiel für die Modellierung von Schnittstellen auf Bildebene ist ein einfaches Beispiel, da die Bildverarbeitung ein sehr ausgereiftes Gebiet ist, das bis zum Tod untersucht und optimiert wurde. Weniger offensichtlich könnte jedoch ein Partikel in einem Partikelemitter sein, ein Sprite gegen eine Sammlung von Sprites, eine Kante in einem Kantendiagramm oder sogar eine Person gegen eine Sammlung von Personen.
Der Schlüssel zum Ermöglichen datenorientierter Optimierungen (im Voraus oder im Nachhinein) besteht häufig darin, Schnittstellen in großen Mengen auf einer viel gröberen Ebene zu entwerfen. Die Idee, Schnittstellen für einzelne Entitäten zu entwerfen, wird durch die Idee ersetzt, Entitätensammlungen mit großen Operationen zu entwerfen, die sie in großen Mengen verarbeiten. Dies betrifft insbesondere und unmittelbar sequentielle Zugriffsschleifen, die auf alles zugreifen müssen und eine lineare Komplexität aufweisen müssen.
Datenorientiertes Design beginnt oft mit der Idee, Daten zu aggregierten Modellierungsdaten in großen Mengen zusammenzuführen. Eine ähnliche Denkweise spiegelt sich in den dazugehörigen Schnittstellendesigns wider.
Dies ist die wertvollste Lektion, die ich aus datenorientiertem Design gezogen habe, da ich nicht über ausreichende Kenntnisse der Computerarchitektur verfüge, um bei meinem ersten Versuch häufig das optimale Speicherlayout für etwas zu finden. Es wird zu etwas, zu dem ich mit einem Profiler in der Hand iteriere (und manchmal mit einigen Fehlern auf dem Weg, auf dem ich es nicht geschafft habe, die Dinge zu beschleunigen). Der Aspekt des Schnittstellendesigns bei datenorientiertem Design lässt mir jedoch Raum, nach immer effizienteren Datendarstellungen zu suchen.
Der Schlüssel liegt darin, Schnittstellen auf einer gröberen Ebene zu entwerfen, als wir normalerweise versucht sind. Dies hat häufig auch Nebeneffekte wie die Verringerung des mit virtuellen Funktionen, Funktionszeigeraufrufen, Dylib-Aufrufen und der Unfähigkeit, Inline-Funktionen zu verwenden, verbundenen dynamischen Dispatch-Overheads. Die Hauptidee, um all dies herauszunehmen, besteht darin, die Verarbeitung (falls zutreffend) in großen Mengen zu betrachten.
ball->do_something();
Vergleich zuball_table.do_something(ball)
), es sei denn, Sie möchten eine kohärente Entität über einen Pseudo-Zeiger vortäuschen(&ball_table, index)
.