- Wenn man Enums/Tagged Unions mit unterschiedlich großen Varianten in großer Zahl speichert, steigen in
VecundHashMapdurch die Reservierung nach der größten Variante die Kosten für Padding und Fragmentierung stark an - Zig kann über
comptimeund Typ-Reflexion Feldgrößen, Alignment und Discriminant prüfen und Enum-Container auf Basis des Speicherlayouts generisch umformen - Ein einfaches
Vec<Enum>verbraucht pro Element immer so viel Platz wie die größte Variante; SoA reduziert Tag-Padding, lässt aber die Varianten-Fragmentierung im Wertebereich bestehen - Dense AoVA, das gleich große Varianten bündelt, reduziert beim Beispiel-Enum die 15 Vektoren auf 3 Cluster mit 2, 4 und 8 Byte, erschwert aber typsichere Iteration, wenn mehrere Varianten in derselben Allocation gemischt werden
- Rust-Proc-Macros haben nur schwer Zugriff auf Informationen zu Typgröße und Alignment und sind auch bei der Berechnung generischer Array-Längen eingeschränkt, wodurch Zigs typbewusstes Staging die kombinierbare Speichereffizienz von Systemcode besser sichtbar macht
Warum Rust-Enum-Arrays Platz verschwenden
- Enums/Tagged Unions mit unterschiedlich großen Varianten müssen genug Speicher reservieren, um die größte Variante aufnehmen zu können
- Das Beispiel-Enum
Foohat Varianten mitu8,u16,u32undu64; durch Tag und Alignment wird der Typ 16 Byte groß - Wenn man viele solcher Enums in
VecoderHashMapablegt, belegt jedes Element Platz nach dem Maßstab der größten Variante, wodurch Padding und Fragmentierung zunehmen - Eine Umwandlung in struct of arrays (SoA) mit separater Allocation für den Tag kann etwas Padding reduzieren, beseitigt aber nicht die Fragmentierung im Wertebereich, die durch unterschiedliche Variantengrößen entsteht
- Auch in Rust kann man Datenstrukturen für ein bestimmtes Enum von Hand bauen, aber eine für beliebige Enums generische Datenstruktur mit maximaler Speichereffizienz ist schwer oder praktisch nahezu unmöglich
- Ein Proc-Macro lässt sich bei Typen Dritter oder bei Type Aliases nur schwer mit
#[derive]versehen und ist wenig kombinierbar - Es fehlt Typbewusstsein, und Workarounds auf Basis von
generic_const_exprverteilen ausführlichewhere-Klauseln über den Call-Graph und harmonieren schlecht mit generischen Typparametern
- Ein Proc-Macro lässt sich bei Typen Dritter oder bei Type Aliases nur schwer mit
Warum das Problem bei Compiler-ASTs besonders auffällt
- Eine der wichtigsten Motivationen für effiziente Enum-Arrays ist der Speicherverbrauch von Compiler-ASTs
- Große ASTs verursachen während der Kompilierung Memory-Latency und Cache-Eviction und werden so zu einem erheblichen Kostenfaktor für die Frontend-Performance
- In einem Video über den Carbon-Compiler sagt Chandler Carruth, dass ein geparster clang-AST häufig 50-mal mehr Speicher als der ursprüngliche Quellcode verbraucht
- Ein Beispiel für die Darstellung von Ausdrucksknoten in Rust besteht aus dem Enum
ExprUnitNumberBinary(Operation, ExprId, ExprId)Ident(Symbol)Eval(ExprId, ExprSlice)BlockExpression(ExprId, StatementSlice)
- In OCaml übernimmt das Laufzeitsystem mit dem GC die Speicherverwaltung, sodass sich rekursive Datentypen ohne explizite Indirection ausdrücken lassen
- In Rust verbraucht
Vec<Expr>für jedes Elementsizeof(Enum)an Platz, einschließlich der größten Variante sowie Tag und Padding
Fragmentierung mit SoA und AoVA reduzieren
- Wenn ein einfaches Enum mit drei Varianten Mitglieder von 8, 16 und 32 Bit enthält, reserviert ein normales
Vecfür alle Elemente viel Platz, damit die 32-Bit-Variante und die Alignment-Anforderungen passen - Eine verbreitete Verbesserung ist, mithilfe von Tagged Indices die Enum-Variante selbst klein zu halten
- das
tagged_index-Crate des Rust-Compilers - Beispiele für Small-String-Optimization
- eine häufige Optimierung in Hochleistungscode wie Sprachruntimes, GC, Compilern, Game Engines und OS-Kerneln
- das
- Man kann auch den Container ändern und mit SoA Discriminant und Werte in separaten Allocations speichern
- der selbstgehostete Zig-Compiler nutzt diesen Ansatz
- das durch Tags entstehende Padding wird geringer, aber in der Union-Wertesammlung bleibt Varianten-Fragmentierung bestehen
- Durch Zigs Staged Compilation lassen sich Container generisch bauen, die eine SoA-Transformation für beliebige Typen durchführen
- Rust ist dafür auf Proc-Macros wie
soa_deriveangewiesen und hat die Einschränkung, dass man ohne Änderungen am Quellcode eines Dritttyps kein#[derive]hinzufügen kann
Arrays pro Variante und Clustering nach Größe
- Um die Fragmentierung im Wertebereich weiter zu verringern, kann man pro Variante einen Vektor verwenden
- Beim Einfügen gibt man dann einen Tagged Index zurück, der sowohl den Enum-Tag als auch den Index im jeweiligen Varianten-Array enthält
- Dieses Muster wird array of variant arrays (AoVA) genannt
- AoVA lässt sich in Rust per Proc-Macro und in Zig mit
comptimeumsetzen - Wenn es viele Varianten gibt und mehrere davon gleich groß sind, führt ein Vektor pro Variante schnell zu zu vielen Vektoren
- das Beispiel-Enum
Foohat 15 Varianten - der Ansatz mit einem Vektor pro Variante fügt 15 Vektoren hinzu
- Reallocations und System-Calls können zunehmen, und gegenüber einem naiven
Veckann für die Amortisierung mehr Speicher nötig sein - die Vektoren können zufällig über den Speicher verteilt sein, was die Wahrscheinlichkeit für Cache-Konflikte erhöht
- auch der AoVA-Container selbst kann viel Speicher verbrauchen und eingebettete Strukturen aufblähen
- das Beispiel-Enum
- Gruppiert man stattdessen nach Größe, zerfällt das Beispiel-Enum in drei Cluster: 2 Byte, 4 Byte und 8 Byte
c_2: Vec<[u8; 2]>speichertAbisDc_4: Vec<[u8; 4]>speichertEbisIc_8: Vec<[u8; 8]>speichertJbisO
- Der Dense-AoVA-Ansatz kann die Gesamtzahl der Vektoren um 80 % reduzieren
- Wenn unterschiedliche Varianten in derselben Allocation zusammenliegen, wird typsichere Iteration über den Vektor schwierig
- Zugriff ist dann nur über den beim Einfügen erzeugten Tagged Pointer möglich
- in baumartigen Strukturen auf Basis abgeflachter Indizes, die keine blinde Iteration brauchen, kann das ein akzeptabler Trade-off sein
- Wenn typsichere Iteration nötig ist, kann man den Tag wieder hinzufügen und die Padding-Kosten in Kauf nehmen
- Wenn das Padding zu groß wird, lässt sich auf jedes Varianten-Array wieder eine SoA-Transformation anwenden; dann verdoppelt sich allerdings die Zahl der Vektoren
Die kombinierbare Speicherlayout-Effizienz durch Zig-comptime
- Der Zig-Prototyp ist in osmium implementiert
- Entscheidend ist die Compile-Time-Reflexion, die über Compiler-Built-ins Feldtypen, Bytegröße, Bitgröße und Discriminant untersucht
- Im Beispielcode prüft
@typeInfo(inner)die Art des Typs und verarbeitet nur den Union-Fall- es iteriert über die Union-Felder
- mit
@max(field.alignment, @sizeOf(field.type))wird der benötigte Platz berechnet - Größeninformationen werden in einem stack-allokierten Vektor gespeichert
- es wird ein Mapping von Union-Feld zu Cluster-Index aufgebaut
- ist der Typ keine Union, wird ein Compile Error ausgelöst
- Der genaue Codeschnipsel steht in dieser Quelle
- Dasselbe Beispiel lässt sich mit einem Rust-Proc-Macro im Grunde nicht erzeugen
- Proc-Macros haben keinen Zugriff auf Informationen zu Typgröße oder Alignment
- man könnte zwar ein
const fnfür die Cluster-Berechnung eines bestimmten Enums erzeugen, es aber nicht zur Festlegung generischer Array-Längen eines generischen Typs verwenden
- In Rust ist es schwierig, einen generischen Container je nach gegebenem Typ bedingt unterschiedlich zu implementieren, etwa ob es sich um ein Enum oder eine Struct handelt
- In Zig wäre konzeptionell so etwas möglich wie die Auswahl zwischen
EfficientEnumArray<T>undEfficientStructArray<T>abhängig vonT.isEnum() - Auch die AoVA-Implementierung kann anhand von Enum-Eigenschaften ausgewählt werden
- zum Beispiel ließe sich spezialisieren, dass das gemeinsame Platzieren unterschiedlicher Varianten nur dann sinnvoll ist, wenn sich dadurch die Zahl der Vektoren um mehr als 90 % reduzieren lässt
- Wenn die maximale Capacity zur Compile-Zeit bekannt ist, kann die Funktion zur Typerzeugung die für den Tagged Index nötige Bitbreite bestimmen
- Wenn dieser Tagged Index in anderen Datenstrukturen steckt, etwa in einem weiteren Enum, können verbleibende Bits für den Discriminant genutzt werden
- Zig erlaubt es, die benötigte Bitzahl konkret festzulegen, sodass andere Teile des Codes diese Information natürlich weiterverwenden können; das ergibt eine kombinierbare Speichereffizienz
- Dank impliziter Integer-Coercion mit Verbreiterung bleibt die Nutzbarkeit auch bei APIs mit unterschiedlicher Bitbreite erhalten
- Wer in einer Systemprogrammiersprache Wert auf Effizienz und Zero-Cost-Abstraction legt, sollte sich Staged Programming, insbesondere Zigs
comptime, noch einmal ansehen
1 Kommentare
Meinungen auf Hacker News
Es gibt eine andere Strategie, die speichereffizient ist und zugleich die Iteration über die Elemente erhält. Der erste Vektor ist die Liste der Tags, der zweite Vektor enthält die Byte-Offsets der einzelnen Elemente, und der dritte ist weniger ein Vektor als vielmehr die komprimierten Variant-Daten, auf die der zweite Vektor zeigt.
Damit hat man nur halb so viele Vektoren wie in der endgültigen Lösung des Autors (6 vs. 3), verschwendet keine Padding-Bytes außer dort, wo sie wegen Alignment nötig sind, und die Daten liegen unabhängig vom Typ der Reihe nach im Speicher, sodass man cache-freundlich darüber iterieren kann. Auch ein O(1)-Zugriff auf Elemente per Index ist möglich. Insgesamt hat das für heterogene Daten ähnliche Performance-Eigenschaften wie ein
Vec.Trecht viel Platz beanspruchen. Ein Beispiel wäre die Kombination aus 64-Bit-size_tunduint8_t T; solange man nur bei der Offset-Größe aufpasst, wirkt der Ansatz vernünftig.Ich frage mich, wie diese AoVA-Datenstruktur in der Praxis funktioniert. Verliert man nicht den indexbasierten Zugriff, da Index-Arithmetik aus Sicht eines Arrays möglicherweise keine Bedeutung mehr hat? Auch die Iteration scheint die Einfügereihenfolge nicht zu bewahren.
In diesem Kontext halte ich TLV (tag-length-value) mit besseren Caching-Eigenschaften für üblicher. Die Länge kann auch durch den Tag impliziert sein, und zumindest bietet es sinnvolle Vorwärtsiteration. Siehe
getdents,inotifyund Netlink-Messaging.Im Vergleich zum vorherigen SoA-Layout entsteht keine Gesamtordnung, sondern eine partielle Ordnung. Beim Einfügen bekommt man einen getaggten Index zurück, der sowohl den enum-Tag als auch den Index innerhalb des jeweiligen Variant-Arrays enthält. Daher scheint sortierter Zugriff hier außerhalb des Scopes zu liegen. Wenn man in jedem Element einen globalen Index speichert, könnte man sortierte Iteration wiederherstellen; für sortierten Random Access hilft das aber weiterhin nicht, und der Code würde wahrscheinlich ziemlich viele Branches enthalten.
Dinge nach Größe zu speichern, wird auch bei Garbage Collectors und allgemeinen Allokatoren verwendet. Effizienz kann man daraus gewinnen, dass man alle möglichen Objektgrößen kennt, oder durch einfachere Freigabemethoden wie bei einer Arena.
In solchen Fällen kann man die Arrays als Bestandteil einer heap-ähnlichen Struktur betrachten, also wie eine Arena. Der Preis ist, dass der Index zweidimensional sein muss, etwa
(tag_idx, va_for_tag_idx). Da die Anzahl der Tags aber zur Compile-Zeit bekannt ist, kann man die Speicherung optimieren, indem mantag_idxin die oberen 4–5 Bits packt undva_for_tag_idxden Rest nutzen lässt. Siehe: https://www.cs.cornell.edu/~asampson/blog/flattening.htmlIch finde es etwas schade, dass Rusts Pattern Matching nicht eher als eine Art Trait im Typsystem ausgedrückt werden kann, dem beliebige Structs folgen können, statt als expliziter, erstklassiger, fest verdrahteter Objekttyp mit eigener Speicherstruktur.
Ich habe kürzlich, ähnlich wie in diesem Artikel, sowohl einen AST als auch einen Opcode-/Bytecode-Interpreter implementiert, und Rusts enums fühlen sich für beides nicht ganz ideal an. Beim AST wollte ich jedem Statement-Knoten Zeilen-/Spaltenattribute geben; wenn man Zeile/Spalte in jeden Fall des
Stmt-enums einfügt, wird der Boilerplate unschön, und wenn man das enum in ein neuesStmt-Struct einwickelt, das das ursprüngliche enum zusammen mit den Zeilen-/Spaltenattributen enthält, bedeutet das viel Refactoring und wirkt nicht elegant. Auch bei Opcodes ist ein per Pattern Matching behandeltes Rust-enum aus Performance-Sicht für einen VM-Opcode-Interpreter kaum die ideale Codierung, aber die Sprache lenkt einen in diese Richtung, und die Destructuring-Pattern-Funktionalität ist sehr attraktiv. Es scheint Raum für Verbesserungen im Typsystem zu geben, damit man die gewünschte Low-Level-Implementierung verwenden und trotzdem Pattern-Matching-Funktionen bekommen kann.https://en.wikipedia.org/wiki/Structural_type_system
computed goto; in Rust bräuchte man wohl Funktionspointer und etwas, das Tail-Call-Optimierung erzwingt.Wenn man einen solchen indirekten Sprung an den Anfang jeder Opcode-Implementierung setzt, kann der indirekte Sprungprädiktor, den CPUs wegen OOP besitzen, für die Enden unterschiedlicher Opcodes separate Modelle haben, wodurch die Vorhersagegenauigkeit steigen kann. Die nächste Instruktion selbst ist zwar schwer vorherzusagen, aber zum Beispiel ist es deutlich wahrscheinlicher, dass auf
testeinbranchfolgt. Allerdings dürften andere Techniken, etwa bei einer Stack Machine den obersten Stack-Wert in einem Register zu halten, wichtiger sein, und ich bin mir nicht sicher, ob diese Technik heute noch relevant ist.Dass „der geparste clang-AST regelmäßig 50-mal mehr Speicher frisst als der ursprüngliche Quellcode“ wirkt ziemlich groß, aber der fehlende Kontext ist: Wie gut kann es überhaupt werden? Wenn man die Quellposition jedes Tokens erhalten und genug Informationen kodieren muss, um sie aus dem AST korrekt wiederherzustellen, frage ich mich, ob der ideale Zuwachs gegenüber dem Original eher 1,5× oder 15× beträgt.
Es ist schwer zu sagen, wie hoch die ideale Aufblähung von Source→AST in einer Sprache ist, die sowohl nutzerfreundlich als auch compilerentwicklerfreundlich ist, aber 50× funktioniert immerhin. Im Originaltext wird die 50-fache Aufblähung als Motivation genutzt, eine bestimmte Optimierung zu automatisieren. Es wäre interessant, wenn Rusts enum-Vektoren enum-Werte automatisch in Tags und opaque Werte zerlegen könnten, sodass sie als Struct-of-Arrays gespeichert werden können, wie es der Originaltext in Zig macht. Es gäbe wohl auch nicht viele Stellen, an denen man den Einsatz von
unsafeverstecken müsste.Bei Dokumenten, die größtenteils aus
[]-Zeichen oder0,-Zeichen bestehen, scheint der maximale Overhead bei etwa 8× zu liegen.Source-Bytes: 139 KiB, Tokens: 24646 (120 KiB), AST-Knoten: 10998 (140 KiB). Jedes Token ist mit 5 Byte ziemlich minimal gehalten (1-Byte-Tag + 4-Byte-Dateioffset), und die AST-Knoten sind ebenfalls dicht und nicht einheitlich kodiert; in diesem Fall etwa 13 Byte pro Knoten. Selbst mit einer solchen Minimalcodierung ist der Parse Tree fast doppelt so groß wie die Quelldatei. Trotzdem ist 2× viel besser als 50×. Quelle:
zig ast-check -t lib/std/zig/Parse.zig | head -n7Dieser Problemraum fühlt sich wie eine Variante eines Packungsproblems an.
Es wäre schön, wenn man ausgehend von der endgültigen, für Menschen angenehm zu handhabenden Struktur Empfehlungen für Datenstrukturen erzeugen könnte, die Speicherverschwendung reduzieren, Alignment-Regeln einhalten und die räumliche Lokalität erhöhen. https://en.wikipedia.org/wiki/Packing_problems
Es wäre schön, wenn sich proc macros dahin entwickeln würden, dass sie Informationen beim Compiler abfragen können. Dafür bräuchte es wohl ein sorgfältiges Design zusätzlicher Compiler-Phasen, aber Dinge wie „implementiert diese Struktur diesen Trait?“ oder „gib mir die Liste aller konkret implementierten Traits“ sind in proc macros oft sehr nützlich.
Ich habe den Artikel nur teilweise verstanden, aber aus der Perspektive, eine Spreadsheet-Engine in Rust schreiben zu wollen, wirkt das sehr relevant. Zellwerte brauchen so eine Form:
pub enum Expr { Number(i32), String(String), Reference(Position), Binary(Box, char, Box), Function(String, Vec), }Ich werde weiter lesen und lernen; Hinweise auf Referenzmaterial sind willkommen.
Eine gängige Technik aus dem Games-Bereich ist, ein Array of Structs (AoS) in ein Struct of Arrays (SoA) aufzuteilen. Wenn man zum Beispiel
struct Humans { healths: Vec, ammo: Vec, … }hat, entspricht der i-te Index jedes Vektors dem i-tenHumanim AoS-Layout. Solche parallelen Vektoren sind nur ein Beispiel und nicht optimal effizient, weil für jedes Feld Länge und Kapazität doppelt Buch geführt werden und dadurch Platz verschwendet wird. Dieser Artikel versucht im Grunde, eine ähnliche Idee automatisch auf enums anzuwenden, was in Rust nicht ohne Weiteres geht. Wie groß dieses Problem in der Praxis ist, könnte etwas übertrieben sein. Bei einer Spreadsheet-Engine sollte man es zunächst nur als mögliche Optimierung im Hinterkopf behalten und zuerst entscheiden, ob man sie für Geschwindigkeit oder für Einfachheit und Verständlichkeit baut.Wenn du 1 Million × 1 Million Zellen zulässt und für jede nicht gefüllte Zelle
nullspeicherst, geht dir der Speicher aus. Daher könntest du überlegen, Zellinhalte spärlich zu speichern. Eine Möglichkeit ist die Nutzung einer Hash-Map-Implementierung wiehashbrown. Der Punkt dieses Artikels betrifft Low-Level-Details; wenn du von Anfang an mit einer Hash Map startest und die anfänglichen Speicherbeschränkungen umgehst, musst du im Moment nicht allzu tief darüber nachdenken.Das schwierigste Einzelproblem ist die Evaluierungsstrategie.
Wie wäre es mit https://doc.rust-lang.org/reference/type-layout.html#the-alignment-modifiers?
Ich glaube, im Beispielcode ist ein Bug:
field_map[idx] = svec.len - 1;Wenn
svecbereitssizean einer Stelle enthält, die nicht der letzte Eintrag ist, wird das falsch sein.