1 Punkte von GN⁺ 2025-06-28 | 1 Kommentare | Auf WhatsApp teilen
  • 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

 
GN⁺ 2025-06-28
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

    • Ich frage mich, warum so oft ursprünglich einfache kleine C-Utilities in Rust neu geschrieben werden. Viel häufiger als Portierungen großer C-Programme mit 100.000 Zeilen sieht man winzige Codebasen. Mich würde interessieren, wie sich Rust und C bei der Compile-Geschwindigkeit kleiner Programme vergleichen. Es geht nicht um die Programmgröße, sondern um die Compile-Geschwindigkeit. Zur Einordnung: Bei meinen letzten Messungen war die Rust-Compiler-Toolchain etwa doppelt so groß wie das GCC, das ich verwende. 1. Bei so kleinen Programmen ist die Wahrscheinlichkeit versteckter Memory-Safety-Probleme in jeder Sprache gering, und wegen des kleinen Umfangs lassen sie sich auch leicht auditieren. Das ist eine ganz andere Situation als bei einem C-Programm mit 100.000 Zeilen
    • Jedes Mal, wenn man einen neuen Typ definiert, spürt man direkt, dass der Compiler langsamer wird. Soweit ich mich erinnere, wird die Compiler-Performance mit der „Tiefe“ von Typen exponentiell schlechter. Bei GraphQL ist das wegen der vielen verschachtelten Typen besonders gravierend
    • Um das Problem anzugehen, dass Makros, die sich um Dutzende oder Hunderte Zeilen erweitern, die Codebasis geometrisch vergrößern können, wurde kürzlich Unterstützung für Analysetools hinzugefügt. Siehe dazu: https://nnethercote.github.io/2025/06/26/how-much-code-does-that-proc-macro-generate.html
  • 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

    • Je mehr Arbeit der Compiler zur Build-Zeit übernimmt, desto länger dauern Builds. Go schafft selbst bei großen Codebasen Build-Zeiten unter einer Sekunde. Es hat ein einfaches Modulsystem und Typsystem, das die für den Build notwendige Arbeit minimiert, und überlässt die meisten Dinge der Runtime-GC. Wenn man dagegen Makros, komplexe Typsysteme und ein hohes Maß an Robustheit verlangt, werden Build-Zeiten zwangsläufig länger
    • Auch bei Rust ist die Build-Einheit das gesamte Crate, und der Compiler zerlegt es dann in passend große LLVM-IR-Einheiten. Das Gleichgewicht zwischen doppelter Arbeit und inkrementellen Builds wird dabei automatisch austariert. Gemessen an Source-Code-Zeilen ist Rust beim Bauen oft schneller als C++. Allerdings werden bei Rust-Projekten typischerweise auch alle Abhängigkeiten mitkompiliert
    • Rust und Swift kompilieren langsamer als C-Compiler, weil die Sprachen selbst viel mehr Analysearbeit verlangen. Der Borrow Checker von Rust kommt zum Beispiel nicht gratis. Schon allein Compile-Time-Checks verbrauchen erhebliche Ressourcen. C ist schnell, weil es über grundlegende Syntaxprüfungen hinaus fast nichts überprüft. C prüft nicht einmal merkwürdige Kombinationen wie einen Aufruf von foo(int) bei foo(char*)
    • Ich habe in den 2000ern C++-Projekte mit Zehntausenden Zeilen kompiliert, und selbst auf alter Hardware war der Build in unter einer Sekunde fertig. Dagegen brauchte ein HELLO WORLD nur mit Boost schon mehrere Sekunden. Letztlich hängt die Build-Geschwindigkeit also nicht nur von Sprache oder Compiler ab, sondern stark von Code-Struktur und verwendeten Features. Man könnte DOOM auch mit C-Makros bauen, aber schnell wäre das vermutlich nicht. Umgekehrt kann man Rust ebenfalls so strukturieren, dass Builds schnell sind
    • Dass Sprachen wie C und Go, die ausdrücklich auf schnelle Kompilierung zielen, schnell sind, ist wenig überraschend. Die eigentliche Schwierigkeit besteht darin, Rusts Semantik schnell zu kompilieren. Das wird auch in der offiziellen Rust-FAQ thematisiert
  • 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 mag Go auch, aber ich glaube nicht, dass diese Sprache das Produkt einer überragenden kollektiven Intelligenz des organisatorischen Google ist. Wenn wirklich so viel Google-Erfahrung eingeflossen wäre, hätte man zum Beispiel Funktionen wie die statische Eliminierung von Null-Pointer-Exceptions ergänzt. Eher wirkt es auf mich wie das Ergebnis einiger Google-Entwickler, die die Sprache gebaut haben, die sie selbst wollten
    • Go hat Vorteile wie schnelle Kompilierung, ein brauchbares Typsystem und GC, aber diesen Platz im Designraum hatte im Grunde schon Java besetzt. Ich denke, Go entstand vor allem aus dem Wunsch, etwas Eigenes zu schaffen, und wurde am Ende stärker von Nutzern von Skriptsprachen wie Python, Ruby oder JS aufgenommen als von der ursprünglichen Zielgruppe auf der Server-Seite mit C/C++/Java. Nutzer von Skriptsprachen wollten nur ein einfaches, schnelles Typsystem, und Java war ihnen zu altbacken und zu langweilig. Im Bereich Server/Konferenzen/Bibliotheken war Java ohnehin schon fest besetzt
    • Es gibt auch die Geschichte, dass Google-Entwickler Go entworfen haben, während sie auf das Kompilieren von C++-Projekten warteten
    • Ich würde gern fragen, was mit „obnoxious type“ gemeint ist. Typen stellen Daten entweder korrekt dar oder eben nicht, und in jeder Sprache kann man einen Type Checker letztlich irgendwie zum Schweigen bringen
    • Go ist eine Sprache, die exakt zu ihrem Designziel und ihrem tatsächlichen Einsatzzweck passt. Das größte Risiko liegt im Parallelitätsmodell und darin, veränderbaren Zustand über Channels zu teilen; gerade dort können subtile oder fragile Bugs entstehen. Die meisten Nutzer verwenden solche Muster jedoch gar nicht. Ich arbeite selbst mit Rust, und bei mir geht es darum, langsame Algorithmen auf langsamer Hardware bis an die Grenze auszureizen. Deshalb ist umfangreiche Parallelisierung ein sehr heikles oder praktisch unmögliches Thema
  • Ich verstehe nicht, warum die Installation eines einzelnen statischen Binaries einfacher sein soll als Container-Management

    • Es wirkt, als sei nicht ganz klar, was Docker tatsächlich tut. Zum Beispiel wurde gesagt: „Wenn man mit Docker-Images deployed, baut man jedes Mal alles neu“, aber in internen Build- und Deployment-Umgebungen muss das überhaupt kein Problem sein. Für private Nutzung kann man auch einfach lokal gebaute Dateien in den Container legen und trotzdem den Komfort der Entwicklung behalten. Man muss nur auf Pfade achten, die Spuren der Build-Umgebung enthalten. In CI/CD oder Teamprojekten geht es darum sicherzustellen, dass Builds überall reproduzierbar von null an erzeugt werden können; bei persönlicher Arbeit ist das nicht zwingend nötig
    • Im Originaltext ging es nicht um Vereinfachung, sondern um Modernisierung. Die Aussage war: „In den letzten zehn Jahren hat der Großteil von Software Container-Deployment als Standard übernommen, also deploye ich auch meine Website in Containern wie Docker oder Kubernetes.“ Container bieten verschiedene Vorteile wie Prozessisolierung, Sicherheit, standardisiertes Logging und horizontale Skalierbarkeit
  • 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

    • Es gibt einen passenden Artikel, in dem von etwa 6 Minuten auf einem M1 Max und etwa 11 Minuten auf einem M1 Air die Rede ist
  • 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!

    • Beim jüngsten Bevy Game Jam habe ich ein Tool namens „subsecond“ verwendet, das aus der Dioxus-Community stammt. Wie der Name schon sagt, ermöglicht es System-Hot-Reload in unter einer Sekunde und war dadurch eine große Hilfe beim UI-Prototyping. https://github.com/TheBevyFlock/bevy_simple_subsecond_system
    • Soweit ich weiß, versucht auch das Zig-Team, ohne LLVM einen eigenen Compiler bzw. ein eigenes Backend zu bauen, um Build-Zeiten sehr schnell zu machen
    • Früher unterstützte Cranelift meines Wissens kein macOS aarch64, aber ich habe vor Kurzem erfahren, dass das inzwischen doch der Fall ist
    • Ist es nicht etwas übertrieben, Rust wegen 16 Sekunden Build-Zeit fast aufzugeben?
  • 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

    • Sehe ich genauso. Ich habe Rust-Builds nie als besonders lästig empfunden. Vermutlich hält sich hier einfach ein schlechter Ruf aus der Anfangszeit
    • Der Speicherverbrauch beim Kompilieren ist im Vergleich zu C/C++ allerdings sehr hoch. Wenn ich auf meiner VM für YouTube-Demos ein großes Rust-Projekt kompilieren will, brauche ich mehr als 8 GB. Bei C/C++ muss ich mir darüber keine Sorgen machen
    • Da C++-Templates Turing-vollständig sind, ist es wenig sinnvoll, Build-Zeiten zu vergleichen, ohne den tatsächlichen Code-Stil zu berücksichtigen
  • Aus Sicht eines früheren C++-Entwicklers kann ich die Behauptung, Rust-Builds seien langsam, nicht gut nachvollziehen

    • Genau deshalb heißt es ja auch, Rust richte sich an C++-Entwickler. Wer viel C++-Erfahrung hat, bringt oft schon eine Art Stockholm-Syndrom gegenüber unbequemen Tools mit
    • Auch wenn Rust schneller als C++ ist, kann es absolut betrachtet trotzdem langsam sein. Der katastrophale Ruf von C++-Builds ist ja allgemein bekannt. Rust hat nicht dieselben strukturellen Sprachprobleme, daher sind die Erwartungen wohl höher
    • Für mich ist das ein klassischer Fall: Es werden ständig neue Features hinzugefügt, aber auf tatsächliches Nutzerfeedback und Problemlösungen wird nicht genug gehört
    • C war schnell, weil die Compile-Phasen wenige und einfach waren, aber bei C++ fühlt es sich durch den Einsatz von Templates so an, als hätte man einen Großteil der ganzen Kapselungsarbeit wieder eingerissen. Wenn man nur einen einzigen Template-Header ändert, hat man das Gefühl, als würden am Ende 98 % des gesamten Projekts betroffen sein
  • 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 inkrementellen Artefakte meines Projekts sind über 150 GB groß. Als ich Docker-Images in dieser Größenordnung verwendet habe, gab es in der Praxis wirklich massive Probleme
  • 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

    • Unter C/C++ gibt es immer Kommentare, die Rust loben, und unter Rust immer Kommentare, die Zig loben. (Wie sich herausstellte, ist der Autor dieses Kommentars der Hauptentwickler von Zig.) Ich denke, Sprach-Missionierung schadet Communities und erzeugt in der Praxis eher Ablehnung, statt neue Nutzer zu gewinnen. Wenn man eine Sprache wirklich liebt, hilft es mehr, diese Missionierungskultur einzudämmen
    • Statt nur eine einzelne Compile-Time-Kennzahl zu posten, wäre etwas direktere Diskussion oder Einordnung zum Thema des Originalposts hilfreicher gewesen
    • Meine Rust-Website mit React-ähnlichem Framework und faktischem Webserver braucht mit cargo watch bei 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
    • Ich würde @AndyKelley wirklich gern fragen, was seiner Meinung nach der entscheidende Grund dafür ist, dass Zig extrem schnell kompiliert, während Rust und Swift immer langsam sind
    • Zig garantiert doch keine Memory Safety, oder?