- Die Parallelisierung des Rust-Compilers stützte sich bisher vor allem auf Cargo und das LLVM-Backend; nun kommt auch eine parallele Ausführung des Frontends hinzu, wodurch Engpässe in der späteren Build-Phase reduziert werden können
- In nightly lässt sich die experimentelle Funktion mit
-Z threads=8 aktivieren; der Standard bleibt weiterhin der Single-Thread-Modus, sodass es ohne explizite Einstellung keine Beschleunigung gibt
- Das neue Frontend basiert auf Rayon und teilt feingranulare Kompilieraufgaben auf; in einer Beispielmessung sank die Frontend-Zeit von 10,2 auf 5,9 Sekunden
- Bei Messungen an realem Code sank die Kompilierzeit um bis zu 50%, allerdings variiert der Effekt je nach Code-Eigenschaften und Build-Konfiguration stark, und der Speicherverbrauch kann um bis zu 35% steigen
- Die Funktion ist noch experimentell; die Stabilisierung von
-Z threads und die standardmäßige Multi-Thread-Ausführung in stable werden für 2024 angestrebt
Eine neue Achse der Parallelisierung beim Rust-Kompilieren
- Das Frontend des Rust-Compilers kann nun durch parallele Ausführung die Kompilierzeit verkürzen
- Mit der Option
-Z threads=8 im nightly-Compiler lässt sich ein Multi-Thread-Frontend testen
- Die Funktion ist noch experimentell; die Zielmarke für die Aufnahme in den stable-Compiler ist 2024
Bisherige Optimierungen und verbleibende Engpässe
- Die Compiler Performance Working Group verbessert seit mehreren Jahren die Performance des Rust-Compilers
- In den ersten 10 Monaten des Jahres 2023 sank die durchschnittliche Kompilierzeit laut Performance-Messwerkzeugen um 13%
- Der maximale Speicherverbrauch ging um 15% zurück
- Die Binärgröße wurde um 7% kleiner
- Da der Compiler bereits stark optimiert ist, liegt der verbleibende große Verbesserungshebel eher in mehr Parallelität
Grenzen der Parallelisierung von Cargo und Backend
- Cargo führt beim Build eines Rust-Programms mehrere
rustc-Prozesse aus und kompiliert Crates parallel
- Wird diese Parallelisierung mit dem Flag
-j1 deaktiviert, steigt die Kompilierzeit großer Rust-Programme deutlich
- Cargos Flag
--timings erzeugt ein Zeitdiagramm der Crate-Kompilierung
- In einem Beispiel-Build von ripgrep auf einer Maschine mit 28 virtuellen Kernen erscheinen 60 Prozesszeilen
- Die meisten davon sind
rustc, einige sind Build-Skripte
- Die ersten 20 Prozesse können gleichzeitig starten, weil es keine Abhängigkeiten zwischen den Crates gibt
- Je weiter der Build fortschreitet, desto mehr Crate-Abhängigkeiten entstehen, wodurch die Parallelität abnimmt
- Mit pipelined compilation lässt sich die Kompilierung abhängiger Crates teilweise überlappen, doch bei großen Rust-Programmen gibt es in der späten Build-Phase deutlich weniger parallele Ausführung
Aufgaben von Frontend und Backend
- Der Rust-Compiler ist grob in Frontend und Backend unterteilt
- Das Frontend übernimmt Parsing, Typprüfung, Borrow Checking und Ähnliches
- Das bisherige Frontend konnte keine parallele Ausführung nutzen
- Das Backend ist für die Codegenerierung zuständig, erzeugt Code in Einheiten namens „codegen units“, und LLVM verarbeitet diese anschließend parallel
- Die Standardanzahl von codegen units in Release-Builds beträgt 16, weshalb im Beispielprofil 16 LLVM-Threads erscheinen
Engpässe im bisherigen Backend-Profil
- In einem mit Samply gemessenen Beispiel beim Release-Build der letzten Crate von Cargo dauerte das Frontend 10,2 Sekunden
- Das Backend dauerte 6,2 Sekunden, und die LLVM-Threads liefen davon 5,9 Sekunden
- Die LLVM-basierte parallele Codegenerierung ist effektiv, aber selbst auf einer 28-Core-Maschine liefen nicht alle 16 LLVM-Threads gleichzeitig
- Der main thread führt die Umwandlung von MIR in LLVM IR seriell aus, wodurch am Anfang der codegen threads eine treppenartige Form entsteht
- Das vollständig seriell arbeitende Frontend blieb der größte Ansatzpunkt für Verbesserungen
Umsetzung des neuen parallelen Frontends
- Das neue Frontend nutzt Rayon, um feingranulare parallele Kompilieraufgaben auszuführen
- Mehrere Datenstrukturen werden mit Mutexes und Read-Write-Locks synchronisiert; an den nötigen Stellen kommen atomare Typen zum Einsatz
- Viele Frontend-Aufgaben wurden parallelisiert, doch die Änderungen konzentrieren sich auf vergleichsweise wenige zentrale Stellen
- Der Großteil des Frontend-Codes musste nicht geändert werden
Messergebnisse mit 8 Threads
- Im selben Beispiel sank die Frontend-Laufzeit bei aktiviertem parallelem Frontend und 8 Threads von 10,2 auf 5,9 Sekunden
- Die Backend-Laufzeit sank von 6,2 auf 5,3 Sekunden, die Laufzeit der LLVM-Threads von 5,9 auf 4,9 Sekunden
- Im Frontend laufen 7 zusätzliche Threads, die als
rustc angezeigt werden
- Die Thread-Auslastung ist nicht gleichmäßig, und alle 8 Threads haben inaktive Phasen, sodass weiteres Verbesserungspotenzial bleibt
- Der Grund, warum 8 LLVM-Threads gleichzeitig starten, ist, dass 8
rustc-Threads parallel LLVM IR für 8 codegen units erzeugen
- Wird die Anzahl der Frontend-Threads auf 16 erhöht, verschwindet die treppenartige Form vollständig, die endgültige Laufzeit dieses Falls ändert sich jedoch kaum
Kombination von Parallelisierung zwischen Prozessen und innerhalb von Prozessen
- Rust-Kompilierung profitiert seit Langem von Cargos Parallelisierung zwischen Prozessen und der Parallelisierung innerhalb des Backend-Prozesses
- Nun kann auch das Frontend Vorteile aus Parallelisierung innerhalb eines Prozesses ziehen
- Wenn mehrere
rustc-Prozesse gleichzeitig laufen und jeder Prozess mehrere Threads erzeugt, begrenzt das jobserver protocol die Thread-Anzahl
- Ist die Parallelität zwischen Prozessen hoch, wird die Parallelität innerhalb der Prozesse entsprechend reduziert, und die Gesamtzahl der Threads überschreitet nicht die Kernanzahl
Verwendung
- Der nightly-Compiler enthält das parallele Frontend
- Standard ist der Single-Thread-Modus, sodass sich die Kompilierzeit unverändert nicht verkürzt
- Der Multi-Thread-Modus muss explizit mit der Option
-Z threads aktiviert werden
RUSTFLAGS="-Z threads=8" cargo build --release
- Um dies für ein oder mehrere Projekte über
config.toml zu konfigurieren, Folgendes hinzufügen
[build]
rustflags = ["-Z", "threads=8"]
- Der Grund, warum der Single-Thread-Modus Standard ist, liegt in einer vorsichtigen Einführung
- Das parallele Frontend enthält viel neuen Code
- Der Single-Thread-Modus führt den Großteil des neuen Codes aus, schließt aber die Möglichkeit von Threading-Bugs wie Deadlocks aus
- Parallele Programme sind auch in Rust schwieriger korrekt zu schreiben als serielle Programme
- Deshalb wird das parallele Frontend vorerst nicht in Beta- oder stable-Releases enthalten sein
Auswirkungen auf Performance und Speicher
- Im Single-Thread-Modus ist das parallele Frontend in der Regel 0% bis 2% langsamer als das bisherige serielle Frontend
- Im Multi-Thread-Modus mit
-Z threads=8 kann sich die Kompilierzeit in Messungen an realem Code um bis zu 50% verkürzen
- Der Performance-Effekt hängt stark von den Code-Eigenschaften und der Build-Konfiguration ab
- Development-Builds sehen eher größere Verbesserungen als Release-Builds
- Release-Builds verbringen typischerweise mehr Zeit mit Backend-Optimierung
- Bei einigen kleinen Programmen, die ohnehin schnell kompilieren, kann der Multi-Thread-Modus langsamer sein als der Single-Thread-Modus
- Der empfohlene Wert sind 8 Threads
- Dies ist die am meisten getestete Einstellung und dafür bekannt, gute Ergebnisse zu liefern
- Werte unter 8 bringen geringere Vorteile, sind aber für Hardware mit weniger als 8 Kernen passend
- Werte über 8 unterliegen abnehmendem Nutzen und können die Performance sogar verschlechtern
- Dass eine Erhöhung von 1 auf 8 Threads nur Verbesserungen in der Größenordnung von 50% bringt, liegt daran, dass das Frontend nur einen Teil der gesamten Kompilierzeit ausmacht und das Backend bereits parallelisiert ist
- Im Multi-Thread-Modus kann der Speicherverbrauch deutlich steigen; beobachtet wurde ein Anstieg um bis zu 35%
Korrektheit und Feedback
- Die Zuverlässigkeit des Single-Thread-Modus dürfte hoch sein
- Im Multi-Thread-Modus gibt es bekannte Bugs, darunter Deadlocks
- Wenn die Kompilierung hängen bleibt, ist vermutlich einer der bekannten Bugs aufgetreten
- Unabhängig vom verwendeten Frontend sollte der vom Compiler erzeugte Binärcode identisch sein; Unterschiede gelten als Bug
- Bei Problemen sollte zuerst in den
WG-compiler-parallel-gelabelten Issues nachgesehen werden; falls es kein passendes bestehendes Issue gibt, kann ein neues geöffnet werden
- Allgemeines Feedback wird im wg-parallel-rustc Zulip channel entgegengenommen; besonderes Interesse besteht an den Performance-Auswirkungen in realem Code
Stable-Ziel für 2024
- Die Arbeit an Performance-Verbesserungen des parallelen Frontends läuft
- Wie im Profil zu sehen war, gibt es bei der Auslastung der Frontend-Threads noch Verbesserungspotenzial
- Auch die verbleibenden Bugs im Multi-Thread-Modus werden bereinigt
- Ziel für die Stabilisierung der Option
-Z threads und die Bereitstellung des parallelen Frontends als Multi-Thread-Standard in stable-Releases ist 2024
1 Kommentare
Hacker-News-Kommentare
Ich weiß, dass das noch sehr früh ist, aber ich würde sagen, dass Rusts Nachteil die Kompiliergeschwindigkeit ist.
Als ich an einem Rust-Monorepo gearbeitet habe, war mein größter Frust die Kompiliergeschwindigkeit; sie hat die CI/CD-Kosten erhöht, und wenn wir den Cache leeren mussten, wurde auch die Entwicklungszeit deutlich langsamer.
Die Ursache war kein Cargo-, sondern ein Docker-Bug, aber trotzdem sind solche Fortschritte willkommen.
Es wurde bereits stark optimiert, und der aktuelle Rust-Compiler hat schon mehr Parallelität als fast alle Mainstream-Compiler.
Das Sprachdesign von Rust selbst macht das Kompilieren schwieriger als bei Sprachen wie Go, die von Anfang an auf schnelles Kompilieren ausgelegt wurden.
Ich weiß nicht, wie direkt sich diese Arbeit dort anwenden lässt, aber ich hoffe, dass es auch dort große Verbesserungen geben wird.
Für moderne IDE-Unterstützung ist das eindeutig wichtig.
Ich bin Maintainer eines mittelgroßen Open-Source-Rust-Projekts [1], und lokal kommt mir das Kompilieren in Rust immer erstaunlich schnell vor.
Auf einem MacBook Pro dauern Debug-Builds nur ein paar Sekunden, und Release-Builds sowie CI/CD sind zwar langsamer, aber seit ich vor zwei Jahren mit Rust angefangen habe, hat sich Rust-Kompilierung für mich insgesamt sehr schnell angefühlt.
Der Fairness halber: Beruflich arbeite ich hauptsächlich mit Java/Kotlin und Gradle, und dort kann man wirklich von glazialen Kompilierzeiten sprechen.
In meinem Open-Source-Rust-Projekt halte ich die Abhängigkeiten minimal, verwende keine Makros außer Dingen wie
derive[Debug, Clone]und gehe auch mit Generics sehr sparsam um.Es wäre schön, wenn jemand das Projekt mit
cargo buildbauen und Feedback zu den Kompilierzeiten geben könnte.[1]: https://github.com/Orange-OpenSource/hurl
Und auch, ob das Projekt an geeigneten Stellen in mehrere Crates aufgeteilt wurde.
Wie viele Minuten hat der Monorepo-Build gedauert?
Du kannst ruhig ganz offen sein. Mein Hauptcompiler ist GHC, also erschreckt mich so schnell nichts.
Vielleicht ist das eine dumme Frage, aber muss das Backend warten, bis das Frontend mit der Borrow-Checker-Prüfung fertig ist? Falls ja, warum?
Ich meine nicht, dass dann etwas falsch wäre, sondern frage mich, ob der Borrow Checker mehr als nur einfache Korrektheit prüft und Invarianten etabliert, von denen das Backend abhängt.
Zum Beispiel: Gibt es einen Grund, warum man keine spekulative Backend-Arbeit machen kann, die man bei Borrow-Checker-Fehlern wieder verwirft?
Allerdings erzeugt er vielleicht nicht den bestmöglich optimierten Code.
Soweit ich weiß, gibt es Optimierungen, die Informationen nutzen, die während der Borrow-Prüfung etabliert werden, etwa die berüchtigte
noalias-Optimierung. Bis die aktiviert werden konnte, waren mehrere Anläufe nötig[1].Unklar ist auch die Beziehung zu NLL (Non-Lexical Lifetimes), aber wenn das Backend Informationen braucht, die dabei etabliert werden, dann wohl mindestens einen primitiven Borrow Checker.
Andererseits kompiliert mrustc auch Rust-Versionen mit NLL-Funktionalität ohne Borrow Checker, daher scheint es eher um Optimierung als um eine zwingende Voraussetzung zu gehen.
[0]: https://github.com/thepowersgang/mrustc
[1]: https://stackoverflow.com/a/57259339
Gibt es eine Möglichkeit, statt feste Werte in Konfigurationsdateien einzutragen, die auf verschiedenen Maschinen verwendet werden, einfach die Anzahl der CPU-Kerne zu nutzen?
Ich würde erwarten, dass der stabilisierte Standardwert die Kernanzahl sein wird.
Ich weiß nicht, wie weit diese Arbeit gerade ist, aber zeitweise wollte man zwischen den rustc-Aufrufen von Cargo über einen Jobserver koordinieren; dann würde man die Anzahl der Cargo-Jobs verwenden, deren Standardwert bereits die Kernanzahl ist.
Cargo unterstützt auch negative Werte, mit denen man von der Kernanzahl etwas abziehen kann.
RUSTFLAGSverwenden, das steht auch im Artikel:Schön! Als ich Rust vor sehr langer Zeit benutzt habe, war selbst das Kompilieren von Spielzeugbeispielen ziemlich langsam. Als ich kürzlich wieder zurückkam, war Rust wirklich viel besser, und ich benutze es inzwischen überall dort, wo es passt, fast ohne über die Kompilierzeiten nachzudenken.
Bei einem etwas größeren Projekt fingen aber schon einfache Änderungen wieder an, mehr als 5 Sekunden zum Kompilieren zu brauchen, und sofort kamen die alten Erinnerungen zurück.
Ich habe mich sogar dabei ertappt, das Speichern hinauszuzögern, damit der Analyzer nicht losläuft, bis ich andere Dinge aufgeräumt habe, bevor der Laptop wie ein Flugzeugtriebwerk hochdreht.
Für mich persönlich ist das der größte Schmerzpunkt, daher ist jeder Fortschritt hier sehr willkommen.
Sehr gut! Anders als im Ökosystem der Library-Crates waren meine Binary-Crates grundsätzlich eher groß und monolithisch.
Inzwischen teile ich sie in mehrere Library-Crates auf.
Das heißt aber, dass in späten Phasen der Kompilierung nicht nur weniger parallelisiert werden kann, sondern auch, dass die größten Crates nacheinander verarbeitet werden, daher freue ich mich sehr über diese Änderung.
Nachdem ich Rust einige Jahre lang eher halb gemieden und in Umgebungen wie Python oder TypeScript gearbeitet habe, habe ich es kürzlich wieder für ein Projekt verwendet, und die Kompiliergeschwindigkeit war fast sofortig.
Noch besser ist natürlich immer schön, aber es ist schon jetzt in einem ziemlich großartigen Zustand.
Mit dem Cheatcode namens ChatGPT kommt man heutzutage außerdem fast immer auch durch schwierige Rust-Probleme, an denen man vor ein paar Jahren wohl hängen geblieben wäre, daher sieht die Zukunft für Rust ziemlich gut aus.
Dann dauert allein der Docker-Image-Build 60 bis 90 Minuten, und man merkt erst richtig, wie viele Abhängigkeiten das gesamte Projekt eigentlich hat.
Gibt es eine Möglichkeit, die parallele Compiler-Option abzuschalten, ohne den Compiler neu zu bauen?
Ich brauche sie nicht, habe die Codegen Units ohnehin auf 1 gesetzt, und sie scheint einen ICE auszulösen, den ich nicht debuggen möchte.
Ich weiß, dass der Standardwert 1 Thread ist, aber ich würde sie gern komplett deaktivieren.
„Im Multithread-Modus gibt es bekannte Bugs, darunter Deadlocks. Wenn die Kompilierung hängen bleibt, bist du vermutlich auf einen davon gestoßen.“
Dann warte ich wohl lieber noch ein bisschen, bevor ich
-Z threadsverwende ;)