Warum ist der Rust-Compiler so langsam?
(sharnoff.io)- Selbst wenn Abhängigkeiten beim Build einer Rust-Website für Docker-Deployment zwischengespeichert wurden, dauerte allein das finale Crate noch etwa 175 Sekunden; der Flaschenhals ließ sich auf das Innere von
rustcund die LLVM-Optimierungsphase eingrenzen - Nach dem schrittweisen Einsatz von
cargo-chef,cargo --timings,-Zself-profileundmeasuremezeigte sich, dass nicht einfache Abhängigkeitsprobleme, sondern vor allem die Kosten von LTO und LLVM-Codegenerierung die Build-Zeit dominierten - Die alten Einstellungen
lto = "thin"unddebug = "full"inCargo.tomlhatten großen Einfluss; nach dem Abschalten sank der Build der finalen Binärdatei von 172,2 Sekunden auf etwa 50 Sekunden - Im LLVM-Trace zeigten sich
OptFunction,InlinerPass,core::ptr::drop_in_place, große async-Funktionen und generische Monomorphisierung als Hauptkostentreiber; weniger Inlining, das Aufteilen von Funktionen,Pin<Box<dyn Future>>und das Entfernen von Generics brachten weitere Verbesserungen - Nach dem Einsatz von
-Zshare-genericsund dem Wechsel zu Debian-basierten Builds fiel die Compile-Zeit schließlich von 29,1 Sekunden auf 9,1 Sekunden; neben der Code-Struktur spielten also auch Allocator und die Nutzung des musl-Targets eine große Rolle
Flaschenhals im Docker-Build
- Die Website wird hauptsächlich als einzelne Rust-Binärdatei ausgeliefert; bisher wurde eine statisch gelinkte Binärdatei gebaut, auf den Server kopiert und dann der Dienst neu gestartet
- Beim Umstieg auf containerbasiertes Deployment zeigte sich, dass schnelle Rust-Builds in Docker schwieriger einzurichten waren als erwartet
- Das grundlegende Dockerfile baute bei jeder Quellcodeänderung alles neu
rust:1.87-alpine3.22wurde als Builder verwendet und für das Targetx86_64-unknown-linux-muslgebaut- Das finale Image kopierte nur die Binärdatei nach Alpine
- Ein Clean Build mit diesem Ansatz dauerte 3 Minuten 51 Sekunden, inklusive 10 Sekunden für das Herunterladen der Crates
Abhängigkeits-Cache mit cargo-chef getrennt, aber nicht ausreichend
- cargo-chef erzeugt in einem Workspace eine vereinfachte Recipe-Datei und baut darauf basierend Abhängigkeiten vorab in einer separaten Docker-Cache-Schicht
- Da die Website Hunderte von Abhängigkeiten nutzt, wurde ein starker Cache-Effekt erwartet
- In der Praxis dauerte der Abhängigkeits-Build 1 Minute 7 Sekunden, und der Build der finalen Binärdatei mit gecachten Abhängigkeiten 2 Minuten 50 Sekunden
- Nur etwa 25 % der Gesamtzeit entfielen auf Abhängigkeiten; der Großteil wurde von einem einzelnen
rustc-Aufruf für das finale Crateweb-http-serververbraucht
cargo --timings und das Self-Profiling von rustc
cargo build --release --timingszeigt die Compile-Zeit pro Crate; die Zeit für das finale Crate lag bei 174,1 Sekunden und passte damit grob zu den 2 Minuten 54 Sekunden aus der Ausgabe voncargo build- Weil der Flaschenhals auf ein einzelnes finales Crate konzentriert war, ließ sich die genaue Ursache mit
cargo --timingsallein nur schwer erkennen - Für die Self-Profiling-Funktion von
rustcwurde-Zself-profileverwendet- Um das instabile
-Z-Flag mit dem stabilen Compiler zu nutzen, wurdeRUSTC_BOOTSTRAP=1eingesetzt - Um eine Invalidierung des
cargo-chef-Caches zu vermeiden, wurde stattcargo rustc -- -Z self-profileRUSTFLAGS='-Zself-profile'verwendet
- Um das instabile
- Mit den Werkzeugen
summarize,flamegraphundcroxaus measureme wurden die Self-Profiling-Daten ausgewertet - Die Top-Einträge in
summarizelagen fast alle im LLVM-BereichLLVM_lto_optimize: 851,95 Sekunden, 33,389 % der GesamtzeitLLVM_module_codegen_emit_obj: 674,94 Sekunden, 26,452 %LLVM_thin_lto_import: 317,75 Sekunden, 12,453 %LLVM_module_optimize: 189,00 Sekunden, 7,407 %
- Im Flamegraph machte
codegen_module_perform_ltorund 80 % der Gesamtzeit aus
Einfluss von LTO- und Debug-Symbol-Einstellungen
- Der Rust-Compiler teilt ein Crate in Codegen Units auf und übergibt sie als separate Module an LLVM
- LTO ist eine Option, die beim Linken Inlining und Optimierung zwischen Codegen Units oder Crates ausführt
- Cargo und
rustcbieten dabei folgende LTO-Optionen- LTO aus
"thin"LTO"fat"LTO- Wenn nichts angegeben ist, ein auf ein einzelnes Crate beschränktes „thin local LTO“
- In der bestehenden
Cargo.tomlstanden noch Werte aus früheren Jahrenlto = "thin"debug = "full"
debug = "full"aktiviert vollständige Debug-Symbole, die im Release-Profil standardmäßig nicht enthalten sind- Messungen mit verschiedenen Kombinationen aus
ltounddebugzeigten große Unterschiede- LTO aus,
debug=none: 50,0 Sekunden / 21,0 MiB - Thin local LTO,
debug=full: 88,2 Sekunden / 256,8 MiB "thin"LTO,debug=full: 172,2 Sekunden / 197,5 MiB"fat"LTO,debug=full: 287,1 Sekunden / 155,9 MiB
- LTO aus,
- Vollständige Debug-Symbole erhöhten die Compile-Zeit um 30 bis 50 %, und fat LTO dauerte etwa viermal so lange wie komplett deaktiviertes LTO
- Selbst ohne LTO und Debug-Symbole blieben für das Kompilieren der finalen Binärdatei noch rund 50 Sekunden übrig
Warum der Docker-Cache statt inkrementeller Kompilierung beibehalten wurde
- Bei lokaler Entwicklung kann inkrementelle Kompilierung genutzt werden, wenn das Verzeichnis
/targetim Dockerfile als Cache-Mount eingebunden und zwischen Builds erhalten bleibt - Trotzdem wurde weiter
cargo-chefverwendet, um die Eigenschaft eines potenziell sauberendocker buildbei jedem Lauf beizubehalten und zugleich das Docker-eigene Cache-System zu nutzen
Verbleibende LLVM-Optimierungskosten nach LTO
- Auch nach dem Abschalten von LTO und Debug-Symbolen dauerte das Kompilieren der finalen Binärdatei noch etwa 50 Sekunden
- Ein erneuter Blick auf das Self-Profiling zeigte, dass etwa 70 % der Zeit in
LLVM_module_optimizegingen, also in die eigentliche LLVM-Codeoptimierung - Es wurde ausprobiert, das Standard-
opt-level = 3des Release-Profils nur für das finale Crate zu senken- Abhängigkeiten bleiben gecacht, daher blieb
opt-level = 3inprofile.release.package."*"erhalten - Nur für das finale Crate wurde
opt-levelreduziert
- Abhängigkeiten bleiben gecacht, daher blieb
- Die Messungen unterschieden sich stark je nach Optimierungsstufe
- Finales
opt-level=0: etwa 15 Sekunden - Finales
opt-level=1: etwa 48 Sekunden - Finales
opt-level=2oder3: etwa 50 bis 55 Sekunden - Finales
opt-level="z": etwa 42 Sekunden
- Finales
- Sobald irgendeine Optimierung für die finale Binärdatei aktiv war, entstand eine Basislinie von rund 50 Sekunden; ohne Optimierung sank sie auf etwa 15 Sekunden
Schwierigkeiten bei der Erfassung von LLVM-Trace-Daten
rustchat Flags, um Informationen aus LLVM sichtbar zu machen-Z time-llvm-passes: gibt LLVM-Profildaten als Klartext aus-Z llvm-time-trace: gibt ein LLVM-Profil im Chrome-Tracing-Format aus
-Z time-llvm-passeslief in die Standard-Loglimits von Docker BuildKitBUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- Diese Umgebungsvariablen müssen nicht beim Aufruf von
docker build, sondern für den Docker-Daemon gesetzt werden; unter Linux geht das persystemd-Drop-in fürdocker.service - Nach dem Anheben der Limits entstanden etwa 200.000 Zeilen Textausgabe, die sich nur schwer direkt auswerten ließen
-Z llvm-time-traceerzeugte zwar*.llvm_timings.json, aber die Trace-Datei der finalen Binärdatei war ein 1,4 GiB großes JSON in nur einer Zeile- Firefox Profiler, Perfetto UI und
chrome://tracingin Chromium hatten alle Probleme mit dieser Datei - Deshalb wurde das JSON in JSONL umgewandelt, um es mit allgemeineren Werkzeugen zu verarbeiten
- Das
traceEvents-Array des einzelnen JSON-Objekts wurde in je eine Zeile pro Event zerlegt - Nach der Umwandlung ergaben sich 7.301.865 Zeilen mit Events
- Das
Flaschenhälse in den LLVM-Events
- LLVM-Trace-Events waren überwiegend Complete Events mit
"ph":"X"; das Felddurenthält die Dauer in Mikrosekunden "ph":"M"waren Metadata-Events, die in dieser Analyse wenig nützliche Informationen lieferten- Bei den aggregierten Events entfielen die höchsten Zeiten auf folgende Einträge
Total ModuleInlinerWrapperPass: 665,37 SekundenTotal ModuleToPostOrderCGSCCPassAdaptor: 656,47 SekundenTotal DevirtSCCRepeatedPass: 632,44 SekundenTotal OptFunction: 189,62 SekundenTotal InlinerPass: 182,25 Sekunden
- Dieser Lauf dauerte auf einer Maschine mit 16 Kernen etwa 110 Sekunden, daher wurden manche Pass-Zeiten mehrfach aggregiert
- Die zwei großen Themen waren die Funktionsoptimierung
OptFunctionund das Inlining inInlinerPass
Anpassen der Inlining-Schwellenwerte
- LLVM-Inlining-Optionen können über
-C llvm-argsanrustcweitergereicht werden - Stand Juni 2025 listet
rustc -C llvm-args='--help-list-hidden'etwa 100 Inlining-bezogene Optionen auf - Drei davon wurden in den Experimenten verwendet
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- Ein Threshold erlaubt grob gesagt das Inlining von Funktionen, deren Kosten unter diesem Wert liegen; niedrigere Werte bedeuten also weniger Inlining
- Wurden alle drei Schwellenwerte auf 50 gesenkt, fiel die Zeit von 48,8 Sekunden auf 42,2 Sekunden
- Für den Anwendungsfall einer privaten Website mit praktisch keiner Last erschien sogar ein Threshold von 10 vielversprechend
OptFunction und generische Monomorphisierung
- In
OptFunction-Events enthältargs.detaildas mangelte Symbol der gerade optimierten Funktion - Mit rustfilt lässt sich das demangeln, um das ursprüngliche Rust-Symbol zu sehen
__rustc::__rust_allocserde_json::value::to_value
- Dass
serde_json::value::to_valuemit mehreren Hashes auftauchte, lag daran, dass generische Funktionen für unterschiedliche Typparameter monomorphisiert werden - Auch Funktionen aus anderen Crates werden im finalen Crate optimiert, weil die Monomorphisierung für konkrete Typen im Kontext des aufrufenden Crates stattfindet
- Beispiele für besonders teure optimierte Funktionen waren
- eine Closure in
web_http_server::photos::PhotosState::new - eine Closure in
web_http_server::run tokio_postgres::connect_raw- eine generische Funktion mit rund 500 Zeilen in
pulldown_cmark - mehrere konkrete Typen von
core::ptr::drop_in_place
- eine Closure in
- Grob nach äußerem Crate-Namen aggregiert war
coremit 61,53 Sekunden der größte Posten; davon entfielen 84 % auf parametrisierte Varianten voncore::ptr::drop_in_place
Mit v0-Symbol-Mangling die Position von async-Funktionen klarer sehen
- Das standardmäßige Legacy-Symbol-Mangling machte es schwer, Closures voneinander zu unterscheiden
- Mit
-C symbol-mangling-version=v0wurden Closure-Nummern und generische Typinformationen deutlicher sichtbar - So ließ sich etwa bei
serde_json::value::to_valuedie vollständige generische Argumentliste erkennen, mit der die Funktion für einenweb_http_server-Typ monomorphisiert wurde - Im v0-Output waren unter den teuren Einträgen unter anderem
<web_http_server::photos::PhotosState>::new::{closure#0}: 1,99 Sekundenweb_http_server::run::{closure#0}: 1,56 Sekundencore::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1,22 Sekunden
- Äußerlich wirkten diese Closures klein, aber ein Dump des LLVM-IR zeigte, dass async-Funktionen und async-Blöcke intern als verschachtelte Closures dargestellt werden
- Für das Mangling von async function/block gab es in Rust bereits ein offenes Issue
Große async-Funktionen und Pin<Box<dyn Future>>
- Die teuren Einträge waren weniger die Closures selbst als vielmehr die großen async-Funktionskörper
- Die Optimierungszeit rund um
PhotosState::newlag anfangs insgesamt bei 5,3 Sekunden - Ein erster Versuch, die Funktion einfach nur aufzuteilen, senkte das nur leicht auf 4,66 Sekunden
- Ein Versuch, benachbarte
.awaitzusammenzufassen und so die Zahl der.awaitvon 10 auf 3 zu reduzieren, erhöhte die Zeit sogar auf 6,24 Sekunden - Da async-Funktionen intern zu komplexen Zustandsmaschinen abgesenkt werden, wurde versucht, Implementierungsdetails gegenüber dem Aufrufer zu verbergen, indem
Futurezu einem Trait-Objekt ausgelöscht wurde - Verwendet wurde dazu eine Funktion, die
impl Future<Output = T>inPin<Box<dyn Send + Future<Output = T>>>einpackt - Eingesetzt an jeder
.await-Stelle, etwa alserase(get_img_candidates()).await?, ergab sich:- Die Zeit für
PhotosState::newsank auf 2,14 Sekunden - Die gesamte Build-Zeit ohne Profiling sank von 48,8 Sekunden auf 46,8 Sekunden
- Die Zeit für
- Auch
#[inline(never)]und das Abschalten des Inlinings der Poll-Funktion wurden ausprobiert, brachten aber weniger als das Boxing
Ergebnis nach Kombination mehrerer Änderungen
- Drei Ansätze wurden kombiniert
- weniger Inlining über LLVM-Args
- das Aufteilen teurer Funktionen im Haupt-Crate und Boxing von async Futures
- weniger Generics in Abhängigkeits-APIs, damit weniger Teile im finalen Crate neu kompiliert werden müssen
- Im finalen Dockerfile wurden
RUSTFLAGSgesetzt, um alle drei Inlining-Thresholds auf 10 zu senken, und zwar sowohl fürcargo chef cookals auch fürcargo build - Im Haupt-Crate führten die Änderungen verteilt über 10 Dateien zu 898 hinzugefügten und 657 entfernten Zeilen
- Dazu kamen Änderungen auf Abhängigkeitsseite
- ein PR, der eine generische Funktion in
pulldown-cmarknicht-generisch machte - ein lokales Crate, das nicht-generische Varianten von APIs bereitstellt, die in
lol_htmlunddeadpool_postgresverwendet werden
- ein PR, der eine generische Funktion in
- Mit dieser Kombination sank die finale Compile-Zeit auf 32,3 Sekunden
Update 2025-06-27: -Zshare-generics und kein Alpine mehr
- Nach Vorschlägen aus Bluesky und Lobsters wurden zwei zusätzliche Experimente durchgeführt
- Aktivierung von
-Zshare-generics - Weg von Alpine
- Aktivierung von
-Zshare-genericsist ein Flag zur Wiederverwendung generischer Instanzen aus Crate-Abhängigkeiten- In Release-Builds ist es standardmäßig nicht aktiv
- In Dev-Builds auf der stabilen Toolchain ist es aktiviert
- Das Flag selbst ist nur auf nightly nutzbar
- Mit aktiviertem
-Zshare-genericssank die Gesamt-Compile-Zeit von 32,3 Sekunden auf 29,1 Sekunden - Es wurden weiterhin viele
drop_in_place-Instanzen kompiliert, aber deren Optimierungszeit sank von 21,7 Sekunden auf 17,4 Sekunden - Nach dem Wechsel von Alpine zu Debian und dem Entfernen von
--target=x86_64-unknown-linux-muslfiel die Gesamt-Compile-Zeit drastisch von 29,1 Sekunden auf 9,1 Sekunden - Hintergrund des Vorschlags war, dass der Standard-Allocator einen großen Einfluss auf die Build-Zeit haben kann
Endwerte und offene Aufgaben
- Die finalen Änderungen ergaben folgende Entwicklung
- Ausgangspunkt: etwa 175 Sekunden
- LTO und Debug-Symbole deaktiviert: 51 Sekunden, -71 %
- Finales Crate mit
opt-level = 1: 48,8 Sekunden, -4 % - Weniger Inlining über
-C llvm-args: 40,7 Sekunden, -16 % - Lokale Codeänderungen: 37,7 Sekunden, -7 %
- Änderungen an Abhängigkeiten: 32,3 Sekunden, -14 %
-Zshare-generics: 29,1 Sekunden, -10 %- Alpine entfernt: 9,1 Sekunden, -69 %
- Im Analyseprozess funktionierten die Werkzeuge und Dokumentationen gut genug, um tatsächlich Verbesserungen zu erzielen
- Einige komplexe Probleme bleiben allerdings offen
- Die Compile-Zeit tiefer async-Aufrufgraphen sollte weiter verbessert werden
- Eine Sonderbehandlung, bei der
core::ptr::drop_in_place<T>im Crate kompiliert wird, dasTdefiniert, könnte in manchen Fällen helfen, ist aber bei generischen Typen schwer anzuwenden und birgt das Risiko, ungenutzten Drop-Glue mitzukompilieren -Zshare-genericshilft, ist aber keine vollständige Lösung- Es könnte mehr Werkzeuge brauchen, die isolieren, welche Teile einer Codebasis besonders viel Compile-Zeit verbrauchen, und passende Gegenmaßnahmen vorschlagen
- Praktisch betrachtet kann es bereits ausreichen, für das finale Crate
opt-level = 0zu setzen
Noch keine Kommentare.