- Um strukturelle Mängel der bisherigen Implementierung zu beheben, wurden 300.000 Zeilen Rust in Zig neu geschrieben. Nach 487 Tagen wurde Funktionsgleichheit erreicht; als erstes offizielles nummeriertes Release ist 0.1.0 für die zweite Jahreshälfte geplant.
- Der neue Compiler unterstützt Hot Code Loading, reproduzierbare Cross-Compilation, String-Interpolation innerhalb von Pattern Matching sowie das Entfernen von Heap-Allokationen beim HTTP-Routing; außerdem wurde die wasm-Größe von
Rocci Birdauf 31 KB reduziert, weniger als die Hälfte. - Die wichtigsten Gründe für die Wahl von Zig waren Build-Zeiten, fein granulare Kontrolle über Allocators und Datenlayout, ein für Compiler-Entwicklung geeignetes Ökosystem sowie Prüfungen für speicherunsicheren Code; der inkrementelle Build von Zig 0.17.0 baut rund 460.000 Zeilen in 35 ms neu.
- In der tatsächlichen Fehlerklassifizierung gab es beim Rust-Compiler 21 Fälle von Speicherbeschädigung und beim Zig-Compiler 10, die meisten davon jedoch wegen fehlerhafter Codegenerierung; die einzigen Speichersicherheitsfehler im Zig-Compiler selbst waren 2 Use-after-free-Fälle, die Dateinamen beschädigten.
- Zig passte gut zu pointerlosen Datenstrukturen, Deserialisierung ohne Parsing und der Wiederverwendung eines LLVM-Bitcode-Serializers; bei automatischer Speicherfreigabe in Tests, Polymorphismus, privaten Struct-Feldern, Dead-Code-Erkennung und Kompatibilität zwischen Releases war die Developer Experience von Rust jedoch besser.
Neufassung erreicht Funktionsgleichheit
- Das Compiler-Team von Roc hat über etwa anderthalb Jahre 300.000 Zeilen Rust in Zig neu geschrieben und damit Funktionsgleichheit mit dem bisherigen Compiler erreicht.
- Funktionsgleichheit ist ein wichtiger Meilenstein, aber noch kein offizielles Release; 0.1.0 des neuen Compilers ist für die zweite Jahreshälfte geplant.
- Das WASM-4-Spiel Rocci Bird von 2024 lässt sich nun mit dem neuen Compiler bauen.
- Das gesamte Spiel umfasst weniger als 1.000 Zeilen Roc-Code.
- Der aktualisierte Source ist kompakter als das Original.
- Das von
roc build --opt=sizeerzeugte wasm-Binary ist 31 KB groß und damit kleiner als die Hälfte des Ergebnisses des bisherigen Compilers.
- Auf der Homepage kann man mit einem 2,5 MB großen WebAssembly-Compiler einfache Roc-Programme im Browser schreiben und ausführen.
- Die Neufassung dauerte 487 Tage und damit 476 Tage länger als die 11 Tage, in denen Bun rund 500.000 Zeilen Zig nach Rust übertrug.
- Bun wurde direkt portiert, während es sich bei Roc eher um eine Neufassung handelte, bei der mehrere Compiler-Strukturen und Funktionen stark verändert wurden.
- Daher lassen sich Größe oder Dauer der beiden Codebasen nicht allein anhand von Rust und Zig direkt vergleichen.
Developer Experience des neuen Compilers
-
Hot Code Loading und Cross-Compilation
- Wenn man den Server mit
roc server.rocstartet und danach den Code ändert, wird ab der nächsten Anfrage automatisch der neue Code angewendet. - In interpretierten Sprachen wie Python ist das üblich, bei performanten kompilierten Sprachen wie Roc jedoch selten.
- Das funktioniert nicht nur bei Webservern, sondern auf dieselbe Weise auch bei einfachen 2D-Spielen.
- Für das Deployment erzeugt
roc build server.rocein Standalone-Binary mit LLVM-Optimierungen. - Mit
roc build --target=x64musllässt sich ein statisches Binary für Alpine Linux cross-kompilieren. - Werden dieselben Source-Bytes eingegeben, erzeugt jedes Build-System, einschließlich Mac, dieselben Output-Bytes.
- Wenn man den Server mit
-
String-Interpolation innerhalb von Pattern Matching
- String-Interpolation wie
"/users/${id}"wird innerhalb von Pattern Matching unterstützt. - Anders als beim Routing im Express-Stil werden Template-Strings zur Laufzeit nicht geparst, sondern direkt vom Compiler verarbeitet.
- HTTP-Methode und Pfad können gemeinsam gematcht werden; auch verschachtelte Pfade oder Default-Werte lassen sich per Pattern verzweigen.
- Der vollständige HTTP-Request-Handling-Code im Beispiel führt während der Compile-Time Typsicherheitsprüfungen aus und nimmt überhaupt keine Heap-Allokationen vor.
- Durch die Ausführung reiner Funktionen zur Compile-Time wurde HTTP-Request-Routing ohne Allokationen implementiert; die Syntax lässt sich im WebAssembly-Compiler auf der Homepage ausprobieren.
- String-Interpolation wie
Warum von Grund auf neu geschrieben wurde
- Anders als Rust, C oder Zig ist Roc keine Systemsprache und nutzt automatische Speicherverwaltung auf Basis von Reference Counting.
- Pausen durch einen tracing Garbage Collector werden vermieden.
- Es nutzt die Perceus-Optimierung und opportunistische Änderungen im Koka-Stil.
- Statt wie typische Nicht-Systemsprachen bei jedem Closure-Capture den Heap zu allokieren, verwendet Roc polymorphe Defunktionalisierung durch Lambda-Set-Spezialisierung.
- Defunktionalisierung ermöglicht in funktionalen Sprachen wie Inlining verschiedene nachgelagerte Optimierungen, doch die bestehende Implementierung korrekt zu machen war äußerst schwierig.
- Durch den OCaml-Prototyp von Ayaz Hafiz wurde bestätigt, dass es sich um einen strukturellen Mangel handelt, der sich über mehrere Compiler-Stufen erstreckt, und dass zur Behebung der Großteil des Compilers neu geschrieben werden müsste.
- Auch andere Beitragende planten aus unterschiedlichen Gründen, mehrere Teile neu zu schreiben; statt eines schrittweisen Austauschs fast aller Komponenten nach dem Schiff-des-Theseus-Prinzip wurde daher eine vollständige Neufassung geprüft.
- In erfolgreichen Compiler-Projekten ist es üblich, etwa für Self-Hosting von Grund auf neu zu schreiben; es gibt auch Beispiele wie die TypeScript-Neufassung, die nicht in die eigene Sprache, sondern nach Go wechselte.
- Für den Roc-Compiler gilt zwar weiterhin die Richtlinie, kein Self-Hosting zu betreiben, doch in diesem Fall wurden die Vorteile der Neufassung als größer eingeschätzt als die bekannten Kosten.
Kriterien für Zig statt erneut Rust
- Das Team nutzte Zig bereits für mehrere grundlegende Funktionen der Standardbibliothek und kannte als Systemsprachen nur Rust und Zig gut genug, sodass nur diese beiden ernsthaft geprüft wurden.
- Je nach Projekt eignet sich eine andere Sprache; Rust weiterhin für andere Aufgaben zu verwenden und für Roc Zig zu wählen, ist kein Widerspruch.
-
Build-Zeit
- Rust-Builds mit
cargodauerten selbst bei inkrementellen Builds lange und blieben mit wachsender Codebasis ein wesentlicher Schmerzpunkt. - Durch den Wechsel zu Zig erwartete man deutlich schnellere Builds.
- Rust-Builds mit
-
Kontrolle über Speicher und Datenlayout
- In jeder Compiler-Phase werden verschiedene Allocators verwendet, insbesondere Arena-Allocators, und das Struct-of-Arrays-Layout (SoA) wird umfassend genutzt.
- Das Rust-Ökosystem geht im Allgemeinen von einem einzigen globalen Allocator aus, während das Zig-Ökosystem fein granulare Allocator-Weitergabe als Standard betrachtet und auch SoA-Unterstützung in der Standardbibliothek bietet.
-
Umfang des benötigten Ökosystems
- Das gesamte Rust-Ökosystem ist größer als das von Zig, doch für die speziellen Anforderungen von Roc gab es in beiden Ökosystemen nicht viele relevante Pakete.
- Die benötigten Nischenfunktionen, etwa Code, der schnell Bitcode erzeugt, statt die LLVM-C++-Bibliothek zu wrappen, waren eher auf Zig-Seite zu finden.
-
Unterstützung für speicherunsicheren Code
- In den 300.000 Zeilen des bisherigen Rust-Compilers gab es rund 1.200 Verwendungen von
unsafe. - Zum Vergleich: Der Rust-Compiler selbst verwendet in 3,5 Millionen Zeilen rund 40.000 Mal
unsafe. - In einem Compiler, der Maschinencode erzeugt, sind speicherunsichere Operationen ein wichtiger Teil der Arbeit.
- Rusts Modell besteht darin, seltene
unsafe-Blöcke zu isolieren und mit Miri oder Valgrind zu prüfen; bei Roc warunsafejedoch nicht selten. - Zig bot mehr Funktionen, um speicherunsicheren Code korrekt zum Laufen zu bringen, und genau in diesem Bereich wollte das Team die meiste Unterstützung.
- In den 300.000 Zeilen des bisherigen Rust-Compilers gab es rund 1.200 Verwendungen von
Speichersicherheit ohne Borrow Checker
-
Prüfumfang von Rust und Zig
- Laut einer Microsoft-Veröffentlichung von 2019 betrafen etwa 70 % der jährlich per Sicherheitsupdate behobenen Schwachstellen Speichersicherheitsprobleme
- Teilt man die Kategorien aus dem Jahr 2018 aus dieser Veröffentlichung aus Sicht von Rust und Zig auf, ergibt sich Folgendes
- 83,6 % entfielen auf Out-of-bounds-Lesen/-Schreiben, unsichere Casts, Lesen nicht initialisierter Werte, Stack Overflows und nicht speichersicherheitsbezogene Probleme; das sind Kategorien, die von der Wahl zwischen Rust und Zig nicht beeinflusst werden
- 16,4 % waren Use-after-free-Probleme; diese Kategorie lässt sich mit Rusts Borrow Checker, Zigs
ReleaseSafeoder Prüfungen nach Fil-C-Art erkennen - Zigs
ReleaseSafelöst zur Laufzeit eine Panic aus, wenn freigegebener Speicher verwendet wird- Die Prüfungen sind weniger umfassend als die sichere Teilmenge von Rust
- Es entstehen Laufzeitkosten, und das Programm kann paniken
ReleaseFastlässt die Prüfungen in der Produktion weg, behält sie aber in Debug-Builds und Tests bei- Wenn man alle tatsächlich möglichen Ausführungspfade testet, kann man eine Sicherheit wie bei
ReleaseSafeerreichen, doch eine solche Testabdeckung ist in der Regel nicht realistisch
-
Andere Zig-Projekte und Rusts
unsafe- TigerBeetle nutzt
ReleaseSafe; bei einer präzisen Jepsen-Verifikation wurden zwei Sicherheitsbugs gefunden, beide hatten jedoch nichts mit Speichersicherheit zu tun - Auch Ghostty und der Zig-Compiler, die
ReleaseFastverwenden, haben keine CVEs, die durch Speichersicherheitsprobleme in Zig-Code verursacht wurden - Auch in Rust-Programmen können über
unsafeinnerhalb von Abhängigkeiten Lücken in der Speichersicherheit entstehen - Unsafe Rust hat ähnliche Risiken wie Zig
ReleaseFast, bietet aber kein Pendant zu Zigs Laufzeitprüfungen, die Probleme während der Entwicklung aufdecken - Miri und Valgrind können helfen, werden aber von nicht vielen Rust-Projekten genutzt
- Stattdessen sorgt in der Praxis die Kultur,
unsafeselten einzusetzen und strenger zu prüfen, für Rusts starken Ruf bei der Speichersicherheit - Auch bei Rust-basierten Projekten gab es Fälle von Schwachstellen im Zusammenhang mit
unsafe- Deno hatte CVEs für Out-of-bounds-Lesen und Use-after-free
- Rocket hatte eine Use-after-free-CVE
- Bei Actix traten in einer Phase mit ungewöhnlich hohem
unsafe-Einsatz mehrere CVEs wegen Speichersicherheitsproblemen auf
- Da bei Roc die meisten Allokationen in Arenen mit einfachen Lebensdauern stattfinden, wurde Use-after-free nicht als großes Risiko gesehen; außerdem wurde entschieden, dass Zigs zusätzliche Prüfungen bei inhärent unsicherem Code nützlicher sind
- TigerBeetle nutzt
Nach der Neufassung gefundene Speicherbeschädigungsbugs
- Die Klassifizierung des Roc-Issue-Trackers mit Claude Opus 4.8 ergab Folgendes
| Compiler | Speicherbeschädigung aufgetreten | Keine Speicherbeschädigung | Gesamt |
|---|---|---|---|
| Rust | 21 | 2.575 | 2.596 |
| Zig | 10 | 421 | 431 |
- Die 21 Fälle von Speicherbeschädigung im Rust-Compiler waren keine Beschädigungen der internen Compilerlogik
- Der Borrow Checker erfüllte seine beabsichtigte Aufgabe
- Es handelte sich um Miscompilation-Bugs, bei denen falsch erzeugter Maschinencode den Speicher im kompilierten Programm beschädigte
- Auch 8 der 10 Speicherbeschädigungsfälle im Zig-Compiler waren Miscompilations
- Die übrigen 2 Fälle waren Use-after-free-Probleme im Code für Fehlerberichte
- In Fehlermeldungen von
roc checkundroc bundlewurden Dateinamen als U+FFFD-Ersatzzeichen verstümmelt - Der Rust-Borrow-Checker hätte beide Bugs erkennen können
- In Fehlermeldungen von
- Die tatsächlichen Auswirkungen einer anderen Tool-Wahl auf reale Nutzer wären folgende gewesen
| Tool-Wahl | Tatsächliche Auswirkung auf Speichersicherheit |
|---|---|
Zig ReleaseFast |
2 Bugs, bei denen Dateinamen in einigen Fehlermeldungen nicht gerendert wurden |
Zig ReleaseSafe |
2 Bugs, bei denen einige Fehler panikten und Dateinamen nicht gerendert werden konnten |
| Rust-Borrow-Checker | Beide Bugs verhindert |
- Über 18 Monate, Hunderttausende Codezeilen und Hunderte Bugberichte betrachtet, war der Unterschied zwischen den drei Optionen für das Projekt praktisch nicht groß
- Bei Bun machten Use-after-free, Double-free und nicht freigegebener Speicher einen großen Anteil aus, weil es Werte aus JavaScripts Tracing Garbage Collection zusammen mit manuell verwalteten Werten handhabt
- Der Roc-Compiler integriert sich nicht mit JavaScript oder einem anderen Tracing Garbage Collector und hat daher nicht dieselben Probleme beim Lebensdauermanagement
- Roc braucht eher Werkzeuge, um Speicherfehler im erzeugten Ausgabecode zu finden, als Speicherfehler innerhalb des Compilers; Ersteres liegt außerhalb des Bereichs des Borrow Checkers
Tatsächliche Ergebnisse bei den Build-Zeiten
zig build --watch -fincrementalbaut Änderungen in derzeit etwa 450.000–460.000 Zeilen Zig-Code in rund 35 ms neu- Die stabile Version Zig 0.16.0 enthält einen Bug, der
-fincrementalin der Roc-Codebasis kaputtmacht- Der Fix ist bereits eingeflossen, aber um ihn zu nutzen, müsste man auf einen vorab gebauten, nicht abwärtskompatiblen Zig-0.17.0-Build wechseln
- Da auch die zugehörigen Abhängigkeiten mit vendort und auf 0.17.0 aktualisiert werden müssten, wurde entschieden, auf das nächste stabile Release zu warten
- Die Messungen auf einem Intel-Desktop mit Ubuntu 26 ergaben Folgendes
| Roc-Compiler | Codeumfang | Cold Build | Inkrementeller Build |
|---|---|---|---|
| Original auf Basis von Rust 1.85.0 | 354K | 32,4 s | 10,0 s |
| Original auf Basis von Rust 1.97.0 | 354K | 25,4 s | 3,4 s |
| Zig 0.16.0 zum Zeitpunkt der Funktionsgleichheit | 320K | 39,6 s | 8,6 s |
| Aktuelle Neufassung mit Zig 0.17.0 | 464K | 32,1 s | 0,035 s |
- Zum Zeitpunkt der Funktionsgleichheit wurden auch Artefakte, die sich fast nie änderten, jedes Mal neu gebaut; heute werden sie nur noch bei Bedarf erzeugt
- Durch diese Änderung ist der Code um etwa 50 % gewachsen, trotzdem ist der Cold Build schneller geworden
- Von Rust 1.85.0 zu 1.97.0 sank der inkrementelle Build von 10 Sekunden auf 3,4 Sekunden, also um rund zwei Drittel innerhalb von 18 Monaten
- Zigs 35 ms entsprechen etwa einem Hundertstel von 3,4 Sekunden und wurden mit einer Codebasis gemessen, die etwa 50 % größer ist als der für Rust gemessene Code
- Derzeit funktioniert
-fincrementalnur auf x86-64, und viele Beitragende nutzen ARM-basierte Macs; daher lassen sich die Build-Zeit-Vorteile noch nicht vollständig nutzen
Pointerlose Strukturen und Deserialisierung ohne Parsing
- Der neue Disk-Cache nutzt, ähnlich wie beim Zig-Compiler und in der Spieleentwicklung, ein zur Ausführung effizientes Speicherlayout direkt als Disk-Format
- Alle Compiler-Datenstrukturen werden statt mit Pointern als Arrays von 32-Bit-Indizes dargestellt; an vielen Stellen kommen Array-of-Structs-Formate zum Einsatz
- Reduziert den Speicherverbrauch und erhöht die Ausführungsgeschwindigkeit
- Datenstrukturen können direkt auf die Disk geschrieben werden, ohne sie in ein separates Serialisierungsformat umzuwandeln
- Beim Deserialisieren werden die Bytes von der Disk nicht geparst
- Die Bytes werden in den Speicher gelesen
- Ein Teil wird umgebogen, sodass die bestehenden Datenstrukturen auf die neuen Arrays zeigen
- Danach sind sie sofort verwendbar
- Die Geschwindigkeit ist praktisch durch die I/O-Rate beim Lesen von Bytes von der Disk in den Speicher begrenzt
- Liegen die Daten im Disk-Cache des Betriebssystems, werden frühere Build-Ergebnisse ungefähr mit
memcpy-Geschwindigkeit geladen
- Liegen die Daten im Disk-Cache des Betriebssystems, werden frühere Build-Ergebnisse ungefähr mit
roc checkspeichert beim ersten Lauf Ergebnisse wie Parsing und Typprüfung auf die Disk- Wenn sich die Eingabequellen nicht geändert haben, werden beim zweiten Lauf die Datenstrukturen direkt von der Disk in den Speicher übertragen
roc testcached auch die Ergebnisse deterministischer Tests reiner Funktionen- Der Cache arbeitet dateibasiert; ändert man eine Datei, werden nur diese Datei und abhängige Dateien erneut verarbeitet
- Dieser Ansatz ist möglich, weil der gesamte Compiler statt Pointern Indizes verwendet; in üblichen pointerzentrierten Strukturen ist Deserialisierung ohne Parsing nicht möglich
-
Sicherheitsgrenzen indexbasierter Strukturen
- So wie ein Pointer auf eine falsche Adresse zeigen kann, kann auch ein Index ein falsches Array abfragen und beliebige Bytes lesen
- Der Rust Borrow Checker behandelt Pointer-Lebensdauern, prüft aber nicht, welcher Index zu welchem Array gehört
- Wenn die benötigte Anzahl von Arrays im Voraus bekannt ist, kann Rusts
compact_arenaper Makro Typ-Tags erzeugen und so Abfragen des falschen Arrays verhindern - Wenn sich die Anzahl der Arrays wie bei Roc je nach Modulanzahl ändert, lässt sich diese Technik nicht anwenden; daher markiert auch
compact_arenaSmallArena::newalsunsafe - Das Erzeugen einer leeren Arena schafft an sich keine Gefahr; das eigentliche Risiko liegt im sehr häufig ausgeführten Array-Indexing
- Safe Rust ist wirksam unter der Annahme, dass
unsafeklein und isoliert bleibt; wennunsafewie bei Roc breitflächig vorkommt, gilt diese Annahme nicht
Das Zig-Ökosystem passte zu Roc
- Für Bun war Rusts
Drop, das Aufräumcode nur einmal ausführt, wegen der Interoperabilität zwischen JavaScript und manueller Speicherverwaltung nützlich - Roc wollte hingegen pro Modul und Compiler-Phase separate Arenen verwenden, weshalb Rust-Pakete, die einen globalen Allocator und implizites
Dropvoraussetzen, unbequem waren - Im Zig-Ökosystem sind APIs üblich, denen Allocators explizit übergeben werden; das passt gut zu Rocs Speicherverwaltungsansatz
- Das Rust-Ökosystem passte jeweils besser zu der von Bun gewünschten Struktur, das Zig-Ökosystem besser zu der von Roc gewünschten Struktur
-
Wiederverwendung des LLVM-Bitcode-Serialisierers
- LLVM ist eine zentrale Abhängigkeit von Rocs Optimierer und führt nach Rocs eigener Optimierung zusätzliche Optimierungen durch
- LLVM bricht häufig die Kompatibilität wichtiger APIs, sodass Versionsupgrades erheblichen Zeit- und Kostenaufwand erforderten
- Das serialisierte Bitcode-Format von LLVM ist stabil und abwärtskompatibel; mit einem eigenen Serialisierer kann man sich von Änderungen an der C++-API lösen
- Dafür wird ein handgeschriebener Bitcode-Serialisierer benötigt, der von den LLVM-C++-Bibliotheken getrennt ist
- Die bekannte bestehende Implementierung befand sich im Zig-Compiler, und Rocs neuer Compiler verwendet diesen Zig-Code wieder
- Die größte Abhängigkeitsquelle, die Roc aus dem Zig-Ökosystem bezieht, ist eher der Zig-Compiler selbst als gewöhnliche Pakete
Rust-Funktionen, die in Zig fehlen
- Bei der Compiler-Implementierung ist explizite Kontrolle über Allokationen nötig, aber in Tests war Rusts automatische Allokation und Freigabe bequemer
- Zigs Test-Allocator findet Speicherlecks und kann auch Lecks in kompiliertem Roc-Code erkennen
- Dafür muss jeder Test
initunddefer deinitkorrekt schreiben; ist auch nur eines falsch, schlägt der Test wegen eines Lecks fehl
- Zigs
comptimeüberschneidet sich mit parametrischem und Ad-hoc-Polymorphismus, aber beide Formen von Polymorphismus werden vermisst- Rusts
Allocator-Trait kannselfentgegennehmen - Implementierungen wie Zigs
ArenaAllocatormüssen einenanyopaque-Pointer entgegennehmen und ihn dann in den eigenen Typ casten
- Rusts
- Da es keine privaten Struct-Felder gibt, kann der Compiler keinen Fehler ausgeben, wenn auf Felder direkt zugegriffen wird, auf die nicht direkt zugegriffen werden sollte
- In Code-Diff-Reviews sieht man nur den Feldzugriff, nicht aber die Dokumentation des ursprünglichen Structs, daher muss man jedes Mal separat nachsehen
- Manchmal fehlt die Konsistenz von Rust, wo Funktionen, Variablen und Konstanten alle
snake_caseverwenden unsafeund der Borrow Checker hatten zwar Kosten, gaben aber die Sicherheit, sich um bestimmte Probleme nur innerhalb vonunsafe-Blöcken kümmern zu müssen- Das ist keine Position, dass Zig dieselben Features hinzufügen sollte
- In Zig wurde toter Code häufiger erst spät entdeckt als in Rust
- Auch die eingebauten Zig-Tools und TigerBeetles
tidy.zigfinden manchen toten Code nicht - Toter Code wird nicht ins Binary erzeugt und betrifft Nutzer daher nicht, ist aber für die Pflege der Codebasis nachteilig
- Auch die eingebauten Zig-Tools und TigerBeetles
- Rust-Upgrades auf Minor-Versionen und Editionswechsel waren meist unkompliziert
- Derzeit zielt Zig nicht auf Abwärtskompatibilität ab; das war eine erwartete Bedingung und daher kein großes Problem, aber Rusts einfache Upgrade-Erfahrung war besser
Zufriedenstellende Aspekte von Zig
- Zig hat den Reiz eines subtraktiven Designs: Es reduziert vertraute Werkzeuge wie in der funktionalen Programmierung, gewinnt dafür aber andere Eigenschaften
- Es gibt keine Makros; viele Probleme, einschließlich parametrischem Polymorphismus, lassen sich mit
comptimeoder normalen Funktionen lösen - Das Datenlayout lässt sich fein steuern
- Integer-Typen, die keine Zweierpotenzen sind, wie
u7undu5, können ohne gesonderte Bit-Verarbeitung verwendet werden - Packed Structs werden nativ unterstützt
- Funktionen können an der Aufrufstelle statt an der Deklarationsstelle inline gesetzt werden
- Funktionen, die in Rust macrobasierte Crates erfordern, sind ohne zusätzliche Abhängigkeiten nutzbar
- Integer-Typen, die keine Zweierpotenzen sind, wie
- Zigs Build-Toolchain eignete sich gut zum Erzeugen eigenständiger Binaries für Alpine Linux und WebAssembly
- Sie verarbeitet auch spezielle Builds, bei denen die Builtins, die Rocs Standardbibliothek entsprechen, als opaker binärer Blob kompiliert und in die finale ausführbare Datei eingebunden werden
- Auch Uber nutzt die Zig-Toolchain in seiner Build-Infrastruktur, ohne die Sprache Zig zu verwenden
- Zigs Fehlerbehandlung behandelt auch fehlgeschlagene Heap-Allokationen als normale Userspace-Fehler
- Roc verwendet eine ähnliche Strategie, bei der sich Fehler über anonyme Summentypen und Payloads natürlich akkumulieren
- Gegenüber Rusts
anyhow,thiserrorund der Standardbehandlung auf Basis vonResultwerden die Ansätze von Zig und Roc bevorzugt - Bei der Syntax zur Fehlerweitergabe wird Rusts postfixes
?gegenüber Zigstrybevorzugt; daher hat Roc ebenfalls den Postfix-Operator?übernommen - Einschließlich der fein granularen Allocator-APIs und wiederverwendbarem Code für Hochleistungs-Compiler ist man mit der Entscheidung für Zig insgesamt sehr zufrieden
Nächste Schritte für Roc
- Die 0.1.0 des neuen Compilers soll in der zweiten Jahreshälfte erscheinen und wird Rocs erstes nummeriertes Release sein
- Auch vor dem Release können Nightly-Builds ausprobiert werden, derzeit gibt es aber noch mehrere Bugs, unfertige Features und unvollständige Dokumentation
- Die Roc Programming Language Foundation ist eine gemeinnützige US-Organisation nach 501(c)(3); Spenden werden hauptsächlich zur Vergütung von Beitragenden verwendet
- Weitere Entwicklungsfortschritte und Fragen sind im Roc Zulip zu finden
2 Kommentare
Lobste.rs-Kommentare
Rein zahlenmäßig sind 35 ms mit Zig fast 100-mal schneller als 3,4 Sekunden mit Rust, und der Code ist zudem etwa 50 % umfangreicher, aber in der tatsächlichen Entwicklung wirkt dieser Unterschied wohl übertrieben
Wenn man den Compiler nicht jede Minute, sondern nur alle 10 Minuten neu baut, spart man nur etwa 3 Sekunden, und auch 3,4 Sekunden sind schnell genug
Mit wachsender Codebasis kann Rust langsamer werden, aber die Cold-Build-Zeit hat sich sogar verschlechtert, und wenn man bedenkt, dass man ohnehin alle zwei Jahre den Laptop ersetzt und sich auch der Compiler selbst verbessert, lässt sich kaum sicher behaupten, dass Rusts Build-Zeiten langfristig weiter ansteigen werden
Rusts
check-Modus und LSP sind zwar schneller als ein vollständiger Rebuild, erreichen aber nicht dasselbe NiveauMit der Kombination
--watch -fincrementalwird bei jedem Speichern neu kompiliert, sodass man sehr schnell und sehr häufig Feedback bekommtIch habe ein privates Projekt von Rust auf Zig umgestellt und Rust auch jahrelang beruflich genutzt; die Kompilierungsgeschwindigkeit von Zig war wirklich eine befreiende Veränderung
35 ms sind erstaunlich schnell
Schon reines Neulinken dürfte länger dauern, daher frage ich mich, was der Compiler tatsächlich macht
Wenn man die Implementierung einer Funktion ändert, kann man den neuen Assemblercode in die bestehende Binärdatei einfügen, aber Inlining, Änderungen an Funktionssignaturen, Abhängigkeiten und nötige Verschiebungen bei Platzmangel erfordern zusätzliche Analyse
Wenn man eine Funktion ändert oder hinzufügt, sucht er im
.text-Abschnitt der erzeugten ausführbaren Datei einen ausreichend großen freien Bereich und schreibt dort den neuen Maschinencode hineinReicht der Platz nicht, muss
.texterweitert und andere Daten eventuell verschoben werden, aber der Abschnitt wird exponentiell vergrößert, um die Kosten zu verteilenFalls nötig, werden auch Symboltabellen und Relocation-Einträge ergänzt; auch dafür wird vorab freier Platz gelassen, sodass man nach der Arbeit nur noch die Datei schließen muss
Nach einer Schätzung auf Basis eines kürzlich gesehenen Tracy-Ergebnisses entfiel von den gesamten 35 ms auf den Linker nur etwa 1 ms
Die Zahl, dass in 300.000 Zeilen Rust-Code etwa 1.200-mal
unsafeverwendet wurde, ist mehr als erwartetZum Beispiel gibt es in Inko nur 162
unsafe { ... }-Ausdrücke und 87unsafe-Funktionen, die über das C-ABI dem erzeugten Code zugänglich gemacht werden, bei insgesamt rund 88.000 Zeilen Rust-CodeAllerdings ist das gesamte LLVM-Backend wegen der Funktionsweise von Inkwell faktisch ein riesiger
unsafe-Bereich, weshalb sich aus einem einfachen Vergleich schwer eine Schlussfolgerung ziehen lässtIch markiere nur Funktionen als
unsafe, die die tatsächliche Speichersicherheit beeinträchtigen, etwa wenn direkt mit Zeigern gearbeitet wird; andere Entwickler markieren damit auch, dass man bestimmte Schritte zuerst ausführen muss, um Panics zu vermeidenDie Zahl, dass
unsafein Rust-Standardbibliothek und Compiler 40.000-mal vorkommt, ist ungenauGezählt wurden auch Vorkommen in Tests und Kommentaren, und der Großteil steckt in der Standardbibliothek; im Compiler selbst sind es sogar inklusive Kommentare und Tests weniger als 2.000
Während meiner Mitarbeit an rustc lag der Anteil meiner PRs mit
unsafebei unter 1 %, so selten ist unsicherer Code im CompilerinnerenDass die Standardbibliothek, die das grundlegende Runtime-Fundament für ganz Rust implementiert, viel
unsafeenthält, ist natürlich; solcher Code ist in jeder Sprache unsicher, ob explizit oder implizitDieser Compiler ist etwa 10-mal kleiner als rustc, verwendet aber ähnlich viel
unsafe; daher würde ich nicht sagen, dass es allgegenwärtig ist, aber man begegnet ihm doch oft, und ich frage mich, warum hier so viel mehr davon nötig ist als in rustcWir haben
unsafeim Standardbibliotheksanteil beider Codebasen zwar nicht getrennt ausgewertet, aber wenn wir den neuen Compiler in Rust geschrieben hätten, wäre wegen des Cachings und einer Struktur, die statt Zeigern Indizes verwendet, wohl mehrunsafenötig gewesen als bei rustcEs war nicht meine Absicht, die Arbeit des Rust-Teams herabzusetzen; ich wollte lediglich unsere Entscheidung und unseren Fortschritt darstellen und dabei die Leistungen anderer Projekte respektieren
Ein ausgewogenes Urteil, das die jeweiligen Vor- und Nachteile von Rust und Zig nicht verschweigt; besonders ehrlich wirkt die Schlussfolgerung, dass sich das Projektergebnis wohl nicht wesentlich verändert hätte, wenn man nach 18 Monaten mit mehreren hunderttausend geschriebenen Zeilen und der Bearbeitung von Hunderten Bugs unter den Optionen eine andere Sprache gewählt hätte.
Allerdings ist die Erklärung schwer nachzuvollziehen, dass bei Compilern, die wie Roc oder rustc Maschinencode erzeugen, speicherunsichere Operationen einen großen Teil der Arbeit ausmachen.
Es gibt auch viele Compiler, die in OCaml oder Haskell geschrieben sind, und die Erzeugung von Maschinencode selbst besteht letztlich darin, Bytes in einem Vektor zusammenzustellen und in eine Datei zu schreiben, weshalb sie keinen unsicheren Charakter haben muss.
Bei Interpreting oder JIT-Compilation wäre das nachvollziehbar, aber warum das auch für gewöhnliche Compilation nötig sein soll, würde mich interessieren.
Das Risiko entsteht beim Ausführen des erzeugten Maschinencodes, und da reale Compiler Maschinencode oft gleichzeitig erzeugen und ausführen, wurde das als großer Teil der Arbeit bezeichnet.
Dazu gehört neben Interpreting und JIT auch das Auswerten von User-Code zur Compile-Zeit, etwa bei Rusts
const fnoder bei Ausdrücken, die in Roc auf die oberste Ebene gezogen werden können, sowie Aufgaben wie das Ausführen von Tests und die anschließende Prüfung ihrer Ausgabe, um festzulegen, was dem Nutzer angezeigt werden soll.Ob der beschädigte Speicher nun im Compiler-Prozess oder im erzeugten Programm liegt: Maßgeblich ist, dass die Ursache für das Fehlverhalten des Prozessors eine vom Compiler erzeugte Instruktion war und dass die zu behebende Stelle deshalb im Compiler-Code liegt.
Komponenten wie extern gelinkter Code und der Garbage Collector benötigen auch in Rust viel
unsafe, was die Vorteile von sicherem Rust zu einem guten Teil aufhebt.Man kann zwar nur den Compiler in einer sicheren Sprache schreiben, aber dann wird die Performance zum Problem; zudem verwenden Zig und der Roc-Compiler Strukturen aus Arrays (SoA) sowie verbreitet Array-Indizes statt Pointern.
Eine Umsetzung davon in Rust würde den Borrow Checker umgehen und damit die entsprechenden Sicherheitsvorteile verlieren.
Die Lifetime-Beziehungen im Compiler sind überraschend einfach: In einem Schritt kann man Daten in einer Arena allokieren, erzeugen und verändern, sie im nächsten Schritt schreibgeschützt weitergeben und danach die gesamte Arena verwerfen.
Komplexer sind inkrementelle Compilation und Linking, bei denen Zustand von der Festplatte gelesen und Binärdateien direkt verändert werden; dabei können Zustandsbeschädigung, Bugs, Fehlcompilation und Speicherprobleme entstehen, doch das ist von der Speichersicherheit des Compiler-Prozesses selbst getrennt zu betrachten.
Sicherheit und Korrektheit sind viel weiter gefasste Konzepte als die interne Speichersicherheit eines Programms, besonders wenn es darum geht, Arbeiten, die sich in Rust schwer ausdrücken lassen, sicher und korrekt auszuführen.
Es ist schön, als Ersteller von
compact_arenaerwähnt zu werden, aber die Art, wie die Verwendung vonunsafein dieser Library beschrieben wurde, ist falsch.Das Ziel von
compact_arenaist es, in sicherem Rust sicherzustellen, dassnewnicht falsch verwendet werden kann, und damit Indexierung sicher zu machen.Das Makro
mk_arenakann auch innerhalb von Schleifen, in denen man die benötigte Anzahl an Arenen nicht kennt, aus sicherem Rust-Code heraus aufgerufen werden.newsicher verwendet wird, missverstanden, aber im Sicherheitsabschnitt von https://docs.rs/compact_arena/0.5.0/… steht, dass das an den Konstruktor übergebene Tag die Grundlage der Indexierungsmechanik ist und dass seine Verwendung in einer anderen Arena dazu führen kann, dass sich Indizes zweier Arenen vermischen, was Out-of-Bounds-Zugriffe und Undefined Behavior nach sich ziehen kann.Mein Punkt ist nicht der Aufruf von
new()selbst, sondern dass das Risiko beim Indexieren von mitnew()erzeugten Werten entsteht; die Stellen, die man auditieren und alsunsafekennzeichnen sollte, sind aus meiner Sicht die eigentlichen Indexierungsaufrufe.Allerdings kann es sein, dass ich das Design falsch verstanden habe.
Soweit ich weiß, muss man bei Python das Programm neu starten, damit neuer Code übernommen wird, und Hot Reloading erfordert nicht standardisierte Erweiterungen sowie sorgfältig geschriebenen Code.
Hot Reloading ist eher in image-basierten Entwicklungsumgebungen wie Lisp oder Smalltalk oder in Erlang verbreitet.
importlib.reload()ist Hot Reloading auch in Python möglich, wie verbreitet das genutzt wird, weiß ich aber nicht.Ich frage mich, ob man die Build-Zeit verkürzen kann, wenn man nicht
cargo buildverwendet.Ob der Aufwand, Bazel einzurichten, größer oder kleiner ist als eine Portierung nach Zig, ist unklar; natürlich war die Build-Zeit aber nicht der einzige Grund für den Sprachwechsel.
Hacker-News-Kommentare
Der Artikel insgesamt ist in Ordnung, aber ich kann der Aussage schwer zustimmen, dass speicherunsichere Operationen einen großen Teil der Arbeit in Compilern ausmachen, die wie Roc oder
rustcMaschinencode erzeugen.Für Binary-Patching zur Laufzeit oder Code-Reloading mag unsicherer Code nötig sein, aber beim gewöhnlichen Erzeugen einer ausführbaren Datei gibt es keinen Grund, warum die Erzeugung von Maschinencode selbst unsicher sein müsste.
Eher würde ich erwarten, unsicheren Code in der Runtime einer Sprache zu finden.
In der Praxis erzeugen viele Compiler den Maschinencode allerdings nicht nur, sondern führen ihn auch direkt aus, daher die Formulierung „ein großer Teil der Arbeit“ — zwingend beide Aufgaben erledigen muss ein Compiler aber nicht.
Gemeint waren nicht nur Binary-Patching, Code-Reloading und Runtime, sondern auch die Auswertung von User-Code zur Compile-Zeit, etwa bei Rusts
const fnoder bei Ausdrücken in Roc, die auf Top-Level hochgezogen werden können, sowie das Ausführen von Tests und das Prüfen ihrer Ausgabe, um zu entscheiden, was angezeigt werden soll.Das ist ungefähr dieselbe Logik wie zu sagen, Sicherheitsgurte seien nur lästig und man solle sie nicht benutzen, weil man sich auch mit Gurt den Kopf stoßen kann.
Bei extrem leistungsoptimiertem Code unterscheiden sich schon Datenstrukturen, Algorithmen und Strategien zur Speicherallokation, und TigerBeetle ist dafür bekannt, beim Start den gesamten Speicher auf einmal zu allokieren.
Der Roc-Compiler scheint ähnliche Kompromisse wie Zig eingehen zu wollen, daher ist es nur natürlich, dass dabei viele gemeinsame Muster auftauchen.
Für die Behauptung,
ReleaseSafefange Use-after-free per Runtime-Prüfung ab, sehe ich keine Grundlage.Ich habe in der Zig-Dokumentation zu Runtime-Prüfungen auf Speichersicherheit nachgesehen, aber unter
use-after-free,UaFundsafety-checkednichts Entsprechendes gefunden, und selbst mitDebugAllocatorin Release-Builds lässt sich so etwas nicht zuverlässig erkennen.Das ist hier zusammengefasst: https://landaire.net/memory-safety-by-default-is-non-negotia...
Diese Einschätzung stammt noch aus der Zeit, bevor ich KI nutzte und Code selbst schrieb; mit der Kombination aus Zig und LLMs mag sich manches ändern, aber je weiter ich lese, desto mehr seltsam formulierte Behauptungen fallen mir auf, sodass ich dem Ganzen schwer vertrauen kann.
Es liest sich weniger wie eine ehrliche technische Einschätzung als wie ein Text, der bestehende Streitpunkte rechtfertigen soll; da ich aber ungewöhnliche Texte und Sprachen mag und dem überzogenen KI-Hype eher kritisch gegenüberstehe, versuche ich zunächst, wohlwollend zu bleiben.
ReleaseSafeBounds-Checks hinzu und löst bei unerreichbarem Code einen Panic aus.Zig scheint keine temporale Speichersicherheit bereitzustellen.
Interessant ist, dass das ausgereifte OCaml zwar flexibel und ausdrucksstark genug für Prototyping war, aber nicht als Sprache für die endgültige Implementierung gewählt wurde.
Ob Zigs inkrementelle Builds wirklich spürbar schneller sind als
dune, ist ebenfalls fraglich, und obwohl Cross-Compilation ein Vorteil ist, wurde das unter „Warum Zig?“ nicht behandelt.Ich frage mich, ob feingranulare Speicherkontrolle für einen Compiler wirklich so wichtig ist und an welchem Meilenstein Maintainer — wie bei Rust und WASM, die beide mit OCaml angefangen haben — entscheiden, auf eine andere Sprache zu wechseln.
Zigs inkrementelle Builds sind klar ein ausschlaggebendes Feature, und ich kann nachvollziehen, dass man allein dafür kurzfristig die Sprache wechselt.
Mittelfristig würde ich aber erwarten, dass Rust in naher Zukunft etwas Ähnliches bekommt.
Ich will Geschwindigkeit, aber nicht so schnell vorpreschen, dass ich mir am Ende selbst schade, und baue gerade selbst eine Sprache, die Rusts Sicherheit, Zigs Features und eine Go-Runtime ohne Garbage Collection kombiniert.
Das erscheint mir realistischer und sogar im User-Space umsetzbar.
Es wäre deutlich überzeugender gewesen, wenn bei der Wahl der Implementierungssprache für den Compiler ein tatsächlich wissenschaftlicher Vergleich durchgeführt worden wäre.
Ausgehend von der unbewiesenen Annahme, dass ein Hochleistungs-Compiler eine Low-Level-Systemsprache brauche (https://www.roc-lang.org/faq#self-hosted-compiler), scheint man zu dem Schluss gekommen zu sein, dass neben Rust nur Zig als Option bleibt.
Die Compiler-Performance wird von Algorithmen dominiert; selbst schnelle verwaltete Sprachen liegen bei denselben Algorithmen meist innerhalb des Doppelten der Laufzeit, während es bei Leistungsunterschieden durch unterschiedliche Algorithmen nach oben praktisch keine Grenze gibt.
Zig selbst ist ein Gegenbeispiel zu der Theorie, dass ein in einer Low-Level-Sprache geschriebener Compiler automatisch schneller wird, und Rocs ungefähr 15.000 Zeilen pro Sekunde sind nicht schnell. Es gibt Material dazu, dass schon 1998 ein ML-Compiler 3.000 Zeilen pro Sekunde verarbeitete (https://flint.cs.yale.edu/cs421/case-for-ml.html).
Es könnte für die Zukunft hilfreicher sein, die aktuelle Compiler-Arbeit zu stoppen und stattdessen einen selbstgehosteten Compiler für eine möglichst kleine Roc-Teilmenge zu bauen, die mit weniger als 10.000 Zeilen auskommt.
So könnte man statt einer 300.000-Zeilen-Implementierung auf dem Niveau von 10.000 Zeilen verschiedene Implementierungen erproben und überprüfen, ob eine Low-Level-Sprache für die tatsächlichen Performance-Ziele wirklich nötig ist.
Im Prozess des Self-Hostings würden die wirklich wichtigen Roc-Features sichtbar, man würde auch mehr Roc-Code schreiben, und wenn man die für den Compiler nötigen allgemeinen Features verbessert, profitieren davon auch die darunterliegenden Anwendungen.
Allein aus der Tatsache, dass ML in den 1990ern schnell kompiliert werden konnte, lässt sich die heutige Kompiliergeschwindigkeit von Roc kaum beurteilen, weil das Sprachdesign starke Einschränkungen für die benötigten Algorithmen mit sich bringt und moderne Hardware ebenfalls viel komplexer ist.
Roc scheint ein gewisses Maß an Overloading zu haben und auch ausgefeilte Algorithmen zu verwenden, um Closures nicht auf dem Heap allozieren zu müssen; solche Anforderungen können zu einer algorithmischen Komplexität führen, die sich nicht beseitigen lässt.
Wenn die Grenze der algorithmischen Optimierung erreicht ist, bleibt nur noch die Reduktion konstanter Faktoren; insbesondere High-Level-Sprachen mit Speicherverwaltung setzen dabei eine klare Untergrenze dafür, wie weit sich diese Faktoren senken lassen.
Ich habe in realem Code Fälle gesehen, in denen durch direkte Kontrolle des Memory-Layouts die Performance um mehr als das Zehnfache verbessert wurde, und in der Spielebranche macht solche Arbeit teils einen großen Teil der Karriere aus. Die Vorstellung, dass ein cleverer Algorithmus jedes Performance-Problem verschwinden lässt, ist ziemlich realitätsfern.
Rust-Builds gehören zu den Hauptgründen dafür, dass auf praktisch jedem Computer enorm viel Speicherplatz verschwendet wird; sobald man mehrere Bibliotheken baut, sammeln sich schnell Dutzende von GB an.
Man kann zwar einen globalen Build-Ordner einrichten, um Abhängigkeiten projektübergreifend wiederzuverwenden, aber welche Lösung auch immer gewählt wird, wünschenswert wäre, dass sie als Standardverhalten bereitgestellt wird.
Allerdings braucht es Garbage Collection für den Cache, und eine neue Umstellung der Ablage von Zwischen-Build-Artefakten, die das vereinfachen soll, ist fast abgeschlossen.
node_modulesständig kritisiert wird, ist das ein interessanter Kontrast.Selbst in einem Tauri-Projekt, in dem der Backend-Code viel kleiner ist als das Frontend, sind die Rust-Build-Artefakte 9 GB groß, während
node_modulesnur 550 MB belegt.Ich verstehe Roc als Skriptsprache, die sich in die C-ABI einbetten lässt, und frage mich, was die tatsächlichen Einsatzgebiete sind.
Will Roc in einer Plugin-Umgebung mit einer großen Roc-Plattform gegen WASM antreten, und warum sollte ein Anwendungsentwickler überhaupt eine Roc-Schicht freilegen, statt einfach WASM zu nutzen, bei dem Plugin-Entwickler jede beliebige Sprache verwenden können?
Falls es eher eine Sprache auf Anwendungsebene mit kleiner Roc-Plattform ist, frage ich mich auch, ob man serverseitig im HTTP-Bereich mit Gleam und clientseitig mit Elm konkurrieren will.
Kompilierzeit ist ein massiv unterschätzter Faktor. Mein größter Frust ist es, 10 Minuten auf C++-Builds zu warten; das zerreißt den Entwicklungsfluss komplett.
Der Unterschied fühlt sich so an, als hätte man gleich den ganzen Computer ausgetauscht, wenn man von einer
.rs-Datei zu einer.ts-Datei wechselt.Ich nutze Zig selbst nicht direkt, habe aber große Erwartungen an einige Möglichkeiten: neue Spiele, die in dem gegenüber C angenehmer nutzbaren Zig geschrieben werden, verteilte Software, deren Potenzial TigerBeetle bereits gezeigt hat, und besonders der Bereich Robotik, der mich persönlich interessiert.