Async Rust n’a jamais dépassé le stade MVP
(tweedegolf.nl)- 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 étatsUnresumed,Returned,Panicked,Suspend0etSuspend1, alors que la version synchrone n’a besoin que de 23 lignes - Remplacer le
panicaprès un nouveaupolld’un future déjà terminé par un retour dePoll::Pendingpermet 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 }, sansawait, construit aujourd’hui une machine à états avec 3 états de base, mais l’optimiser pour renvoyer systématiquementPoll::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 àawaitunique, 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
- Le sujet est déjà connu, et une PR qui en traite une partie est ouverte : https://github.com/rust-lang/rust/pull/135527
Structure des futures générés
- Dans l’exemple,
foo()retourneasync { 5 }etbar()exécutefoo().await + foo().await- Exemple Godbolt : godbolt
barcomporte 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_resumeest 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
bargénère 360 lignes de MIR, tandis que la version synchrone n’en utilise que 23 - Le
CoroutineLayoutproduit par le compilateur correspond en pratique à un ensemble d’états sous forme d’enumUnresumed: état initialReturned: état terminéPanicked: état après panicSuspend0: premier point d’attente, qui stocke le future defooSuspend1: second point d’attente, qui stocke le premier résultat et le second future defoo
Future::pollest 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 renvoieReadypuis fait passer le future à l’étatReturned - Un nouveau
polldans cet état provoque un panic
- Aujourd’hui, après
- L’état
Panickedsemble servir à empêcher qu’un future soit repollé après qu’une fonction async a paniqué puis que ce panic a été intercepté aveccatch_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
Panickedn’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
Returnedpaniquent 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::Pendinglorsqu’il est repollé, le contrat du typeFuturepeut ê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 = falsepour 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’étatPanicked, 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 queasync { 5 }, donc la forme optimale en implémentation manuelle serait un future sans état qui retourne toujoursPoll::Ready(5)- Pourtant, le MIR généré par le compilateur contient toujours les trois états de base
Unresumed,ReturnedetPanicked- 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
- Lors du
- 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
pollultérieur - Après optimisation, le future retournerait toujours
Ready
- Aujourd’hui, le compilateur déclenche un panic lors d’un
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
fooretourne 5, mais n’optimise pas le résultat debaren 10 - L’appel à la fonction
polldefooest toujours présent - Cela vient de chemins de panic potentiels que le compilateur ne parvient pas à écarter complètement
- LLVM ne sait pas que
foon’est réellement appelé qu’une seule fois et qu’il ne panique pas
- Dans l’assembleur généré, LLVM comprend bien que
- 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 quefoo(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 defoo - Une approche plus efficace serait que
barsoit directement le future defoo
- Lorsqu’il existe un préambule et un postambule, le cas devient plus complexe
- Exemple :
bar(input)construitblahavecinput > 10, puis exécutefoo(blah).awaitavant d’appliquer* 2au 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
- Exemple :
- Même sous cette forme,
barn’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,
barne peut pas être simplement remplacé parfoolui-même ; il peut surtout s’appuyer sur l’essentiel de l’état defoo
- Aucune donnée, en dehors de la valeur capturée par
- Dans une implémentation manuelle,
BarFutpourrait avoir les étatsUnresumed { input }etInlined { foo: FooFut }- Au premier
poll, il exécute le préambule, créefoo(blah)puis passe à l’étatInlined - Ensuite, il applique le postambule au résultat de
foo.poll(cx)
- Au premier
- On pourrait aussi supprimer l’état
Unresumedsi 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
- Par exemple, si l’on sait qu’un future retourne toujours immédiatement ready au premier
- 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).awaitCommandId::B => send_response(456).await
- Dans ce cas, le
CoroutineLayoutcrée_s0et_s1, qui stockent chacun le même type de coroutine desend_response, ainsi que deux étatsSuspend0etSuspend1 - 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 disparaissentCommandId::Adonne123CommandId::Bdonne456- puis
send_response(response).await
- Après refactorisation, le
CoroutineLayoutne contient plus qu’un seul future stocké et un unique étatSuspend0 - 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
- En appliquant ensemble les deux expériences, on obtient environ 3 % de performances en plus sur un benchmark synthétique x86 utilisant l’exécuteur
smol - No panics in poll after ready: https://github.com/rust-lang/rust/compare/main...diondokter:rust:resume-pending
- No await, no statemachine: https://github.com/rust-lang/rust/compare/main...diondokter:rust:no-statemachine-when-no-await
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
Avis sur Lobste.rs
C’était un article bien plus constructif que ce à quoi je m’attendais en voyant seulement le titre
J’espère que celles et ceux qui veulent travailler là-dessus recevront le soutien nécessaire
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 :
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?On ne peut pas faire un polling incorrect avec
.awaitQuelques réflexions :
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, avecclonepour faire unwaitsur le thread d’exécution au lieu d’utiliserpthread_join. Je peux me tromper là-dessusLe 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 ?
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