Optimisation bas niveau et Zig
(alloc.dev)- L’optimisation bas niveau consiste à aider le compilateur à mieux comprendre les intentions et contraintes du code, et Zig s’y prête bien grâce à la facilité avec laquelle on peut expliciter les types, l’alignement, l’aliasing et les informations de compilation
- Même des compilateurs optimisants comme LLVM ne produisent pas toujours le meilleur code possible, donc dans les sections critiques il reste nécessaire d’inspecter le code généré et d’ajuster le code source
- Zig peut produire un code vectorisé plus compact que l’exemple JavaScript en indiquant
noalias,align, une taille de tableau fixe et le type des éléments au moment de la compilation comptimeexécute du code Zig ordinaire à la compilation, ce qui permet la métaprogrammation comme la génération de constantes, l’implémentation de génériques, la réflexion sur les types et l’optimisation des comparaisons de chaînes- La force de Zig réside moins dans des macros modifiant directement l’AST que dans l’exécution à la compilation intégrée au langage, et certaines valeurs d’exécution peuvent aussi être aiguillées vers des fonctions spécialisées à la compilation
Pourquoi il faut faire confiance au compilateur, mais vérifier
- L’optimisation ne sert pas seulement à rendre un programme plus rapide ; elle est aussi liée à la réduction des coûts, à une meilleure scalabilité et au maintien de la simplicité du système
- Les compilateurs modernes produisent des résultats impressionnants avec des backends comme LLVM, mais dans certaines situations ils génèrent encore un code sous-optimal
- Si les langages bas niveau sont rapides, ce n’est pas seulement parce qu’ils ont moins de garbage collection ou moins de surcharge d’interpréteur, mais aussi parce qu’ils peuvent exprimer davantage d’informations d’intention compréhensibles par le compilateur
- Le compilateur ne peut pas changer l’algorithme ni le paradigme de programmation eux-mêmes, et il optimise en général dans un cadre limité, souvent au niveau des boucles
Exemple JavaScript et Zig pour le maximum entre tableaux
- L’exemple JavaScript stocke dans
xle maximum élément par élément de deux tableaux sous la formex[i] = y[i] > x[i] ? y[i] : x[i] - Le code est clair pour un humain, mais le bytecode généré par V8 est volumineux
- L’exemple Zig précise plus concrètement, dans les paramètres de fonction, les informations nécessaires à l’optimisation
noalias x:xn’est aliasé avec aucun autre pointeur*align(64): alignement sur 64 octets[65536]f64: taille du tableau et type des élémentsconst: argument en lecture seule
- Grâce à ces informations, le compilateur peut produire un meilleur code, et dans l’exemple il génère un assembleur vectorisé
- Un code Rust équivalent génère lui aussi un assembleur presque identique
Les points forts de Zig pour l’optimisation, et ses limites
- Zig autorise des formulations verbeuses qui permettent de transmettre beaucoup d’informations de code à LLVM
- En matière d’optimisation, Zig fournit notamment les éléments suivants
- Le modèle mémoire de Rust permet au compilateur de supposer en permanence que les arguments de fonction ne créent pas d’alias, alors qu’en Zig il faut le spécifier explicitement
- Si le compilateur ne peut pas savoir qu’il n’y a pas d’alias dans les arguments d’une fonction Zig, une fonction Zig non annotée peut être plus lente qu’une fonction Rust
- Même si l’on se limite à de l’IR LLVM bien annoté, Zig obtient de bons résultats, mais son plus grand atout reste l’exécution à la compilation
Le rôle de comptime
- Le
comptimede Zig est une fonctionnalité de génération de code à la compilation - À la compilation, on peut notamment
- générer des constantes et les inclure dans le binaire
- éviter de réécrire la même structure de hashmap pour plusieurs types de données
- orienter l’optimisation pour supprimer du code inutile à partir de données connues à la compilation
- inspecter, réfléchir et générer des types pour implémenter des génériques
- Le code
comptimeest du code Zig ordinaire exécuté à la compilation, et il ne peut pas avoir d’effets de bord comme des E/S réseau - La machine d’émulation utilisée à la compilation correspond à la cible de compilation
- Presque tout le code Zig peut être exécuté à la compilation via
comptime, et tous les types peuvent y être inspectés, réfléchis et générés
En quoi cela diffère des macros
- L’objectif de
comptimeressemble à celui des macros, mais le fonctionnement est différent - Certaines macros modifient du texte brut, d’autres manipulent directement l’AST du programme
- Le
comptimede Zig ne modifie pas directement l’AST, et il n’a pas non plus l’équivalent de macros de collage de jetons - Zig vise un langage lisible ; il ne s’accorde donc pas avec un style de macros qui crée ou modifie des variables dans des portées non liées
- Voici ce que les macros peuvent faire, mais que
comptimene fait pas directement dans Zig- définir d’autres macros
- modifier l’AST
- implémenter directement des mini-langages ou des DSL
- Cela dit, on peut aussi créer des DSL en Zig, et la fonction
printde Zig analyse la chaîne de format aveccomptimeafin de construire le graphe de fonctions qui sérialise les données - On peut citer comme exemples le DSL de test de comptes de TigerBeetle, comath, zilliam
Optimisation des comparaisons de chaînes avec comptime
- Une comparaison de chaînes classique renvoie
falsesi les longueurs diffèrent, puis compare chaque octet dans l’ordre si elles sont égales - Cette méthode oblige à lire et comparer des octets dans chacune des deux chaînes
- Or l’une des chaînes est souvent déjà connue à la compilation ; en Zig, on peut donc exiger qu’un des arguments soit
comptimefn staticEql(comptime a: []const u8, b: []const u8) bool
- Lors d’une comparaison avec une chaîne statique comme
"Hello!\n", le compilateur génère du code composé d’une comparaison de longueur et de comparaisons constantes sur chaque octet - L’objectif de cette section n’est pas seulement de montrer une optimisation que le compilateur peut faire automatiquement, mais aussi qu’on peut forcer cette transformation avec
comptimeafin d’ouvrir des opportunités que le compilateur ne verrait pas seul
Comparaison par blocs plus larges et usage du SIMD
- Une simple comparaison de chaînes avec
comptimecompare encore les données octet par octet - Une version améliorée détermine la taille des blocs à comparer avec
std.simd.suggestVectorLength(u8)ou@sizeOf(usize) - Après avoir vérifié la longueur de la chaîne, elle calcule le nombre de grands blocs comparables et le nombre d’octets restants
- Chaque bloc est converti avec
@bitCasten un type entier créé parstd.meta.Int(.unsigned, block_len * 8)puis comparé - Les octets restants sont eux aussi comparés via un type entier distinct
- Pour l’exemple
"Hello, World!\n", l’assembleur généré utilise des registres plus larges et réduit le nombre de branchements conditionnels - Pour des comparaisons de chaînes plus longues, l’assembleur généré utilise de plus grands registres SIMD
Combiner valeurs d’exécution et spécialisations à la compilation
- Le
comptimede Zig ne se limite pas aux données connues à la compilation - Dans les cas simples, on peut générer plusieurs procédures à la compilation, puis effectuer un dispatch dynamique vers la bonne procédure selon une valeur d’exécution
- Dans l’exemple, le code envoie les valeurs
inline 0...100duswitch (runtime_val)versstaticFn(comptime_val), et traite le reste avecruntimeFn(runtime_val) - Si l’on ne veut pas augmenter la taille du binaire, on peut revenir à une implémentation entièrement à l’exécution
Conclusion
- Le
comptimede Zig remplit le rôle de remplacement pour les templates, les macros, les génériques et la génération manuelle de code - On peut faire des choses similaires dans d’autres langages, mais dans Zig,
comptimeest intégré de façon plus naturelle au langage - Zig facilite l’écriture d’un code performant dans des situations réellement utiles, à l’opposé d’un Turing tar-pit où tout est possible mais où les tâches intéressantes sont difficiles
- Sur les guerres de langages, il reste à la fois l’idée générale que la complétude de Turing suffit, et celle que les gens peuvent malgré tout préférer certains langages
- Des affirmations comme « C est plus rapide que Python » peuvent être trompeuses si l’on prend le langage lui-même comme objet du benchmark ; en pratique, ce qu’on mesure, ce n’est pas un langage mais un code et une implémentation donnés
1 commentaires
Avis sur Hacker News
Ce qui m’attire le plus dans Zig, c’est la simplicité du système de build, la compilation croisée et l’objectif d’itérations rapides.
Comme je suis développeur de jeux, j’ai des exigences de performance, mais j’estime que la plupart des langages offrent largement les performances nécessaires, donc ce n’est pas mon premier critère de choix.
On peut écrire du code puissant dans n’importe quel langage, mais l’essentiel est de choisir un framework très pérenne permettant de maintenir du code modulaire pendant des décennies.
C/C++ était la réponse par défaut parce qu’il est pris en charge partout, et j’ai l’impression que Zig pourrait atteindre ce niveau.
Zig est hostile à l’encapsulation, et il est impossible de rendre privés les membres d’une structure : https://github.com/ziglang/zig/issues/9909#issuecomment-9426...
La citation clé défend l’idée que « les champs privés et les getter/setter sont un antipattern popularisé par Java ; les champs sont des données qui existent, il faut donc les nommer avec soin et les documenter comme faisant partie de l’API publique ».
Si l’on ne peut pas masquer la représentation interne, il est difficile de définir correctement le contrat d’API qui constitue la base de la modularité logicielle, alors qu’il faut pouvoir changer cette représentation interne sans casser le code utilisateur.
La position de Zig semble être qu’il ne devrait pas y avoir de représentation interne distincte, et qu’il faut exposer, documenter et garantir la représentation elle-même ; j’espère qu’un jour cette décision sera revue et que les champs privés seront pris en charge.
Beaucoup de choses ont fonctionné directement, et j’ai même pu déboguer des bugs étranges avec un vieux GDB.
Zig m’a convaincu moi aussi, et j’ai écrit à ce sujet ici : https://news.ycombinator.com/item?id=44211041
Je ne comprends pas vraiment pourquoi il suscite autant de rejet.
Les génériques peu esthétiques existent aussi en C# ou en TypeScript, et le borrow checker est un concept compréhensible si l’on a déjà fait du bas niveau.
Parmi les outils construits avec Zig, celui qui m’impressionne vraiment est bun, et ma vie est devenue beaucoup plus simple depuis que je l’utilise.
On peut dire quelque chose de similaire de uv, qui est écrit en Rust.
En général, les consoles n’aiment pas ce qui n’est pas C/C++, mais comme Zig peut être converti en C, il ne sera peut-être pas totalement exclu.
À propos de l’affirmation selon laquelle « même les compilateurs modernes violent la spécification du langage (Clang suppose que toutes les boucles sans effets de bord se terminent) », je ne doute pas que les compilateurs enfreignent parfois les spécifications, mais dans ce cas Clang a raison, au moins depuis C11.
C11 indique qu’une boucle dont l’expression de contrôle n’est pas une expression constante et qui n’effectue ni entrée/sortie, ni accès volatile, ni synchronisation, ni opération atomique peut être supposée se terminer par l’implémentation.
Donc une simple boucle infinie en C comme
for (;;);doit réellement être compilée comme une boucle infinie, et la forme moins opaque de Rust,loop {}, devrait l’être aussi.Mais LLVM étant conçu par des gens qui oublient parfois qu’ils ne construisent pas toujours un compilateur C++, il y a eu un moment où, quand Rust demandait « donnez-moi une boucle infinie », LLVM répondait « selon C++, ça n’existe pas, donc je vais l’optimiser », ce qui était une application erronée à d’autres langages.
Il n’est pas indispensable d’avoir comptime pour inline et dérouler une comparaison de chaînes.
C peut aussi le faire : https://godbolt.org/z/6edWbqnfT
J’ai corrigé la faute de frappe.
Un meilleur exemple est https://github.com/RetroDev256/comptime_suffix_automaton.
Cela dit, le code Godbolt en lien montre en fait aussi l’un des deux mauvais exemples.
Je ne pense pas que comparer l’exemple JavaScript aux exemples Zig/Rust soit une bonne comparaison
On demande aux compilateurs Zig et Rust de cibler un CPU très moderne, alors que V8 ne semble pas être dans les mêmes conditions
Un JIT optimisant sait aussi vectoriser quand les conditions sont réunies
À titre de référence, la plupart des langages modernes appliquent la même optimisation aux chaînes, et voici un exemple en C++ : https://godbolt.org/z/TM5qdbTqh
L’exemple Zig utilise un tableau de taille fixe et de type connu, tandis que le code JS est « générique » à l’exécution :
xetypeuvent être n’importe quels objetsIl est normal de devoir payer ce coût en JS, mais ironiquement, dans cet exemple précis, on peut mieux transmettre les informations de type au JIT
Si on fait en sorte d’appeler cette fonction toujours avec des Float64Array de même taille, le JIT peut le savoir et produire une boucle plus rapide. Même sans vectorisation, ce sera bien mieux
Cela dit, les tableaux typés ont un coût d’initialisation élevé, donc en pratique on les utilise peu, sauf si l’on alloue une fois un grand tableau typé et qu’on le réutilise beaucoup
L’article disait aussi que le bytecode JS était assez gonflé ; c’est probablement en grande partie parce que le JIT ne peut pas garantir que
65536est égal à la longueur des deux tableaux, et doit donc insérer des gardesMais en pratique, personne n’écrirait une boucle
forde cette façon : on utiliseraiti < x.length, et dans ce cas le JIT éliminerait au moins une vérification de tableautargetpour un CPU plus ancienDésolé de ne pas avoir pensé aux limites de la cible JS
L’exemple C++ lié est un bon exemple de ce que Clang peut faire en C++, mais même en tenant compte du fait que Zig compile pour un CPU précis, l’assembleur généré paraît un peu décevant
Un port C++ de https://github.com/RetroDev256/comptime_suffix_automaton serait très intéressant à voir
C’est un usage de comptime qu’un compilateur C++ ne peut pas inférer proprement
Je ne suis pas sûr que la phrase « les langages de haut niveau manquent de l’intention riche des langages de bas niveau » soit vraiment juste
L’expression de l’intention ne me semble pas être un élément du spectre haut niveau/bas niveau ; au contraire, plus on dispose de moyens d’exprimer finement l’intention, plus on devrait se rapprocher du haut niveau
C’est une intention portant sur ce que l’on veut faire faire à la machine, plutôt que sur ce que l’on cherche à accomplir
Un code comme
purchase.calculate_tax().await.map_err(|e| TaxCalculationError { source: e })?;est plein d’intention, mais on ne peut pas savoir quel code machine en sortira au finalCette syntaxe de boucle
forest horribleIl y a deux listes côte à côte, et la position d’un élément dans une liste correspond à la position d’un élément dans l’autre ?
Rien qu’à la voir, ça me fait mal aux yeux
J’ai l’impression que les langages modernes ont pris une mauvaise direction en ajoutant toutes sortes de « magie » au parseur et en semant de petits symboles partout dans le code
Ce n’est pas une forme que j’aurais envie de regarder pendant des heures
Il est donc naturel que Zig fournisse une syntaxe qui facilite exactement cela tout en rendant clairement visible ce qui se passe
Personnellement, je trouve ça plutôt réussi, mais je me demande ce qui te fait mal aux yeux
J’aime vraiment beaucoup le modèle d’allocateurs de Zig
J’aimerais qu’en Go on puisse utiliser quelque chose comme un allocateur par requête au lieu du garbage collection
Comme le langage lui-même n’a aucun moyen d’exprimer et d’imposer des règles de propriété, on finit par écrire du C avec une syntaxe légèrement différente en espérant que tout se passe bien
Sans garbage collection, même C++ est bien plus sûr que Go
Le modèle mémoire de Rust permet au compilateur de toujours supposer que les arguments d’une fonction ne sont jamais des références aliasées, alors qu’en Zig il faut le préciser manuellement
J’évite ce genre d’annotation d’aliasing
Peu de gens les comprennent, et mal les utiliser peut introduire dans le code des bugs difficiles à comprendre
À propos de l’affirmation selon laquelle la flexibilité de comptime dans Zig aurait apporté de bonnes améliorations à d’autres langages, l’exécution de fonctions à la compilation et les fonctions acceptant des arguments constants ont été introduites par D en 2007, et ont poussé plusieurs langages à adopter des mécanismes similaires
https://dlang.org/spec/function.html#interpretation
J’aime Zig, mais dire « j’aime la verbosité de Zig » me paraît étrange
C est effectivement trop permissif à bien des endroits, mais Zig, selon les critères actuels, a parfois un peu trop basculé dans l’autre sens, au point de produire beaucoup trop de bruit de typage qui ressemble à des commentaires
C’est particulièrement vrai pour les conversions explicites d’entiers dans les expressions ; j’en ai écrit un peu ici : https://floooh.github.io/2024/08/24/zig-and-emulators.html
Si, en termes de performances, du code Zig est plus rapide qu’un code C similaire, c’est généralement dû aux réglages d’optimisation LLVM plus agressifs de Zig
Par exemple, Zig utilise par défaut
-march=nativeainsi que l’optimisation de tout le programme, et compile tout le code Zig d’un projet comme une seule unité de compilationPresque toutes les « astuces », comme utiliser
unreachablecomme indice d’optimisation, sont aussi possibles en C, même s’il faut parfois recourir à des extensions non standard du langageLes compilateurs C, en particulier Clang, font eux aussi du repliement de constantes de manière très agressive, et peuvent réduire de grandes zones de code calculables en constantes, même avec une pile d’appels profonde
Donc, si l’on ne regarde que la génération de code, il n’y a souvent pas grande différence avec le comptime de Zig
L’avantage de comptime est qu’il ne retombe pas silencieusement vers du code exécuté à l’exécution ; et le code non-comptime reste, comme en C, éligible aux mêmes optimisations de repliement de constantes
Par exemple, si une fonction « pure » non-comptime est appelée avec des arguments constants, le compilateur remplacera tout de même l’appel de fonction par son résultat
En résumé, si du code C est plus lent que du code Zig, il faut vérifier les réglages du compilateur C. Au final, tout le gros du travail d’optimisation se fait en dessous, dans LLVM
fn signExtendCast(comptime T: type, x: anytype) T { const ST = std.meta.Int(.signed, @bitSizeOf(T)); const SX = std.meta.Int(.signed, @bitSizeOf(@TypeOf(x))); return @bitCast(@as(ST, @as(SX, @bitCast(x)))); }export fn addi8(addr: u16, offset: u8) u16 { return addr +% signExtendCast(u16, offset); }Cela compile vers le même assembleur, c’est réutilisable et l’intention devient claire
Je suis d’accord avec cela
Virgil permettait d’utiliser tout le langage à la compilation dès 2006, et prenait aussi en charge la compilation de tout le programme
Mais comme Virgil ne cible pas LLVM, la comparaison de vitesse revient finalement à comparer deux backends de compilateur
Virgil s’appuie beaucoup sur l’analyse d’accessibilité et les optimisations de spécialisation que ce modèle de compilation rend possibles
Par exemple, il dévirtualise agressivement les appels de méthodes, élimine les champs et objets inaccessibles, promeut des constantes via les champs et les objets du tas, et monomorphise complètement le code polymorphe
Tout simplement parce que cela les rend plus faciles à manipuler par l’IA
La question de savoir si utiliser l’IA pour coder est une bonne idée, et jusqu’à quel point, est un autre sujet ; mais beaucoup de développeurs le croient, et les langages chercheront à les accueillir