- En reconstruisant à plusieurs reprises un site web écrit en Rust avec Docker, l’auteur s’est heurté à des problèmes de temps de build
- Avec la configuration Docker par défaut, toutes les dépendances sont recompilées à chaque fois, ce qui prend plus de 4 minutes
- Même en utilisant
cargo-chef et des outils de cache, la construction du binaire final reste très longue
- Le profilage montre que l’essentiel du temps est consommé par la LTO (Link Time Optimization) et l’optimisation des modules LLVM
- En ajustant les options d’optimisation, les informations de débogage et les réglages de LTO, on peut obtenir quelques améliorations, mais la compilation du binaire final demande encore au minimum 50 secondes
Problème soulevé et contexte
- À chaque modification de son site personnel en Rust, l’auteur devait répéter une tâche fastidieuse : construire un binaire à liaison statique, le copier sur le serveur, puis redémarrer le service
- Il a voulu passer à un déploiement basé sur des conteneurs, via Docker ou Kubernetes, mais la vitesse de build Docker de Rust s’est révélée être un obstacle majeur
- Dans Docker, même une petite modification du code entraînait une reconstruction complète depuis zéro, ce qui créait une forte inefficacité
Build Rust dans Docker : l’approche de base
- L’approche Dockerfile classique consiste à copier toutes les dépendances et le code source, puis à exécuter cargo build
- Dans ce cas, on ne profite pratiquement pas du cache, et la reconstruction complète se répète sans cesse
- Pour son site, un build complet prenait environ 4 minutes — sans compter le temps supplémentaire nécessaire au téléchargement des dépendances
Améliorer le cache de build Docker : cargo-chef
- L’outil
cargo-chef permet de mettre en cache séparément la couche contenant uniquement les dépendances
- Ainsi, lors d’une modification du code, la compilation des dépendances peut être réutilisée, ce qui laisse espérer une amélioration de la vitesse de build
- En pratique, seulement 25 % du temps total étaient consacrés à la compilation des dépendances, tandis que la construction du binaire final du service web restait très coûteuse (2 min 50 s à 3 min)
- Bien que le projet ne comporte que des dépendances majeures (axum, reqwest, tokio-postgres, etc.) et environ 7 000 lignes de code maison, une seule exécution de rustc prenait quand même 3 minutes
Analyse du temps de build de rustc : cargo --timings
cargo --timings permet de voir le temps de build de chaque crate (unité de compilation)
- Le résultat montre que la construction du binaire final représente la majeure partie du temps total
- L’outil aide à affiner l’analyse, mais ne permet pas vraiment de comprendre en détail le fonctionnement interne du compilateur
Utilisation du profilage interne de rustc (-Zself-profile)
- La fonction de profilage interne de rustc peut être activée avec l’option
-Zself-profile afin de mesurer précisément le temps passé dans chaque étape
- Pour cela, le profilage a été activé via des variables d’environnement
- En analysant les résultats avec l’outil de résumé, l’auteur a constaté que la LTO LLVM (Link Time Optimization) et la génération de code des modules LLVM représentaient plus de 60 % du temps total
- Une visualisation en flamegraph a également montré que l’étape codegen_module_perform_lto consommait à elle seule 80 % du temps total
LTO (Link Time Optimization) et options d’optimisation du build
- Un build Rust est d’abord découpé par codegen unit, puis la LTO applique une optimisation globale relativement tard dans le processus
- La LTO propose plusieurs options, comme off, thin ou fat, chacune ayant un impact sur les performances et sur le binaire final
- Dans le projet de l’auteur,
Cargo.toml configurait la LTO sur thin et les symboles de débogage sur full
- En testant différentes combinaisons LTO / symboles de débogage, il a observé :
- que les symboles de débogage complets augmentent le temps de build, et que la fat LTO provoque un ralentissement d’environ 4×
- que même en supprimant LTO et les informations de débogage, il fallait encore au moins 50 secondes pour compiler
Optimisations supplémentaires et remarques
- Environ 50 secondes ne posent pas de gros problème pour son propre site, qui a très peu de charge réelle, mais sa curiosité technique l’a poussé à approfondir l’analyse
- En exploitant correctement la compilation incrémentale (incremental compilation) avec Docker, il serait possible d’obtenir des builds plus rapides, mais cela suppose de bien concilier propreté de l’environnement de build et cache Docker
Profilage détaillé de l’étape LLVM
- Même après suppression de la LTO et des symboles de débogage, l’étape LLVM_module_optimize consommait encore près de 70 % du temps
- L’auteur a compris que, dans le profil release, la valeur par défaut de opt-level (3) entraînait un coût d’optimisation élevé, et a testé une réduction de ce niveau uniquement pour le binaire
- Ses essais sur diverses combinaisons d’optimisation montrent qu’avec opt-level=0, le build prend environ 15 secondes, contre environ 50 secondes avec des niveaux d’optimisation de 1 à 3
Analyse approfondie des événements de trace LLVM
- Avec des options supplémentaires de rustc (
-Z time-llvm-passes, -Z llvm-time-trace), il est possible de suivre en détail le temps d’exécution de chaque étape LLVM
-Z time-llvm-passes produit une sortie si volumineuse qu’elle dépasse souvent la limite de logs de Docker, ce qui oblige à ajuster la configuration des logs
- En enregistrant les logs dans un fichier pour les analyser, on peut mesurer séparément le temps d’exécution de chaque passe d’optimisation LLVM
- L’option
-Z llvm-time-trace génère un énorme JSON au format chrome tracing, souvent trop volumineux pour les outils de texte et d’analyse classiques
- En le découpant ligne par ligne au format jsonl, il devient possible de l’analyser dans un environnement CLI ou avec des scripts
Principaux enseignements et conclusion
- Lorsqu’on construit un projet Rust complexe avec Docker, le goulot d’étranglement principal se situe surtout dans la construction du binaire final et dans les étapes d’optimisation LLVM associées
- Il existe un compromis clair entre temps de build et taille du binaire lorsqu’on ajuste la LTO, les symboles de débogage et
opt-level
- En réglant activement les options d’optimisation, on peut réduire fortement le temps de build, mais l’absence d’optimisation peut entraîner une baisse des performances
- Si l’efficacité du build est importante dans un environnement avec de nombreuses dépendances de crates ou en production, il est pertinent de recourir activement au profilage pour identifier précisément les goulots d’étranglement
- La conception d’un pipeline de build Rust demande donc une combinaison soigneusement pensée entre LTO,
opt-level, symboles de débogage et stratégie de cache
1 commentaires
Avis sur Hacker News
Le projet Rust paraît souvent petit en apparence, ce qui est intéressant. D’abord, les dépendances ne reflètent pas la taille réelle du codebase. En C++, on intègre souvent les dépendances de gros projets en vendoring, voire on ne les utilise pas du tout, donc si 400 000 lignes de code compilent lentement, on peut se dire : « normal, il y a beaucoup de code ». Ensuite, le problème bien plus grave, ce sont les macros. Des macros qui s’étendent en répétant 10 ou 100 lignes peuvent transformer très vite un projet de 10 000 lignes en un million de lignes. Troisièmement, il y a les génériques. Chaque instanciation générique consomme des ressources CPU. Cela dit, pour défendre un peu Rust, ces fonctionnalités permettent aussi de réduire un programme de 100 000 lignes en C ou 25 000 lignes en C++ à seulement quelques milliers de lignes en Rust. Mais il est vrai que leur usage excessif donne aussi l’impression d’un écosystème lent. Par exemple, dans mon entreprise, on utilise async-graphql ; la bibliothèque elle-même est excellente, mais elle dépend énormément des procedural macros. Des problèmes de performance sont ouverts depuis des années, et à chaque ajout de type de données, on sent clairement que le compilateur ralentit
Ryan Fleury a créé Epic RAD Debugger en C, avec 278 000 lignes dans un mode de build unity (tout le code dans un seul fichier, une seule unité de compilation), et une compilation propre sous Windows ne prend que 1,5 seconde. Rien que cet exemple montre qu’une compilation peut être extrêmement rapide, donc je me demande pourquoi on n’arrive pas à faire pareil en Rust ou en Swift
foo(int)avecfoo(char*)Je suis vraiment content que Go ait privilégié la vitesse de compilation plutôt que l’optimisation. Pour les serveurs, le réseau et le glue code, compiler très vite est de loin ce qu’il y a de plus important. Je veux aussi un minimum de sûreté de type, mais sans que cela empêche un prototypage souple. Le fait qu’il y ait un GC est aussi pratique. Je pense que Google, après son expérience du développement à grande échelle, a conclu que des types simples, un GC et une compilation ultra rapide étaient bien plus importants que la vitesse d’exécution ou la perfection sémantique. Vu tous les gros logiciels réseau et infrastructure écrits en Go, ce choix a complètement fait mouche. Bien sûr, dans des environnements où un GC est inacceptable ou où l’exactitude absolue est plus importante, on peut ne pas choisir Go, mais pour mon travail, les choix de Go sont optimaux
Je ne comprends pas l’argument selon lequel installer un seul binaire statique serait plus simple que gérer des conteneurs
Sur mon portable (Mac M4 Pro), la compilation complète de Deno, un gros projet Rust, prend 2 minutes. En ligne de commande, cela donne environ 1 minute 54 en debug et environ 8 minutes 17 en release. Ces chiffres ont été mesurés sans compilation incrémentale. En pratique, les builds de déploiement tournent dans un système de CI/CD, donc on n’a pas besoin de les attendre soi-même
Où est-ce qu’on parle de Cranelift ? J’ai failli presque abandonner le développement de jeux en Rust à cause des temps de compilation trop longs. En me renseignant, j’ai vu que LLVM est lent quel que soit le niveau d’optimisation. C’est ce que les développeurs du langage Jai soulignent depuis longtemps. J’ai aussi déjà constaté un passage de 16 secondes à 4 secondes de temps de build avec Cranelift. Impressionnant, l’équipe Cranelift !
Je ne trouve pas que Rust soit lent. Par rapport aux langages comparables, il est suffisamment rapide, et comparé à des compilations C++/Scala qui prenaient 15 minutes, c’est bien plus rapide
En tant qu’ancien développeur C++, j’ai du mal à comprendre l’idée que les builds Rust seraient lents
La compilation incrémentale est vraiment puissante. Après le build initial, on peut figer un snapshot du cache incrémental et, tant qu’il n’y a pas de changements, s’en servir pour build/deployer rapidement. Ça se marie aussi très bien avec docker. Sauf changement de version du compilateur ou grosse mise à jour d’un site web, on ne touche pas aux couches de build de l’image. S’il n’y a que des changements de code, on peut configurer les choses pour éviter de reconstruire cette couche, ce qui est efficace
Le temps de build de ma page d’accueil est de 73 ms. Le static site generator recompile en 17 ms seulement. L’exécution réelle du generator ne prend que 56 ms. J’ai joint le log de build zig