1 points par GN⁺ 2025-06-28 | 1 commentaires | Partager sur WhatsApp
  • 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

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

    • Je me demande pourquoi on voit si souvent des réécritures en Rust d’outils C initialement très simples, comme de petits utilitaires, plutôt que des portages de gros programmes C de 100 000 lignes. Ce qu’on voit le plus souvent, c’est du code de très petite taille. Je suis curieux de savoir comment Rust et C se comparent sur la vitesse de compilation de petits programmes. Ce n’est pas la taille du programme qui m’intéresse, mais la vitesse de compilation. À titre indicatif, d’après une mesure récente, la taille de la toolchain du compilateur Rust est environ deux fois celle du GCC que j’utilise. 1. Pour des programmes aussi petits, quelle que soit la langue, il est peu probable qu’il y ait des problèmes cachés de sécurité mémoire, et comme l’échelle est réduite, l’audit est aussi plus facile. Ce n’est pas la même situation qu’un programme C de 100 000 lignes
    • On sent physiquement le compilateur ralentir à chaque fois qu’on définit un nouveau type. De mémoire, les performances du compilateur ralentissent de manière exponentielle selon la « profondeur » des types. Avec quelque chose comme GraphQL, où les types imbriqués sont nombreux, ce problème est particulièrement grave
    • Pour répondre au problème des macros qui s’étendent en dizaines ou centaines de lignes et peuvent faire grossir le codebase de manière géométrique, un support d’analyse a récemment été ajouté. Voir : https://nnethercote.github.io/2025/06/26/how-much-code-does-that-proc-macro-generate.html
  • 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

    • Plus le compilateur effectue de travail au moment du build, plus le temps de build s’allonge. Go peut atteindre des temps de build inférieurs à une seconde même sur de gros codebases. Son système de modules et son système de types sont simples et minimisent ce qu’il faut faire au build, et la plupart des fonctionnalités sont confiées au GC à l’exécution. À l’inverse, si on exige des macros, un système de types complexe et un haut niveau de robustesse, les temps de build ne peuvent qu’augmenter
    • Rust aussi prend comme unité de build le crate entier, et le compilateur le découpe ensuite à une taille appropriée en LLVM IR. Il équilibre lui-même le travail dupliqué et l’incremental build. Il arrive souvent que Rust build plus vite que C++ à nombre de lignes de code source égal. En revanche, les projets Rust ont la particularité de compiler aussi toutes les dépendances
    • Si Rust et Swift compilent plus lentement que les compilateurs C, c’est parce que ces langages demandent intrinsèquement bien plus d’analyse. Par exemple, le borrow checker de Rust n’est pas gratuit. Rien que les vérifications faites à la compilation consomment déjà beaucoup de ressources. Si C est rapide, c’est parce qu’il ne vérifie pratiquement rien au-delà de la syntaxe de base. C ne vérifie même pas des combinaisons absurdes comme appeler foo(int) avec foo(char*)
    • Dans les années 2000, j’ai compilé des projets C++ de plusieurs dizaines de milliers de lignes, et même sur du vieux matériel, le build finissait en moins d’une seconde. En revanche, un simple HELLO WORLD avec Boost prenait plusieurs secondes. Au final, la vitesse de build dépend énormément non seulement du langage ou du compilateur, mais aussi de la structure du code et des fonctionnalités utilisées. On pourrait sans doute faire DOOM avec des macros C, mais ce ne serait probablement pas rapide. À l’inverse, on peut aussi structurer du Rust pour que ça build vite
    • Il n’y a rien de très étonnant à ce que des langages conçus pour compiler vite, comme C ou Go, soient rapides. La vraie difficulté, c’est de compiler rapidement la sémantique de Rust. Ce point figure aussi dans la FAQ officielle de Rust
  • 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

    • J’aime aussi Go, mais je ne pense pas que ce langage soit le produit d’une immense intelligence collective organisationnelle de Google. S’il avait réellement incorporé toute l’expérience de Google, on aurait par exemple ajouté des fonctionnalités comme l’élimination statique des exceptions de pointeur nul. J’ai plutôt l’impression que c’est le résultat de quelques développeurs Google qui ont créé le langage qu’ils voulaient eux-mêmes
    • Go a certes des atouts comme la compilation rapide, un système de types modéré et un GC, mais dans l’espace de conception, Java occupait déjà une place assez proche. J’ai l’impression que la création de Go venait surtout d’un désir de création, et qu’au final il a davantage été adopté par les utilisateurs de langages de script (Python/Ruby/JS) que par sa cible initiale (C/C++/Java côté serveur). Les utilisateurs de langages de script voulaient seulement un système de types simple et rapide, et Java paraissait trop vieux et pas amusant. Java n’avait déjà plus vraiment de place dans les domaines serveur/conférences/bibliothèques
    • On raconte aussi que des développeurs Google ont conçu Go pendant qu’ils attendaient la compilation de projets C++
    • J’aimerais bien demander ce qu’est exactement un « obnoxious type ». Un type soit représente correctement les données, soit non ; en pratique, on peut toujours forcer un type checker à se taire, quel que soit le langage
    • Go correspond exactement à ses objectifs de conception et à ses usages réels. Le plus gros risque, c’est la manière de faire du parallélisme en partageant de l’état mutable via des channels ; cela peut provoquer des bugs subtils ou fragiles. En général, la plupart des utilisateurs n’emploient pas vraiment ce pattern. Moi, j’utilise Rust parce que mon travail consiste à tirer le maximum possible d’algorithmes lents sur du matériel lent. Du coup, la parallélisation à grande échelle y est un problème extrêmement subtil, voire impossible
  • Je ne comprends pas l’argument selon lequel installer un seul binaire statique serait plus simple que gérer des conteneurs

    • J’ai l’impression qu’on ne comprend pas clairement ce que fait réellement docker. Par exemple, le texte original dit : « si on déploie via une image docker, il faut tout reconstruire à chaque fois », mais dans un environnement interne de build/déploiement, ce problème n’existe pas forcément. Pour un usage personnel, on peut aussi simplement mettre dans le conteneur un fichier buildé localement tout en gardant le confort de développement. Il faut juste faire attention aux chemins qui trahissent l’environnement de build. En CI/CD ou sur des projets d’équipe, l’objectif est surtout de garantir qu’on puisse générer le build depuis zéro partout, mais pour un travail personnel ce n’est pas nécessaire
    • Dans le texte original, l’objectif n’est pas la simplification mais la modernisation. C’est l’idée de dire : « Ces dix dernières années, la plupart des logiciels se déploient par défaut dans des conteneurs, donc je vais aussi déployer mon site web dans des conteneurs comme docker ou kubernetes. » Les conteneurs offrent plusieurs avantages : isolation des processus, sécurité, journalisation standardisée, scalabilité horizontale, etc.
  • 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

    • Il existe un article connexe indiquant environ 6 minutes sur M1 Max et 11 minutes sur M1 Air
  • 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 !

    • Lors d’une récente Bevy game jam, j’ai utilisé un outil appelé « subsecond » venu de la communauté Dioxus, et comme son nom l’indique, il permettait du hot reload système en moins d’une seconde, ce qui m’a beaucoup aidé pour prototyper l’UI. https://github.com/TheBevyFlock/bevy_simple_subsecond_system
    • Il me semble que l’équipe zig essaie elle aussi d’obtenir des temps de build très rapides en développant son propre compilateur (backend) sans LLVM
    • Il me semble qu’autrefois Cranelift ne supportait pas macOS aarch64, mais j’ai appris récemment que c’est désormais le cas
    • Dire qu’on a failli abandonner Rust pour 16 secondes de build, ce n’est pas un peu exagéré ?
  • 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

    • Je suis d’accord aussi. Je n’ai jamais trouvé les builds Rust particulièrement gênants. J’ai l’impression que cette réputation vient surtout d’une mauvaise image des débuts qui persiste encore
    • L’utilisation mémoire à la compilation est très élevée par rapport à C/C++. Si je veux compiler un gros projet Rust sur une VM pour une démo YouTube, il me faut plus de 8 Go. En C/C++, je n’ai jamais ce souci
    • Puisque les templates C++ sont Turing-complets, comparer uniquement les temps de build sans tenir compte du style de code réel n’a pas beaucoup de sens
  • En tant qu’ancien développeur C++, j’ai du mal à comprendre l’idée que les builds Rust seraient lents

    • C’est justement pour cela qu’on dit parfois que Rust vise les développeurs C++. Les développeurs ayant beaucoup d’expérience en C++ ont déjà un Stockholm syndrome bien ancré vis-à-vis de l’inconfort des outils
    • Même si c’est plus rapide que C++, cela peut rester lent en valeur absolue. La réputation désastreuse des builds C++ est déjà bien connue de tout le monde. Rust n’a pas de problème structurel de langage de ce genre, donc les attentes sont plus élevées
    • J’ai l’impression que c’est un exemple classique où on continue d’ajouter des fonctionnalités, sans vraiment écouter les utilisateurs ni résoudre leurs problèmes concrets
    • Les étapes de compilation de C étaient peu nombreuses et simples, donc c’était rapide, mais avec l’usage des templates, C++ a au contraire donné l’impression de détruire la plupart des efforts d’encapsulation. Il suffit de modifier un seul header template pour avoir l’impression que 98 % du projet entier est impacté
  • 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

    • Les artefacts incrémentaux de mon projet dépassent 150 Go. Quand j’ai utilisé des images docker de cette taille, cela a provoqué de très gros problèmes en pratique
  • 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

    • J’ai l’impression qu’il y a toujours des commentaires du genre « Rust est mieux » sur C/C++, et « Zig est mieux » sur Rust. (On découvre ensuite que l’auteur de ce commentaire est le développeur principal de zig.) L’évangélisation des langages est mauvaise pour les communautés et, en pratique, elle suscite surtout du rejet plutôt qu’elle n’attire de nouveaux utilisateurs. Si on aime vraiment un langage, mieux vaut freiner cette culture de l’évangélisation
    • Au lieu de simplement donner un unique chiffre de temps de compilation, j’aurais préféré qu’il y ait une discussion ou une interprétation directement liée au sujet du billet original
    • Mon site web en Rust (avec un framework de type React et un véritable serveur web) prend lui aussi environ 1,25 seconde en build incrémental avec cargo watch. Avec quelque chose comme subsecond[0], qui ajoute aussi l’incremental linking et le hotpatch, ça devient encore plus rapide. Ce n’est pas aussi rapide que Zig, mais on s’en approche beaucoup. Si les 331 ms mentionnées plus haut correspondent à un build clean (sans cache), alors c’est nettement plus rapide que les 12 secondes de build clean de mon site. [0] : https://news.ycombinator.com/item?id=44369642
    • J’aimerais vraiment demander à @AndyKelley quelle est, selon lui, la raison décisive pour laquelle zig compile extrêmement vite alors que Rust et Swift sont toujours lents
    • Zig ne garantit pas la sécurité mémoire, si ?