Ich drehe einige der zentralsten Teile meiner Codebasis (eine ECS-Engine) um die Art der von Ihnen beschriebenen Datenstruktur, obwohl sie kleinere zusammenhängende Blöcke verwendet (eher 4 Kilobyte anstelle von 4 Megabyte).
Es verwendet eine doppelte freie Liste, um Einfügungen und Entfernungen in konstanter Zeit zu erreichen, mit einer freien Liste für freie Blöcke, die zum Einfügen bereit sind (Blöcke, die nicht voll sind), und einer subfreien Liste innerhalb des Blocks für Indizes in diesem Block bereit, beim Einsetzen zurückgefordert zu werden.
Ich werde die Vor- und Nachteile dieser Struktur behandeln. Beginnen wir mit einigen Nachteilen, denn es gibt eine Reihe von Nachteilen:
Nachteile
- Das Einfügen von ein paar hundert Millionen Elementen in diese Struktur dauert ungefähr viermal länger als
std::vector
(eine rein zusammenhängende Struktur). Und ich bin ziemlich vernünftig bei Mikrooptimierungen, aber es gibt nur konzeptionell mehr Arbeit zu tun, da der übliche Fall zuerst den freien Block oben in der Liste der freien Blöcke untersuchen muss, dann auf den Block zugreifen und einen freien Index aus den Blöcken einfügen muss freie Liste, schreibe das Element an die freie Position und überprüfe dann, ob der Block voll ist und lösche den Block aus der Liste der freien Blöcke, wenn dies der Fall ist. Es ist immer noch eine Operation mit konstanter Zeit, aber mit einer viel größeren Konstante, als wenn man zurückschiebt std::vector
.
- Der Zugriff auf Elemente mit einem Direktzugriffsmuster dauert etwa doppelt so lange, da die zusätzliche Indizierungsarithmetik und die zusätzliche Indirektionsebene erforderlich sind.
- Der sequenzielle Zugriff wird einem Iteratorentwurf nicht effizient zugeordnet, da der Iterator jedes Mal, wenn er inkrementiert wird, eine zusätzliche Verzweigung ausführen muss.
- Der Speicherbedarf beträgt in der Regel etwa 1 Bit pro Element. 1 Bit pro Element klingt vielleicht nicht nach viel, aber wenn Sie damit eine Million 16-Bit-Ganzzahlen speichern, sind das 6,25% mehr Speicher als bei einem perfekt kompakten Array. In der Praxis wird jedoch in der Regel weniger Speicher verwendet, als
std::vector
wenn Sie die komprimieren vector
, um die überschüssige Kapazität zu beseitigen, die sie reserviert. Auch verwende ich es im Allgemeinen nicht, um solche jugendlichen Elemente zu speichern.
Vorteile
- Der sequenzielle Zugriff mit einer
for_each
Funktion, die Rückrufverarbeitungsbereiche von Elementen innerhalb eines Blocks verwendet, ist mit der Geschwindigkeit des sequenziellen Zugriffs nahezu konkurrierend std::vector
(nur wie ein 10% iger Unterschied). Die meiste Zeit in einer ECS-Engine wird mit sequenziellem Zugriff verbracht.
- Es ermöglicht zeitlich konstante Entfernungen aus der Mitte, wobei die Struktur die Zuordnung von Blöcken aufhebt, wenn sie vollständig leer sind. Infolgedessen ist es im Allgemeinen recht anständig, sicherzustellen, dass die Datenstruktur niemals wesentlich mehr Speicher als erforderlich belegt.
- Es macht keine Indizes für Elemente ungültig, die nicht direkt aus dem Container entfernt werden, da nur Löcher zurückbleiben, indem ein Ansatz mit einer freien Liste verwendet wird, um diese Löcher beim anschließenden Einfügen zurückzugewinnen.
- Selbst wenn diese Struktur eine epische Anzahl von Elementen enthält, müssen Sie sich nicht so viele Gedanken über Speichermangel machen, da nur kleine zusammenhängende Blöcke angefordert werden, die für das Betriebssystem keine Herausforderung darstellen, eine große Anzahl nicht verwendeter zusammenhängender Blöcke zu finden Seiten.
- Es eignet sich gut für Parallelität und Thread-Sicherheit, ohne die gesamte Struktur zu sperren, da Operationen im Allgemeinen auf einzelne Blöcke beschränkt sind.
Jetzt war einer der größten Vorteile für mich, dass es trivial geworden ist, eine unveränderliche Version dieser Datenstruktur zu erstellen:
Seitdem öffnete sich jede Art von Türen für das Schreiben von mehr Funktionen ohne Nebenwirkungen, was es viel einfacher machte, Ausnahmesicherheit, Thread-Sicherheit usw. zu erreichen. Die Unveränderlichkeit war eine Sache, mit der ich entdeckte, dass ich sie leicht erreichen konnte Diese Datenstruktur ist im Nachhinein und aus Versehen entstanden, aber wahrscheinlich einer der schönsten Vorteile, die sie mit sich gebracht hat, da sie die Pflege der Codebasis erheblich vereinfacht hat.
Nicht zusammenhängende Arrays haben keine Cache-Lokalität, was zu einer schlechten Leistung führt. Bei einer Blockgröße von 4 MB scheint es jedoch genügend Orte für eine gute Zwischenspeicherung zu geben.
Bei Blöcken dieser Größe sollte man sich nicht mit der Lokalität von Referenzen befassen, geschweige denn mit 4-Kilobyte-Blöcken. Eine Cache-Zeile hat normalerweise nur 64 Byte. Wenn Sie Cache-Ausfälle reduzieren möchten, konzentrieren Sie sich nur auf die richtige Ausrichtung dieser Blöcke und bevorzugen nach Möglichkeit sequentiellere Zugriffsmuster.
Eine sehr schnelle Möglichkeit, ein Direktzugriffsspeichermuster in ein sequentielles umzuwandeln, besteht in der Verwendung eines Bitsets. Angenommen, Sie haben eine Schiffsladung Indizes und diese sind in zufälliger Reihenfolge. Sie können sie einfach durchpflügen und Bits im Bitset markieren. Dann können Sie durch Ihren Bitsatz iterieren und prüfen, welche Bytes ungleich Null sind, indem Sie beispielsweise jeweils 64 Bits prüfen. Sobald Sie auf einen Satz von 64-Bit stoßen, von denen mindestens ein Bit gesetzt ist, können Sie mithilfe von FFS- Anweisungen schnell feststellen, welche Bits gesetzt sind. Die Bits geben an, auf welche Indizes Sie zugreifen sollen, es sei denn, Sie erhalten die Indizes nacheinander sortiert.
Dies hat einen gewissen Overhead, kann aber in einigen Fällen einen lohnenden Austausch bedeuten, insbesondere wenn Sie diese Indizes mehrmals durchlaufen werden.
Der Zugriff auf ein Objekt ist nicht ganz so einfach, es gibt eine zusätzliche Indirektionsebene. Würde das weg optimiert werden? Würde es Cache-Probleme verursachen?
Nein, es kann nicht weg optimiert werden. Zumindest der Direktzugriff kostet bei dieser Struktur immer mehr. Es erhöht Ihre Cache-Ausfälle jedoch häufig nicht so stark, da Sie mit dem Array von Zeigern auf Blöcke in der Regel eine hohe zeitliche Lokalität erzielen, insbesondere, wenn Ihre allgemeinen Ausführungspfade sequentielle Zugriffsmuster verwenden.
Da nach Erreichen des 4-MB-Grenzwerts ein lineares Wachstum zu verzeichnen ist, können Sie weit mehr Zuweisungen vornehmen als normalerweise (z. B. maximal 250 Zuweisungen für 1 GB Arbeitsspeicher). Nach 4M wird kein zusätzlicher Speicher kopiert. Ich bin mir jedoch nicht sicher, ob die zusätzlichen Zuordnungen teurer sind als das Kopieren großer Speicherblöcke.
In der Praxis ist das Kopieren oft schneller, weil es selten vorkommt und nur so etwas wie " log(N)/log(2)
times total" auftritt, während gleichzeitig der übliche schmutzig-billige Fall vereinfacht wird, in dem Sie ein Element viele Male in das Array schreiben können, bevor es voll wird und erneut zugeteilt werden muss. In der Regel werden Sie mit dieser Art von Struktur keine schnelleren Einfügungen erhalten, da die allgemeine Fallarbeit teurer ist, selbst wenn sie sich nicht mit dem teuren seltenen Fall der Neuzuweisung großer Arrays befassen muss.
Die Hauptattraktivität dieser Struktur liegt für mich trotz aller Nachteile in der Reduzierung des Speicherbedarfs, da ich mir keine Gedanken über OOM machen muss und Indizes und Zeiger speichern kann, die nicht ungültig werden, die Parallelität und die Unveränderlichkeit. Es ist schön, eine Datenstruktur zu haben, in der Sie Dinge in konstanter Zeit einfügen und entfernen können, während sie sich selbst bereinigt und Zeiger und Indizes in der Struktur nicht ungültig macht.