1 Punkte von GN⁺ 2025-06-28 | Noch keine Kommentare. | Auf WhatsApp teilen
  • 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 rustc und die LLVM-Optimierungsphase eingrenzen
  • Nach dem schrittweisen Einsatz von cargo-chef, cargo --timings, -Zself-profile und measureme zeigte 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" und debug = "full" in Cargo.toml hatten 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-generics und 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.22 wurde als Builder verwendet und für das Target x86_64-unknown-linux-musl gebaut
    • 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 Crate web-http-server verbraucht

cargo --timings und das Self-Profiling von rustc

  • cargo build --release --timings zeigt 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 von cargo build
  • Weil der Flaschenhals auf ein einzelnes finales Crate konzentriert war, ließ sich die genaue Ursache mit cargo --timings allein nur schwer erkennen
  • Für die Self-Profiling-Funktion von rustc wurde -Zself-profile verwendet
    • Um das instabile -Z-Flag mit dem stabilen Compiler zu nutzen, wurde RUSTC_BOOTSTRAP=1 eingesetzt
    • Um eine Invalidierung des cargo-chef-Caches zu vermeiden, wurde statt cargo rustc -- -Z self-profile RUSTFLAGS='-Zself-profile' verwendet
  • Mit den Werkzeugen summarize, flamegraph und crox aus measureme wurden die Self-Profiling-Daten ausgewertet
  • Die Top-Einträge in summarize lagen fast alle im LLVM-Bereich
    • LLVM_lto_optimize: 851,95 Sekunden, 33,389 % der Gesamtzeit
    • LLVM_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_lto rund 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 rustc bieten 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.toml standen noch Werte aus früheren Jahren
    • lto = "thin"
    • debug = "full"
  • debug = "full" aktiviert vollständige Debug-Symbole, die im Release-Profil standardmäßig nicht enthalten sind
  • Messungen mit verschiedenen Kombinationen aus lto und debug zeigten 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
  • 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 /target im Dockerfile als Cache-Mount eingebunden und zwischen Builds erhalten bleibt
  • Trotzdem wurde weiter cargo-chef verwendet, um die Eigenschaft eines potenziell sauberen docker build bei 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_optimize gingen, also in die eigentliche LLVM-Codeoptimierung
  • Es wurde ausprobiert, das Standard-opt-level = 3 des Release-Profils nur für das finale Crate zu senken
    • Abhängigkeiten bleiben gecacht, daher blieb opt-level = 3 in profile.release.package."*" erhalten
    • Nur für das finale Crate wurde opt-level reduziert
  • 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=2 oder 3: etwa 50 bis 55 Sekunden
    • Finales opt-level="z": etwa 42 Sekunden
  • 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

  • rustc hat 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-passes lief in die Standard-Loglimits von Docker BuildKit
    • BUILDKIT_STEP_LOG_MAX_SIZE
    • BUILDKIT_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 per systemd-Drop-in für docker.service
  • Nach dem Anheben der Limits entstanden etwa 200.000 Zeilen Textausgabe, die sich nur schwer direkt auswerten ließen
  • -Z llvm-time-trace erzeugte 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://tracing in 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

Flaschenhälse in den LLVM-Events

  • LLVM-Trace-Events waren überwiegend Complete Events mit "ph":"X"; das Feld dur enthä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 Sekunden
    • Total ModuleToPostOrderCGSCCPassAdaptor: 656,47 Sekunden
    • Total DevirtSCCRepeatedPass: 632,44 Sekunden
    • Total OptFunction: 189,62 Sekunden
    • Total 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 OptFunction und das Inlining in InlinerPass

Anpassen der Inlining-Schwellenwerte

  • LLVM-Inlining-Optionen können über -C llvm-args an rustc weitergereicht 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ält args.detail das mangelte Symbol der gerade optimierten Funktion
  • Mit rustfilt lässt sich das demangeln, um das ursprüngliche Rust-Symbol zu sehen
    • __rustc::__rust_alloc
    • serde_json::value::to_value
  • Dass serde_json::value::to_value mit 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
  • Grob nach äußerem Crate-Namen aggregiert war core mit 61,53 Sekunden der größte Posten; davon entfielen 84 % auf parametrisierte Varianten von core::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=v0 wurden Closure-Nummern und generische Typinformationen deutlicher sichtbar
  • So ließ sich etwa bei serde_json::value::to_value die vollständige generische Argumentliste erkennen, mit der die Funktion für einen web_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 Sekunden
    • web_http_server::run::{closure#0}: 1,56 Sekunden
    • core::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::new lag 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 .await zusammenzufassen und so die Zahl der .await von 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 Future zu einem Trait-Objekt ausgelöscht wurde
  • Verwendet wurde dazu eine Funktion, die impl Future<Output = T> in Pin<Box<dyn Send + Future<Output = T>>> einpackt
  • Eingesetzt an jeder .await-Stelle, etwa als erase(get_img_candidates()).await?, ergab sich:
    • Die Zeit für PhotosState::new sank auf 2,14 Sekunden
    • Die gesamte Build-Zeit ohne Profiling sank von 48,8 Sekunden auf 46,8 Sekunden
  • 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 RUSTFLAGS gesetzt, um alle drei Inlining-Thresholds auf 10 zu senken, und zwar sowohl für cargo chef cook als auch für cargo 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-cmark nicht-generisch machte
    • ein lokales Crate, das nicht-generische Varianten von APIs bereitstellt, die in lol_html und deadpool_postgres verwendet werden
  • 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
  • -Zshare-generics ist 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-generics sank 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-musl fiel 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, das T definiert, könnte in manchen Fällen helfen, ist aber bei generischen Typen schwer anzuwenden und birgt das Risiko, ungenutzten Drop-Glue mitzukompilieren
    • -Zshare-generics hilft, 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 = 0 zu setzen

Noch keine Kommentare.

Noch keine Kommentare.