1 points par GN⁺ 2024-08-20 | 1 commentaires | Partager sur WhatsApp
  • Avec l’ajout de musttail dans 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 jmp plutôt que d’un call, 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 musttail reste 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, upb pull/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 jmp au lieu d’un call
    • Il évite de créer une nouvelle frame de pile et de stocker une adresse de retour
    • L’appelant f() saute directement vers le callee g()
    • 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 n tail calls consécutifs, l’utilisation de la pile passe de O(n) à O(1)
    • Le surcoût de call disparaît, ce qui permet de traiter l’appel de fonction comme un branchement ordinaire
  • 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
    • musttail fournit précisément cette garantie

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 while contenant un switch, 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 return vers fallback()
    • 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é
  • 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 jmp vers fallback ou dispatch
    • Les arguments étant déjà dans les bons registres, aucun code supplémentaire de passage de paramètres n’est nécessaire
  • 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

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 ADDVN de 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 jmp distinct est généré après un branchement conditionnel
    • Au lieu de jmp qword ptr [rsi + 8*rax], le code charge dans rax puis utilise jmp rax
  • 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_none est jugé moins intrusif et donc préférable
  • Autre contrainte : musttail reste 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 vrai return par 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 vers dispatch ou 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, upb a été mis à jour pour utiliser musttail, 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

 
GN⁺ 2024-08-20
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...

    • Je me demande pourquoi return goto serait plus facile à implémenter. À première vue, [[musttail]] semble aussi mettre fin à la durée de vie des objets locaux
      En 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/54964
      Dire « 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
    • Voir les efforts pour ajouter de nouvelles fonctionnalités à C reprendre de la vigueur est à la fois enthousiasmant et assez inquiétant
      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 garantie
    Elle 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 switch classique 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 statique

    • Comme le dit aussi l’article, même avec des goto calculés, le graphe de flot de contrôle de la fonction est trop complexe, ce qui rend fragile l’allocation de registres pour les variables fréquemment utilisées
      Si chaque fonction est petite et reçoit les variables importantes en arguments, l’allocation de registres devient beaucoup moins fragile
    • Je suis curieux à propos de l’expression « statistiquement, une branche indirecte par opcode ». Ce serait bien d’expliquer un peu plus en détail ce que cela signifie par rapport à une seule branche indirecte, et comment on y parvient
    • J’avais entendu dire que cette approche était passée de mode depuis un moment. L’idée était que les prédicteurs de branchement étaient devenus suffisamment bons pour qu’elle ne soit plus nécessaire
      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/

    • Ces dernières années, j’ai vu plusieurs langages supprimer une couche JIT, puis la réintroduire. Dans certains cas, c’est lié aux compétences des programmeurs et aux leçons apprises, mais parfois aussi aux changements de générations de CPU
      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
    • Clang a récemment ajouté une nouvelle convention d’appel qui rend ce type d’appels terminaux beaucoup moins coûteux. Elle évite au code appelant d’avoir à préserver certains registres
      Son nom me revient toujours de travers, mais ce doit être preserve_all ou preserve_none. Tout dépend du point de vue selon lequel on parle de préservation
  • À ma connaissance, l’attribut musttail est en cours d’ajout à GCC. Le patch est en cours de revue, et sa sémantique est compatible avec Clang.

    • Je me demande ce qu’il en est de l’attribut 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.
    • C’est un problème difficile. Beaucoup d’ABI ne permettent pas d’effectuer des appels terminaux même dans des cas très simples, comme l’appel d’une fonction externe dont les arguments et le type de retour correspondent.
      Clang semble avoir des heuristiques qui modifient la séquence d’appel pour les appels musttail. Par exemple, sur i686, il les transforme en appels noplt. Rien de tout cela ne figure dans la documentation de Clang : https://clang.llvm.org/docs/AttributeReference.html#musttail
      En 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.
    • GNUC possède pas mal de fonctionnalités proches de Scheme ; c’est surprenant qu’il soit en retard sur celle-ci.
  • 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 de a a lieu après l’appel à bar().

    • Si le compilateur peut prouver qu’il n’y a pas d’effets de bord à distance entre ces lignes, ne pourrait-il pas appeler le destructeur avant d’exécuter 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.

    • Il est évident que les compilateurs éliminent les appels terminaux depuis longtemps, mais avec cette technique, « produit assez régulièrement » ne suffit pas. Il faut que ce soit garanti, ou que la compilation échoue.
      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.
    • Comme le montre le désassemblage de l’article, le problème du chemin de fallback n’est pas la vitesse de cet appel en lui-même. La simple présence de cet appel peut pousser le compilateur à créer un cadre de pile pour toute la fonction et à y déverser des registres. Même le chemin rapide est affecté.
      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.

    • C est souvent utilisé comme langage cible pour les compilateurs de langages de haut niveau.
      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....
    • À mon avis, si l’optimisation des appels terminaux est rapide, c’est parce que la boucle obtenue est prévisible, ce qui permet au préchargement d’instructions du CPU et au préchargement mémoire de bien fonctionner.
      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

    • Je ne comprends pas la partie disant que « dans le cas général, le parsing Protobuf ne peut pas être fait en streaming à cause du traitement des doublons »
      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 debug
    Bien 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...