3 points par GN⁺ 2023-07-04 | 1 commentaires | Partager sur WhatsApp
  • 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

1 commentaires

 
GN⁺ 2023-07-04
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 memcpy de 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 conception
    L’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

    • Dans « il faut pouvoir ouvrir les fichiers des anciennes versions, donc la conception des structures de données se fige », l’élément manquant est une manière de faire évoluer les structures de données dans le temps, comme avec les migrations de base de données
      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
    • J’ai travaillé sur une grosse application commerciale basée sur un framework d’entrée/sortie maison fonctionnant selon un principe similaire, et c’était vraiment pénible
      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
    • Il me semble que Microsoft Word, à l’époque des anciens fichiers « doc », procédait aussi en vidant directement les structures sur le disque
      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
    • Un jeu PlayStation sur lequel j’ai travaillé fonctionnait exactement de cette manière, et comme tout était signé, on ne s’inquiétait absolument pas des attaquants
      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
    • Il me semble aussi que Microsoft Word utilisait cette approche au départ, et que cela est devenu très douloureux à mesure que le format de fichier évoluait
  • 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œud MaybeEmphasis, 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 MaybeEmphasis est 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œud
    Cette 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 child et next avec 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

    • D’après le peu d’expérience que j’ai avec le moteur de jeu Bevy en Rust, un entity component system donnait l’impression d’écrire du code spaghetti très sophistiqué
      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
    • Oui. Les durées de vie de Rust sont moins convaincantes dans certains domaines de programmation haute performance où l’on évite le tas, ou même où il n’y a pas de tas du tout
      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
    • Autrement dit, on peut voir cela comme le fait de forcer des entités à longue durée de vie à posséder la mémoire et à la prêter à des objets de courte durée de vie
      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
    • Oui. Je vois souvent des mentions d’arènes ou de structures d’arbres/graphes aplaties sans que l’on parle de sécurité mémoire
      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 pointeurs ne sont rien d’autre que des indices dans l’espace mémoire. Si Rust veut résoudre les problèmes de programmation généraux, on a l’impression que le borrow checker devrait être capable de traiter non seulement des indices spécialisés pour l’espace mémoire, mais tous les indices
  • 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 donne
    expr(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 Expr sur 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 malloc sé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 pointeurs
    J’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 TODO
    Mê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

    1. Si on stocke les nœuds dans un tableau redimensionnable, le compilateur a besoin d’un bloc de mémoire contigu de plus en plus grand à mesure que le programme d’entrée grossit. Ce bloc peut exister… ou non. On peut contourner le problème en allouant des blocs de la taille d’une page pour les utiliser comme pool
    2. Il faut faire attention à la manière dont le code représente les nœuds de l’AST. Par exemple, si on stocke les nœuds sous forme de type union, l’union prend la taille de son plus grand membre et, comme tous les nœuds de l’AST n’ont pas la même taille, les petits nœuds reçoivent inutilement du padding pour s’aligner sur la taille du plus grand
    • Le fait que les petits nœuds d’AST soient inutilement paddés à cause de la taille du plus grand nœud d’AST est un très bon point, et j’en ai aussi parlé dans mon billet de blog sur les entiers à largeur de bits personnalisée : https://alic.dev/blog/custom-bitwidth
      Les tagged unions sont aussi possibles, mais il faut gérer l’usage mémoire intelligemment
    • Parfois, je me dis que j’aimerais qu’on ne soit pas arrivés à un espace d’adressage plat
      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 chemin memcpy(), quel dommage
    • On peut atténuer ça avec une structure proche d’une rope qui découpe le vecteur en chunks de taille fixe et avec de vrais pointeurs. Plus besoin de réallocation
      L’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...