- Stocker en masse un enum/tagged union avec des variants de tailles différentes réserve de l’espace selon le plus grand variant, ce qui augmente les coûts de padding et de fragmentation dans
VecetHashMap - Grâce à
comptimeet à la réflexion de type, Zig peut inspecter la taille des champs, leur alignement et leur discriminant, puis transformer génériquement un conteneur d’enum en fonction de sa disposition mémoire - Un simple
Vec<Enum>fait consommer à chaque élément l’espace du plus grand variant, et un SoA réduit le padding des tags mais laisse subsister la fragmentation des variants dans la zone des valeurs - Le dense AoVA, qui regroupe les variants de même taille, ramène dans l’exemple 15 vecteurs à 3 clusters de 2, 4 et 8 octets, mais il devient difficile d’itérer de façon type-safe si plusieurs variants cohabitent dans la même allocation
- Les proc macros de Rust accèdent difficilement aux informations de taille et d’alignement des types, et le calcul générique de longueur de tableau reste limité, ce qui met mieux en évidence avec Zig la composabilité de l’efficacité mémoire dans le code système
Pourquoi les tableaux d’enums Rust gaspillent de l’espace
- Un enum/tagged union dont les variants ont des tailles différentes doit réserver assez de mémoire pour contenir le plus grand variant
- L’enum d’exemple
Foopossède des variantsu8,u16,u32etu64, et à cause du tag et de l’alignement la taille du type atteint 16 octets - Quand on place beaucoup de ces enums dans un
Vecou unHashMap, chaque élément occupe l’espace du plus grand variant, ce qui accroît le padding et la fragmentation - Transformer la structure en struct of arrays (SoA), avec le tag dans une allocation séparée, réduit une partie du padding, mais n’élimine pas la fragmentation de la zone des valeurs causée par les écarts de taille entre variants
- En Rust, on peut toujours construire à la main une structure dédiée à un enum donné, mais créer une structure générique aussi économe que possible en mémoire pour un enum arbitraire est difficile, voire pratiquement impossible
- une proc macro s’applique mal à des types tiers ou à des alias de type via
#[derive], et compose mal - elle n’a pas de connaissance des types, et les contournements fondés sur
generic_const_exprpropagent des clauseswhereverbeuses dans le graphe d’appels et s’accordent mal avec les paramètres de type génériques
- une proc macro s’applique mal à des types tiers ou à des alias de type via
Pourquoi le problème se voit particulièrement dans les AST de compilateurs
- L’une des grandes motivations derrière des tableaux d’enums efficaces est l’empreinte mémoire des AST de compilateurs
- Les gros AST provoquent de la latence mémoire et des évictions de cache pendant la compilation, avec un coût important pour les performances du frontend
- Dans une vidéo sur le compilateur Carbon, Chandler Carruth explique qu’un AST clang parsé consomme fréquemment 50 fois plus de mémoire que le code source d’origine
- En Rust, un exemple de représentation des nœuds d’expression prend la forme d’un enum
ExprUnitNumberBinary(Operation, ExprId, ExprId)Ident(Symbol)Eval(ExprId, ExprSlice)BlockExpression(ExprId, StatementSlice)
- En OCaml, le runtime et le GC prennent en charge la gestion mémoire, ce qui permet d’exprimer des types récursifs sans indirection explicite
- En Rust,
Vec<Expr>fait consommer à chaque élémentsizeof(Enum), ce qui inclut la taille du plus grand variant, le tag et le padding
Réduire la fragmentation avec SoA et AoVA
- Quand un enum simple à 3 variants contient des membres de 8, 16 et 32 bits, un
Vecclassique réserve beaucoup d’espace à tous les éléments afin de satisfaire le variant 32 bits et les contraintes d’alignement - Une optimisation courante consiste à garder l’enum lui-même petit en utilisant par exemple un tagged index
- le crate
tagged_indexdu compilateur Rust - des cas de small-string optimization
- c’est une optimisation fréquente dans les runtimes de langage, les GC, les compilateurs, les moteurs de jeu ou les noyaux d’OS
- le crate
- On peut aussi changer le conteneur et stocker séparément le discriminant et les valeurs via un SoA
- c’est l’approche utilisée par le compilateur Zig auto-hébergé
- cela réduit le padding lié aux tags, mais la collection de valeurs du union conserve une fragmentation entre variants
- La compilation étagée de Zig permet de créer génériquement des conteneurs qui appliquent cette transformation SoA à des types arbitraires
- Rust doit s’appuyer sur des proc macros comme
soa_derive, avec la contrainte qu’on ne peut pas ajouter#[derive]à un type tiers sans modifier son code source
Tableaux par variant et regroupement par taille
- Pour réduire davantage la fragmentation de la zone des valeurs, on peut utiliser un vecteur par variant
- Lors de l’insertion, on renvoie un tagged index qui contient à la fois le tag de l’enum et l’index dans le tableau du variant correspondant
- Ce motif est appelé array of variant arrays (AoVA)
- AoVA peut être implémenté avec une proc macro en Rust, et avec
comptimeen Zig - Quand il y a beaucoup de variants et que plusieurs ont la même taille, avoir un vecteur par variant devient excessif
- l’enum d’exemple
Foocomporte 15 variants - l’approche un vecteur par variant ajoute 15 vecteurs
- cela peut augmenter le nombre de réallocations et d’appels système, et nécessiter plus de mémoire pour l’amortissement qu’un
Vecnaïf - les vecteurs peuvent se retrouver dispersés en mémoire, augmentant le risque de conflits de cache
- le conteneur AoVA lui-même peut consommer beaucoup de mémoire et gonfler la structure qui l’embarque
- l’enum d’exemple
- En regroupant par taille, l’enum d’exemple se divise en trois clusters de 2 octets, 4 octets et 8 octets
c_2: Vec<[u8; 2]>stockeAàDc_4: Vec<[u8; 4]>stockeEàIc_8: Vec<[u8; 8]>stockeJàO
- L’approche dense AoVA peut réduire de 80 % le nombre total de vecteurs
- Si des variants différents partagent la même allocation, il devient difficile d’itérer sur le vecteur de façon type-safe
- l’accès n’est alors possible qu’au moyen du tagged pointer produit à l’insertion
- pour une structure arborescente basée sur des index aplatis, qui n’a pas besoin d’itération aveugle, ce compromis peut être acceptable
- Si une itération type-safe est nécessaire, on peut réintroduire les tags en acceptant le coût du padding
- Si le padding reste trop élevé, on peut appliquer une transformation SoA à chaque tableau de variant, au prix d’un doublement du nombre de vecteurs
La composabilité de la disposition mémoire grâce à comptime en Zig
- Un prototype Zig est implémenté dans osmium
- Le point clé est la réflexion à la compilation via des built-ins du compilateur pour inspecter le type des champs, leur taille en octets, leur taille en bits et leur discriminant
- Le code d’exemple inspecte le type avec
@typeInfo(inner)et ne traite que les unions- il parcourt les champs du union
- il calcule l’espace requis avec
@max(field.alignment, @sizeOf(field.type)) - il stocke les informations de taille dans un vecteur alloué sur la pile
- il construit un mapping entre les champs du union et l’index de cluster
- s’il ne s’agit pas d’un union, il déclenche une erreur de compilation
- L’extrait exact se trouve dans ce code source
- Reproduire le même exemple avec une proc macro Rust est fondamentalement impossible
- une proc macro n’a pas accès aux informations de taille ou d’alignement d’un type
- elle peut générer un
const fnqui calcule des clusters pour un enum précis, mais ne peut pas l’utiliser pour fixer la longueur de tableaux d’un type générique
- En Rust, il est difficile de faire varier conditionnellement l’implémentation d’un conteneur générique selon que le type fourni est un enum ou une struct
- En Zig, on peut conceptuellement choisir entre
EfficientEnumArray<T>etEfficientStructArray<T>selon quelque chose commeT.isEnum() - L’implémentation AoVA peut elle aussi être sélectionnée en fonction des propriétés de l’enum
- par exemple en spécialisant seulement si le fait de regrouper plusieurs variants dans une même allocation réduit de plus de 90 % le nombre de vecteurs
- Si la capacité maximale est connue à la compilation, la fonction de création de type peut déterminer la largeur en bits nécessaire au tagged index
- Si ce tagged index est ensuite embarqué dans une autre structure de données, par exemple un autre enum, les bits restants peuvent servir au discriminant
- Zig permet de spécifier précisément le nombre de bits nécessaires, ce qui offre une efficacité mémoire composable que le reste du code peut exploiter naturellement
- Grâce à l’implicit widening integer coercion, l’ergonomie reste bonne même avec des API manipulant des largeurs de bits différentes
- Pour un langage de programmation système axé sur l’efficacité et les zero-cost abstractions, il vaut la peine de reconsidérer la programmation étagée, en particulier
comptimede Zig
1 commentaires
Commentaires Hacker News
Il existe une autre stratégie qui offre une bonne efficacité de stockage tout en conservant l’itération sur les éléments. Le premier vecteur contient la liste des tags, le deuxième les offsets en octets de chaque élément, et le troisième n’est pas vraiment un vecteur, mais les données compressées des variants pointées par le deuxième vecteur.
Ainsi, on a deux fois moins de vecteurs que dans la solution finale de l’auteur (6 contre 3), on ne gaspille pas d’octets de padding sauf si c’est nécessaire pour l’alignement, et les données sont disposées en mémoire dans l’ordre, indépendamment du type, ce qui permet une itération favorable au cache. L’accès à un élément par indice est également possible en O(1). Globalement, cela offre des caractéristiques de performance proches de
Vecpour des données hétérogènes.T. Par exemple avec unsize_t64 bits et unuint8_t T. En faisant simplement attention à la taille des offsets, l’approche semble raisonnable.Je me demande comment cette structure de données AoVA fonctionne réellement. Du point de vue d’un tableau, l’arithmétique des indices peut ne plus avoir de sens ; ne perd-on pas alors l’accès par indice ? L’itération ne semble pas non plus préserver l’ordre d’insertion.
Dans ce contexte, je pense qu’on utilise plus couramment le TLV (tag-length-value), qui a de meilleures propriétés de cache. La longueur peut être implicite dans le tag, et cela fournit au moins une itération vers l’avant qui a du sens. Voir
getdents,inotifyet la messagerie Netlink.Par rapport au layout SoA précédent, on obtient un ordre partiel plutôt qu’un ordre global. À l’insertion, la structure renvoie un indice tagué contenant à la fois le tag de l’enum et l’indice dans le tableau du variant correspondant. L’accès trié semble donc hors périmètre ici. Stocker un indice global dans chaque élément permettrait de retrouver une itération triée, mais cela n’aiderait toujours pas pour l’accès aléatoire trié et produirait probablement un code avec pas mal de branchements.
Le stockage d’objets par taille est aussi utilisé dans les garbage collectors et les allocateurs généralistes. On peut gagner en efficacité parce que toutes les tailles d’objets possibles sont connues, ou grâce à un mode de libération plus simple, comme dans une arena.
Dans ce cas, les tableaux peuvent être vus comme un composant d’une structure ressemblant au tas, autrement dit comme une arena. Le coût est que l’indice doit devenir bidimensionnel, du type
(tag_idx, va_for_tag_idx). Mais comme le nombre de tags est connu à la compilation, on peut optimiser le stockage en plaçanttag_idxdans les 4 ou 5 bits de poids fort et en laissantva_for_tag_idxutiliser le reste. Référence : https://www.cs.cornell.edu/~asampson/blog/flattening.htmlJe trouve un peu dommage que le pattern matching de Rust ne puisse pas être exprimé davantage comme une sorte de trait du système de types auquel des structures arbitraires pourraient se conformer, plutôt que comme un type d’objet explicite, de première classe et codé en dur, avec sa propre structure de stockage.
Récemment, comme dans cet article, j’ai implémenté à la fois un AST et un interpréteur d’opcodes/bytecode, et j’ai eu l’impression que les enums de Rust n’étaient totalement idéales pour aucun des deux. Pour l’AST, je voulais attacher des attributs de numéro de ligne/colonne à tous les nœuds de statement ; mettre ligne/colonne dans chaque cas de l’enum
Stmtrendait le boilerplate pénible, tandis qu’envelopper l’enum d’origine dans une nouvelle structureStmtcontenant à la fois l’enum et les attributs ligne/colonne impliquait beaucoup de refactoring et n’était pas élégant. Côté opcodes, il est difficile de dire qu’une enum Rust utilisée avec du pattern matching soit l’encodage idéal pour les performances d’un interpréteur de bytecode de VM, mais le langage pousse dans cette direction et les fonctionnalités de motifs de déstructuration sont très séduisantes. Il semble y avoir de la place pour améliorer le système de types afin de permettre d’utiliser l’implémentation bas niveau voulue tout en bénéficiant du pattern matching.https://en.wikipedia.org/wiki/Structural_type_system
computed gotopour cela, et en Rust il faudrait sans doute quelque chose qui force les pointeurs de fonction et l’optimisation des appels terminaux.Si l’on place ce saut indirect au début de chaque implémentation d’opcode, le prédicteur de sauts indirects que les CPU possèdent à cause de l’OOP peut maintenir des modèles distincts pour les fins de différents opcodes, ce qui peut améliorer le taux de prédiction. L’instruction suivante elle-même peut être difficile à prédire, mais par exemple il est beaucoup plus probable qu’un test soit suivi d’un branch. Cela dit, d’autres techniques, comme garder le sommet de pile dans un registre sur une machine à pile, sont probablement plus importantes, et je ne sais pas si cette technique a encore du sens aujourd’hui.
Le fait que « l’AST clang parsé consomme régulièrement 50 fois plus de mémoire que le code source d’origine » paraît assez énorme, mais le contexte manquant est jusqu’où on peut l’améliorer. Si l’on doit préserver la position source de chaque token et encoder assez d’informations pour pouvoir les reconstruire correctement depuis l’AST, je me demande si l’augmentation idéale par rapport à l’original est de 1,5× ou de 15×.
Il est difficile de dire quel est le taux idéal de gonflement source→AST pour un langage à la fois convivial pour les utilisateurs et pour les développeurs du compilateur, mais 50× fonctionne malgré tout. Le texte original utilise ce facteur 50× comme motivation pour automatiser une optimisation particulière. Il serait intéressant que les vecteurs d’enum de Rust puissent automatiquement décomposer les valeurs d’enum en tags et valeurs opaques, afin de les stocker sous forme de structure de tableaux comme le fait l’article en Zig. Il ne semble pas non plus y avoir beaucoup d’endroits où cacher l’usage de
unsafe.Pour des documents composés majoritairement de caractères
[]ou de caractères0,, le surcoût maximal semble être d’environ 8×.Octets source : 139 KiB, tokens : 24 646 (120 KiB), nœuds AST : 10 998 (140 KiB). Chaque token est très minimal, à 5 octets (tag d’1 octet + offset de fichier de 4 octets), et les nœuds AST sont également encodés de manière dense et non uniforme, autour de 13 octets par nœud dans ce cas. Même avec cet encodage minimal, le parse tree atteint presque 2 fois la taille du fichier source. Cela dit, 2× reste bien meilleur que 50×. Source :
zig ast-check -t lib/std/zig/Parse.zig | head -n7Cet espace de problèmes ressemble à une variante des problèmes de packing.
Ce serait bien de pouvoir partir de la structure finale, facile à manipuler pour un humain, puis générer des recommandations de structures de données qui réduisent le gaspillage mémoire, respectent les règles d’alignement et améliorent la localité spatiale. https://en.wikipedia.org/wiki/Packing_problems
J’aimerais que les proc macros évoluent pour pouvoir interroger le compilateur. Cela nécessiterait une conception prudente autour de l’ajout d’étapes de compilation, mais des choses comme « cette structure implémente-t-elle ce trait ? » ou « donne-moi la liste de tous les traits concrètement implémentés » sont souvent très utiles dans une proc macro.
Je n’ai compris qu’une partie de l’article, mais pour quelqu’un qui veut écrire un moteur de tableur en Rust, cela semble très pertinent. Les valeurs des cellules ont besoin d’une forme comme celle-ci :
pub enum Expr { Number(i32), String(String), Reference(Position), Binary(Box, char, Box), Function(String, Vec), }Je compte continuer à lire et à étudier le sujet, et toute ressource de référence est bienvenue.
Une technique courante venue du jeu vidéo consiste à découper un tableau de structures (AoS) en structure de tableaux (SoA). Par exemple, avec
struct Humans { healths: Vec, ammo: Vec, … }, l’index i de chaque vecteur correspond au iᵉHumande la disposition AoS. Ces vecteurs parallèles ne sont qu’un exemple et ne sont pas optimalement efficaces, car la comptabilité de longueur et de capacité est dupliquée pour chaque champ, ce qui gaspille de l’espace. L’article cherche essentiellement à appliquer automatiquement une idée similaire aux enums, ce qui est difficile à faire tel quel en Rust. L’importance réelle du problème est peut-être un peu exagérée. Pour un tableur, il vaut mieux garder cela en tête comme une optimisation possible, puis décider d’abord si l’objectif est la vitesse ou la simplicité et la compréhensibilité.Si vous autorisez 1 million × 1 million de cellules et stockez
nulldans toutes les cellules non remplies, vous allez épuiser la mémoire. Il faut donc envisager un stockage clairsemé du contenu des cellules. Une solution consiste à utiliser une implémentation de table de hachage commehashbrown. Le propos de cet article porte sur des détails de bas niveau ; si vous commencez par une table de hachage pour éviter les premières contraintes mémoire, vous n’avez pas besoin d’y réfléchir trop profondément pour l’instant.Le problème isolé le plus difficile est la stratégie d’évaluation.
Et https://doc.rust-lang.org/reference/type-layout.html#the-alignment-modifiers ?
Il me semble qu’il y a un bug dans le code d’exemple.
field_map[idx] = svec.len - 1;Ce sera incorrect si
sveccontient déjàsizeailleurs que dans la dernière entrée.