1 points par GN⁺ 2025-06-08 | 1 commentaires | Partager sur WhatsApp
  • 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
  • comptime exé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 x le maximum élément par élément de deux tableaux sous la forme x[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 : x n’est aliasé avec aucun autre pointeur
    • *align(64) : alignement sur 64 octets
    • [65536]f64 : taille du tableau et type des éléments
    • const : 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 comptime de 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 comptime est 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 comptime ressemble à celui des macros, mais le fonctionnement est différent
  • Certaines macros modifient du texte brut, d’autres manipulent directement l’AST du programme
  • Le comptime de 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 comptime ne 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 print de Zig analyse la chaîne de format avec comptime afin 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 false si 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 comptime
    • fn 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 comptime afin 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 comptime compare 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 @bitCast en un type entier créé par std.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 comptime de 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...100 du switch (runtime_val) vers staticFn(comptime_val), et traite le reste avec runtimeFn(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 comptime de 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, comptime est 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

 
GN⁺ 2025-06-08
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.

    • J’aime Zig, mais je pense que la maintenabilité à long terme et la modularité font plutôt partie de ses plus grandes faiblesses.
      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.
    • Pour m’amuser, j’ai essayé de faire tourner Zig sur un vieux Kindle avec un Linux 4.1.15 amputé, et l’expérience a été assez intéressante ; j’ai été agréablement surpris par la maturité de Zig.
      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
    • J’ai un peu utilisé Rust et je l’ai apprécié, mais j’ai fait une pause après avoir entendu dire qu’il avait mauvaise réputation ; en m’y remettant, il me plaît toujours autant.
      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.
    • Zig ressemble à un Rust plus simple et à un meilleur Go.
      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.
    • Je me demande comment Zig fonctionnerait sur console.
      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.

    • Jusqu’à l’arrivée de C++26, C++ dit cela de toutes les boucles, mais comme indiqué, ce n’est pas le cas du C lui-même : cela ne concerne que les cas où « l’expression de contrôle n’est pas une expression constante ».
      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.

  • 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

    • Globalement, ça donne l’impression de comparer des pommes avec une salade de fruits, mais c’est pertinent pour montrer la différence d’usages entre JS et Zig
      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 : x et y peuvent être n’importe quels objets
      Il 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 65536 est égal à la longueur des deux tableaux, et doit donc insérer des gardes
      Mais en pratique, personne n’écrirait une boucle for de cette façon : on utiliserait i < x.length, et dans ce cas le JIT éliminerait au moins une vérification de tableau
    • Dans les exemples godbolt de Rust et Zig, on peut changer target pour un CPU plus ancien
      Dé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

    • Je suis d’accord, et j’irais même plus loin : la différence fondamentale entre les langages de haut niveau et de bas niveau tient au fait que, dans les langages de haut niveau, on exprime l’intention, tandis que dans les langages de bas niveau, on ne peut qu’exprimer le mécanisme sous-jacent
    • L’intention dont il est question ici semble plus proche de « décale cet octet de trois positions vers la gauche » que de « calcule le taux de taxe de cet achat »
      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 final
  • Cette syntaxe de boucle for est horrible
    Il 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

    • Ce genre de tableaux est un motif très courant dans le code bas niveau, quel que soit le langage, et les parcourir en parallèle l’est tout autant
      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

    • En Go aussi, les allocateurs personnalisés et les arènes sont possibles et existent réellement, mais leur ergonomie est très mauvaise et il est difficile de les utiliser correctement
      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=native ainsi que l’optimisation de tout le programme, et compile tout le code Zig d’un projet comme une seule unité de compilation
    Presque toutes les « astuces », comme utiliser unreachable comme indice d’optimisation, sont aussi possibles en C, même s’il faut parfois recourir à des extensions non standard du langage
    Les 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

    • À propos de l’exemple de conversion, on peut encapsuler le cast dans une fonction
      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
    • Zig contient des idées intéressantes, et je pensais que l’article se concentrerait davantage sur l’optimisation bas niveau, mais il disait finalement que « comptime et la compilation de tout le programme sont excellents »
      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
    • Quand le nouveau backend x86 arrivera, on pourra peut-être voir quelle part de l’écart de performances entre C et Zig peut être attribuée au projet Zig lui-même
    • Pour les conversions explicites d’entiers, il semble qu’un nettoyage soit bientôt prévu : https://ziggit.dev/t/short-math-notation-casting-clarity-of-...
    • Dans les prochaines années, la dynamique générale des langages va probablement pencher assez fortement vers des langages plus verbeux et explicites
      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