- Beim wiederholten Bauen einer mit Rust erstellten Website per Docker trat ein Problem mit der Build-Zeit auf
- Mit der Standardkonfiguration von Docker wird jedes Mal die gesamte Abhängigkeitskette neu gebaut, was mehr als 4 Minuten dauert
- Selbst mit
cargo-chef und Caching-Tools kostet der Build des finalen Binärprogramms weiterhin viel Zeit
- Profiling zeigte, dass der Großteil der Zeit für LTO (Link Time Optimization) und LLVM-Moduloptimierung verbraucht wird
- Durch Anpassen von Optimierungsoptionen, Debug-Informationen und LTO-Einstellungen sind gewisse Verbesserungen möglich, dennoch dauert die Kompilierung des finalen Binärprogramms mindestens 50 Sekunden
Problemstellung und Hintergrund
- Jedes Mal, wenn Änderungen an einer persönlichen, mit Rust erstellten Website vorgenommen wurden, musste wiederholt ein statisch gelinktes Binärprogramm gebaut, auf den Server kopiert und anschließend neu gestartet werden
- Es sollte auf containerbasierte Deployments mit Docker oder Kubernetes umgestellt werden, doch die Build-Geschwindigkeit von Rust in Docker erwies sich als großes Problem
- Selbst bei kleinen Codeänderungen musste innerhalb von Docker alles von Grund auf neu gebaut werden, was zu einer ineffizienten Situation führte
Rust-Builds in Docker – der Standardansatz
- Ein typischer Dockerfile-Ansatz besteht darin, alle Abhängigkeiten und den Quellcode zu kopieren und danach
cargo build auszuführen
- In diesem Fall gibt es keinen Vorteil durch Caching, sodass komplette Rebuilds immer wieder stattfinden
- Bei der Website des Autors dauert ein vollständiger Build etwa 4 Minuten — zusätzlich kommt Zeit für das Herunterladen der Abhängigkeiten hinzu
Besseres Build-Caching in Docker – cargo-chef
- Mit dem Tool
cargo-chef lassen sich nur die Abhängigkeiten vorab in einer separaten Layer cachen
- Dadurch können bei Codeänderungen bereits gebaute Abhängigkeiten wiederverwendet werden, was schnellere Builds erwarten lässt
- In der Praxis entfielen jedoch nur 25 % der Gesamtzeit auf den Build der Abhängigkeiten, während der Build des finalen Webservice-Binärprogramms weiterhin viel Zeit beanspruchte (2 Minuten 50 Sekunden bis 3 Minuten)
- Obwohl das Projekt nur aus wichtigen Abhängigkeiten (axum, reqwest, tokio-postgres usw.) und rund 7.000 Zeilen eigenem Code besteht, dauert ein einzelner Lauf von
rustc dennoch 3 Minuten
Analyse der rustc-Build-Zeit: cargo --timings
- Mit
cargo --timings lässt sich die Build-Zeit pro Crate (Compilation Unit) prüfen
- Das Ergebnis zeigte, dass der Build des finalen Binärprogramms den Großteil der Gesamtzeit ausmacht
- Für eine feinere Ursachenanalyse ist das hilfreich, gibt aber noch keinen genauen Einblick in das interne Verhalten des Compilers
Einsatz von rustc-Self-Profiling (-Zself-profile)
- Die Self-Profiling-Funktion von
rustc wurde mit dem Flag -Zself-profile aktiviert, um die Laufzeit einzelner interner Schritte zu messen
- Dazu wurde das Profiling über Umgebungsvariablen eingeschaltet
- Die Auswertung mit dem Tool
summarize zeigte, dass LLVM-LTO (Link Time Optimization) und LLVM-Modul-Codegenerierung mehr als 60 % der Gesamtzeit beanspruchen
- Auch die Visualisierung per Flamegraph bestätigte, dass in der Phase
codegen_module_perform_lto rund 80 % der Gesamtzeit verbraucht werden
LTO (Link Time Optimization) und Build-Optimierungsoptionen
- Ein Rust-Build wird zunächst standardmäßig in einzelne Codegen Units aufgeteilt, danach wird die globale Optimierung durch LTO vergleichsweise spät angewendet
- Für LTO gibt es mehrere Optionen wie off, thin und fat, die sich jeweils auf Performance und das finale Ergebnis auswirken
- Im Projekt des Autors war in der
Cargo.toml-Datei LTO auf thin und die Debug-Symbole auf full gesetzt
- Tests mit verschiedenen Kombinationen aus LTO und Debug-Symbolen ergaben:
- volle Debug-Symbole erhöhen die Build-Zeit deutlich, und fat LTO verlangsamt den Build ungefähr um das Vierfache
- selbst ohne LTO und Debug-Symbole sind mindestens 50 Sekunden Build-Zeit nötig
Weitere Optimierungen und Anmerkungen
- Für die eigene Website mit praktisch keiner Last im Produktivbetrieb sind etwa 50 Sekunden kein großes Problem, dennoch wurde aus technischer Neugier weiter analysiert
- Mit inkrementeller Kompilierung (incremental compilation) ließen sich in Docker potenziell noch schnellere Builds erreichen, allerdings nur in Kombination mit sauberem Build-Setup und Docker-Caching
Detailliertes Profiling der LLVM-Phase
- Auch nach dem Entfernen von LTO und Debug-Symbolen entfielen noch fast 70 % der Zeit auf die Phase
LLVM_module_optimize
- Es wurde erkannt, dass im Release-Profil der Standardwert
opt-level (3) hohe Optimierungskosten verursacht; deshalb wurde getestet, den Wert nur für das Binärprogramm zu senken
- Experimente mit verschiedenen Optimierungskombinationen zeigten: ohne Optimierung (
opt-level=0) dauert der Build etwa 15 Sekunden, mit Optimierung (1 bis 3) dagegen etwa 50 Sekunden
Tiefere Analyse von LLVM-Trace-Events
- Mit zusätzlichen
rustc-Flags (-Z time-llvm-passes, -Z llvm-time-trace) lässt sich die Laufzeit einzelner LLVM-Schritte detailliert nachverfolgen
- Die Ausgabe von
-Z time-llvm-passes ist so umfangreich, dass sie häufig das Log-Limit von Docker überschreitet, weshalb die Log-Konfiguration angepasst werden muss
- Speichert man die Logs in einer Datei und analysiert sie dort, lässt sich die Laufzeit jeder einzelnen LLVM-Optimierungspass separat prüfen
- Die Option
-Z llvm-time-trace erzeugt eine sehr große JSON-Ausgabe im Chrome-Tracing-Format; die Datei ist so groß, dass sie sich mit gewöhnlichen Texteditoren oder Analysetools nur schwer verarbeiten lässt
- Durch Aufteilen in Zeilenebene (
jsonl) ist eine Analyse in CLI- oder Skript-Umgebungen möglich
Zentrale Erkenntnisse und Fazit
- Wenn komplexe Rust-Projekte in Docker gebaut werden, liegt der Flaschenhals bei der Build-Geschwindigkeit meist im finalen Binärprogramm und den zugehörigen LLVM-Optimierungsphasen
- Beim Anpassen von LTO, Debug-Symbolen und
opt-level gibt es klare Trade-offs zwischen Build-Zeit und Binärgröße
- Durch gezieltes Tuning der Optimierungsoptionen lässt sich die Build-Zeit deutlich verkürzen, allerdings kann der Verzicht auf Optimierung zu Performance-Einbußen führen
- Bei umfangreichen Crate-Abhängigkeiten und in Umgebungen, in denen Build-Effizienz wichtig ist, ist es eine gute Strategie, Profiling aktiv einzusetzen, um konkrete Bottlenecks detailliert zu identifizieren
- Beim Entwurf einer Rust-Build-Pipeline ist eine sorgfältige Kombination aus LTO,
opt-level, Debug-Symbolen und Caching-Strategien erforderlich
1 Kommentare
Hacker-News-Kommentare
Es ist interessant, dass Rust-Projekte oft nach außen hin klein wirken. Erstens hängen Abhängigkeiten nicht direkt mit der tatsächlichen Größe der Codebasis zusammen. In C++ werden große Projektabhängigkeiten oft vendort oder gar nicht verwendet, sodass man bei 400.000 Zeilen langsamen Codes leicht denkt: „Klar, bei so viel Code ist das eben langsam.“ Zweitens sind Makros ein viel größeres Problem. Makros, die sich wiederholt um 10 oder 100 Zeilen erweitern, können ein Projekt mit 10.000 Zeilen im Handumdrehen zu einem mit einer Million Zeilen machen. Drittens sind da Generics. Jede Instanziierung von Generics kostet CPU-Ressourcen. Zur Ehrenrettung muss man aber sagen: Dank solcher Features lässt sich etwas, das in C 100.000 Zeilen oder in C++ 25.000 Zeilen braucht, in Rust auf ein paar tausend Zeilen reduzieren. Gleichzeitig stimmt aber auch, dass das Ökosystem durch übermäßigen Einsatz solcher Features langsam wirkt. Wir verwenden zum Beispiel in unserer Firma async-graphql; die Bibliothek selbst ist hervorragend, hängt aber stark von prozeduralen Makros ab. Performance-Probleme dazu sind seit Jahren offen, und jedes Mal, wenn wir einen Datentyp hinzufügen, merkt man deutlich, dass der Compiler langsamer wird
Ryan Fleury hat den Epic RAD Debugger in C mit 278.000 Zeilen im Unity-Build-Stil gebaut, also als eine einzige Compilation Unit, bei der der gesamte Code in einer Datei landet, und ein Clean Compile unter Windows dauert nur 1,5 Sekunden. Schon dieses Beispiel zeigt, wie extrem schnell Kompilierung sein kann. Daher frage ich mich, warum sich Ähnliches nicht auch in Rust oder Swift erreichen lässt
Ich bin sehr froh, dass Go Compile-Geschwindigkeit über Optimierung gestellt hat. Für Server, Networking und Glue-Code ist schnelle Kompilierung wichtiger als alles andere. Ich möchte auch ein gewisses Maß an Typsicherheit, aber nur so weit, dass lockeres Prototyping nicht ausgebremst wird. Auch die GC ist praktisch. Ich denke, Google ist nach seinen Erfahrungen mit Entwicklung im großen Maßstab zu dem Schluss gekommen, dass einfache Typen, GC und extrem schnelle Kompilierung viel wichtiger sind als Laufzeitgeschwindigkeit oder semantische Perfektion. Schon an den vielen großen Networking- und Infrastrukturprojekten in Go sieht man, dass diese Entscheidung voll ins Schwarze getroffen hat. Natürlich würde man Go nicht dort einsetzen, wo GC unzulässig ist oder perfekte Korrektheit wichtiger ist, aber für mein Arbeitsumfeld ist Gos Design optimal
Ich verstehe nicht, warum die Installation eines einzelnen statischen Binaries einfacher sein soll als Container-Management
Auf meinem Notebook (Mac M4 Pro) dauert ein kompletter Build von Deno, also einem großen Rust-Projekt, zwei Minuten. Laut Kommando sind es für debug etwa 1 Minute 54 Sekunden und für release etwa 8 Minuten 17 Sekunden. Das wurde ohne inkrementelle Kompilierung gemessen. Release-Builds laufen in der Praxis ohnehin im CI/CD-System, sodass ich nicht persönlich darauf warten muss
Wo wird eigentlich Cranelift erwähnt? Ich hätte die Spieleentwicklung mit Rust wegen der langen Compile-Zeiten fast aufgegeben. Bei meinen Nachforschungen stellte sich heraus, dass LLVM unabhängig vom Optimierungslevel langsam ist. Die Entwickler von Jai weisen schon lange darauf hin. Ich habe auch erlebt, dass sich die Build-Zeit mit Cranelift von 16 Sekunden auf 4 Sekunden reduziert hat. Großartige Arbeit vom Cranelift-Team!
Ich finde nicht, dass Rust langsam ist. Im Vergleich zu ähnlich mächtigen Sprachen ist es schnell genug, und verglichen mit C++- oder Scala-Compiles, die 15 Minuten dauerten, sogar deutlich schneller
Aus Sicht eines früheren C++-Entwicklers kann ich die Behauptung, Rust-Builds seien langsam, nicht gut nachvollziehen
Inkrementelle Kompilierung ist wirklich extrem stark. Nach dem ersten Build kann man einen Snapshot des inkrementellen Caches festschreiben und ihn, solange sich nichts ändert, für schnelle Builds und Deployments weiterverwenden. Das passt auch gut zu Docker. Abgesehen von Compiler-Versionen oder großen Website-Updates werden die Image-Build-Layer nicht berührt. Wenn sich nur Code ändert, kann man es so konfigurieren, dass genau diese Layer nicht neu gebaut werden, was sehr effizient ist
Die Build-Zeit meiner Homepage beträgt 73 ms. Ein Static-Site-Generator kompiliert in 17 ms neu. Die eigentliche Ausführung des Generators dauert nur 56 ms. Ich hänge das Zig-Build-Log an
cargo watchbei inkrementellen Builds ungefähr 1,25 Sekunden. Mit Dingen wie subsecond[0], also inkrementellem Linking und Hotpatching, geht es noch schneller. Nicht ganz so schnell wie Zig, aber fast. Falls die oben genannten 331 ms ein Clean Build ohne Cache waren, dann ist das natürlich viel schneller als mein Clean Build von 12 Sekunden. [0]: https://news.ycombinator.com/item?id=44369642