Low-Level-Optimierung und Zig
(alloc.dev)- Low-Level-Optimierung bedeutet, dem Compiler zu helfen, Absichten und Randbedingungen des Codes besser zu verstehen, und Zig eignet sich dafür gut, weil sich Typen, Alignment, Aliasing und Compile-Time-Informationen leicht explizit angeben lassen
- Selbst optimierende Compiler wie LLVM erzeugen nicht immer den bestmöglichen Code, daher bleiben in Engpassbereichen die Prüfung des generierten Codes und Anpassungen am Code weiterhin nötig
- Zig kann mit
noalias,align, festen Array-Größen und zur Compile-Zeit übergebenen Elementtypen vektorisierten Code erzeugen, der kompakter ist als das JavaScript-Beispiel comptimeführt normalen Zig-Code zur Compile-Zeit aus und ermöglicht so Metaprogrammierung wie das Erzeugen von Konstanten, generische Implementierungen, Typ-Reflexion und Optimierungen bei String-Vergleichen- Die Stärke von Zig liegt nicht in Makros, die direkt das AST verändern, sondern in der in die Sprache integrierten Compile-Time-Ausführung; auch einige Laufzeitwerte können über zur Compile-Zeit spezialisierte Funktionen dispatcht werden
Warum man dem Compiler vertrauen, aber trotzdem nachprüfen sollte
- Optimierung ist mehr als nur eine Technik für schnellere Programme; sie hängt auch mit Kostensenkung, besserer Skalierbarkeit und der Wahrung der Systemeinfachheit zusammen
- Moderne Compiler liefern mit Backends wie LLVM beeindruckende Ergebnisse, erzeugen in manchen Situationen aber weiterhin suboptimalen Code
- Low-Level-Sprachen sind nicht nur deshalb schnell, weil sie weniger Garbage Collection oder Interpreter-Overhead haben, sondern auch weil sie mehr Intentionsinformationen ausdrücken können, die der Compiler versteht
- Compiler können weder den Algorithmus noch das Programmierparadigma selbst verändern und optimieren meist nur in einem begrenzten Rahmen wie etwa in Schleifen
Beispiel mit Array-Maximum in JavaScript und Zig
- Das JavaScript-Beispiel speichert in der Form
x[i] = y[i] > x[i] ? y[i] : x[i]das elementweise Maximum zweier Arrays inx - Für Menschen ist der Code klar, aber der von V8 erzeugte Bytecode ist aufgebläht
- Das Zig-Beispiel beschreibt in den Funktionsargumenten die für die Optimierung nötigen Informationen viel genauer
noalias x:xist kein Alias zu anderen Zeigern*align(64): auf 64 Byte ausgerichtet[65536]f64: Array-Größe und Elementtypconst: schreibgeschütztes Argument
- Dank dieser Informationen kann der Compiler besseren Code erzeugen; im Beispiel entsteht vektorisierte Assembler-Ausgabe
- Entsprechender Rust-Code erzeugt nahezu identischen Assembler
Wo Zig bei Optimierung im Vorteil ist – und wo die Grenzen liegen
- Zig erlaubt ausführlichere Schreibweisen, um LLVM viele Informationen über den Code zu geben
- Die wichtigsten für Optimierung relevanten Elemente in Zig sind:
- Built-in Functions
- Pointer
- das Schlüsselwort
unreachable - illegal behavior
comptime
- Das Speichermodell von Rust erlaubt es dem Compiler, grundsätzlich anzunehmen, dass Funktionsargumente kein Aliasing erzeugen, während man das in Zig explizit angeben muss
- Wenn der Compiler bei Zig-Funktionsargumenten nicht weiß, ob kein Aliasing vorliegt, kann eine unannotierte Zig-Funktion langsamer sein als eine Rust-Funktion
- Schon auf Basis gut annotierten LLVM-IR liefert Zig gute Ergebnisse, seine größere Stärke liegt aber in der Compile-Time-Ausführung
Die Rolle von comptime
comptimeist in Zig die Funktion für Code-Erzeugung zur Compile-Zeit- Zur Compile-Zeit sind unter anderem folgende Dinge möglich:
- Konstanten erzeugen und in das Binary aufnehmen
- dieselbe Hashmap-Struktur nicht für mehrere Datentypen wiederholt schreiben müssen
- Optimierungen anstoßen, damit auf Basis von zur Compile-Zeit bekannten Daten unnötiger Code entfernt wird
- Typen prüfen, reflektieren und erzeugen, um Generics zu implementieren
comptime-Code ist normaler Zig-Code, der zur Compile-Zeit ausgeführt wird; Nebeneffekte wie Netzwerk-IO sind nicht möglich- Die Emulationsmaschine zur Compile-Zeit entspricht dem Kompilierungsziel
- Fast jeder Zig-Code kann mit
comptimezur Compile-Zeit ausgeführt werden, und alle Typen können zur Compile-Zeit geprüft, reflektiert und erzeugt werden
Der Unterschied zu Makros
- Das Ziel von
comptimeähnelt dem von Makros, die Arbeitsweise ist jedoch anders - Manche Makros verändern Rohtext, andere modifizieren direkt das AST des Programms
- Zigs
comptimeverändert das AST nicht direkt und bietet auch nichts wie Token-Pasting-Makros - Zig zielt auf eine gut lesbare Sprache ab und passt daher nicht zu Makro-Stilen, die Variablen in nicht zusammenhängenden Scopes erzeugen oder verändern
- Was Makros können, Zig-
comptimeaber nicht direkt leistet:- andere Makros definieren
- das AST verändern
- Mini-Sprachen oder DSLs direkt implementieren
- Dennoch lassen sich auch in Zig DSLs bauen; Zigs
print-Funktion parst Format-Strings mitcomptime, um einen Funktionsgraphen zur Serialisierung von Daten aufzubauen - Beispiele sind TigerBeetle account testing DSL, comath, zilliam
Optimierung von String-Vergleichen mit comptime
- Ein gewöhnlicher String-Vergleich liefert bei unterschiedlicher Länge
falsezurück und vergleicht bei gleicher Länge jedes Byte der Reihe nach - Dazu müssen aus beiden Strings jeweils Bytes gelesen und verglichen werden
- Häufig ist eine Seite des Vergleichs bereits zur Compile-Zeit bekannt, daher kann Zig verlangen, dass ein Argument
comptimeistfn staticEql(comptime a: []const u8, b: []const u8) bool
- Beim Vergleich mit einem statischen String wie
"Hello!\n"erzeugt der Compiler Code, der aus einem Längenvergleich und konstanten Vergleichen der einzelnen Bytes besteht - Der Zweck dieses Abschnitts ist nicht nur zu zeigen, welche Optimierung der Compiler automatisch leisten kann, sondern dass sich mit
comptimeeine Transformation auch erzwingen lässt, um Chancen zu eröffnen, die der Compiler sonst nicht sieht
Vergleiche in größeren Blöcken und Nutzung von SIMD
- Ein einfacher
comptime-String-Vergleich arbeitet weiterhin Byte für Byte - Eine verbesserte Version verwendet
std.simd.suggestVectorLength(u8)oder@sizeOf(usize), um die Größe der Vergleichsblöcke festzulegen - Zuerst wird die String-Länge geprüft, danach werden die Anzahl der großen vergleichbaren Blöcke und die verbleibenden Bytes berechnet
- Jeder Block wird mit einem über
std.meta.Int(.unsigned, block_len * 8)erzeugten Integer-Typ per@bitCastverglichen - Die verbleibenden Bytes werden ebenfalls mit einem separaten Integer-Typ verglichen
- Beim Beispiel
"Hello, World!\n"nutzt der generierte Assembler größere Register und reduziert die Anzahl bedingter Verzweigungen - Bei längeren String-Vergleichen entsteht Assembler, der größere SIMD-Register verwendet
Laufzeitwerte zusammen mit Compile-Time-Spezialisierung nutzen
- Zigs
comptimeist nicht auf Daten beschränkt, die nur zur Compile-Zeit bekannt sind - In einfachen Fällen lassen sich mehrere Prozeduren zur Compile-Zeit erzeugen und zur Laufzeit abhängig vom Wert dynamisch an die passende Prozedur dispatchen
- Im Beispielcode werden in
switch (runtime_val)Werte im Bereichinline 0...100anstaticFn(comptime_val)weitergereicht, während der Rest durchruntimeFn(runtime_val)verarbeitet wird - Wenn keine Vergrößerung des Binarys gewünscht ist, kann auf eine vollständige Laufzeit-Implementierung zurückgefallen werden
Fazit
- Zigs
comptimeersetzt Templates, Makros, Generics und manuelle Code-Erzeugung - Ähnliche Dinge sind auch in anderen Sprachen möglich, in Zig ist
comptimejedoch natürlicher in die Sprache integriert - Zig erleichtert das Schreiben performanten Codes in praktisch nützlichen Situationen und steht damit im Kontrast zu einem Turing tar-pit, in dem zwar alles möglich ist, interessante Aufgaben aber schwer sind
- In Sprachkriegen bleibt sowohl die große Perspektive bestehen, dass Turing-Vollständigkeit allein ausreiche, als auch die Ansicht, dass Menschen trotzdem Sprachen bevorzugen dürfen
- Aussagen wie „C ist schneller als Python“, die eine Sprache selbst zum Benchmark-Objekt machen, können irreführend sein; tatsächlich wird nicht die Sprache, sondern konkreter Code und dessen Implementierung benchmarkt
1 Kommentare
Meinungen auf Hacker News
Was mich an Zig am meisten reizt, ist, dass es auf einfaches Build-System, Cross-Compilation und schnelle Iterationszyklen abzielt.
Als Spieleentwickler habe ich zwar Performance-Anforderungen, aber ich denke, dass die meisten Sprachen die nötige Performance ausreichend liefern; deshalb ist das für mich nicht das wichtigste Kriterium bei der Sprachwahl.
In jeder Sprache kann man leistungsfähigen Code schreiben, aber entscheidend ist, ein zukunftsfähiges Framework zu wählen, mit dem sich modularer Code über Jahrzehnte warten lässt.
C/C++ war die Standardantwort, weil es überall unterstützt wird, und Zig fühlt sich so an, als könnte es dieses Niveau erreichen.
Zig ist Kapselung gegenüber feindlich eingestellt, und Struct-Member können nicht privat gemacht werden: https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
Das zentrale Zitat vertritt die Position: „Private Felder und Getter/Setter sind ein von Java popularisiertes Anti-Pattern; Felder sind vorhandene Daten, also sollte man sie sorgfältig benennen und als Teil der öffentlichen API dokumentieren.“
Wenn man die interne Repräsentation nicht verbergen kann, ist es schwierig, einen API-Vertrag sauber zu schaffen, der die Grundlage von Software-Modularität bildet; man sollte die interne Repräsentation ändern können, ohne Nutzer-Code zu brechen.
Zigs Haltung scheint zu sein, dass es keine separate interne Repräsentation geben sollte, sondern dass die Repräsentation selbst öffentlich gemacht, dokumentiert und garantiert werden muss; ich hoffe, dass diese Entscheidung irgendwann rückgängig gemacht wird und private Felder unterstützt werden.
Vieles funktionierte sofort, und ich konnte mit einem alten GDB sogar seltsame Bugs debuggen.
Ich bin ebenfalls von Zig überzeugt worden und habe hier etwas dazu geschrieben: https://news.ycombinator.com/item?id=44211041
Ich verstehe nicht so recht, warum es so gehasst wird.
Unansehnliche Generics gibt es auch in C# oder TypeScript, und der Borrow Checker ist ein Konzept, das einleuchtet, wenn man schon einmal Low-Level-Arbeit gemacht hat.
Unter den auf Zig gebauten Tools beeindruckt mich bun wirklich, und seit ich bun verwende, ist mein Leben viel einfacher geworden.
Ähnliches kann man über das in Rust gebaute uv sagen.
Konsolen mögen normalerweise nichts, was nicht C/C++ ist, aber da Zig nach C übersetzen kann, ist es vielleicht nicht völlig ausgeschlossen.
Zur Behauptung „Selbst moderne Compiler brechen die Sprachspezifikation (Clang nimmt an, dass alle Schleifen ohne Seiteneffekte terminieren)“: Ich bezweifle nicht, dass Compiler gelegentlich die Spezifikation brechen, aber in diesem Fall liegt Clang zumindest seit C11 richtig.
In C11 steht, dass eine Schleife, deren Steuerbedingung kein konstanter Ausdruck ist und die keine Ein-/Ausgabe, keinen volatile-Zugriff, keine Synchronisation und keine atomaren Operationen ausführt, von der Implementierung als terminierend angenommen werden darf.
Deshalb muss eine einfache Endlosschleife in C wie
for (;;);tatsächlich als Endlosschleife kompiliert werden, und Rusts weniger opakesloop {}ebenso.LLVM wurde jedoch von Leuten gebaut, die manchmal vergessen, dass sie nicht immer einen C++-Compiler bauen; deshalb gab es den Punkt, an dem Rust „Gib mir eine Endlosschleife“ sagte und LLVM antwortete: „Nach C++ gibt es so etwas nicht, also optimiere ich es weg“ — und das war eine falsche Anwendung auf eine andere Sprache.
Um String-Vergleiche zu inlinen und zu entfalten, braucht man nicht zwingend comptime.
Das geht auch in C: https://godbolt.org/z/6edWbqnfT
Den Tippfehler habe ich korrigiert.
Ein besseres Beispiel ist https://github.com/RetroDev256/comptime_suffix_automaton.
Allerdings zeigt der verlinkte Godbolt-Code tatsächlich auch eines von zwei eher schlechten Beispielen.
Ich halte den Vergleich zwischen dem JavaScript-Beispiel und den Zig/Rust-Beispielen nicht für besonders gelungen.
Den Zig- und Rust-Compilern wird gesagt, sie sollen eine sehr moderne Ziel-CPU wählen, während V8 offenbar nicht unter denselben Bedingungen läuft.
Ein optimierender JIT kann ebenfalls vektorisieren, wenn die Bedingungen stimmen.
Nebenbei: Die meisten modernen Sprachen führen dieselbe Optimierung für Strings durch; ein C++-Beispiel gibt es hier: https://godbolt.org/z/TM5qdbTqh
Das Zig-Beispiel verwendet Arrays bekannter Typen mit fester Größe, während der JS-Code zur Laufzeit „generisch“ ist, sodass
xundybeliebige Objekte sein können.In JS muss man diesen Preis zahlen, aber ironischerweise kann man dem JIT in genau diesem Beispiel die Typinformationen besser vermitteln.
Wenn man dafür sorgt, dass diese Funktion immer mit Float64Array derselben Größe aufgerufen wird, kann der JIT das erkennen und eine schnellere Schleife erzeugen. Es ist vielleicht keine Vektorisierung, aber deutlich besser.
Allerdings haben Typed Arrays hohe Initialisierungskosten, weshalb man sie in der Praxis kaum nutzt, außer man allokiert einmal ein großes Typed Array und verwendet es häufig wieder.
Außerdem hieß es im Artikel, der JS-Bytecode sei ziemlich aufgebläht; vermutlich liegt ein großer Teil daran, dass der JIT nicht garantieren kann, dass
65536der Länge beider Arrays entspricht, und deshalb Guards einfügt.In der Praxis würde aber ohnehin niemand eine
for-Schleife so schreiben, sonderni < x.lengthverwenden; in diesem Fall entfernt der JIT zumindest eine Array-Prüfung.targetauf eine ältere CPU umstellen.Sorry, dass ich die Einschränkungen des JS-Ziels nicht bedacht habe.
Das verlinkte C++-Beispiel ist ein gutes Beispiel dafür, was Clang mit C++ leisten kann, aber selbst wenn man berücksichtigt, dass Zig für eine bestimmte CPU kompiliert, wirkt der erzeugte Assembly-Code etwas enttäuschend.
Ein C++-Port von https://github.com/RetroDev256/comptime_suffix_automaton wäre wohl sehr interessant.
Das ist eine Nutzung von comptime, die ein C++-Compiler nicht sauber ableiten kann.
Ich bin mir nicht sicher, ob der Satz „Hochsprachen fehlt die reiche Intentionalität von Low-Level-Sprachen“ wirklich stimmt.
Intentionsausdruck scheint kein Element auf dem Hoch-/Low-Level-Spektrum zu sein; eher sollte etwas umso höherstufiger sein, je mehr Möglichkeiten es bietet, Absichten detaillierter auszudrücken.
Es geht um die Absicht, was man die Maschine tun lassen will, weniger darum, was man erreichen möchte.
Code wie
purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?;ist voller Absicht, aber man weiß nicht, welcher Maschinencode am Ende herauskommt.Diese
for-Schleifen-Syntax ist furchtbar.Zwei Listen stehen nebeneinander, und die Position eines Eintrags in der einen Liste entspricht der Position eines Eintrags in der anderen?
Schon beim Hinsehen tun mir die Augen weh.
Moderne Sprachen scheinen auf den falschen Weg geraten zu sein, seit sie Parsern allerlei „Magie“ hinzufügen und überall im Code kleine Symbole verstreuen.
Das ist nichts, worauf ich stundenlang starren möchte.
Deshalb ist es naheliegend, dass Zig eine Syntax anbietet, die genau das bequem macht und zugleich klar sichtbar lässt, was passiert.
Ich persönlich finde das ziemlich gelungen und frage mich, warum es dir in den Augen wehtut.
Das Allocator-Modell von Zig gefällt mir wirklich gut.
Ich wünschte, man könnte auch in Go statt Garbage Collection so etwas wie Request-scoped Allocators verwenden.
Weil die Sprache selbst keine Möglichkeit hat, Ownership-Regeln auszudrücken und durchzusetzen, läuft es am Ende darauf hinaus, C mit etwas anderer Syntax zu schreiben und zu hoffen, dass alles gutgeht.
Selbst C++ ist unter der Bedingung, dass es keine Garbage Collection gibt, deutlich sicherer als Go.
Das Speichermodell von Rust erlaubt dem Compiler, immer davon auszugehen, dass Funktionsargumente niemals aliasierende Referenzen sind; in Zig muss man das hingegen manuell angeben.
Solche Alias-Angaben vermeide ich.
Denn nur wenige verstehen sie, und wenn man sie falsch verwendet, können schwer nachvollziehbare Bugs im Code entstehen.
Zur Behauptung, dass die comptime-Flexibilität von Zig gute Verbesserungen in andere Sprachen gebracht habe: Funktionsausführung zur Compile-Zeit und Funktionen mit konstanten Argumenten wurden 2007 von D eingeführt und haben mehrere Sprachen dazu gebracht, Ähnliches zu übernehmen.
https://dlang.org/spec/function.html#interpretation
Ich mag Zig, aber die Aussage „Ich mag die Ausführlichkeit von Zig“ klingt seltsam.
C ist an vielen Stellen tatsächlich zu locker, aber Zig ist nach heutigen Maßstäben ein Stück zu weit in die Gegenrichtung ausgeschlagen, sodass es manchmal zu viel Rauschen durch typannotationsartige Kommentare gibt.
Besonders gilt das für explizite Integer-Casts in Ausdrücken; dazu habe ich hier etwas geschrieben: https://floooh.github.io/2024/08/24/zig-and-emulators.html
Wenn Zig-Code bei der Performance schneller ist als vergleichbarer C-Code, liegt das meist an den aggressiveren LLVM-Optimierungseinstellungen von Zig.
Zig verwendet zum Beispiel standardmäßig
-march=nativeund Whole-Program-Optimierung und kompiliert sämtlichen Zig-Code eines Projekts als eine einzige Compilation Unit.Fast alle „Tricks“ wie die Verwendung von
unreachableals Optimierungshinweis sind auch in C möglich, nur braucht man dafür mitunter nichtstandardisierte Spracherweiterungen.C-Compiler, insbesondere Clang, betreiben ebenfalls sehr aggressives Constant Folding und können auch bei tiefen Call Stacks große Codebereiche auf Konstanten reduzieren, wenn das möglich ist.
Betrachtet man also nur die Codegenerierung, gibt es oft keinen großen Unterschied zu Zig-comptime.
Der Vorteil von comptime ist, dass es nicht stillschweigend wieder zu Runtime-Code zurückfällt; und auch Code ohne comptime ist wie in C Ziel derselben Constant-Folding-Optimierungen.
Wird etwa eine „reine“ Nicht-comptime-Funktion mit konstanten Argumenten aufgerufen, ersetzt der Compiler den Funktionsaufruf trotzdem durch das Ergebnis.
Kurz gesagt: Wenn C-Code langsamer ist als Zig-Code, sollte man die C-Compiler-Einstellungen prüfen. Die schwere Arbeit bei der Optimierung passiert letztlich komplett darunter in LLVM.
fn signExtendCast(comptime T: type, x: anytype) T { const ST = std.meta.Int(.signed, @bitSizeOf(T)); const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x))); return @bitCast(@as(ST, @as(SX, @bitCast(x)))); }export fn addi8(addr: u16, offset: u8) u16 { return addr +% signExtendCast(u16, offset); }Das kompiliert zu derselben Assembly, ist wiederverwendbar und macht die Absicht klarer.
Dem stimme ich zu.
Virgil konnte schon seit 2006 die gesamte Sprache zur Compile-Zeit verwenden und unterstützte ebenfalls Whole-Program-Compilation.
Da Virgil allerdings nicht auf LLVM abzielt, wird ein Geschwindigkeitsvergleich letztlich zu einem Vergleich der beiden Compiler-Backends.
Virgil stützt sich stark auf Reachability-Analyse und Spezialisierungsoptimierungen, die dieses Kompilierungsmodell ermöglicht.
Zum Beispiel werden Methodenaufrufe aggressiv devirtualisiert, unerreichbare Felder und Objekte entfernt, Konstanten durch Felder und Heap-Objekte hindurch hochgestuft und polymorpher Code vollständig monomorphisiert.
Und zwar allein deshalb, weil sie dadurch für KI leichter handhabbar werden.
Ob es eine gute Idee ist, KI fürs Coding einzusetzen, und bis zu welchem Grad, ist eine andere Frage; aber viele Entwickler glauben daran, und Sprachen werden versuchen, ihnen entgegenzukommen.