Pourquoi le compilateur Rust est-il si lent ?
(sharnoff.io)- Même avec les dépendances mises en cache lors de la compilation d’un site web Rust destiné à un déploiement Docker, le crate final prend à lui seul environ 175 secondes ; le goulet d’étranglement se situe dans
rustcet dans les phases d’optimisation de LLVM - Après avoir appliqué successivement
cargo-chef,cargo --timings,-Zself-profileetmeasureme, il apparaît que le problème n’est pas simplement lié aux dépendances : le coût du LTO et de la génération de code LLVM domine le temps de build - Les anciens réglages
lto = "thin"etdebug = "full"dansCargo.tomlavaient un impact important ; une fois désactivés, le build du binaire final est passé de 172,2 secondes à environ 50 secondes - Les traces LLVM ont mis en évidence
OptFunction,InlinerPass,core::ptr::drop_in_place, de grosses fonctions async et la monomorphisation des génériques comme principaux coûts ; réduire l’inlining, scinder des fonctions, utiliserPin<Box<dyn Future>>et supprimer certains génériques ont apporté des améliorations supplémentaires - Enfin, en activant
-Zshare-genericset en passant à un build basé sur Debian, le temps de compilation est tombé de 29,1 secondes à 9,1 secondes, montrant que la structure du code, mais aussi l’allocator et le choix ou non de la cible musl, pèsent fortement sur le temps de build
Le goulet d’étranglement révélé par le build Docker
- Le site web est principalement fourni sous la forme d’un unique binaire Rust ; auparavant, le binaire lié statiquement était compilé, copié sur le serveur, puis le service était redémarré
- En migrant vers un déploiement basé sur des conteneurs, configurer des builds Rust rapides dans Docker s’est avéré plus délicat que prévu
- Le Dockerfile de base reconstruit tout à chaque changement du code source
rust:1.87-alpine3.22est utilisé comme builder, avec une compilation pour la ciblex86_64-unknown-linux-musl- L’image finale ne fait que copier le binaire dans Alpine
- Avec cette approche, un build propre prend 3 min 51 s, dont 10 secondes pour télécharger les crates
cargo-chef a séparé le cache des dépendances, mais cela n’a pas suffi
- cargo-chef crée un fichier recipe simplifié à partir du workspace, puis s’en sert pour compiler les dépendances à l’avance dans une couche de cache Docker séparée
- Comme le site web utilise plusieurs centaines de dépendances, l’effet du cache était attendu comme important
- En pratique, les mesures ont donné 1 min 7 s pour compiler les dépendances, puis 2 min 50 s pour compiler le binaire final avec les dépendances en cache
- Seuls environ 25 % du temps total étaient consacrés aux dépendances ; la majeure partie du reste était passée dans un unique appel à
rustcpour le crate finalweb-http-server
cargo --timings et self-profile de rustc
cargo build --release --timingsaffiche le temps de compilation par crate ; le temps du crate final était de 174,1 secondes, ce qui correspond à peu près aux 2 min 54 s affichées parcargo build- Comme le goulet d’étranglement était concentré dans un seul crate final,
cargo --timingsne suffisait pas à comprendre la cause précise - Pour utiliser la fonction self-profile de
rustc,-Zself-profilea été employéRUSTC_BOOTSTRAP=1a été utilisé afin de pouvoir passer un flag instable-Zavec le compilateur stable- Pour éviter d’invalider le cache de
cargo-chef,RUSTFLAGS='-Zself-profile'a été utilisé au lieu decargo rustc -- -Z self-profile
- Les outils
summarize,flamegraphetcroxde measureme ont servi à analyser les données de self-profile - Les premières entrées de
summarizeétaient concentrées sur des tâches liées à LLVMLLVM_lto_optimize: 851,95 secondes, 33,389 % du totalLLVM_module_codegen_emit_obj: 674,94 secondes, 26,452 %LLVM_thin_lto_import: 317,75 secondes, 12,453 %LLVM_module_optimize: 189,00 secondes, 7,407 %
- Dans le flamegraph,
codegen_module_perform_ltoreprésentait environ 80 % du temps total
Impact des réglages LTO et symboles de débogage
- Le compilateur Rust divise un crate en codegen units et les transmet à LLVM sous forme de modules séparés
- Le LTO est une option qui effectue de l’inlining et des optimisations entre codegen units ou entre crates au moment de l’édition de liens
- Les choix LTO de Cargo et de
rustcsont les suivants- LTO désactivé
- LTO
"thin" - LTO
"fat" - Si rien n’est spécifié, un « thin local LTO » limité à l’intérieur d’un seul crate
- L’ancien
Cargo.tomlcontenait encore des valeurs configurées plusieurs années auparavantlto = "thin"debug = "full"
debug = "full"active l’ensemble des symboles de débogage, qui sont exclus par défaut du profil release- Les mesures de différentes combinaisons de
ltoetdebugont montré de grands écarts- LTO désactivé,
debug=none: 50,0 s / 21,0 MiB - Thin local LTO,
debug=full: 88,2 s / 256,8 MiB - LTO
"thin",debug=full: 172,2 s / 197,5 MiB - LTO
"fat",debug=full: 287,1 s / 155,9 MiB
- LTO désactivé,
- Les symboles de débogage complets augmentaient le temps de compilation de 30 à 50 %, et le fat LTO prenait environ 4 fois plus de temps que la désactivation complète du LTO
- Même sans LTO ni symboles de débogage, compiler l’unique binaire final prenait encore environ 50 secondes
Pourquoi conserver le cache Docker plutôt que la compilation incrémentale
- En développement local, il est possible d’utiliser la compilation incrémentale en montant le répertoire
/targetcomme cache mount dans le Dockerfile et en le conservant entre les builds - Toutefois, pour préserver la possibilité pour
docker buildde partir à chaque fois d’un environnement propre et pour exploiter le système de cache propre à Docker,cargo-chefa été conservé
Le coût des optimisations LLVM restant après le LTO
- Même après désactivation du LTO et des symboles de débogage, la compilation du binaire final prenait environ 50 secondes
- En relançant le self-profile, environ 70 % du temps était passé dans
LLVM_module_optimize, c’est-à-dire l’étape où LLVM optimise le code - Une configuration diminuant l’optimisation uniquement pour le binaire final, en abaissant le
opt-level = 3par défaut du profil release, a été testée- Comme les dépendances sont mises en cache,
opt-level = 3est conservé dansprofile.release.package."*" - Seul le
opt-leveldu crate final est abaissé
- Comme les dépendances sont mises en cache,
- Les mesures variaient fortement selon l’activation ou non des optimisations
opt-level=0final : environ 15 sopt-level=1final : environ 48 sopt-level=2ou3final : environ 50 à 55 sopt-level="z"final : environ 42 s
- Dès qu’une quelconque optimisation est activée pour le binaire final, un plancher d’environ 50 secondes apparaît ; en désactivant complètement l’optimisation, le temps tombe à environ 15 secondes
Difficultés de collecte des traces LLVM
rustcdispose de flags permettant d’observer les informations LLVM-Z time-llvm-passes: affiche les informations de profil LLVM en texte brut-Z llvm-time-trace: produit un profil LLVM au format Chrome tracing
-Z time-llvm-passess’est heurté à la limite de logs par défaut de Docker BuildKitBUILDKIT_STEP_LOG_MAX_SIZEBUILDKIT_STEP_LOG_MAX_SPEED
- Ces variables d’environnement doivent être configurées sur le daemon Docker, et non lors de l’appel à
docker build; sous Linux, elles peuvent être définies surdocker.servicevia un drop-insystemd - Une fois la limite levée, environ 200 000 lignes de texte étaient produites, ce qui était difficile à exploiter directement
-Z llvm-time-tracea généré des fichiers*.llvm_timings.json, mais le fichier de trace du binaire final était un JSON sur une seule ligne de 1,4 GiB- Firefox Profiler, Perfetto UI et
chrome://tracingde Chromium ont tous eu des problèmes avec ce fichier - Le JSON a été converti en JSONL pour être traité avec des outils classiques
- Le tableau
traceEventsde l’objet JSON unique a été séparé en une ligne par événement - Après conversion, le nombre d’événements était de 7 301 865 lignes
- Le tableau
Goulets d’étranglement visibles dans les événements LLVM
- Les événements de trace LLVM étaient principalement des complete events avec
"ph":"X", le champdurindiquant la durée en microsecondes "ph":"M"correspondait à des metadata events, qui n’apportaient pas beaucoup d’informations utiles dans cette analyse- Dans les événements agrégés, les postes les plus coûteux étaient les suivants
Total ModuleInlinerWrapperPass: 665,37 secondesTotal ModuleToPostOrderCGSCCPassAdaptor: 656,47 secondesTotal DevirtSCCRepeatedPass: 632,44 secondesTotal OptFunction: 189,62 secondesTotal InlinerPass: 182,25 secondes
- Comme cette exécution a pris environ 110 secondes sur une machine à 16 cœurs, certains temps de passes sont comptabilisés plusieurs fois
- Les grands axes étaient
OptFunction, l’optimisation de fonctions, etInlinerPass, l’inlining
Ajustement des seuils d’inlining
- Les options d’inlining de LLVM peuvent être transmises via
-C llvm-argsderustc - En juin 2025,
rustc -C llvm-args='--help-list-hidden'contenait environ 100 options liées à l’inlining - Trois options ont été utilisées dans l’expérience
--inlinedefault-threshold=225--inline-threshold=225--inlinehint-threshold=325
- Le threshold autorise grossièrement l’inlining des fonctions dont le coût est inférieur à cette valeur ; abaisser la valeur réduit donc l’inlining
- En abaissant les trois seuils à 50, le temps est passé de 48,8 s à 42,2 s
- Pour un site web personnel quasiment sans charge, un threshold de 10 a aussi été considéré comme une option prometteuse
OptFunction et monomorphisation des génériques
- Dans les événements
OptFunction,args.detailcontient le symbole manglé de la fonction en cours d’optimisation - En le démanglant avec rustfilt, on peut voir le symbole Rust d’origine
__rustc::__rust_allocserde_json::value::to_value
- La raison pour laquelle le même
serde_json::value::to_valueapparaît avec plusieurs hachages est qu’une fonction générique est monomorphisée avec différents paramètres de type - Des fonctions d’autres crates sont également optimisées dans le crate final, car l’endroit où une fonction est monomorphisée pour un type donné est le contexte du crate appelant
- Voici des exemples de fonctions dont l’optimisation a été coûteuse
- une closure dans
web_http_server::photos::PhotosState::new - une closure dans
web_http_server::run tokio_postgres::connect_raw- une fonction générique d’environ 500 lignes de
pulldown_cmark - plusieurs types concrets de
core::ptr::drop_in_place
- une closure dans
- En agrégeant grossièrement par nom de crate externe,
coreétait le plus important avec 61,53 secondes, dont 84 % correspondaient à des paramétrisations decore::ptr::drop_in_place
Mieux localiser les fonctions async avec le mangling de symboles v0
- Le symbol mangling legacy par défaut rendait les closures difficiles à distinguer
- Ajouter
-C symbol-mangling-version=v0fait mieux apparaître les numéros de closures et les informations de types génériques - Par exemple, il devenait possible de voir l’ensemble des arguments génériques indiquant avec quel type de
web_http_serverserde_json::value::to_valueavait été monomorphisé - Dans la sortie v0, les éléments coûteux étaient les suivants
<web_http_server::photos::PhotosState>::new::{closure#0}: 1,99 sweb_http_server::run::{closure#0}: 1,56 score::ptr::drop_in_place::<axum::routing::Endpoint<web_http_server::AppState>>: 1,22 s
- À première vue, il s’agissait de petites closures, mais un dump de l’IR LLVM a montré que les fonctions async et les async blocks étaient représentés en interne comme des closures imbriquées
- Rust avait déjà une issue ouverte sur le mangling des async functions/blocks
Grosses fonctions async et Pin<Box<dyn Future>>
- Les éléments coûteux n’étaient pas tant les closures elles-mêmes que le corps de grosses fonctions async
- Au départ, le temps d’optimisation lié à
PhotosState::newtotalisait 5,3 secondes - Une première tentative consistant simplement à découper la fonction n’a réduit ce temps qu’à 4,66 secondes
- Une tentative regroupant des
.awaitadjacents pour réduire leur nombre de 10 à 3 l’a au contraire augmenté à 6,24 secondes - Comme les fonctions async sont abaissées en interne en state machines complexes, une approche consistant à effacer les détails d’implémentation pour le caller via un
Futureen trait object a été testée - La fonction utilisée enveloppait
impl Future<Output = T>sous la formePin<Box<dyn Send + Future<Output = T>>> - En l’appliquant à chaque point
.await, par exempleerase(get_img_candidates()).await?, les résultats ont été les suivants :- le temps lié à
PhotosState::newest tombé à 2,14 secondes - le temps de build global, sans profiling, est passé de 48,8 s à 46,8 s
- le temps lié à
#[inline(never)]et la désactivation de l’inlining de la fonctionpollont aussi été essayés, mais n’ont pas été aussi efficaces que le boxing
Résultat de la combinaison de plusieurs changements
- Trois approches ont été appliquées
- réduction de l’inlining via des args LLVM
- séparation des fonctions coûteuses du crate principal et boxing des async Futures
- réduction des génériques dans les API des dépendances pour limiter les parties recompilées dans le crate final
- Dans le Dockerfile final, des
RUSTFLAGSabaissant les trois seuils d’inlining à 10 ont été appliqués à la fois àcargo chef cooket àcargo build - Dans le crate principal, cela a entraîné 898 lignes ajoutées et 657 lignes supprimées sur 10 fichiers
- Des changements côté dépendances ont aussi été intégrés
- PR rendant non générique une fonction générique de
pulldown-cmark - crate local exposant des versions non génériques des API utilisées dans
lol_htmletdeadpool_postgres
- PR rendant non générique une fonction générique de
- Avec cette combinaison, le temps de compilation final est passé à 32,3 secondes
Mise à jour du 27/06/2025 : -Zshare-generics et abandon d’Alpine
- Deux pistes suggérées sur Bluesky et Lobsters ont été testées en plus
- activer
-Zshare-generics - sortir d’Alpine
- activer
-Zshare-genericsest un flag qui réutilise les instances génériques des dépendances de crates- il n’est pas activé par défaut dans les builds release
- il est activé dans les dev builds de la toolchain stable
- ce flag n’est utilisable qu’avec nightly
- Avec
-Zshare-generics, le temps de compilation total est passé de 32,3 s à 29,1 s - De nombreuses instances de
drop_in_placeétaient toujours compilées, mais le temps d’optimisation correspondant est passé de 21,7 s à 17,4 s - En remplaçant Alpine par Debian et en supprimant
--target=x86_64-unknown-linux-musl, le temps de compilation total a fortement chuté de 29,1 s à 9,1 s - Cette suggestion reposait notamment sur le fait que l’allocator par défaut peut fortement influencer le temps de build
Chiffres finaux et sujets restants
- L’évolution finale est la suivante
- point de départ : environ 175 secondes
- désactivation du LTO et des symboles de débogage : 51 s, -71 %
opt-level = 1pour le crate final : 48,8 s, -4 %- réduction de l’inlining avec
-C llvm-args: 40,7 s, -16 % - changements de code local : 37,7 s, -7 %
- changements dans les dépendances : 32,3 s, -14 %
-Zshare-generics: 29,1 s, -10 %- suppression d’Alpine : 9,1 s, -69 %
- Durant l’analyse, les outils et la documentation ont suffisamment bien fonctionné pour permettre de réelles améliorations
- Plusieurs problèmes complexes restent toutefois ouverts
- le temps de compilation des graphes d’appels async profonds doit encore être amélioré
- un traitement spécial compilant
core::ptr::drop_in_place<T>dans le crate qui définitTpourrait aider dans certains cas, mais il est difficile à appliquer aux types génériques et risque de compiler du drop glue inutilisé -Zshare-genericsaide, mais ne constitue pas une solution complète- il pourrait être utile de disposer de meilleurs outils pour isoler les parties d’une base de code qui consomment beaucoup de temps de compilation et suggérer des contournements
- En pratique, définir
opt-level = 0pour le crate final peut aussi être un choix tout à fait suffisant
Aucun commentaire pour le moment.