Datenstrukturen für Spielprogrammierer: Massendaten

Bild

Jeder Programmierer profitiert von einem Verständnis der verschiedenen Datenstrukturen und der Analyse ihrer Leistung. In der Praxis habe ich mich jedoch nie für AVL-Bäume , rot-schwarze Bäume , Präfixbäume , Überspringlisten usw. als nützlich erwiesen . Ich verwende einige Datenstrukturen nur für einen bestimmten Algorithmus und für nichts anderes (zum Beispiel Heaps zum Implementieren einer Prioritätswarteschlange im A * -Pfad-Suchalgorithmus ).

In der täglichen Arbeit mache ich normalerweise mit überraschend wenigen Datenstrukturen. Meistens sind sie für mich nützlich:

  • Shared Data Arrays (Massendaten) - eine Möglichkeit, eine große Anzahl von Objekten effektiv zu speichern.
  • Schwache Referenzen (oder Handles ) - eine Möglichkeit, auf Objekte in Massendaten zuzugreifen, ohne dass ein Programm abstürzt, wenn das Objekt gelöscht wird.
  • Indizes sind eine Möglichkeit, schnell auf einzelne Teilmengen in Massendaten zuzugreifen.
  • Arrays von Arrays sind eine Möglichkeit, Massendatenobjekte mit dynamischen Größen zu speichern.

Ich werde einige Artikel darüber widmen, wie ich normalerweise all diese Strukturen implementiere. Beginnen wir mit den einfachsten und nützlichsten Massendaten.

Massendaten


Es gibt keinen gemeinsamen Begriff für dieses Konzept (oder ich weiß nichts darüber). Ich nenne eine " Massendaten " jede große Sammlung ähnlicher Objekte. Zum Beispiel könnte es sein:

  • Alle Kugeln im Spiel.
  • Alle Bäume im Spiel.
  • Alle Münzen im Spiel.

Wenn Sie Code auf einer höheren Abstraktionsebene schreiben, kann dies Folgendes sein:

  • Alle Entitäten im Spiel.
  • Alle Maschen im Spiel.
  • Alle Sounds im Spiel.

Normalerweise verfügt jedes System (Rendering, Sound, Animation, Physik usw.) in einem Spiel über verschiedene Arten von Objekten, die verfolgt werden müssen. Für ein Soundsystem könnte es beispielsweise sein:

  • Alle Soundressourcen, die gespielt werden können.
  • Alle Sounds werden gerade abgespielt.
  • Alle Effekte (Dämpfung, Tonänderungen usw.) werden auf Sounds angewendet.

Bei Massendaten gehe ich von Folgendem aus:

  • Die Speicherreihenfolge der Objekte ist nicht wichtig. Das heißt, Wir nehmen das Array als viele Objekte wahr.
  • Jedes Objekt wird als einfache Datenstruktur (POD-Struktur) fester Größe dargestellt, die mit memcpy() verschoben oder dupliziert werden kann.

Natürlich können Sie sich Situationen einfallen lassen, in denen die Reihenfolge wichtig ist . Wenn Objekte beispielsweise Elemente zum Rendern bezeichnen, müssen sie möglicherweise vor dem Rendern von vorne nach hinten sortiert werden, um das Neuzeichnen zu reduzieren.

Ich glaube jedoch, dass es in den meisten Fällen vorzuziehen ist, die Daten so zu sortieren, wie sie verwendet werden , anstatt sie in einem sortierten Container wie rot-schwarzen Bäumen oder B-Bäumen zu speichern. Beispielsweise können wir die gerenderten Objekte von vorne nach hinten sortieren, bevor sie an den Renderer übergeben werden, oder die Dateien alphabetisch sortieren, bevor sie in einer Liste angezeigt werden. Das Sortieren der Daten in jedem Frame mag kostspielig erscheinen, wird jedoch in vielen Fällen in O (n) mithilfe der Radix-Sortierung durchgeführt .

Da ich nur einfache Datenstrukturen verwende, bevorzuge ich C ++ - Objekte gegenüber C ++ - Objekten, da es einfacher ist, die Vorgänge im Speicher zu verstehen und ihre Leistung zu bewerten. Es gibt jedoch Situationen, in denen Sie Daten in Massendaten speichern müssen, die keine feste Größe haben. Zum Beispiel der Name oder die Liste der untergeordneten Objekte. Ich werde über diese Fälle in einem separaten Beitrag sprechen, in dem wir uns „Arrays of Arrays“ ansehen. Nehmen wir zunächst an, dass alle Objekte einfache Datenstrukturen mit fester Größe sind.

So sehen beispielsweise Massendatenstrukturen für unser hypothetisches Soundsystem aus:

 typedef struct { resource_t *resource; // Resource manager data uint64_t bytes; // Size of data uint64_t format; // Data format identifier } sound_resource_t; typedef struct { sound_resource_t *resource; // Resource that's playing uint64_t samples_played; // Number of samples played float volume; // Volume of playing sound } playing_sound_t; typedef struct { playing_sound_t *sound; // Faded sound float fade_from; // Volume to fade from float fade_to; // Volume to fade to double fade_from_ts; // Time to start fade double fade_to_ts; // Time to end fade } playing_fade_t; 

Bei der Überlegung, wie Massendaten gespeichert werden sollen, müssen einige Ziele berücksichtigt werden:

  • Das Hinzufügen und Entfernen von Objekten sollte schnell gehen.
  • Die Daten sollten sich in einer für das Caching geeigneten Form befinden, damit Sie sie schnell durchlaufen können, um das System zu aktualisieren.
  • Es muss den Verknüpfungsmechanismus unterstützen - es muss eine Möglichkeit geben, Informationen über bestimmte Objekte in Massendaten zu übertragen. Im obigen Beispiel sollte Fade in der Lage sein, anzugeben, welcher Ton gedämpft wird. Im Beispiel habe ich die Links als Zeiger geschrieben, aber ihre Implementierung hängt davon ab, wie die Massendaten angeordnet sind.
  • Daten müssen allokatorfreundlich sein - sie müssen mehrere große Speicherzuordnungen verwenden und dürfen keine einzelnen Objekte auf dem Heap zuordnen.

Die zwei einfachsten Möglichkeiten zur Darstellung von Massendaten sind ein statisches Array oder ein C ++ - Vektor:

 // Static array #define MAX_PLAYING_SOUNDS 1024 uint32_t num_playing_sounds; playing_sound_t playing_sounds[MAX_PLAYING_SOUNDS]; // C++ vector std::vector<playing_sound_t> playing_sounds; 

Das Arbeiten mit einem Array ist äußerst einfach und kann für Sie problemlos funktionieren, wenn Sie genau wissen, wie viele Objekte in der Anwendung benötigt werden. Wenn Sie dies nicht wissen , verschwenden Sie entweder Ihr Gedächtnis oder es gehen Ihnen die Objekte aus.

Der Vektor std::vector ist ebenfalls eine sehr wertvolle und einfache Lösung, aber hier müssen Sie einige Aspekte berücksichtigen:

  • Die Standardimplementierung von std::vector aus Visual Studio ist im Debug-Modus aufgrund des Debuggens von Iteratoren langsam. Sie sollten auf _ITERATOR_DEBUG_LEVEL = 0 gesetzt sein .
  • Zum Erstellen und Zerstören von Objekten verwendet std::vector Konstruktoren und Destruktoren. In einigen Fällen können sie viel langsamer als memcpy() .
  • std::vector viel schwieriger zu analysieren als die Implementierung eines einfachen „Stretchy Buffer“ .

Darüber hinaus unterstützen ohne zusätzliche Maßnahmen weder reguläre Arrays noch Vektoren Verweise auf einzelne Objekte. Schauen wir uns dieses Thema sowie andere wichtige Entwurfsentscheidungen an, die bei der Erstellung des Massendatensystems eine Rolle spielen.

Entfernungsstrategie


Die erste wichtige Entscheidung: Was ist beim Löschen des Objekts zu tun? a[i] . Hier sind drei Hauptoptionen:

  • Sie können alle nachfolgenden Elemente a[i+1]a[i] , a[i+2]a[i+1] usw. verschieben, um einen leeren Steckplatz zu schließen.
  • Sie können das letzte Element des Arrays in einen leeren Steckplatz verschieben: a[i] = a[n-1] .
  • Oder Sie können den Steckplatz leer lassen, indem Sie ein Loch im Array erstellen. Dieses Loch kann später verwendet werden, um ein neues Objekt zu platzieren.

Die erste Option ist schrecklich - O (n) wird für die Bewegung all dieser Elemente ausgegeben. Der einzige Vorteil der ersten Methode besteht darin, dass beim Sortieren des Arrays die Reihenfolge darin beibehalten wird. Aber wie oben erwähnt, stört uns die Bestellung nicht. Beachten Sie, dass genau dies passieren wird, wenn Sie a.erase() , um das std::vector Element zu entfernen!

Die zweite Option wird oft als "Swap-and-Pop" bezeichnet. Warum? Wenn Sie einen C ++ - Vektor verwenden, wird diese Option normalerweise implementiert, indem Sie das zu löschende Element gegen das letzte austauschen und anschließend das letzte Element löschen oder löschen:

 std::swap(a[i], a[a.size() - 1]); a.pop_back(); 

Warum ist das alles notwendig? Wenn wir in C ++ a[i] = a[n-1] zuweisen , müssen wir zuerst a[i] entfernen, indem wir seinen Destruktor aufrufen, und dann den Kopierkonstruktor aufrufen, um eine Kopie a[n-1] an Position i und zu erstellen Schließlich nennen wir den Destruktor a[n-1] wenn wir den Vektor verschieben. Wenn der Kopierkonstruktor Speicher zuweist und Daten kopiert, kann dies ziemlich schlecht sein. Wenn wir std::swap anstelle von Zuweisung verwenden, können wir nur mit den Konstruktoren verschieben und sollten keinen Speicher zuweisen.

Aus diesem Grund bevorzuge C ++ einfache Datenstrukturen und C-Operationen. C ++ weist viele Leistungsprobleme auf, in die Sie geraten können, wenn Sie nicht wissen, was im Inneren vor sich geht. In C ist der Swap-Löschvorgang sehr einfach:

 a.data[i] = a.data[--an]; 

Bei Verwendung von Swap-and-Pop bleiben Objekte dicht gepackt. Um ein neues Objekt zu platzieren, hängen Sie es einfach an das Ende des Arrays an.

Wenn wir die Option "Mit Löchern" I verwenden, müssen wir beim Platzieren eines neuen Objekts zunächst prüfen, ob freie "Löcher" verwendet werden können. Es lohnt sich, das Array nur dann zu vergrößern, wenn keine freien „Löcher“ vorhanden sind. Andernfalls wächst das Objekt beim Löschen und Erstellen von Objekten auf unbestimmte Zeit.

Sie können einen separaten std::vector<uint32_t> , um die Lochpositionen zu verfolgen. Es gibt jedoch eine bessere Lösung, für die kein zusätzlicher Speicher erforderlich ist.

Da die Daten des Objekts im „Loch“ für nichts verwendet werden, können Sie damit einen Zeiger auf das nächste freie Loch speichern. Somit bilden alle Löcher im Array eine einfach verbundene Liste , und bei Bedarf können wir Elemente hinzufügen und daraus entfernen.

Diese Art von Datenstruktur, in der nicht verwendeter Speicher zum Binden freier Elemente verwendet wird, wird normalerweise als freie Liste bezeichnet .

In einer herkömmlichen verknüpften Liste zeigt ein spezielles Listenkopfelement auf den ersten Knoten in der Liste, und das letzte Listenelement zeigt auf NULL, was das Ende der Liste bedeutet. Stattdessen bevorzuge ich eine zirkuläre verknüpfte Liste , in der die Überschrift nur ein spezielles Listenelement ist und das letzte Listenelement auf ein Überschriftenelement verweist:


Traditionelle und ringgebundene Listen.

Der Vorteil dieses Ansatzes besteht darin, dass der Code viel einfacher wird, indem die Anzahl der Sonderfälle am Anfang und Ende der Liste verringert wird.

Beachten Sie, dass sich Zeiger auf Objekte bei jeder Neuverteilung des Vektors ändern, wenn Sie std::vector zum Speichern von Objekten verwenden. Dies bedeutet, dass wir keine regulären Zeiger auf eine verknüpfte Liste verwenden können, da sich die Zeiger ständig ändern. Um dieses Problem zu umgehen, können Sie Indizes als „Zeiger“ auf die verknüpfte Liste verwenden, da der Index auch bei der Neuverteilung des Arrays ständig auf einen bestimmten Slot verweist. Wir werden im nächsten Abschnitt mehr über die Neuzuweisung sprechen.

Sie können einem speziellen Element des Listentitels Platz zuweisen, indem Sie es immer im Array-Slot 0 speichern.

Der Code sieht ungefähr so ​​aus:

 // The objects that we want to store: typedef struct {...} object_t; // An item in the free list points to the next one. typedef struct { uint32_t next_free; } freelist_item_t; // Each item holds either the object data or the free list pointer. typedef union { object_t; freelist_item_t; } item_t; typedef struct { std::vector<item_t> items; } bulk_data_t; void delete_item(bulk_data_t *bd, uint32_t i) { // Add to the freelist, which is stored in slot 0. bd->items[i].next = bd->items[0].next; bd->items[0].next = i; } uint32_t allocate_slot(bulk_data_t *bd) { const uint32_t slot = bd->items[0].next; bd->items[0].next = bd->items[slot].next; // If the freelist is empty, slot will be 0, because the header // item will point to itself. if (slot) return slot; bd->items.resize(bd->items.size() + 1); return bd->items.size() - 1; } 

Was ist die beste Entfernungsstrategie? Verschieben Sie das letzte Element in einen leeren Steckplatz, stellen Sie sicher, dass das Array dicht gepackt ist, oder halten Sie alle Elemente an ihrem Platz, indem Sie anstelle des gelöschten Elements „Löcher“ im Array erstellen.

Bei der Entscheidung müssen zwei Aspekte berücksichtigt werden:

  • Das Iterieren über ein dicht gepacktes Array ist schneller, da wir weniger Speicher umgehen und nicht zu viel Zeit damit verbringen müssen, leere Slots zu überspringen.
  • Wenn wir ein dicht gepacktes Array verwenden, bewegen sich die Elemente. Dies bedeutet, dass wir den Index eines Elements nicht als konstante Kennung für externe Verweise auf Elemente verwenden können. Wir müssen jedem Element einen anderen Bezeichner zuweisen und die Nachschlagetabelle verwenden, um diese konstanten IDs mit den aktuellen Objektindizes abzugleichen. Diese Nachschlagetabelle kann eine Hash-Tabelle oder ein std::vector mit Löchern sein, wie oben beschrieben (die zweite Option ist schneller). Wie auch immer, wir benötigen zusätzlichen Speicher für diese Tabelle und einen zusätzlichen indirekten Schritt für Bezeichner.

Die Auswahl der besten Option hängt von Ihrem Projekt ab.

Sie können sagen, dass das Speichern eines dicht gepackten Arrays besser ist, da Iterationen über alle Elemente (um das System zu aktualisieren) häufiger auftreten als das Abgleichen externer Links. Andererseits können wir sagen, dass die Leistung eines „Arrays mit Löchern“ nur bei einer großen Anzahl von Löchern schlechter ist, und bei der Spieleentwicklung ist uns normalerweise die Leistung im schlimmsten Fall wichtig (wir möchten eine Bildrate von 60 Hz haben, selbst wenn die maximalen Operationen im Spiel ausgeführt werden). . Im schlimmsten Fall haben wir die maximale Anzahl realer Objekte, und in diesem Fall gibt es keine Löcher im Array. Löcher treten nur auf, wenn die Anzahl der Objekte abnimmt und wir einige dieser Objekte löschen.

Es gibt auch Strategien, mit denen die Verarbeitung von Arrays mit vielen Löchern beschleunigt werden kann. Zum Beispiel können wir die Länge kontinuierlicher Lochsequenzen verfolgen, um ganze Lochsequenzen gleichzeitig zu überspringen, anstatt Element für Element. Da diese Daten nur für "Löcher" und nicht für gewöhnliche Elemente benötigt werden, können Sie sie zusammen mit dem Zeiger der Freigabeliste im nicht zugewiesenen Speicher von Objekten speichern und keinen zusätzlichen Speicher verschwenden.

Wenn Sie den Code für schnelle Iterationen nicht optimieren müssen, ist es meiner Meinung nach wahrscheinlich am besten, die Option "Array mit Löchern" zu verwenden. Es ist einfacher, erfordert keine zusätzlichen Suchstrukturen und Sie können den Index des Objekts als ID verwenden, was sehr praktisch ist. Darüber hinaus werden durch das Fehlen beweglicher Objekte mögliche Fehler beseitigt.


Strategien zum Entfernen von Massendaten.

Schwache Zeiger


Als Hinweis möchte ich sagen, dass es einfach ist, die Unterstützung für "schwache Zeiger" oder "Deskriptoren" für Massendatenobjekte zu implementieren.

Ein schwacher Zeiger ist ein Verweis auf ein Objekt, der auf irgendeine Weise feststellen kann, dass das Objekt, auf das er verweist, gelöscht wurde. Bei schwachen Zeigern ist es praktisch, dass Sie damit Objekte löschen können, ohne sich Gedanken darüber machen zu müssen, wer auf sie verweisen kann. Ohne schwache Zeiger zum Entfernen eines Objekts müssten wir nach jedem einzelnen Link suchen und ihn für ungültig erklären. Dies kann besonders schwierig sein, wenn die Links im Skriptcode, auf anderen Computern im Netzwerk usw. gespeichert sind.

Denken Sie daran, dass wir bereits eine ID haben, die vorhandene Objekte eindeutig identifiziert. Bei der Option "mit Löchern" ist diese ID einfach der Index des Elements (da sich die Elemente niemals bewegen). Bei dicht gepackten Arrays ist dieser Objektindex ein Datensatz im Sucharray .

Die ID selbst kann nicht als schwacher Zeiger verwendet werden, da IDs wiederverwendet werden können. Wenn ein Element gelöscht und ein neues Element im selben Slot erstellt wird, können wir es nicht allein anhand der ID ermitteln. Um einen schwachen Zeiger zu erhalten, müssen Sie die ID mit dem generation kombinieren:

 typedef struct { uint32_t id; uint32_t generation; } weak_pointer_t; 

Das generation ist ein Feld in der Objektstruktur, das verfolgt, wie oft der Steckplatz im Massendatenarray wiederverwendet wurde. (Bei dichtem Packen wird nachverfolgt, wie oft der Steckplatz im Suchfeld wiederverwendet wurde.)

Wenn Sie einen Artikel löschen, erhöhen wir die Generierungsnummer in seinem Steckplatz. Um zu überprüfen, ob der schwache Zeiger noch gültig ist, prüfen wir, ob die generation in der Struktur des schwachen Zeigers mit der Generierung des durch seine id angegebenen Slots übereinstimmt. Wenn sie übereinstimmen, ist das Quellobjekt, auf das wir verweisen, noch vorhanden. Wenn nicht, bedeutet dies, dass es gelöscht wird und der Steckplatz entweder auf der Versionsliste steht oder wiederverwendet wurde.

Beachten Sie, dass das generation sowohl für Löcher als auch für vorhandene Objekte erforderlich ist und Sie es außerhalb der Union speichern müssen:

 typedef struct { uint32_t generation; union { object_t; freelist_item_t; }; } item_t; 

Vertriebsstrategie


Wenn Sie std::vector zum Speichern von Elementdaten verwenden, wird das gesamte Array von Elementen neu verteilt, wenn das Array voll ist und vergrößert werden muss. Vorhandene Elemente werden in das neue Array kopiert.

std::vector wächst geometrisch . Dies bedeutet, dass jedes Mal, wenn ein Vektor erhöht werden muss, die Anzahl der verteilten Elemente mit einem Faktor multipliziert wird (normalerweise mit × 2). Das geometrische (exponentielle) Wachstum ist wichtig, da es die Kosten für die Erhöhung des Arrays konstant hält.

Bei der Neuverteilung des Arrays müssen alle Elemente verschoben werden, was O (n) erfordert. Wenn das Array jedoch wächst, fügen wir Platz für weitere n Elemente hinzu, da wir die Größe verdoppeln. Dies bedeutet, dass wir das Array erst wieder vergrößern müssen, wenn wir n weitere Elemente hinzufügen. Das heißt, die Erhöhungskosten sind gleich O (n) , aber wir führen sie nur * n (n) * zum n-ten Mal des Schreibens in das Array aus, dh die Kosten für das Schreiben eines Elements betragen im Durchschnitt O (n) / O (n) = O (1) .

Die Kosten für die Erfassung eines Artikels werden als amortisierte Konstante bezeichnet . Wenn Sie alle ausgeführten Datensätze mitteln, werden die Kosten festgelegt. Wir sollten jedoch nicht vergessen, dass sich die Kosten vor dem Durchschnitt als sehr krampfhaft herausstellen. Nach jedem O (n) -Datensatz erhalten wir einen Peak der Höhe O (n) :


Die Kosten für das Schreiben in std::vector .

Mal sehen, was passiert, wenn wir kein geometrisches Wachstum verwenden. Angenommen, anstatt den Speicher während des Wachstums zu verdoppeln, fügen wir einfach weitere 128 Slots hinzu. Das Verschieben alter Daten kostet uns immer noch O (n) , aber jetzt müssen wir dies alle 128 hinzugefügten Elemente tun, dh die durchschnittlichen Kosten betragen jetzt O (n) / O (128) = O (n) . Die Kosten für das Schreiben eines Elements in ein Array sind proportional zur Größe des Arrays. Wenn das Array also groß wird, beginnt es mit einer Schildkrötengeschwindigkeit zu arbeiten. Ups!

Die Verteilungsstrategie std::vector ist eine gute Standardoption, die in den meisten Fällen gut funktioniert, jedoch einige Probleme aufweist:

  • Amortized Constant ist für Echtzeitsoftware nicht gut geeignet. Wenn Sie ein sehr großes Array haben, z. B. Hunderte Millionen Elemente, kann das Erhöhen dieses Arrays und das Verschieben aller Elemente zu einer spürbaren Verlangsamung der Bildrate führen. Dies ist aus demselben Grund problematisch, aus dem die Speicherbereinigung in Spielen problematisch ist. Es spielt keine Rolle, wie niedrig die durchschnittlichen Kosten sind, wenn in einigen Frames die Kosten steigen können, was zu Spielfehlern führt.
  • In ähnlicher Weise kann diese Zuordnungsstrategie bei großen Arrays viel Speicher verschwenden. Nehmen wir an, wir haben ein Array von 16 Millionen Elementen und müssen ein weiteres darin schreiben. Dadurch wächst das Array auf 32 Millionen. Jetzt haben wir 16 Millionen Elemente im Array, die wir nicht verwenden. Für eine Plattform mit wenig Speicher ist dies viel.
  • Schließlich verschiebt die Neuzuweisung Objekte im Speicher, wodurch alle Zeiger auf Objekte ungültig werden. Dies kann eine Quelle von Fehlern sein, die schwer zu verfolgen sind.

Der folgende Code ist ein Beispiel für Fehler, die beim Verschieben von Objekten auftreten können:

 // Create two items and return the sum of their costs. float f(bulk_data_t *bd) { const uint32_t slot_1 = allocate_slot(bd); item_t *item_1 = &bd->items[slot_1]; const uint32_t slot_2 = allocate_slot(bd); item_t *item_2 = &bd->items[slot_2]; return item_1->cost + item_2->cost; } 

Das Problem hierbei ist, dass die Funktion allocate_slot() möglicherweise das Array neu verteilen muss, um Platz für item_2 zu schaffen. In diesem Fall wird item_1 in den Speicher verschoben und der Zeiger auf item_1 ist nicht mehr gültig. In diesem speziellen Fall können wir den Fehler beseitigen, indem wir das Zuweisungselement_1 item_1 . Ähnliche Fehler können jedoch unmerklicher auftreten. Persönlich haben sie mich oft gebissen.

Eine solche Situation ist perfide durch die Tatsache, dass der Fehler nur dann slot_2 , wenn das Array genau zum Zeitpunkt der slot_2 . Das Programm kann lange Zeit korrekt funktionieren, bis sich das Verteilungsmuster ändert. Danach funktioniert der Fehler.

All diese Probleme können mit einer anderen Vertriebsstrategie gelöst werden. Hier sind einige der Optionen:

  • : 16, 32, 64, …, . , 16 , 32 , .… , std::vector .
  • , . , . . , O(n) push() , .
  • , , , , .

, . , - , , . , , .

, — ? , . , 16 , 16 , . , , 50 %. .

, , , . *16 K * n*, n — bulk data , , ( ).

. -, , blocks\[i / elements_per_block\][i % elements_per_block] . -, , (heap allocator), .

, « », - std::vector , , . , , .

, , ID . , , . , 64 , 32 (4 — ).





(Array of Structures, AoS) (Structure of Arrays, SoA). . , , , , :

 typedef struct { float t; vec3_t pos; vec3_t vel; vec3_t col; } particle_t; 

struct . « ». :

 uint32_t num_particles; particle_t *particles; 

, .

(SoA) struct:

 uint32_t num_particles; typedef struct { float *t; vec3_t *pos; vec3_t *vel; vec3_t *col; } particles; 

, , vec3_t struct:

 uint32_t num_particles; typedef struct { float *t; float *pos_x; float *pos_y; float *pos_z; float *vel_x; float *vel_y; float *vel_z; float *col_r; float *col_g; float *col_b; } particles; 

, AoS, ? :

  • . , tick() t . simulate_physics() pos vel . SoA struct. ( ), . , tick() 1/10 , , 10 .
  • SoA SIMD . , FPU . AVX float , 8 .

, tick() 80 ? Nein. 10 , , , SIMD .

SoA:

  • .
  • , .
  • particle_t * , . .
  • ,
  • ( ), . , .

, , struct , VM ( ). - 10 struct . 8- -, , . Ups!

— SIMD. :

 uint32_t num_particles; typedef struct { float t[8]; float position_x[8]; float position_y[8]; float position_z[8]; float velocity_x[8]; float velocity_y[8]; float velocity_z[8]; float color_r[8]; float color_g[8]; float color_b[8]; } eight_particles_t; eight_particles_t *particles; 

- SIMD- , -, . , .

tick() 32 , 288 , 32 .. , 10- , t . -, - 64 , , , 5 . , , -, 100% .

, . , [16] , float - . , , , :


AoS SoA.

, SoA — « », SIMD , ( «»).

SIMD- «» , , , «» . , , , . next , SIMD- . struct.

, , , struct . , , .

AoS SoA, , . «» AoS SoA , SIMD-, , . .

— AoS SoA - . , AoS SoA, , AoS ( ). , , .

, « ». 16- , SoA, . scratch buffer 16 .

Fazit


, « » bulk data :

«» , VM ( ), ( 16 , ).

, :

, 8 SIMD VM .

.

Source: https://habr.com/ru/post/de469861/


All Articles