1 points par GN⁺ 1 시간 전 | 1 commentaires | Partager sur WhatsApp
  • Async Rust permet d’exécuter le même code, indépendant de l’exécuteur, sur des serveurs et des microcontrôleurs, mais la machine à états générée par le compilateur entraîne une augmentation notable de la taille des binaires, en particulier dans l’embarqué
  • Même un exemple simple comme bar(), avec deux points d’attente, génère 360 lignes de MIR ainsi que les états Unresumed, Returned, Panicked, Suspend0 et Suspend1, alors que la version synchrone n’a besoin que de 23 lignes
  • Remplacer le panic après un nouveau poll d’un future déjà terminé par un retour de Poll::Pending permet de respecter le contrat sans comportement unsafe, et les expériences montrent une réduction de 2 % à 5 % de la taille des binaires sur des firmwares embarqués
  • Même async { 5 }, sans await, construit aujourd’hui une machine à états avec 3 états de base, mais l’optimiser pour renvoyer systématiquement Poll::Ready(5) réduit de 0,2 % la taille des binaires embarqués
  • Le Project Goal proposé vise à faire progresser côté compilateur la suppression du panic après complétion en mode release, la suppression de la machine à états pour les blocs async sans await, l’inlining des futures à await unique, et la fusion d’états identiques

Le problème de gonflement au niveau compilateur dans Async Rust

  • Async Rust permet d’exécuter un même code indépendant de l’exécuteur sur des serveurs et des microcontrôleurs, mais sur les petits microcontrôleurs l’augmentation de la taille des binaires est particulièrement visible
  • Le blog Rust a présenté async/await comme une abstraction à coût nul, mais en pratique async provoque beaucoup de gonflement, y compris sur desktop et serveur ; le problème y est simplement moins visible grâce à des ressources mémoire et CPU plus abondantes
  • Après des contournements pour éviter ce gonflement lors de l’écriture de code async, un Project Goal a été soumis pour traiter le problème dans le compilateur
  • Le problème des futures inutilement volumineux et des copies excessives est exclu du périmètre

Structure des futures générés

  • Dans l’exemple, foo() retourne async { 5 } et bar() exécute foo().await + foo().await
  • bar comporte deux points d’attente, donc la machine à états a besoin d’au moins deux états, mais en pratique le compilateur en génère davantage
  • Le compilateur Rust peut dumper le MIR à plusieurs passes, et la passe coroutine_resume est la dernière passe MIR spécifique à async
    • async existe encore dans le MIR, mais plus dans le LLVM IR ; la transformation d’async en machine à états se produit donc dans les passes MIR
  • La fonction bar génère 360 lignes de MIR, tandis que la version synchrone n’en utilise que 23
  • Le CoroutineLayout produit par le compilateur correspond en pratique à un ensemble d’états sous forme d’enum
    • Unresumed : état initial
    • Returned : état terminé
    • Panicked : état après panic
    • Suspend0 : premier point d’attente, qui stocke le future de foo
    • Suspend1 : second point d’attente, qui stocke le premier résultat et le second future de foo
  • Future::poll est une fonction sûre ; même si on l’appelle de nouveau après la fin d’un future, cela ne doit pas provoquer d’UB
    • Aujourd’hui, après Suspend1, il renvoie Ready puis fait passer le future à l’état Returned
    • Un nouveau poll dans cet état provoque un panic
  • L’état Panicked semble servir à empêcher qu’un future soit repollé après qu’une fonction async a paniqué puis que ce panic a été intercepté avec catch_unwind
    • Après un panic, le future peut être dans un état incomplet ; le repoller pourrait donc mener à de l’UB
    • Ce mécanisme ressemble beaucoup au mutex poisoning
    • Cette interprétation de l’état Panicked n’est pas documentée de manière totalement claire ; le niveau de confiance annoncé est d’environ 90 %

Faut-il vraiment paniquer lors d’un poll après complétion ?

  • Les futures à l’état Returned paniquent actuellement, mais ce n’est pas une obligation
    • La seule contrainte nécessaire est d’éviter toute UB
  • Un panic est relativement coûteux et ajoute un chemin à effets de bord difficile à éliminer par optimisation
  • Si un future déjà terminé retourne Poll::Pending lorsqu’il est repollé, le contrat du type Future peut être respecté sans comportement unsafe
  • Après modification du compilateur pour tester cette approche, une réduction de 2 % à 5 % de la taille des binaires a été observée sur des firmwares embarqués async
  • Il est proposé d’exposer ce comportement via un switch, à la manière de overflow-checks = false pour les dépassements d’entiers
    • En build debug, le panic resterait en place pour révéler immédiatement un comportement incorrect
    • En build release, on obtiendrait des futures plus petits
  • Avec panic=abort, il serait peut-être possible de supprimer complètement l’état Panicked, mais l’impact doit être étudié plus en détail

Une machine à états est générée même sans await

  • foo() ne retourne que async { 5 }, donc la forme optimale en implémentation manuelle serait un future sans état qui retourne toujours Poll::Ready(5)
  • Pourtant, le MIR généré par le compilateur contient toujours les trois états de base Unresumed, Returned et Panicked
    • Lors du poll, le discriminant de l’état courant est vérifié puis un branchement est effectué
    • Si le future est repollé après sa complétion, cela déclenche un panic avec `async fn` resumed after completion
  • Dans ce cas, on peut optimiser en supprimant totalement la machine à états et en retournant simplement Poll::Ready(5) à chaque appel
  • Une application expérimentale de cette idée dans le compilateur a réduit de 0,2 % la taille des binaires embarqués
    • Le gain est modeste, mais l’optimisation est simple, donc elle peut valoir le coup
  • Cette optimisation modifie légèrement le comportement, mais seuls les exécuteurs qui ne respectent pas le contrat seraient affectés
    • Aujourd’hui, le compilateur déclenche un panic lors d’un poll ultérieur
    • Après optimisation, le future retournerait toujours Ready

LLVM seul ne suffit pas

  • Même si le MIR produit est inefficace, LLVM peut parfois tout nettoyer, mais seulement sous certaines conditions
    • Le future doit être suffisamment simple
    • Il faut utiliser opt-level=3
  • Dès que le future devient plus complexe, LLVM n’élimine plus tout ; or dans le code Async Rust idiomatique, les futures sont profondément imbriqués, ce qui fait rapidement exploser la complexité
  • Dans les environnements où l’on optimise fréquemment pour la taille, comme l’embarqué ou wasm, LLVM n’arrive pas à tout optimiser
  • Exemple Godbolt : https://godbolt.org/z/58ahb3nne
    • Dans l’assembleur généré, LLVM comprend bien que foo retourne 5, mais n’optimise pas le résultat de bar en 10
    • L’appel à la fonction poll de foo est toujours présent
    • Cela vient de chemins de panic potentiels que le compilateur ne parvient pas à écarter complètement
    • LLVM ne sait pas que foo n’est réellement appelé qu’une seule fois et qu’il ne panique pas
  • Si l’on commente la branche de panic dans l’IR, l’optimisation devient meilleure : https://godbolt.org/z/38KqjsY8E
  • Plutôt que d’attendre une optimisation a posteriori de LLVM, le compilateur doit fournir à LLVM une meilleure entrée

L’inlining des futures fonctionne mal

  • L’inlining est important parce qu’il ouvre la voie aux passes d’optimisation suivantes, mais les futures générés par Rust ne sont actuellement pas inlinés à un stade suffisamment précoce
  • Chaque future obtient bien une implémentation, puis LLVM et l’éditeur de liens ont une occasion de l’inliner, mais à cause des problèmes précédents cela arrive trop tard
  • Le cas d’inlining le plus direct est celui où bar() ne fait que foo(blah).await
    • C’est un motif fréquent quand on construit des abstractions avec des traits
    • Aujourd’hui, le compilateur génère une machine à états pour bar, puis appelle à l’intérieur la machine à états de foo
    • Une approche plus efficace serait que bar soit directement le future de foo
  • Lorsqu’il existe un préambule et un postambule, le cas devient plus complexe
    • Exemple : bar(input) construit blah avec input > 10, puis exécute foo(blah).await avant d’appliquer * 2 au résultat
    • C’est fréquent lors de la transformation d’une fonction async vers une autre signature, notamment dans des implémentations de trait
  • Même sous cette forme, bar n’a pas besoin de son propre état async
    • Aucune donnée, en dehors de la valeur capturée par foo, n’a besoin d’être conservée au-delà de l’unique point d’attente
    • En revanche, bar ne peut pas être simplement remplacé par foo lui-même ; il peut surtout s’appuyer sur l’essentiel de l’état de foo
  • Dans une implémentation manuelle, BarFut pourrait avoir les états Unresumed { input } et Inlined { foo: FooFut }
    • Au premier poll, il exécute le préambule, crée foo(blah) puis passe à l’état Inlined
    • Ensuite, il applique le postambule au résultat de foo.poll(cx)
  • On pourrait aussi supprimer l’état Unresumed si l’on exécutait le code avant le premier point d’attente à l’avance, mais le contrat garantit qu’un future ne fait rien tant qu’il n’a pas été poll
  • Si l’on pouvait interroger les propriétés d’un future en cours de poll, d’autres optimisations d’inlining deviendraient possibles
    • Par exemple, si l’on sait qu’un future retourne toujours immédiatement ready au premier poll, le future appelant n’a pas besoin de créer un état pour ce point d’attente
    • En appliquant récursivement cette optimisation, beaucoup de futures pourraient être réduits à des machines à états bien plus simples
  • Dans l’architecture actuelle de rustc, chaque bloc async semble être transformé séparément et les données utiles ne sont pas conservées ensuite, ce qui empêche ce type d’interrogation
  • L’inlining des futures n’a pas encore été expérimenté, mais il devrait apporter des gains importants en taille de binaire et en performances

Fusion d’états identiques

  • Chaque point d’attente d’un bloc async ajoute un état supplémentaire à la machine à états
  • Un code comme le suivant est naturel, mais comme les deux branches attendent la même fonction async, il crée deux états identiques
    • CommandId::A => send_response(123).await
    • CommandId::B => send_response(456).await
  • Dans ce cas, le CoroutineLayout crée _s0 et _s1, qui stockent chacun le même type de coroutine de send_response, ainsi que deux états Suspend0 et Suspend1
  • Le MIR de cette fonction comporte 456 lignes, et nombre de blocs de base sont en pratique des doublons
  • En refactorisant manuellement le code pour calculer d’abord la valeur de réponse, puis faire un seul send_response(response).await, ces états redondants disparaissent
    • CommandId::A donne 123
    • CommandId::B donne 456
    • puis send_response(response).await
  • Après refactorisation, le CoroutineLayout ne contient plus qu’un seul future stocké et un unique état Suspend0
  • La longueur totale du MIR tombe à 302 lignes, et la duplication disparaît
  • Une passe d’optimisation capable de détecter et de fusionner les chemins de code et états identiques semble donc utile
    • Cette optimisation pourrait très bien se combiner avec une passe d’inlining des futures

Liens d’expérimentation et benchmarks supplémentaires

Appel à soutien pour le Project Goal

  • Ce travail a été soumis comme Project Goal afin d’avancer côté compilateur : https://rust-lang.github.io/rust-project-goals/2026/async-statemachine-optimisation.html
  • Sans financement, il sera difficile de faire beaucoup progresser le chantier ; un soutien partiel ou total d’entreprises ou d’organisations qui en bénéficieraient est donc nécessaire
  • Le contact indiqué est dion@tweedegolf.com
  • Le périmètre du travail et le montant nécessaire sont flexibles, mais 30 k€ seraient, d’après l’estimation, suffisants pour réaliser tout ou une grande partie du projet

1 commentaires

 
GN⁺ 1 시간 전
Avis sur Lobste.rs
  • C’était un article bien plus constructif que ce à quoi je m’attendais en voyant seulement le titre

    • Je pense que c’est tout simplement assez proche de la réalité. 7 ans après la sortie de la MVP, il n’y a presque pas eu de progrès ni dans la conception du langage ni dans l’implémentation du compilateur, et comme les personnes qui ont principalement produit cette MVP ont réduit leur implication dans le projet à peu près à la même période, la transmission s’est ensuite interrompue
      J’espère que celles et ceux qui veulent travailler là-dessus recevront le soutien nécessaire
  • I want to work on this in the compiler and as such have submitted it as a Project Goal

    Stop generating statemachines that don’t have to be there
    Make the compiler’s job easier by removing panic paths and branches
    Make statemachines smaller

    Je suis content de voir que ce problème est traité. J’ai déjà vu plusieurs fois des billets expliquant qu’en ce moment rustc transmet beaucoup trop de code à LLVM en espérant que l’optimiseur règle tout, et cet article demande en plus un financement pour ce travail

  • Mon Dieu, j’étais idiot
    J’ai toujours pensé que l’async était intrinsèquement « volumineux », puisqu’il faut forcément, sous une forme ou une autre, un runtime, un suivi des tâches et du polling pour vérifier l’achèvement. Donc cette surcharge n’est pas nulle
    Je pensais que la « zéro-cost abstraction » dont on parlait concernait la fonctionnalité du langage, indépendamment du runtime ajouté
    Il ne m’était même jamais venu à l’esprit de regarder ce que rustc émet avant de le passer à LLVM

  • Pour les personnes peu familières avec async Rust :

    It's amazing how we can write executor agnostic code that can run concurrently on huge servers and tiny microcontrollers.

    C’est tout à fait vrai. Même un arbre imbriqué d’appels async se fige, après optimisation maximale, en une seule struct avec une machine à états en interne. C’est vraiment ingénieux

  • Si on atteint ce cas en build de release, est-ce qu’on obtient une sorte de deadlock ? Ou bien y a-t-il aussi un risque de fuite à cause de tâches qui attendent un travail qui reste toujours sur Pending ?

    • Oui. Ces futures se retrouvent alors bloquées et ne se terminent jamais. Cela dit, cet état n’est atteignable qu’à partir de code async bas niveau déjà bogué, et un code incapable de suivre correctement un future terminé produit probablement déjà des fuites et des deadlocks
      On ne peut pas faire un polling incorrect avec .await
  • Quelques réflexions :

    1. Cet article ressemble à un plaidoyer pour déplacer davantage de logique d’optimisation hors de LLVM vers la couche MIR. Par exemple, je comprends pourquoi l’inlining des fonctions async y serait plus simple que dans LLVM. Si on a réussi cela pour l’async au niveau MIR, je me demande s’il ne serait pas possible de généraliser cette logique aux fonctions synchrones aussi, puis de supprimer certains passes d’optimisation de LLVM. Je sais que c’est un gros chantier, et c’est moins une question pratique qu’une question de direction. Une fois qu’un compilateur frontend/middle-end atteint un certain niveau de complexité, il semble possible qu’il soit préférable d’y déplacer une bonne partie des optimisations génériques de LLVM
    2. Je n’aime toujours pas panic=unwind. En dehors de certains test harnesses, je vois rarement des avantages qui compensent son coût par rapport à panic=abort. Même pour les test harnesses, il me semble qu’on pourrait faire un choix similaire sous Linux, de manière un peu obscure, avec clone pour faire un wait sur le thread d’exécution au lieu d’utiliser pthread_join. Je peux me tromper là-dessus
  • Le lien a aussi cessé de marcher chez d’autres personnes ?
    Édition : le billet de blog s’affiche une demi-seconde puis bascule sur une page 404
    Édition 2 : je suis passé par la liste des billets du blog et j’ai cliqué un peu partout, et même en ouvrant cet article depuis la liste, j’arrive sur une page 404. Comment peut-on casser à ce point un blog statique, ou du moins un blog qui devrait l’être ?

    • Le ton paraît un peu inutilement grossier et agressif. Les sites web peuvent aussi avoir des bugs, et le signaler est utile, mais ce commentaire sonne un peu mesquin
      Pour ce que ça vaut, j’ai suivi ce qui semble être les mêmes étapes de reproduction et je n’ai jamais obtenu de 404. J’ai essayé sur téléphone et sur desktop, avec JavaScript activé et désactivé. Il est donc possible que ce que tu as rencontré soit plus complexe que ça en avait l’air