2 points par GN⁺ 2023-11-11 | 1 commentaires | Partager sur WhatsApp
  • La parallélisation du compilateur Rust dépendait jusqu’ici בעיקר de Cargo et du backend LLVM, mais l’ajout d’une exécution parallèle du frontend permet désormais de réduire les goulets d’étranglement en fin de build
  • Sur nightly, la fonctionnalité expérimentale peut être activée avec -Z threads=8, tandis que la valeur par défaut reste le mode mono-thread ; sans configuration explicite, il n’y a donc aucun gain de vitesse
  • Le nouveau frontend, basé sur Rayon, découpe finement les tâches de compilation, et dans les mesures d’exemple le temps du frontend passe de 10,2 s à 5,9 s
  • Sur du code réel, le temps de compilation a baissé jusqu’à 50 %, mais les résultats varient fortement selon les caractéristiques du code et la configuration de build, tandis que l’usage mémoire peut augmenter jusqu’à 35 %
  • La fonctionnalité reste expérimentale, et la stabilisation de -Z threads ainsi que l’activation par défaut du multithreading sur stable sont visées pour 2024

Un nouvel axe pour la parallélisation de la compilation Rust

  • Le frontend du compilateur Rust peut désormais réduire le temps de compilation grâce à une exécution parallèle
  • Sur le compilateur nightly, l’option -Z threads=8 permet d’essayer un frontend multithread
  • Cette fonctionnalité reste expérimentale, et son objectif d’intégration au compilateur stable est fixé à 2024

Optimisations existantes et goulets d’étranglement restants

  • Le Compiler Performance Working Group améliore les performances du compilateur Rust depuis plusieurs années
    • Au cours des dix premiers mois de 2023, le temps moyen de compilation a baissé de 13 % selon les outils de mesure de performance
    • L’utilisation mémoire maximale a diminué de 15 %
    • La taille des binaires a été réduite de 7 %
  • Le compilateur étant déjà fortement optimisé, les principaux gains restants passent surtout par une extension du parallélisme

Les limites de la parallélisation de Cargo et du backend

  • Lorsqu’il build un programme Rust, Cargo lance plusieurs processus rustc pour compiler les crates en parallèle
  • Désactiver cette parallélisation avec le flag -j1 augmente fortement le temps de compilation des gros programmes Rust
  • Le flag --timings de Cargo génère un graphique chronologique de compilation des crates
  • Dans un exemple de build de ripgrep sur une machine à 28 cœurs virtuels, on observe 60 lignes de processus
    • La plupart sont des rustc, et certains sont des scripts de build
    • Les 20 premiers processus peuvent démarrer en même temps car il n’existe pas de dépendances entre ces crates
    • Plus le build avance, plus les dépendances entre crates augmentent, ce qui réduit le parallélisme
  • La pipelined compilation permet de chevaucher dans une certaine mesure la compilation des crates dépendantes, mais sur les gros programmes Rust, l’exécution parallèle chute nettement en fin de build

Ce que font le frontend et le backend

  • Le compilateur Rust se divise globalement en frontend et backend
  • Le frontend prend en charge le parsing, la vérification de types, le borrow checking, etc.
  • L’ancien frontend ne pouvait pas exploiter l’exécution parallèle
  • Le backend s’occupe de la génération de code, en produisant du code par unités de « codegen units », qu’LLVM traite ensuite en parallèle
  • Le nombre par défaut de codegen units pour les builds de release est de 16, ce qui explique la présence de 16 threads LLVM dans le profil d’exemple

Le goulet d’étranglement mis en évidence dans l’ancien profil backend

  • Dans un exemple mesuré avec Samply lors du build en release de la dernière crate de Cargo, le frontend prenait 10,2 secondes
  • Le backend prenait 6,2 secondes, et les threads LLVM tournaient pendant 5,9 secondes sur cette durée
  • La génération de code parallèle basée sur LLVM est efficace, mais même sur une machine à 28 cœurs, les 16 threads LLVM ne s’exécutaient pas tous en même temps
  • Le thread principal convertissait en série le MIR en LLVM IR, ce qui créait une forme en escalier au démarrage des threads de codegen
  • Le frontend, entièrement séquentiel, restait le principal levier d’amélioration

Comment fonctionne le nouveau frontend parallèle

  • Le nouveau frontend utilise Rayon pour exécuter finement des tâches de compilation en parallèle
  • Plusieurs structures de données sont synchronisées avec des mutex et des verrous lecture-écriture, et des types atomiques sont utilisés là où nécessaire
  • Une grande partie du travail du frontend a été parallélisée, mais les modifications se concentrent sur un nombre relativement restreint de points clés
  • La majeure partie du code du frontend n’a pas eu besoin d’être modifiée

Résultats de mesure avec 8 threads

  • Dans le même exemple, avec le frontend parallèle activé et 8 threads, le temps d’exécution du frontend passe de 10,2 secondes à 5,9 secondes
  • Le temps du backend descend de 6,2 secondes à 5,3 secondes, et le temps d’exécution des threads LLVM de 5,9 secondes à 4,9 secondes
  • Sept threads supplémentaires, affichés comme rustc, travaillent côté frontend
  • L’utilisation des threads reste inégale, et chacun des 8 threads présente encore des périodes d’inactivité, signe qu’il reste de la marge d’amélioration
  • Si 8 threads LLVM démarrent simultanément, c’est parce que 8 threads rustc génèrent en parallèle le LLVM IR de 8 codegen units
  • En passant le nombre de threads du frontend à 16, la forme en escalier disparaît complètement, mais le temps d’exécution final change à peine dans ce cas précis

Combiner parallélisation inter-processus et intra-processus

  • La compilation Rust profitait déjà depuis longtemps de la parallélisation inter-processus de Cargo et de la parallélisation intra-processus du backend
  • Désormais, le frontend peut lui aussi bénéficier de la parallélisation intra-processus
  • Lorsque plusieurs processus rustc tournent en parallèle et que chacun crée plusieurs threads, le jobserver protocol limite le nombre de threads
  • Quand le parallélisme entre processus est élevé, le parallélisme à l’intérieur des processus diminue en conséquence, et le nombre total de threads ne dépasse pas le nombre de cœurs

Comment l’utiliser

  • Le frontend parallèle est déjà inclus dans le compilateur nightly
  • La valeur par défaut reste le mode mono-thread, donc les temps de compilation ne diminuent pas sans intervention
  • Le mode multithread doit être activé explicitement avec l’option -Z threads
RUSTFLAGS="-Z threads=8" cargo build --release
  • Pour le configurer dans un ou plusieurs projets via config.toml, ajoutez ceci
[build]
rustflags = ["-Z", "threads=8"]
  • Le mode mono-thread reste la valeur par défaut par souci de prudence dans le déploiement
    • Le frontend parallèle introduit beaucoup de nouveau code
    • Le mode mono-thread exécute la majeure partie de ce nouveau code, tout en excluant les risques de bugs de threading comme les deadlocks
    • Même en Rust, les programmes parallèles sont plus difficiles à écrire correctement que les programmes séquentiels
    • Pour cette raison, le frontend parallèle ne sera pas inclus dans les releases beta ou stable avant un certain temps

Impact sur les performances et la mémoire

  • En mode mono-thread, le frontend parallèle est généralement 0 % à 2 % plus lent que l’ancien frontend séquentiel
  • En mode multithread avec -Z threads=8, les mesures sur du code réel montrent que le temps de compilation peut baisser jusqu’à 50 %
  • L’effet sur les performances varie fortement selon les caractéristiques du code et la configuration du build
    • Les builds de développement ont plus de chances de voir des gains importants que les builds de release
    • Les builds de release passent en général davantage de temps dans les optimisations du backend
    • Pour certains petits programmes qui compilent déjà vite, le mode multithread peut être plus lent que le mode mono-thread
  • La valeur recommandée est 8 threads
    • C’est la configuration la plus testée et elle est connue pour donner de bons résultats
    • Une valeur inférieure à 8 apporte moins d’avantages, mais convient au matériel disposant de moins de 8 cœurs
    • Une valeur supérieure à 8 offre des rendements décroissants et peut même dégrader les performances
  • Si le passage de 1 à 8 threads n’apporte « que » 50 % d’amélioration, c’est parce que le frontend ne représente qu’une partie du temps total de compilation et que le backend est déjà parallélisé
  • En mode multithread, l’usage mémoire peut augmenter fortement, avec une hausse observée allant jusqu’à 35 %

Fiabilité et retours

  • Le mode mono-thread devrait être très fiable
  • Le mode multithread comporte encore des bugs connus, y compris des deadlocks
  • Si la compilation se bloque, il est possible que vous soyez tombé sur l’un de ces bugs connus
  • Quel que soit le frontend utilisé, le compilateur doit produire les mêmes binaires ; toute différence est considérée comme un bug
  • En cas de problème, il est possible de consulter d’abord les issues portant le label WG-compiler-parallel, puis d’ouvrir une nouvelle issue s’il n’en existe pas déjà une correspondante
  • Les retours généraux peuvent être partagés sur le canal Zulip wg-parallel-rustc, avec un intérêt particulier pour les effets de performance observés sur du code réel

Objectif stable en 2024

  • Le travail d’amélioration des performances du frontend parallèle se poursuit
  • Comme le montre le profil, le taux d’utilisation des threads du frontend peut encore être amélioré
  • Les bugs restants du mode multithread sont également en cours de correction
  • La stabilisation de l’option -Z threads et l’objectif de proposer le frontend parallèle comme comportement multithread par défaut sur la release stable sont visés pour 2024

1 commentaires

 
GN⁺ 2023-11-11
Avis sur Hacker News
  • Je sais que c’est encore à un stade précoce, mais à mon avis le point faible de Rust, c’est la vitesse de compilation.
    Quand j’ai travaillé sur un monorepo Rust, ma principale frustration était la vitesse de compilation ; cela augmentait les coûts de CI/CD, et quand il fallait vider le cache, le temps de développement ralentissait fortement.
    La cause était un bug Docker, pas Cargo, mais ce genre de progrès reste bienvenu.

    • Il me semble peu probable que cela s’améliore de façon spectaculaire.
      Beaucoup d’optimisations ont déjà été faites, et le compilateur Rust actuel est plus parallèle que presque tous les compilateurs grand public.
      La conception même du langage Rust rend la compilation plus difficile que pour des langages comme Go, qui ont été conçus pour compiler vite.
    • rust-analyzer consomme encore plus de ressources que rustc lui-même.
      Je ne sais pas dans quelle mesure ce travail s’y appliquera directement, mais j’aimerais vraiment y voir aussi de grosses améliorations.
      C’est clairement nécessaire pour une prise en charge moderne dans les IDE.
    • J’ai assez du mal à être d’accord avec ça.
      Je suis mainteneur d’un projet Rust open source de taille moyenne [1], et en local je trouve toujours les temps de compilation Rust étonnamment rapides.
      Sur un MacBook Pro, les builds debug prennent quelques secondes ; les builds release et la CI/CD sont plus lents, mais depuis que j’ai commencé Rust il y a deux ans, la compilation Rust m’a toujours paru très rapide.
      Pour équilibrer le propos, mon travail principal se fait en Java/Kotlin avec Gradle, et là on peut vraiment parler de temps de compilation glaciaires.
      Dans mon projet Rust open source, je limite les dépendances, je n’utilise pas de macros en dehors de choses comme derive[Debug, Clone], et je reste très mesuré dans l’usage des génériques.
      Ce serait intéressant de builder ce projet avec cargo build et de donner un retour sur les temps de compilation.
      [1] : https://github.com/Orange-OpenSource/hurl
    • N’ayant fait que de petits projets en Rust, je me demande quel était à peu près le nombre de lignes de code.
      Et aussi si le projet avait été découpé en plusieurs crates aux bons endroits.
    • Je me demande à quel point c’était lent en pratique.
      Combien de minutes prenaient les builds du monorepo ?
      Tu peux être aussi franc que possible. Comme mon compilateur principal est GHC, il en faut beaucoup pour me surprendre.
  • Question peut-être idiote : le backend doit-il attendre que le frontend ait terminé la vérification des emprunts ? Si oui, pourquoi ?
    Je ne dis pas que quelque chose cloche ; je me demande si la vérification des emprunts établit, au-delà d’une simple cohérence, des invariants dont dépend le backend.
    Par exemple, y a-t-il une raison pour laquelle on ne pourrait pas faire du travail backend spéculatif, à jeter en cas d’erreur de borrow checking ?

    • Il existe un compilateur Rust sans vérificateur d’emprunts, mrustc[0], donc au moins certaines versions de Rust peuvent être compilées sans borrow checker.
      Cela dit, il peut ne pas produire le code le plus optimisé.
      À ma connaissance, certaines optimisations utilisent des informations établies pendant la vérification des emprunts, comme la fameuse optimisation noalias. Il a fallu plusieurs tentatives avant qu’elle soit activée[1].
      Je ne suis pas non plus certain du lien avec NLL (durées de vie non lexicales), mais il me semble qu’un borrow checker au moins rudimentaire serait nécessaire pour établir les informations qui intéressent le backend.
      Pourtant, mrustc compile aussi sans borrow checker des versions de Rust qui disposent de NLL ; cela ressemble donc davantage à une question d’optimisation qu’à une nécessité absolue.
      [0] : https://github.com/thepowersgang/mrustc
      [1] : https://stackoverflow.com/a/57259339
    • Strictement parlant, je ne pense pas que ce soit indispensable.
  • Existe-t-il un moyen d’utiliser le nombre de cœurs CPU au lieu de mettre une valeur fixe dans des fichiers de configuration utilisés sur différentes machines ?

    • Il faut garder à l’esprit que c’est encore expérimental et limité à nightly, donc ce n’est pas configuré pour un usage général.
      Je m’attends à ce que la valeur par défaut stabilisée soit le nombre de cœurs.
      Je ne sais pas où en est ce travail aujourd’hui, mais à un moment il était question de coordonner les appels rustc de Cargo via jobserver ; dans ce cas, on utiliserait le nombre de tâches de Cargo, dont la valeur par défaut est le nombre de cœurs.
      Cargo prend aussi en charge des valeurs négatives pour retrancher un nombre au nombre de cœurs.
    • Il suffit d’utiliser la variable d’environnement RUSTFLAGS, comme indiqué dans l’article :

      $ RUSTFLAGS="-Z threads=8" cargo build --release

    • Comme il utilise le protocole jobserver et que Cargo l’initialise par défaut au nombre de cœurs, définir le nouveau flag sur une valeur irréaliste comme 10000 devrait limiter l’utilisation au nombre de cœurs restants.
  • Super ! Quand j’utilisais Rust il y a très longtemps, même les exemples jouets compilaient assez lentement ; en y revenant récemment, j’ai trouvé que Rust s’était vraiment amélioré, et je l’utilise partout où je peux sans presque penser aux temps de compilation.
    Cela dit, sur un projet qui a un peu grossi, même un changement simple a commencé à prendre plus de 5 secondes à compiler, ce qui a ravivé de vieux souvenirs.
    J’en venais même à vouloir retarder l’enregistrement pour éviter que l’analyseur ne se lance avant d’avoir rangé autre chose, juste avant que le portable ne se mette à tourner comme un réacteur d’avion.
    C’est personnellement mon plus gros point de douleur, donc tout progrès est très bienvenu.

  • Très bien ! Contrairement à l’écosystème des crates de bibliothèque, mes crates binaires avaient tendance à être grosses et monolithiques par défaut.
    Je suis maintenant en train de les découper en plusieurs crates de bibliothèque.
    Cela signifie non seulement qu’on ne parallélise pas la fin de la compilation, mais aussi que les plus grosses crates sont traitées séquentiellement ; ce changement est donc vraiment bienvenu.

  • Après avoir gardé Rust un peu à distance pendant quelques années et travaillé dans des environnements comme Python ou TypeScript, je l’ai récemment réutilisé sur un projet, et la vitesse de compilation était quasiment instantanée.
    C’est toujours bien que ça s’améliore, mais l’état actuel est déjà très bon.
    De nos jours, avec le cheat code qu’est ChatGPT, on peut presque surmonter les problèmes Rust difficiles qui m’auraient probablement bloqué il y a quelques années, donc les perspectives pour Rust me semblent plutôt bonnes.

    • Mes temps de compilation sont généralement corrects, sauf quand je construis des images Docker multi-architectures sur GitHub Actions.
      Là, le build de l’image Docker prend 60 à 90 minutes, et je mesure vraiment à quel point tout le projet a beaucoup de dépendances.
    • Les temps de compilation dépendent énormément de la quantité, de la taille et de la complexité des dépendances.
  • Existe-t-il un moyen de désactiver l’option de compilateur parallèle sans reconstruire le compilateur ?
    Je n’en ai pas besoin, j’ai de toute façon réglé les codegen units à 1, et elle semble provoquer un ICE que je n’ai pas envie de déboguer.
    Je sais que la valeur par défaut est 1 thread, mais j’aimerais la désactiver complètement.

  • « Le mode multithread comporte des bugs connus, notamment des interblocages. Si la compilation se bloque, il est probable que vous soyez tombé sur l’un d’eux. »
    Dans ce cas, je vais attendre encore un peu avant d’utiliser -Z threads ;)