- Avec l’ajout de
musttaildans Clang, il est désormais possible d’exploiter des tail calls garantis aussi dans les langages de la famille C, et de les appliquer à un parseur protobuf pour démontrer des performances de plus de 2 Go/s - L’idée centrale consiste à rapprocher l’appel de fonction d’un
jmpplutôt que d’uncall, afin de réduire l’usage de la pile lors d’appels successifs de O(n) à O(1) et de les traiter comme une boucle répétitive - Le wire format de protobuf doit interpréter des tags/valeurs et bifurquer vers des champs dans un ordre arbitraire, ce qui lui donne des problèmes d’optimisation similaires à ceux du dispatch d’opcodes dans un interpréteur avec une structure classique en
while+switch - Le parseur expérimental d’upb relie de petites fonctions de parsing par tail calls au lieu d’utiliser une seule grosse fonction, ce qui évite sur le chemin rapide usage de pile, spills de registres et prologue/épilogue
- Cette approche se dégrade fortement dès qu’on mélange des appels non terminaux, et
musttailreste une extension non standard, ce qui impose de prévoir des règles d’appel et des solutions de portabilité pour déployer réellement un parseur rapide
Parsing protobuf haute vitesse rendu possible par musttail dans Clang
- La branche principale de Clang a ajouté l’attribut de statement
[[clang::musttail]]/__attribute__((musttail)), ce qui permet d’obtenir une garantie de tail call en C, C++ et Objective-C - Ici, le tail call n’est pas utilisé comme technique de programmation fonctionnelle, mais comme outil d’optimisation pour réduire le coût des branchements dans les parseurs et les interpréteurs
- En appliquant cette technique au parsing protobuf,
upbpull/310 a démontré des performances de parsing de plus de 2 Go/s- Le résultat est présenté comme plus de deux fois plus rapide que le meilleur niveau précédent
- Plusieurs techniques contribuent ensemble à ce gain, donc il serait incorrect d’en conclure que « le tail call à lui seul a doublé les performances »
- Le tail call est néanmoins l’un des éléments clés qui ont rendu ce gain possible
- Les évolutions ultérieures sont traitées dans A Tail Calling Interpreter For Python (And Other Updates)
Pourquoi le tail call se comporte comme une structure répétitive
- Un tail call est le dernier appel de fonction effectué juste avant qu’une fonction ne retourne
- Quand l’optimisation de tail call s’applique, le compilateur génère une instruction
jmpau lieu d’uncall- Il évite de créer une nouvelle frame de pile et de stocker une adresse de retour
- L’appelant
f()saute directement vers le calleeg() g()retourne alors directement à la fonction qui avait appeléf()
- Grâce à cette propriété, le tail call peut remplacer une structure de boucle
- Même avec
ntail calls consécutifs, l’utilisation de la pile passe de O(n) à O(1) - Le surcoût de
calldisparaît, ce qui permet de traiter l’appel de fonction comme un branchement ordinaire
- Même avec
- L’idée n’est pas nouvelle et remonte jusqu’à l’article de Guy Steele en 1977 ainsi qu’aux “Lambda Papers” publiés entre 1975 et 1980
- Clang savait déjà optimiser les tail calls dans des builds optimisés comme
-O2, mais ce comportement relevait jusque-là surtout du best effort- En build non optimisé, le code pouvait très bien être compilé avec un vrai
call - Pour utiliser le tail call en toute sécurité comme structure répétitive, il faut que l’optimisation soit garantie dans tous les modes de build
musttailfournit précisément cette garantie
- En build non optimisé, le code pouvait très bien être compilé avec un vrai
Le même goulot d’étranglement dans la boucle d’un interpréteur et un parseur protobuf
- Mike Pall, de LuaJIT, a écrit l’interpréteur LuaJIT 2.x en assembleur plutôt qu’en C, et y voyait l’une des principales raisons de sa rapidité
- Les compilateurs C rencontrent surtout deux problèmes dans la boucle principale d’un interpréteur
- Plus la fonction grossit et plus le contrôle de flux devient complexe, plus l’allocateur de registres a du mal à conserver les données importantes dans les registres
- Quand le chemin rapide et le chemin lent sont mélangés dans une même fonction, le chemin lent dégrade aussi la qualité du code du chemin rapide
- Le wire format de protobuf présente une structure proche de celle d’un interpréteur
- Le wire format est une suite de paires tag/valeur
- Le tag contient le numéro de champ et le wire type
- Le tag joue un rôle proche d’un opcode indiquant comment parser les données du champ correspondant
- Comme les numéros de champ peuvent arriver dans n’importe quel ordre, le code doit être prêt à dispatcher vers n’importe quelle partie
- Historiquement, les parseurs protobuf utilisaient le plus souvent une structure avec un
whilecontenant unswitch, et cela a représenté l’état de l’art pendant la majeure partie de l’existence de protobuf - En parsing réel, des cas exceptionnels comme une incompatibilité de wire type, des données corrompues ou l’arrivée en fin de buffer peuvent survenir à presque chaque étape
- Le chemin rapide doit rester aussi court et stable que possible
- Les cas difficiles exigent un code de repli plus gros et plus complexe, avec parfois des appels out-of-line
Conception du parseur upb fondé sur les tail calls
- Le parseur expérimental d’upb n’utilise pas une seule grosse fonction de parsing : chaque opération est isolée dans une petite fonction
- Chaque fonction appelle l’opération suivante via un tail call
- Grâce à la convention d’appel x86-64, les arguments communs du parsing sont passés dans des registres
- Toutes les fonctions de parsing utilisent le même ensemble d’arguments afin de réduire les mouvements de valeurs entre appels
- Dans l’exemple, la fonction qui parse un champ de largeur fixe sur 4 octets suit le déroulement suivant
- Elle décode les informations du champ à partir de
data - Si le wire type ne correspond pas, elle fait un
MUSTTAIL returnversfallback() - Elle saute le tag puis stocke la donnée dans le message
- Elle lit ensuite le tag suivant et fait un tail call vers
dispatch()pour bifurquer vers le parseur du champ approprié
- Elle décode les informations du champ à partir de
- L’assembleur généré par Clang ne contient sur le chemin rapide ni prologue, ni épilogue, ni spill de registres, ni usage de la pile
- Les seuls points de sortie sont des
jmpversfallbackoudispatch - Les arguments étant déjà dans les bons registres, aucun code supplémentaire de passage de paramètres n’est nécessaire
- Les seuls points de sortie sont des
- Conceptuellement, cette structure considère la grande boucle d’interprétation comme une fonction complexe unique, mais l’implémentation réelle la découpe en fonctions correspondant à des blocs de base qui se transmettent le contrôle par tail call
- En séparant le chemin rapide et le chemin lent dans des fonctions distinctes, les modifications du code de fallback ont moins de chances de perturber la qualité du code du chemin rapide
- On peut au besoin empêcher l’inlining avec
noinline - La séquence assembleur du chemin rapide peut ainsi être pratiquement figée
- On peut au besoin empêcher l’inlining avec
Qualité du code généré en C dans l’exemple LuaJIT
- En appliquant le même schéma à l’exemple LuaJIT, on peut obtenir en C un résultat proche d’un assembleur écrit à la main
- La fonction
ADDVNde l’exemple effectue les opérations suivantes- Elle extrait de l’instruction les index de registre et de constante
- En cas d’échec de vérification de type, elle bascule vers le fallback
- Elle ajoute la constante à la valeur du registre
- Elle lit l’opcode suivant puis fait un tail call vers la fonction correspondante dans la table d’opcodes
- Les améliorations encore possibles dans l’assembleur généré restent relativement mineures
- Un
jmpdistinct est généré après un branchement conditionnel - Au lieu de
jmp qword ptr [rsi + 8*rax], le code charge dansraxpuis utilisejmp rax
- Un
- Ces points sont traités comme de petits problèmes de génération de code qui peuvent encore être améliorés dans Clang
Les contraintes : appels non terminaux et portabilité
- Le principal point de vigilance de cette approche est que la présence d’un appel non terminal dans une fonction dégrade fortement la qualité de l’assembleur
- Un seul appel non terminal force la création d’une frame de pile
- Beaucoup de données peuvent alors être spillées sur la pile
- Pour éviter cela, il faut suivre une discipline consistant à inline les autres appels de fonction, ou à n’effectuer que des tail calls
- Dans le parsing protobuf, le traitement des varints est une difficulté représentative
- Le cas courant et rapide est celui d’un varint sur 1 octet
- Les varints plus longs ne sont pas des erreurs, mais restent des cas peu fréquents
- Si ce traitement exceptionnel est inline, la qualité du code du chemin rapide peut se dégrader
- Si l’on fait un tail call vers une fonction de fallback, il n’est pas simple de reprendre ensuite l’opération d’origine, donc le fallback doit traiter l’opération jusqu’au bout
- Il en résulte de la duplication de code et davantage de complexité
- La mise à jour du 2025-01-27 ajoute une méthode pour atténuer ce problème via les conventions d’appel
__attribute__((preserve_most))est une convention d’appel utilisable pour les fonctions de fallback ; elle transfère presque toute la responsabilité de préservation des registres au callee, ce qui déplace le coût des spills du côté du fallback- Le bug de crash Clang lié à cet attribut a été corrigé en 2023
__attribute__((preserve_none))est une convention d’appel utilisable pour les fonctions qui tail callent ; elle supprime la charge de préservation des registres et permet d’utiliser davantage de registres pour les arguments- Parmi les deux,
preserve_noneest jugé moins intrusif et donc préférable
- Autre contrainte :
musttailreste une extension de compilateur non standard- On peut espérer qu’elle s’étende à GCC, Visual C++ et qu’elle soit standardisée, mais ce n’est pas pour tout de suite
- En l’absence de
musttail, il faut au moins un vraireturnpar itération conceptuelle de boucle - upb n’a pas encore implémenté ce fallback, et il faudra probablement un macro qui, selon la disponibilité de
musttail, effectue un tail call versdispatchou retourne simplement
État d’adoption dans upb et potentiel d’extension
- Le parseur à plus de 2 Go/s a été soumis à
upb, une petite bibliothèque protobuf écrite en C - Le code fonctionne entièrement et passe tous les tests de conformité protobuf, mais au moment de la rédaction il n’avait encore été déployé nulle part
- Cette conception n’a pas été implémentée dans la version C++ de protobuf
- Par la suite,
upba été mis à jour pour utilisermusttail, supprimant ainsi un obstacle majeur à la mise en production du parseur rapide - La même technique pourrait aussi apporter des gains de performance significatifs à des interpréteurs majeurs écrits en C, comme Python, Ruby, PHP ou Lua
1 commentaires
Avis sur Hacker News
Une proposition pour le standard C contient une syntaxe dédiée aux appels terminaux, sous la forme
return goto (expression);Ce que je préfère par rapport au
[[musttail]]standard, c’est qu’elle garantit que la durée de vie des objets locaux prend fin. Cela rend donc l’implémentation possible sans analyse d’échappement étendue[0] https://www.open-std.org/jtc1/sc22/wg14/www/docs/n3266.htm#5...
return gotoserait plus facile à implémenter. À première vue,[[musttail]]semble aussi mettre fin à la durée de vie des objets locauxEn parcourant rapidement, il est indiqué que la fonction appelée en position terminale doit être du même type que la cible de l’appel. C’est une condition destinée à garantir qu’aucune conversion de valeur de retour n’est nécessaire, et que l’espace de passage des arguments ainsi que la convention d’appel sont préservés
Une critique que j’ai souvent vue à propos de
[[musttail]], que j’avais implémenté dans Clang, est que cette contrainte est inutilement stricte. Certaines architectures autorisent les appels terminaux même lorsque les types ne correspondent pas parfaitement : https://github.com/llvm/llvm-project/issues/54964Dire « dans ce cas, le code n’est plus portable » est juste, mais l’optimisation des appels terminaux n’est pas intrinsèquement portable. Par exemple, certaines cibles ne prennent fondamentalement pas en charge l’optimisation des appels terminaux, comme WASM sans extension d’appels terminaux
C’est enthousiasmant parce qu’il y a des changements et des ajouts nécessaires, et même des idées qui méritent d’être clarifiées, mais le rythme de mise à jour agressif de C++ semble au final avoir produit une sorte de verrue ajoutée par-dessus une autre verrue
Le problème survient surtout lorsque des fonctionnalités interagissent mal entre elles bien plus tôt que prévu. J’espère que le processus de standardisation ne s’appuiera pas seulement sur des documents justificatifs, mais testera suffisamment les fonctionnalités sur de grands codebases variés, en les sélectionnant de façon très conservatrice
Si Rust vous intéresse, il existe une ancienne RFC qui proposait d’ajouter le mot-clé
become, offrant une optimisation des appels terminaux garantieElle avait initialement été repoussée pour se concentrer sur les objectifs de l’édition 2018, ce qui était la bonne décision, mais l’idée a récemment été réexaminée. Elle pourrait revenir
[0]: https://github.com/rust-lang/rfcs/pull/1888
[1]: https://github.com/rust-lang/rfcs/pull/3407
En C++, les interpréteurs obtiennent généralement ce type de gain de vitesse en utilisant des goto calculés. Ainsi, le chemin d’un opcode au suivant ne comporte pas le bruit lié à la convention d’appel
La principale raison pour laquelle l’approche par goto calculé ou par appels terminaux est plus rapide qu’une boucle
switchclassique est qu’elle réduit la charge du prédicteur de branchement. Statistiquement, on obtient une branche indirecte par opcode, au lieu d’une structure avec une seule branche indirecte statiqueSi chaque fonction est petite et reçoit les variables importantes en arguments, l’allocation de registres devient beaucoup moins fragile
Cela dit, je me demande si cela reste vrai quand la taille de l’interpréteur augmente
Le problème qui reste lorsqu’on utilise des appels terminaux pour les changements de contexte, c’est qu’on emploie des fonctions qui doivent respecter une convention d’appel. Malheureusement, on gaspille des registres pour restaurer l’état à la fin de la fonction
Une analyse détaillée et une alternative utilisant un compilateur intermédiaire se trouvent sur le blog du remake de LuaJIT : https://sillycross.github.io/2022/11/22/2022-11-22/
Comme pour tout le reste en informatique, lorsque l’équilibre des coûts selon les types d’opérations change, le meilleur algorithme peut redevenir celui qu’on utilisait il y a 15 ou 20 ans. C’est pour cela que la programmation peut beaucoup ressembler à une mode. Ressusciter quelque chose n’est pas forcément injustifié, mais oublier pourquoi ce n’était pas une panacée la dernière fois reste un problème
Si le JIT principal devient plus rapide ou plus lent, le rapport entre le coût d’exécution et le gain change, et les seuils qui le déclenchent sont eux aussi ajustés. Cela modifie alors la quantité de code exécutée dans les autres couches, et le coût amorti de ces couches peut également se dégrader. C’est comme équilibrer un double pendule
Si l’on peut rendre une couche JIT suffisamment rapide et grossière, on peut complètement sauter l’interpréteur. Vu de l’extérieur, la charge cognitive liée à la comptabilité entre un interpréteur et environ deux JIT semble élevée ; certains langages paraissent donc avoir mis l’interpréteur en pause et utilisé un JIT optimisé pour le temps de compilation plutôt que pour la vitesse du code produit
Je ne me souviens plus de quel langage il s’agissait, mais il me semble qu’au moins une équipe a fini par supprimer aussi le compilateur intermédiaire à cause de ce problème d’équilibre. Il valait mieux se concentrer sur deux éléments plutôt que d’en gérer trois
Son nom me revient toujours de travers, mais ce doit être
preserve_alloupreserve_none. Tout dépend du point de vue selon lequel on parle de préservationÀ ma connaissance, l’attribut
musttailest en cours d’ajout à GCC. Le patch est en cours de revue, et sa sémantique est compatible avec Clang.preserve_most. Y a-t-il une chance que quelque chose de similaire arrive dans GCC ? Sans cela, les appels non terminaux ruinent l’interpréteur.Clang semble avoir des heuristiques qui modifient la séquence d’appel pour les appels
musttail. Par exemple, sur i686, il les transforme en appelsnoplt. Rien de tout cela ne figure dans la documentation de Clang : https://clang.llvm.org/docs/AttributeReference.html#musttailEn pratique, ce qu’on peut envisager, c’est que le compilateur émette un message de diagnostic lorsqu’il ne peut pas générer d’appel terminal. Pour beaucoup d’utilisateurs, cela suffira probablement. Garantir les appels terminaux comme en Scheme paraît peu probable.
Le support de C++ est également mentionné, mais j’imagine qu’en C++ il y aura très peu d’appels terminaux.
Par exemple,
foo() { auto a = SomeClassWithADestructor(); return bar(); }n’est pas un appel terminal, puisque la destruction deaa lieu après l’appel àbar().bar?Je me demande si la norme C++ impose vraiment d’appeler le destructeur à la fin du bloc, ou si elle permet de le faire dès que la variable n’est plus utilisée.
L’exemple est peut-être trop simple, mais
__attribute__((musttail))ne semble pas indispensable pour obtenir une bonne génération de code.Si la fonction de gestion d’erreur se trouve sur un chemin rare, sa vitesse d’appel ne devrait pas avoir beaucoup d’importance.
Une structure comme
if (unlikely(malformed)) return error(); switch (data_type) { case x: return handle_x(); case y: return handle_y(); }semble produire assez régulièrement une bonne table de sauts.Sinon, cette structure ne fonctionne pas et la pile explose immédiatement. Tout l’intérêt de
[[musttail]]est que l’élimination de l’appel terminal est obligatoire. Le compilateur n’a pas d’autre choix.Bien sûr, dire que cela « force » le compilateur n’est peut-être pas exact. Rien n’impose au compilateur d’avoir une structure unique de cadre de pile pour tous les chemins d’exécution d’une fonction, ni d’utiliser l’ABI standard pour une fonction à liaison interne dont l’adresse n’est pas prise, ou pour une fonction dans un espace de noms anonyme. Mais tous les compilateurs que j’ai vus, y compris Clang, le font en pratique. Il faut donc un moyen de leur dire de ne pas se soucier de l’ABI et de ne pas perdre du temps à préserver des registres entre les appels.
Les tables de sauts, elles, sont bien sûr bien générées. Mais si vous prenez le résultat, que vous l’examinez avec quelque chose comme
perf report, et que le bytecode de test ne représente pas une courte boucle, vous verrez l’un de ces deux phénomènes : soit une erreur de prédiction de branchement à chaque dispatch, soit le compilateur qui se dit « on dirait qu’il essaie d’écrire un interpréteur » et déplace le saut indirect à la fin de chaque case. J’ai déjà vu cela avec Clang. Dans les deux cas, l’allocation des registres du code produit risque généralement d’être médiocre.Je me demande à quelle vitesse irait un trampoline, c’est-à-dire une approche où la fonction suivante est renvoyée sous forme de pointeur de fonction puis appelée dans une boucle externe. L’avantage, c’est que c’est du C portable.
Le langage de programmation Scheme exige que tous les appels terminaux n’augmentent pas la pile. Les implémenteurs ont donc exploré plusieurs techniques, dont les trampolines.
Je n’ai pas de référence à citer, mais on peut trouver des réponses dans les articles sur la compilation de Scheme vers C. Si le langage cible ne garantit pas l’optimisation des appels terminaux, le programme généré sera plus lent.
Au passage, c’est aussi pour cette raison que les implémenteurs de langages de haut niveau, en particulier, se plaignent de la suppression de l’optimisation des appels terminaux dans la spécification JavaScript. Il existe aussi des solutions qui conservent à la fois l’optimisation des appels terminaux et la vérification de pile.
https://github.com/schemedoc/bibliography/blob/master/page8....
En sautant via un pointeur de fonction, ce sera probablement moins prévisible, et il sera difficile d’obtenir les mêmes gains.
Bien sûr, il faudrait mesurer, et je ne l’ai pas encore fait.
J’ai écrit en C un décodeur/encodeur Protobuf, un parseur IML et des bindings Python, et j’ai des choses à dire sur la mesure de la vitesse de parsing
Si cette bibliothèque n’est fournie que sous forme de bindings pour langages managés, cela ajoute un facteur supplémentaire qui écrase tout le reste côté performances. Je ne sais pas pour Ruby ou PHP, mais en Python, j’ai observé une accélération spectaculaire quand on n’utilise pas d’énumérateurs. Convertir des énumérateurs Protobuf en énumérateurs Python fait piétiner tous les gains possibles du code C par le temps de création de divers objets Python. L’écart se compte en plusieurs ordres de grandeur. On peut aller plus loin et implémenter toutes les structures de données auxiliaires en C, en n’exposant qu’une interface minimale à Python. Il est difficile de dire à quel point une telle comparaison est équitable face à du code utilisant les structures intégrées de Python
Le parseur Protobuf de Google pour Python peut toujours être « plus rapide » que 2 Go/s. La raison est qu’il ne parse rien d’autre que le message de plus haut niveau. La structure interne du message est parsée à la demande. Si le code lit immédiatement l’intégralité du contenu parsé, il sera probablement plus lent que 2 Go/s, mais la question est de savoir comment comparer ces deux approches de manière pratique. Les résultats réels dépendent de la nature de l’application, donc il n’y a pas de réponse claire
Dans le cas général, le parsing Protobuf ne peut pas être fait en streaming à cause du traitement des doublons. En pratique, le code qui parse du contenu Protobuf se heurte à un goulot d’étranglement d’E/S, car il faut attendre la fin du message avant de commencer le parsing. Par ailleurs, selon les messages Protobuf typiques de l’application, il peut être possible de paralléliser le parsing, ce qui aura de fortes chances de dépasser la plupart des parseurs monothread. Mais, comme dans les exemples précédents, on ne peut pas dire que ce soit une stratégie gagnante en général
En général, il est bien plus efficace de combiner le parsing avec la création des objets de domaine. Les applications doivent presque toujours passer par cette étape. La manière dont un parseur permet d’aborder cette fonctionnalité détermine souvent quel parseur l’emportera
En conclusion, Protobuf — et peut-être les parseurs en général — ne se prête pas bien aux mesures de vitesse ni aux comparaisons. C’est trop bas niveau, et sa conception n’est pas assez bonne pour en faire une référence de benchmark de performances
J’aimerais qu’on explique plus en détail en quoi la règle selon laquelle le dernier champ l’emporte empêche le parsing en streaming
GCC et Clang disposent depuis longtemps de l’option
-foptimize-sibling-calls, qui permettait d’obtenir des appels terminaux même dans les builds de debugBien sûr, le fait que cette fonctionnalité soit standardisée, garantie et contrôlable au niveau de la fonction constitue une nette amélioration
[1] https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
[2] https://clang.llvm.org/docs/ClangCommandLineReference.html#t...