- Représenter un AST non pas comme un graphe d’objets reliés par des pointeurs, mais avec un seul tableau et des indices simplifie l’organisation en mémoire et la gestion des structures de données de type compilateur
- L’exemple Rust implémente un AST aplati avec de petits changements seulement : remplacer
Box<Expr> par ExprRef et ajouter add/get à ExprPool
- Dans un microbenchmark qui génère puis interprète immédiatement environ 100 millions de nœuds d’AST, l’implémentation classique prend 3,1 s, contre 1,3 s pour l’implémentation aplatie, soit 2,4× plus rapide
- L’écart de performances vient de la localité due à une disposition mémoire contiguë, de références plus petites utilisant des indices 32 bits au lieu de pointeurs 64 bits, d’allocations peu coûteuses et d’une libération par pool
- En exploitant la propriété selon laquelle, dans le tableau, les enfants apparaissent avant les parents, un parcours récursif de l’arbre devient une exécution linéaire, se rapprochant d’un interpréteur de bytecode
Idée de base de l’aplatissement
- Les arenas ou régions sont largement utilisées dans les implémentations modernes de langages ; l’approche présentée ici consiste à utiliser, comme un simple tableau, une arena ne contenant qu’un seul type : c’est ce qu’on appelle l’aplatissement de structure de données
- Il s’agit d’une technique qui remplace les pointeurs par des indices de tableau dans des structures de données riches en pointeurs ; l’exemple central est l’AST, mais elle peut aussi s’appliquer à d’autres structures de données de compilateur
- Le code d’exemple se trouve dans le dépôt flatcalc, et la différence entre l’implémentation classique et l’implémentation aplatie peut être vue dans cette comparaison de branches
- L’ampleur des changements dans le code est faible, mais le microbenchmark montre un gain de vitesse de 2,4×, avec en plus des avantages en matière d’utilisabilité du code
Représentation classique d’un AST
- Le langage d’exemple est un langage d’expressions arithmétiques extrêmement simple, qui ne prend en charge que des littéraux entiers et quatre opérateurs arithmétiques binaires
- Exemples possibles de programmes :
42, 0 + 14 * 3, (100 - 16) / 2
- La représentation Rust est composée des enums
BinOp et Expr
Expr::Binary(BinOp, Box<Expr>, Box<Expr>)
Expr::Literal(i64)
- En Rust,
Box<Expr> correspond à un pointeur vers Expr, avec un rôle similaire à Expr* en C
- Le parseur, le formateur d’affichage et l’interpréteur ont une structure classique, et l’interpréteur est écrit comme une méthode récursive sur
Expr
- La sémantique arithmétique est conçue de sorte que toutes les expressions soient finalement évaluées en
i64
- L’addition, la soustraction et la multiplication utilisent des opérations wrapping
- La division par zéro est traitée avec
checked_div afin de renvoyer 0
- Un générateur de programmes aléatoires utilisant une seed PRNG fixe permet de mesurer les performances de manipulation de l’AST sans coût de parsing ni d’affichage
Remplacer l’AST par un tableau et des indices
- L’aplatissement repose sur deux changements
- Les objets
Expr ne sont plus alloués individuellement sur le tas, mais stockés dans un tableau contigu unique
- Les nœuds enfants sont référencés non plus par des pointeurs, mais par des indices dans le tableau
- Dans l’exemple Rust,
ExprPool est défini comme un newtype de Vec<Expr>
struct ExprPool(Vec<Expr>);
- Le rôle de l’ancien pointeur est rempli par
ExprRef, basé sur un entier 32 bits
struct ExprRef(u32);
- Le changement de type principal consiste à remplacer, dans les champs enfants de
Binary, Box<Expr> par ExprRef
enum Expr {
Binary(BinOp, ExprRef, ExprRef),
Literal(i64),
}
ExprPool reçoit des utilitaires add pour insérer un nouvel Expr et get pour retrouver un Expr à partir d’un ExprRef
- Le parseur ne renvoie plus directement un
Expr : il ajoute les nœuds dans ExprPool puis renvoie un ExprRef
- L’interpréteur non plus n’est plus une méthode de
Expr, mais de ExprPool ; avant le pattern matching, il déréférence via self.get(expr)
- Dans l’ensemble, les modifications se limitent à remplacer
Box<Expr> par ExprRef et à insérer add et get là où nécessaire
Avantages en performance
- L’avantage représentatif d’un AST aplati est la localité mémoire
- Un
Expr classique basé sur des pointeurs présente un risque de fragmentation mémoire
- Un
Expr aplati est densément placé dans une zone mémoire contiguë, ce qui permet au cache de données et au prefetcher de mieux fonctionner
- Un allocateur mémoire suffisamment intelligent pourrait produire un effet similaire, mais un tableau compact réduit l’incertitude
- La taille des références diminue aussi
- Un pointeur classique fait 64 bits sur les architectures modernes
- Si l’on n’a pas besoin de plus de 4 294 967 295 nœuds d’AST, une référence 32 bits suffit
- Il est possible d’économiser 50 % d’espace par référence, ce qui peut réduire l’empreinte mémoire globale dans des AST riches en pointeurs
- Pour des structures de données plus petites, des références 16 bits ou 8 bits sont aussi envisageables
- Le coût d’allocation baisse
- Il n’est plus nécessaire d’appeler
malloc pour chaque nœud
- Si suffisamment de mémoire a été réservée à l’avance, l’espace d’un nouvel
Expr peut être créé par bump allocation en incrémentant un tail pointer
- La libération peut se faire au niveau du pool
- Cela suppose qu’on ne libère pas chaque
Expr individuellement
- Dans beaucoup d’implémentations de langages, les AST ont tendance à être créés ensemble et à disparaître ensemble
- Un AST classique doit suivre les pointeurs pour libérer chaque nœud, alors qu’un AST aplati permet de libérer tout
ExprPool en une seule fois
- Dans les présentations de l’arena allocation, on insiste souvent sur la libération peu coûteuse comme motivation principale, mais dans le contexte des compilateurs, l’AST peut vivre jusqu’à la fin de la compilation, ce qui en fait potentiellement la motivation la moins importante
Avantages pour l’utilisabilité du code
- L’aplatissement simplifie la gestion des durées de vie
- Un AST de n nœuds peut être vu comme ayant une seule durée de vie d’AST au lieu de n durées de vie distinctes
- En Rust, cette simplification affecte directement l’expression des lifetimes dans le code
- Au lieu de gérer les lifetimes de
&Expr, on peut transmettre des ExprRef de type u32 et s’appuyer sur la durée de vie de ExprPool
- La même simplicité peut s’appliquer à des langages à gestion mémoire manuelle comme le C++
- Le tableau aplati facilite aussi la déduplication
- On peut éviter de créer des expressions identiques via du
hash consing ou des approches plus simples
- Par exemple, on peut réserver les 128 premières cases de
ExprPool aux expressions Literal fréquemment utilisées, de 0 à 127
- Lorsqu’on a besoin du littéral entier
42, on peut renvoyer ExprRef(42) au lieu de créer un nouvel Expr
- Des traitements analogues sont possibles avec une représentation à base de pointeurs, mais ils exigent probablement des structures auxiliaires supplémentaires
Résultats du microbenchmark
- Le benchmark génère un programme aléatoire comportant environ 100 millions de nœuds d’AST, puis l’envoie immédiatement à l’interpréteur
- Le parseur et le formateur d’affichage ne sont pas inclus
- Il s’agit de générer un programme puis de l’exécuter tout de suite : ce n’est donc pas un benchmark réaliste
- Les conditions expérimentales présentent plusieurs limites
- Suffisamment d’espace est préalloué dans
Vec<Expr> pour contenir l’ensemble du programme
- Dans un environnement réel, il faudrait davantage estimer la taille de l’arena
- Comme il y a très peu de travail en dehors de la génération et de l’exécution, l’avantage d’une allocation/libération bon marché peut être surestimé
- Le programme est si volumineux qu’une faible proportion tient dans le cache CPU, ce qui peut conduire à sous-estimer l’effet de la localité
- La comparaison a été faite avec Hyperfine, sur un laptop, en moyenne sur 10 exécutions
- L’environnement est un M1 Max 10 cœurs à 3,2 GHz, 32 Go de mémoire, macOS 13.3.1 et Rust 1.69.0
- L’implémentation classique prend 3,1 s, et l’implémentation aplatie 1,3 s, soit un gain de vitesse de 2,4×
- Pour observer séparément le coût de la libération, des versions ignorant la désallocation ont été créées pour les deux implémentations
- Dans l’implémentation aplatie, le temps du mode no-free est presque identique à celui de la version standard, ce qui montre que le temps de libération était faible
- Dans l’implémentation classique, le temps passe de 3,1 s à 1,9 s, ce qui signifie qu’environ 38 % du temps était consacré à la libération mémoire
- Même en comparant les versions no-free, l’implémentation aplatie reste 1,5× plus rapide que l’implémentation classique
Interpréteur exploitant directement la représentation aplatie
- Au départ, l’aplatissement servait surtout de changement d’implémentation interne remplaçant l’allocation classique et les pointeurs, mais il est aussi possible d’exploiter directement les propriétés de la représentation en tableau
- Si
Expr est immuable, les nœuds enfants doivent être créés avant le nœud parent
- Lorsqu’on construit
a * b, a et b apparaissent dans ExprPool avant le * qui les référence
- Dans le tableau, les flèches de référence pointent donc toujours de l’arrière vers l’avant, tandis que le flux de données progresse vers l’avant
- En exploitant cet invariant, on peut construire un interpréteur qui parcourt
ExprPool du début à la fin au lieu de descendre récursivement depuis la racine
- Le parcours visite toujours les enfants avant les parents
- Le résultat de chaque expression est stocké dans un vecteur
state
- Une expression binaire récupère les valeurs de ses enfants en consultant
state via les indices ExprRef
- À la fin, on renvoie le résultat correspondant à la
root demandée
- Cet interpréteur « extra-flat » n’a pas de gestion de pile liée aux appels récursifs et peut parcourir
ExprPool linéairement
- En contrepartie, il peut souffrir d’une moins bonne localité à cause des accès aléatoires à un grand vecteur
state
- Au final, l’interpréteur extra-flat met 1,2 s, contre 1,3 s pour l’interpréteur aplati récursif, soit une amélioration de 8,2 %
Lien avec les interpréteurs de bytecode
- Un commentaire Reddit de Bob Nystrom estime que cette approche revient en pratique à redécouvrir l’idée d’un interpréteur de bytecode
- La structure
Expr se comporte comme des instructions de bytecode, et les références de variables y sont encodées comme des références en u32
- Si l’on remplace la simple table
state par une structure de type pile, on obtient quelque chose de très proche d’un interpréteur de bytecode conçu dès le départ comme tel
- Le simple changement de structure de données de l’AST amène donc naturellement d’un mode de parcours d’arbre à une approche de type bytecode
Ressources associées
- Ressources sur l’aplatissement dans le contexte des compilateurs
- Des idées similaires apparaissent aussi dans d’autres domaines orientés performance, en dehors de l’implémentation de langages
- L’article d’Inanna Malick sur un mini-calculateur en Rust applique la même technique, tout en utilisant aussi les idées de recursion schemes issues du monde Haskell
1 commentaires
Commentaires Hacker News
Blender (logiciel de modélisation 3D) est un cas intéressant de cette approche. Pour rendre le chargement/l’enregistrement des fichiers rapides et sans perte, il utilise la même représentation sur disque et en mémoire
Autrement dit, tout est placé dans une arène, et l’enregistrement/chargement revient presque à faire un
memcpyde l’arène entière. Vu la complexité potentielle du projet Blender et les problèmes de sérialisation/désérialisation, cela semble être une excellente conceptionL’inconvénient, c’est qu’il faut aussi pouvoir ouvrir les fichiers des anciennes versions, donc la conception des structures de données a tendance à se figer
On n’exécute ces transformations qu’au chargement des données d’anciennes versions de l’application, puis on les réenregistre immédiatement sur disque dans la version mise à jour, ce qui évite de repayer ce coût ensuite
https://www.inkandswitch.com/cambria/ créé dans le contexte des CRDT est un exemple d’implémentation de ce concept et peut être une bonne source d’inspiration, même si ce n’est pas directement applicable
Que le framework d’E/S plaise ou non, il sérialisait les structures telles quelles, donc il fallait toujours concevoir les structures de données en pensant à l’avenir, en sachant qu’on resterait liés à cette forme pour toujours. Une époque sombre
C’est pour cela que l’écriture de convertisseurs est devenue un gros problème, et si je me souviens bien il fallait déchiffrer les structures de données internes non documentées de Word
Si quelqu’un pouvait modifier les fichiers sur le disque, cela signifiait qu’il avait déjà pris le contrôle de l’appareil. Résultat, le chargement était absurdement rapide : on lisait tout en mémoire puis on corrigeait simplement l’emplacement des pointeurs vers les adresses effectivement chargées
J’aime beaucoup les arbres de syntaxe abstraite aplatis. J’apprécie particulièrement l’approche utilisée pour le traitement du balisage inline dans pulldown-cmark. Une explication simple se trouve ici : https://fullyfaithful.eu/pulldown-cmark/
L’entrée brute est découpée en une séquence de nœuds, et quelque chose comme
*devient un nœudMaybeEmphasis, car cela peut correspondre à une emphase, ou rester du texte s’il n’y a pas de correspondanceÀ l’étape suivante, on parcourt les nœuds dans l’ordre à l’aide d’une pile pour trouver les correspondances possibles. Lorsqu’une paire est trouvée, le nœud
MaybeEmphasisest remplacé par le nœud d’emphase approprié, et toute la séquence de nœuds entre le nœud ouvrant et le nœud fermant est découpée pour devenir le sous-arbre du nouveau nœudCette transformation d’arbre est assez inhabituelle, si bien qu’une implémentation naïve peut facilement devenir O(n), mais avec une représentation d’AST aplatie, on peut la traiter en O(1), indépendamment du nombre de nœuds ou de la profondeur de la pile
Le détail de la représentation de l’arbre est ici : https://github.com/raphlinus/pulldown-cmark/blob/b7e709c0bd6... ; en gros, on stocke des indices
childetnextavec le contenu du nœud. Le code de chirurgie de l’arbre lors de l’appariement de l’emphase se trouve ici : https://github.com/raphlinus/pulldown-cmark/blob/b7e709c0bd6...Les performances sont excellentes. pulldown-cmark n’est peut-être pas le parseur CommonMark le plus rapide à lui seul, mais il reste très compétitif et, par exemple, bien plus rapide qu’une approche avec allocation pour chaque nœud
Cela rappelle une présentation de la GDC qui faisait l’éloge de Rust. L’idée était que Rust, à cause du borrow checker, soit vous rend fou, soit vous pousse à structurer votre code avec un entity component system
Ce qui est amusant, c’est que dans des cas réels avec des problèmes de durée de vie complexes, la valeur du borrow checker semble finalement résider dans le fait qu’il vous pousse à mettre tout dans des tableaux et à référencer par indices afin de l’éviter autant que possible
Comme les objets ne se référencent plus directement entre eux, l’utilité du système de types diminue fortement, et il devient très difficile de raisonner sur le code. Je veux bien croire que cela devienne utile dans de très gros systèmes, mais dans le petit programme que j’écrivais, c’était presque un obstacle
Cela inclut les jeux, les bases de données, l’embarqué, les traitements batch de type calcul haute performance, et même les compilateurs
Bien sûr, les restrictions d’aliasing existent toujours, donc on n’aura pas de data races, mais des bugs essentiellement équivalents à ceux produits avec des pointeurs bruts peuvent toujours se produire
Dans ce schéma, l’emprunteur vit toujours moins longtemps que le propriétaire, et ne peut accéder qu’à la mémoire allouée tant qu’elle est possédée. Quand le propriétaire meurt, la mémoire est libérée, et ensuite personne ne peut ni l’utiliser ni la libérer à nouveau, ce qui évite les use-after-free, les doubles libérations et les accès à de la mémoire non allouée
On voit même parfois l’étrange affirmation selon laquelle les arènes résoudraient les problèmes de sécurité mémoire en C, alors que selon le programme elles peuvent tout autant provoquer des pointeurs pendants, des use-after-free, etc.
Le même problème apparaît des deux côtés, en C/C++ comme en Rust, simplement sous des formes un peu différentes
Mon commentaire sur cet article il y a deux mois : https://old.reddit.com/r/ProgrammingLanguages/comments/1350d...
En résumé, les avantages sont très concrets, mais il faut aussi parler des inconvénients. Les arènes mettent la sécurité mémoire de côté et peuvent compliquer la propriété, les modifications et l’ajout à des listes/vecteurs, tandis que la représentation par pointeurs est plus conviviale pour le débogueur
En bas de l’article, il y a aussi un lien vers ma page wiki, que j’ai trouvée utile parce qu’elle contient du vrai code et des mesures : https://github.com/oilshell/oil/wiki/Compact-AST-Representat...
Les termes Prolog sont eux aussi représentés de cette manière sur le tas dans la Warren Abstract Machine (WAM). Si l’on écrit le terme Prolog
+(*(a,b), c)en notation opératoire, comme dans l’exemple de l’article, cela donneexpr(E) :- E = a*b + c.La machine virtuelle construit alors une représentation aplatie sur sa pile globale. Dans Scryer Prolog, on peut inspecter les instructions WAM avec
?- wam_instructions(expr/1, Is), maplist(portray_clause, Is).Le résultat ressemble à
put_structure(*,2,x(3)).,set_constant(a).,set_constant(b).,put_structure(+,2,x(2)).,set_value(x(3)).,set_constant(c).,execute(=,2).Les deux termes composés sont tous deux linéarisés, de sorte que sur le tas les arguments suivent le foncteur, et chacun occupe exactement une cellule mémoire du WAM. Les arguments peuvent pointer vers d’autres cellules mémoire
Le tas est un tableau de telles cellules, et toutes les cellules ont le même type concret. Par exemple, Scryer Prolog utilise 8 octets par cellule, ce qui rend l’accès et la modification des cellules très efficaces sur les architectures 64 bits
« Au lieu d’allouer partout des objets
Exprsur le tas, on les met dans un seul tableau contigu, et les enfants les référencent par l’indice dans ce tableau plutôt que par des pointeurs » ressemble moins à un aplatissement qu’à une représentation alternative du tas. La forme même de l’AST n’a pas changéC’est quelque chose qui se fait déjà depuis longtemps dans divers langages, par exemple dans la famille Lisp en mettant les cons cells et d’autres objets dans des tableaux, avec allocation par bump et pointeurs par indice
Quand les objets sont dans un tableau, il est plus facile pour le ramasse-miettes de les parcourir pendant la phase de balayage après le marquage. La phase de marquage parcourt le graphe pour trouver les objets accessibles, et la phase de balayage parcourt le tableau aplati pour effacer les bits du GC et marquer les objets inaccessibles comme réutilisables
Il serait difficile de trouver une implémentation Lisp un tant soit peu sérieuse qui appelle
mallocséparément pour chaque cons cell. Si on faisait cela, il faudrait soit les mettre dans une liste chaînée globale pour la phase de balayage du GC, soit maintenir un tableau global ne contenant que des pointeursJ’ai vu au moins deux projets Lisp bricolés le temps d’un week-end où les cons cells étaient allouées avec
malloc, fuyaient, et où le GC restait un immense TODOMême si les cellules proviennent d’un tas en tableau compact, on peut quand même finir avec un tableau global. Par exemple, pour implémenter un ramasse-miettes générationnel avec un allocateur non copiant, on peut ajouter les jeunes objets à un tableau auxiliaire pour les balayer lors de cycles de GC rapides, et ce tableau représente alors la nursery
Bon article, mais il y a deux pièges
Les tagged unions sont aussi possibles, mais il faut gérer l’usage mémoire intelligemment
Les techniques de « mémoire virtuelle » permettent de raccorder des zones de mémoire physique fragmentées en une zone de mémoire virtuelle continue. Si l’espace d’adressage virtuel avait été segmenté, il n’aurait pas été fragmenté au départ, et n’importe quelle zone mémoire aurait toujours pu grandir sur place sans entrer en conflit avec les autres
Du coup, une implémentation de
realloc()aurait pu se passer du cheminmemcpy(), quel dommageL’intérieur ne serait pas sûr, mais une interface sûre en lecture seule semble possible. La libération serait en O(n), mais ce serait quand même bien plus rapide qu’un arbre
J’ai été surpris de voir qu’il restait deux pointeurs (« références ») dans les nœuds alors qu’on sait maintenant que le premier pointeur indique toujours exactement le nœud suivant
J’ai déjà vu https://github.com/rswier/c4 utiliser cette approche. Bien sûr, la lisibilité du code n’est pas idéale, mais c’est plus petit et plus rapide
Quand on parle d’arena en gestion mémoire, cela me fait plutôt penser à un allocateur d’arena qu’à une « mise à plat »
Quand on alloue beaucoup d’éléments ayant la même durée de vie, on peut les allouer plus efficacement dans un ou plusieurs gros blocs mémoire, puis libérer le gros bloc quand cette durée de vie commune se termine, au lieu de libérer chaque élément individuellement
Comme on se contente d’utiliser séquentiellement l’espace du bloc parent, il n’y a pas besoin de free list comme dans un allocateur de heap généraliste, donc l’allocation peut aussi être plus efficace
Dans ce contexte, la « mise à plat » qui consiste à utiliser des indices plutôt que des pointeurs peut aussi se voir comme l’usage de pointeurs relatifs au bloc parent, autrement dit des offsets
J’ai utilisé ce type d’AST compact en implémentant V7(https://github.com/cesanta/v7), un parseur et interpréteur JavaScript pour des environnements embarqués à la mémoire extrêmement limitée
Plus tard, je suis passé à une étape de compilation de l’AST vers du bytecode, mais pendant un temps je parcourais directement un AST implicite pendant l’interprétation
J’ai fait quelque chose de similaire dans mon compilateur Yaml to Sql sur https://yaml2sql.netlify.app
Le processus de mise à plat est un peu étrange mais amusant, et au final ça valait l’effort investi
Par exemple, la mise à plat des expressions booléennes est un bon exercice pour qui veut s’y essayer : https://github.com/revskill10/yaml2sql/blob/main/app/query.r...