2 points par GN⁺ 4 시간 전 | 2 commentaires | Partager sur WhatsApp
  • Pour corriger des défauts structurels de l’implémentation existante, l’équipe a réécrit 300 000 lignes de Rust en Zig, a atteint la parité fonctionnelle en 487 jours et vise une première version numérotée officielle, la 0.1.0, plus tard cette année
  • Le nouveau compilateur prend en charge le hot code loading et la cross-compilation reproductible, l’interpolation de chaînes dans le pattern matching, supprime les allocations sur le tas dans le routage HTTP, et réduit aussi la taille wasm de Rocci Bird à 31 KB, soit moins de la moitié
  • Les principales raisons du choix de Zig ont été le temps de build, le contrôle fin des allocateurs et de l’agencement des données, un écosystème adapté au développement de compilateurs, ainsi que la vérification du code non sûr en mémoire ; avec Zig 0.17.0, les builds incrémentaux reconstruisent environ 460 000 lignes en 35 ms
  • Dans la classification réelle des bugs, le compilateur Rust comptait 21 cas de corruption mémoire contre 10 pour le compilateur Zig, mais la plupart venaient d’une génération de code incorrecte ; les erreurs de sûreté mémoire propres au compilateur Zig se limitaient à 2 use-after-free qui corrompaient des noms de fichiers
  • Zig convenait bien aux structures de données sans pointeurs, à la désérialisation sans parsing et à la réutilisation du sérialiseur de bitcode LLVM, mais pour la libération automatique de mémoire dans les tests, le polymorphisme, les champs privés de struct, la détection de code mort et la compatibilité entre releases, l’expérience de développement était meilleure en Rust

Une réécriture arrivée à la parité fonctionnelle

  • L’équipe du compilateur Roc a réécrit environ 300 000 lignes de Rust en Zig sur près d’un an et demi, et a atteint la parité fonctionnelle avec l’ancien compilateur
  • La parité fonctionnelle est un jalon important, mais pas une release officielle ; la 0.1.0 du nouveau compilateur est visée pour plus tard cette année
  • Il est désormais possible de compiler avec le nouveau compilateur le jeu WASM-4 de 2024 Rocci Bird
    • Le jeu entier contient moins de 1 000 lignes de code Roc
    • Les sources mises à jour sont plus concises que les sources originales
    • Le binaire wasm généré par roc build --opt=size fait 31 KB, soit moins de la moitié du résultat produit par l’ancien compilateur
  • Sur le site officiel, il est possible d’écrire et d’exécuter dans le navigateur des programmes Roc de base avec un compilateur WebAssembly de 2,5 MB
  • La réécriture a pris 487 jours, soit 476 jours de plus que les 11 jours qu’a mis Bun pour déplacer environ 500 000 lignes de Zig vers Rust
    • Bun a fait un portage direct, alors que Roc s’est rapproché d’une réécriture avec de gros changements de structure du compilateur et de fonctionnalités
    • Il est donc difficile de comparer directement l’ampleur ou la durée des deux bases de code uniquement à travers Rust et Zig

L’expérience de développement offerte par le nouveau compilateur

  • Hot code loading et cross-compilation

    • Après avoir lancé un serveur avec roc server.roc, si l’on modifie le code, le nouveau code est automatiquement appliqué dès la requête suivante
    • C’est courant dans des langages interprétés comme Python, mais rare dans un langage compilé haute performance comme Roc
    • Le même fonctionnement s’applique non seulement aux serveurs web, mais aussi à de simples jeux 2D
    • Au moment du déploiement, roc build server.roc génère un binaire autonome avec les optimisations LLVM
    • Il est possible de cross-compiler un binaire statique pour Alpine Linux avec roc build --target=x64musl
    • À partir des mêmes octets source, le compilateur produit exactement les mêmes octets en sortie sur n’importe quel système de build, y compris sur Mac
  • Interpolation de chaînes dans le pattern matching

    • L’interpolation de chaînes comme "/users/${id}" est prise en charge directement dans le pattern matching
    • Au lieu de parser des chaînes de template à l’exécution comme dans un routage à la Express, c’est le compilateur qui s’en charge directement
    • Il est possible de faire correspondre à la fois la méthode HTTP et le chemin, et d’aiguiller aussi des chemins imbriqués ou des valeurs par défaut avec des patterns
    • Dans l’exemple, le code complet de traitement de la requête HTTP est vérifié pour la sûreté de type à la compilation, tout en n’effectuant aucune allocation sur le tas
    • Le routage des requêtes HTTP sans allocation est implémenté via l’exécution à la compilation de fonctions pures, et la syntaxe peut être testée dans le compilateur WebAssembly du site officiel

Pourquoi repartir de zéro

  • Contrairement à Rust, C et Zig, Roc n’est pas un langage système et utilise une gestion automatique de la mémoire par comptage de références
    • Cela évite les pauses d’un garbage collector traçant
    • Roc exploite l’optimisation Perceus et des mutations opportunistes à la manière de Koka
  • Au lieu d’allouer sur le tas pour chaque capture de closure comme beaucoup de langages non systèmes, Roc utilise la défonctionnalisation polymorphe par spécialisation d’ensembles de lambdas
  • La défonctionnalisation permet ensuite plusieurs optimisations, à l’image de l’inlining dans les langages fonctionnels, mais il s’est avéré très difficile d’obtenir une implémentation existante correcte
  • Le prototype OCaml d’Ayaz Hafiz a confirmé que le problème relevait de défauts structurels s’étendant sur plusieurs étapes du compilateur, et qu’il fallait réécrire l’essentiel du compilateur pour les corriger
  • D’autres contributeurs prévoyaient aussi de réécrire différentes parties pour diverses raisons, si bien qu’au lieu d’un remplacement progressif quasi intégral façon bateau de Thésée, l’équipe a envisagé une réécriture complète
  • Dans les projets de compilateurs qui réussissent, il est courant de repartir de zéro, notamment pour l’auto-hébergement ; il existe aussi le cas de la réécriture de TypeScript vers Go, qui n’est pas son propre langage
  • La politique de Roc reste de ne pas s’auto-héberger, mais cette fois l’équipe a jugé que les avantages de la réécriture dépassaient les coûts connus

Les critères qui ont conduit à choisir Zig plutôt que de revenir à Rust

  • L’équipe utilisait déjà Zig pour plusieurs fonctionnalités de base de la bibliothèque standard, et les seuls langages systèmes qu’elle maîtrisait suffisamment étaient Rust et Zig ; ce sont donc les deux seuls à avoir été sérieusement étudiés
  • Chaque projet appelle un langage différent ; continuer à utiliser Rust pour d’autres tâches n’est pas contradictoire avec le choix de Zig pour Roc
  • Temps de build

    • Les builds cargo de Rust prenaient longtemps, y compris en incrémental, et cela restait une gêne majeure à mesure que la base de code grossissait
    • L’équipe s’attendait à des builds nettement plus rapides en passant à Zig
  • Contrôle de la mémoire et de l’agencement des données

    • Chaque phase du compilateur utilise différents allocateurs, notamment des allocateurs d’arène, ainsi que des agencements en structure of arrays (SoA) de manière extensive
    • L’écosystème Rust part généralement du principe d’un allocateur global unique, alors que l’écosystème Zig considère comme normal le passage fin d’allocateurs et fournit aussi un support SoA en standard
  • Étendue de l’écosystème nécessaire

    • L’écosystème global de Rust est plus vaste que celui de Zig, mais il y avait peu de packages dans les deux cas pour les besoins très spécifiques de Roc
    • Pour des fonctions de niche nécessaires, comme générer rapidement du bitcode au lieu d’encapsuler les bibliothèques C++ de LLVM, Zig offrait davantage d’options
  • Prise en charge du code non sûr en mémoire

    • Le compilateur Rust existant, sur 300 000 lignes, contenait environ 1 200 usages de unsafe
    • À titre de comparaison, le compilateur Rust lui-même utilise environ 40 000 unsafe sur 3,5 millions de lignes
    • Dans un compilateur qui génère du code machine, les opérations non sûres en mémoire font partie importante du travail
    • Rust repose sur un modèle où les blocs unsafe, rares, sont isolés puis vérifiés avec Miri ou Valgrind, mais dans Roc, unsafe n’était pas rare
    • Zig offrait davantage d’outils pour faire fonctionner correctement le code non sûr en mémoire, et c’est précisément dans cette zone que l’équipe voulait le plus de soutien

Une sécurité mémoire obtenue sans borrow checker

  • Périmètre des vérifications de Rust et Zig

    • Selon des données de Microsoft publiées en 2019, environ 70 % des vulnérabilités traitées chaque année via des mises à jour de sécurité étaient liées à des problèmes de sécurité mémoire
    • Si l’on répartit la classification de 2018 de ces données du point de vue de Rust et de Zig, on obtient ceci
    • 83,6 % relevaient de lectures/écritures hors limites, de casts non sûrs, de lectures non initialisées, de débordements de pile ou de problèmes sans rapport avec la sécurité mémoire, des catégories qui ne dépendent pas du choix entre Rust et Zig
    • 16,4 % relevaient de use-after-free, une catégorie que le borrow checker de Rust, le mode ReleaseSafe de Zig et les vérifications de type Fil-C peuvent détecter
    • Le mode ReleaseSafe de Zig déclenche un panic à l’exécution si de la mémoire libérée est réutilisée
      • Les vérifications sont moins complètes que dans le sous-ensemble sûr de Rust
      • Il y a un coût à l’exécution et le programme peut paniquer
    • Le mode ReleaseFast désactive ces vérifications en production, mais les conserve dans les builds de debug et les tests
    • Si tous les chemins d’exécution réels étaient testés, on pourrait obtenir un niveau de sûreté comparable à ReleaseSafe, mais une telle couverture de test n’est généralement pas réaliste
  • Autres projets Zig et unsafe en Rust

    • TigerBeetle utilise ReleaseSafe, et deux bugs de sûreté ont été découverts via une validation Jepsen approfondie, mais aucun des deux n’était lié à la sécurité mémoire
    • Ghostty, qui utilise ReleaseFast, et le compilateur Zig n’ont pas non plus de CVE causées par une insécurité mémoire dans leur code Zig
    • Même dans les programmes Rust, des angles morts en matière de sécurité mémoire peuvent apparaître via du unsafe à l’intérieur des dépendances
    • Unsafe Rust comporte les mêmes risques que Zig en ReleaseFast, mais sans fonctionnalité équivalente aux vérifications à l’exécution de Zig pour détecter les problèmes pendant le développement
    • Miri et Valgrind peuvent aider, mais peu de projets Rust les utilisent
    • À la place, c’est une culture d’usage rare de unsafe et d’examen plus strict qui forge, dans la pratique, la solide réputation de Rust en matière de sécurité mémoire
    • Des cas de vulnérabilités liées à unsafe existent aussi dans des projets basés sur Rust
      • Deno a eu des CVE de lecture hors limites et de use-after-free
      • Rocket a eu une CVE de use-after-free
      • Actix a connu plusieurs CVE d’insécurité mémoire à une époque où son usage de unsafe était anormalement élevé
    • Roc effectue l’essentiel de ses allocations dans des arènes à durée de vie simple, donc le projet ne considérait pas le use-after-free comme un risque majeur, et a jugé que les vérifications supplémentaires de Zig étaient plus utiles pour du code intrinsèquement non sûr

Bugs de corruption mémoire constatés après la réécriture

  • La classification du tracker d’issues de Roc avec Claude Opus 4.8 a donné les résultats suivants
Compilateur Corruption mémoire Pas de corruption mémoire Total
Rust 21 2,575 2,596
Zig 10 421 431
  • Les 21 cas de corruption mémoire du compilateur Rust n’étaient pas des corruptions de la logique interne du compilateur
    • Le borrow checker remplissait bien son rôle
    • Il s’agissait de bugs de miscompilation où le code machine généré incorrectement corrompait la mémoire dans les programmes compilés
  • Parmi les 10 cas de corruption mémoire du compilateur Zig, 8 étaient eux aussi des miscompilations
  • Les 2 autres étaient des use-after-free dans le code de signalement d’erreurs
    • Dans les messages d’erreur de roc check et roc bundle, les noms de fichiers apparaissaient corrompus en caractères de remplacement U+FFFD
    • Le borrow checker de Rust aurait détecté ces deux bugs
  • Voici l’effet concret qu’aurait eu chaque choix d’outil pour les utilisateurs réels
Choix d’outil Impact réel sur la sécurité mémoire
Zig ReleaseFast 2 bugs où des noms de fichiers ne s’affichaient pas dans certains messages d’erreur
Zig ReleaseSafe 2 bugs où certaines erreurs paniquaient et n’affichaient pas les noms de fichiers
Borrow checker de Rust Prévention des deux bugs
  • Au vu de 18 mois, de plusieurs centaines de milliers de lignes et de centaines de rapports de bugs, l’écart entre les trois options n’a pas été significatif en pratique pour le projet
  • Bun manipule à la fois des valeurs JavaScript à garbage collection par traçage et des valeurs gérées manuellement, où use-after-free, double-free et oubli de libération ont représenté une part importante des problèmes
  • Le compilateur Roc n’interagit pas avec JavaScript ni avec d’autres garbage collectors par traçage, et n’est donc pas confronté aux mêmes problèmes de gestion de durée de vie
  • Roc a davantage besoin d’outils pour détecter les erreurs mémoire dans le code de sortie généré que les erreurs mémoire internes au compilateur, et ces dernières sont de toute façon hors du périmètre du borrow checker

Résultats concrets sur les temps de build

  • zig build --watch -fincremental reconstruit actuellement les modifications en environ 35 ms sur quelque 450 000 à 460 000 lignes de code Zig
  • La version stable Zig 0.16.0 contient un bug qui casse -fincremental dans la base de code de Roc
    • Le correctif a été intégré, mais pour en profiter il faudrait passer à une préversion de Zig 0.17.0 qui casse la compatibilité
    • Comme il faudrait aussi vendoriser les dépendances concernées et les mettre à niveau vers 0.17.0, le projet a décidé d’attendre la prochaine release stable
  • Les mesures réalisées sur un PC Intel de bureau et Ubuntu 26 donnent ceci
Compilateur Roc Taille du code Build à froid Build incrémental
Original basé sur Rust 1.85.0 354K 32.4 s 10.0 s
Original basé sur Rust 1.97.0 354K 25.4 s 3.4 s
Zig 0.16.0 au moment de l’équivalence fonctionnelle 320K 39.6 s 8.6 s
Réécriture actuelle en Zig 0.17.0 464K 32.1 s 0.035 s
  • Au moment de l’équivalence fonctionnelle, même les artefacts qui ne changeaient presque jamais étaient reconstruits à chaque fois, alors qu’aujourd’hui ils ne sont générés qu’en cas de besoin
    • Ce changement a augmenté le code d’environ 50 %, tout en accélérant le build à froid
  • En passant de Rust 1.85.0 à 1.97.0, le build incrémental est passé de 10 secondes à 3,4 secondes, soit une amélioration d’environ deux tiers en 18 mois
  • Les 35 ms de Zig représentent environ un centième des 3,4 secondes de Rust, et ce sur une base de code environ 50 % plus grande que celle mesurée côté Rust
  • À l’heure actuelle, -fincremental ne fonctionne que sur x86-64, et comme de nombreux contributeurs utilisent des Mac ARM, l’avantage sur les temps de build ne peut pas encore être pleinement exploité

Structures sans pointeurs et désérialisation sans parsing

  • Le nouveau cache disque utilise directement comme format sur disque une disposition mémoire efficace à l’exécution, comme dans le compilateur Zig et le développement de jeux
  • Toutes les structures de données du compilateur sont représentées par des tableaux d’index 32 bits au lieu de pointeurs, avec à plusieurs endroits un format en tableaux de structures
    • Cela réduit l’utilisation mémoire et améliore la vitesse d’exécution
    • Les structures de données peuvent être écrites directement sur disque sans conversion vers un format de sérialisation distinct
  • Lors de la désérialisation, les octets du disque ne sont pas parsés
    • Les octets sont lus en mémoire
    • Certains réagencements sont effectués pour que les structures de données existantes pointent vers les nouveaux tableaux
    • Elles peuvent ensuite être utilisées immédiatement
  • La vitesse est en pratique limitée par le débit d’E/S de lecture des octets du disque vers la mémoire
    • Si les données sont déjà dans le cache disque du système d’exploitation, les résultats de build précédents sont chargés à une vitesse proche de memcpy
  • roc check enregistre sur disque les résultats du parsing, du typage et d’autres étapes lors de la première exécution
    • Si le code source d’entrée n’a pas changé, la deuxième exécution transfère directement les structures de données du disque vers la mémoire
  • roc test met aussi en cache les résultats de tests de fonctions pures déterministes
  • Le cache fonctionne par fichier, donc si un fichier change, seuls ce fichier et ses dépendances sont retraités
  • Cette approche est possible parce que l’ensemble du compilateur utilise des index à la place de pointeurs ; avec des structures classiques centrées sur les pointeurs, la désérialisation sans parsing est impossible
  • Limites de sécurité des structures fondées sur des index

    • De même qu’un pointeur peut viser une adresse invalide, un index peut consulter le mauvais tableau et lire des octets arbitraires
    • Le borrow checker de Rust gère la durée de vie des pointeurs, mais ne vérifie pas à quel tableau appartient un index donné
    • Si le nombre de tableaux nécessaires est connu à l’avance, le compact_arena de Rust peut générer via des macros des tags de type pour empêcher les accès au mauvais tableau
    • Quand, comme dans Roc, le nombre de tableaux varie selon le nombre de modules, cette technique ne s’applique pas, et compact_arena marque donc aussi SmallArena::new comme unsafe
    • Créer une arène vide n’introduit pas en soi de risque, mais le vrai danger se situe dans l’indexation de tableaux, exécutée très fréquemment
    • Safe Rust est efficace en partant du principe que les parties unsafe sont petites et isolées, mais dans un cas comme Roc où unsafe est répandu, cette hypothèse ne tient plus

Un écosystème Zig adapté à Roc

  • Bun tirait parti de Drop de Rust, qui n’exécute le code de nettoyage qu’une seule fois, en raison de l’interopérabilité entre JavaScript et la mémoire gérée manuellement
  • Roc, à l’inverse, cherchait à utiliser des arènes séparées pour chaque module et chaque étape de compilation, ce qui rendait peu pratiques les paquets Rust fondés sur un allocateur global et un Drop implicite
  • Dans l’écosystème Zig, il est courant d’avoir des API qui reçoivent explicitement un allocateur, ce qui correspond bien à la stratégie de gestion mémoire de Roc
  • L’écosystème Rust convenait davantage à la structure voulue par Bun, tandis que l’écosystème Zig convenait davantage à celle voulue par Roc
  • Réutilisation du sérialiseur LLVM bitcode

    • LLVM est une dépendance clé de l’optimiseur de Roc, et applique des optimisations supplémentaires après les optimisations propres à Roc
    • LLVM cassait souvent la compatibilité de ses principales API, ce qui rendait les mises à niveau de version longues et coûteuses
    • Le format bitcode sérialisé de LLVM est stable et rétrocompatible, donc utiliser un sérialiseur maison permet d’échapper aux changements d’API C++
    • Cela nécessite un sérialiseur bitcode écrit à la main, séparé de la bibliothèque C++ de LLVM
    • L’implémentation connue existante se trouvait dans le compilateur Zig, et le nouveau compilateur de Roc réutilise ce code Zig
    • La principale source de dépendances que Roc tire de l’écosystème Zig n’est pas les paquets généraux, mais bien le compilateur Zig lui-même

Fonctionnalités de Rust qui manquent dans Zig

  • Dans l’implémentation du compilateur, un contrôle explicite des allocations était nécessaire, mais pour les tests, l’allocation/libération automatique de Rust était plus confortable
    • L’allocateur de test de Zig permet de détecter les fuites mémoire, y compris celles du code Roc compilé
    • En contrepartie, chaque test doit écrire correctement init et defer deinit, et la moindre erreur fait échouer le test pour fuite mémoire
  • Le comptime de Zig recoupe le polymorphisme paramétrique et ad hoc, mais ces deux formes de polymorphisme restent regrettées
    • Le trait Allocator de Rust peut recevoir self
    • Une implémentation comme ArenaAllocator dans Zig doit recevoir un pointeur anyopaque puis le caster vers son propre type
  • L’absence de champs privés dans les structures empêche de faire échouer la compilation lorsqu’on accède directement à des champs qui ne devraient pas l’être
    • Lors d’une revue de diff, on ne voit que l’accès au champ et non la documentation de la structure d’origine, ce qui oblige à vérifier séparément à chaque fois
  • La cohérence de Rust, où fonctions, variables et constantes utilisent toutes snake_case, manque parfois
  • unsafe et le borrow checker avaient aussi un coût, mais apportaient l’assurance de n’avoir à se préoccuper de certains problèmes qu’à l’intérieur de blocs unsafe
    • Cela ne signifie pas qu’il faudrait ajouter la même chose à Zig
  • Dans Zig, il arrivait plus souvent qu’en Rust de découvrir du code mort tardivement
    • Les outils intégrés de Zig et tidy.zig de TigerBeetle ne détectent pas non plus une partie de ce code mort
    • Le code mort n’est pas généré dans le binaire, donc n’affecte pas les utilisateurs, mais il complique la maintenance de la base de code
  • Dans Rust, les mises à niveau mineures et les changements d’édition se passaient généralement sans difficulté
  • Aujourd’hui, Zig ne vise pas la rétrocompatibilité ; c’était attendu donc pas un gros problème, mais l’expérience de mise à niveau simple de Rust était meilleure

Ce qui a plu dans Zig

  • Zig a le charme d’une conception soustractive : comme en programmation fonctionnelle, on retire des outils familiers pour obtenir d’autres propriétés
  • Il n’y a pas de macros, et de nombreux problèmes, y compris le polymorphisme paramétrique, peuvent être résolus avec comptime ou de simples fonctions
  • Il permet un contrôle fin de la disposition des données
    • Des types entiers non puissances de deux comme u7 ou u5 peuvent être utilisés sans manipulation de bits supplémentaire
    • Les packed structs sont prises en charge nativement
    • Une fonction peut être inlinée au point d’appel plutôt qu’au point de déclaration
    • Des fonctionnalités qui exigeraient en Rust une crate fondée sur des macros sont disponibles sans dépendance supplémentaire
  • La toolchain de build Zig s’est bien adaptée à la génération de binaires autonomes pour Alpine Linux et WebAssembly
    • Elle gère aussi des builds spéciaux qui compilent les builtins correspondant à la bibliothèque standard de Roc sous forme de blobs binaires opaques inclus dans l’exécutable final
    • Uber aussi utilise la toolchain Zig dans son infrastructure de build sans utiliser le langage Zig lui-même
  • Dans le traitement des erreurs de Zig, même un échec d’allocation sur le tas est traité comme une erreur normale de l’espace utilisateur
  • Roc utilise une stratégie proche, où les erreurs s’accumulent naturellement grâce à des types sommes anonymes et des payloads
  • Cette approche Zig et Roc est préférée à la gestion fondée sur Result par défaut de Rust, anyhow et thiserror
  • Pour la propagation des erreurs, la syntaxe postfixée ? de Rust est préférée au try de Zig ; Roc a donc lui aussi adopté un opérateur postfixé ?
  • Entre son API d’allocateurs détaillée et du code réutilisable pour des compilateurs haute performance, le choix de Zig est jugé très satisfaisant à l’échelle de l’ensemble du projet

Prochaines étapes pour Roc

  • La sortie de la version 0.1.0 du nouveau compilateur est prévue pour la seconde moitié de cette année ; ce sera la première release numérotée de Roc
  • Il est possible d’essayer les builds Nightly avant la sortie, mais il reste actuellement de nombreux bugs, des fonctionnalités inachevées et une documentation incomplète
  • La Roc Programming Language Foundation est une organisation à but non lucratif américaine de type 501(c)(3), et les dons servent principalement à rémunérer les contributeurs
  • Les avancées du développement futur et les questions peuvent être suivies sur le Zulip de Roc

2 commentaires

 
GN⁺ 3 시간 전
Avis sur Lobste.rs
  • Sur le papier, les 35 ms de Zig sont près de 100 fois plus rapides que les 3,4 secondes de Rust, avec environ 50 % de code en plus, mais dans le développement réel, cet écart semble exagéré
    Si l’on ne reconstruit pas le compilateur toutes les minutes mais toutes les 10 minutes, le temps économisé est d’environ 3 secondes, et 3,4 secondes restent déjà très rapides
    Rust peut ralentir à mesure que la base de code grossit, mais le temps de build à froid s’est au contraire dégradé ; avec un remplacement d’ordinateur portable tous les deux ans et les améliorations du compilateur lui-même, il est difficile d’affirmer que le temps de build de Rust continuera d’augmenter à long terme

    • Voir apparaître des erreurs de compilation et des résultats de tests invalidés dès l’enregistrement, avant même d’appuyer sur la touche suivante ou de déplacer le point d’édition, est un avantage énorme
      Le mode check de Rust et le LSP sont plus rapides qu’une recompilation complète, mais ce n’est pas au même niveau
    • Supposer qu’on ne recompile qu’une fois toutes les 10 minutes conduit à sous-estimer la différence
      Avec la combinaison --watch -fincremental, on peut recompiler à chaque sauvegarde et obtenir un feedback très rapide et fréquent
      J’ai migré un projet personnel de Rust vers Zig et j’ai aussi utilisé Rust pendant des années au travail : la vitesse de compilation de Zig a vraiment été un changement qui fait respirer
  • 35 ms, c’est étonnamment rapide
    Rien que le ré-édition de liens devrait prendre plus longtemps, me semble-t-il ; je me demande ce que fait réellement le compilateur

    • Zig dispose de son propre linker
    • Le point clé est l’édition de liens incrémentale et la modification sur place du binaire existant ; si je me souviens bien, dans le compilateur Roc, cela s’appelait le linking chirurgical (surgical linking)
      Si l’on modifie l’implémentation d’une fonction, il suffit d’insérer le nouvel assembleur dans le binaire existant, mais l’inlining, les changements de signature de fonction, les dépendances ou les relocalisations en cas de manque d’espace nécessitent une analyse supplémentaire
    • L’équipe Zig a beaucoup travaillé sur la compilation incrémentale, et le nouveau linker ELF n’est pas encore terminé, mais il a été conçu avec pour objectif l’édition de liens incrémentale au niveau des fonctions individuelles et des variables globales
      Lorsqu’on modifie ou ajoute une fonction, il cherche une zone vide suffisamment grande dans la section .text de l’exécutable de sortie et y écrit le nouveau code machine
      S’il n’y a pas assez d’espace, il peut devoir agrandir .text et déplacer d’autres données, mais il amortit le coût en agrandissant les sections de manière exponentielle
      Si nécessaire, il ajoute aussi des tables de symboles et des entrées de relocalisation ; de l’espace libre est également réservé à l’avance pour celles-ci, donc une fois l’opération terminée, il suffit de fermer le fichier
      D’après les résultats Tracy que j’ai vus récemment, j’estimerais que, sur les 35 ms au total, le linker n’a utilisé qu’environ 1 ms
  • Le chiffre d’environ 1 200 utilisations de unsafe dans 300 000 lignes de Rust est plus élevé que ce à quoi je m’attendais
    Par exemple, Inko ne compte que 162 expressions unsafe { ... } et 87 fonctions unsafe exposées au code généré via l’ABI C, pour environ 88 000 lignes de Rust au total
    Cela dit, en raison du fonctionnement d’Inkwell, l’ensemble du backend LLVM est en pratique une immense zone unsafe, donc il est difficile de tirer une conclusion d’une simple comparaison
    De mon côté, je ne marque comme unsafe que les fonctions qui compromettent réellement la sécurité mémoire, par exemple lorsqu’elles manipulent directement des pointeurs, mais d’autres développeurs l’utilisent aussi comme indication qu’il faut effectuer certaines opérations au préalable pour éviter un panic

  • Le chiffre selon lequel unsafe apparaît 40 000 fois dans la bibliothèque standard et le compilateur Rust est inexact
    Il résulte d’un comptage incluant aussi les occurrences dans les tests et les commentaires ; la plupart se trouvent dans la bibliothèque standard, et dans le compilateur lui-même, même en incluant commentaires et tests, on est à moins de 2 000 occurrences
    Pendant que je contribuais à rustc, moins de 1 % de mes PR contenaient du unsafe, tant le code non sûr est rare à l’intérieur du compilateur
    Il est naturel que la bibliothèque standard, qui implémente le runtime de base dont dépend tout Rust, contienne beaucoup de unsafe ; dans n’importe quel langage, ce type de code est non sûr, que ce soit explicite ou implicite
    Ce compilateur est environ 10 fois plus petit que rustc, mais son usage de unsafe est comparable : on ne peut donc pas forcément dire qu’il est omniprésent, mais on le rencontre souvent, et je me demande pourquoi il en faut autant plus que dans rustc

    • Notre base de code mélange elle aussi compilateur et bibliothèque standard, et une partie de la bibliothèque standard est implémentée sous forme de fonctions intrinsèques du compilateur ; de ce point de vue, l’indicateur compare donc des éléments similaires
      Nous n’avons pas analysé séparément le unsafe lié à la bibliothèque standard dans les deux bases de code, mais si nous avions implémenté le nouveau compilateur en Rust, je pense qu’il aurait contenu plus de unsafe que rustc, à cause de notre architecture fondée sur le caching et sur l’utilisation d’indices plutôt que de pointeurs
      Notre intention n’était pas de dénigrer le travail de l’équipe Rust, mais d’expliquer nos choix et notre progression tout en respectant les réussites d’autres projets
  • C’est un article qui évalue de façon équilibrée les avantages et inconvénients respectifs de Rust et Zig sans les masquer, et sa conclusion est particulièrement honnête : même après avoir écrit des centaines de milliers de lignes en 18 mois et traité des centaines de bugs, choisir un autre langage parmi les options n’aurait probablement pas changé radicalement le résultat du projet
    En revanche, l’explication selon laquelle, dans un compilateur qui génère du code machine comme Roc ou rustc, les opérations non sûres en mémoire représenteraient une grande partie du travail est difficile à comprendre
    Il existe aussi beaucoup de compilateurs écrits en OCaml ou en Haskell, et la génération de code machine consiste en soi à assembler des octets dans un vecteur puis à les écrire dans un fichier, donc il n’y a pas de raison que ce soit non sûr
    Je comprendrais pour de l’interprétation ou de la compilation JIT, mais je me demande pourquoi ce serait nécessaire aussi pour une compilation classique

    • Dire que générer du code machine serait en soi non sûr, alors qu’il s’agit simplement d’écrire des octets, était imprécis
      Le danger apparaît lorsqu’on exécute le code machine généré, et comme les vrais compilateurs génèrent souvent du code machine tout en l’exécutant, j’ai parlé d’une grande partie du travail
      Outre l’interprétation et le JIT, cela concerne aussi l’évaluation de code utilisateur au moment de la compilation, comme les const fn de Rust ou les expressions que Roc peut remonter au niveau supérieur, ou encore l’exécution de tests puis l’inspection de leur sortie pour décider de ce qui sera affiché à l’utilisateur
    • Cela semble vouloir dire que, pour créer un compilateur incrémental très rapide comme le compilateur Zig, il faut un langage bas niveau et une quantité notable d’opérations non sûres
    • Dans l’article, les cas où une mauvaise compilation provoque des bugs de sécurité mémoire sont aussi comptés comme des bugs de sécurité mémoire du compilateur
      Que la mémoire corrompue se trouve dans le processus du compilateur ou dans le programme généré, le critère est que la cause du mauvais comportement du processeur est une instruction produite par le compilateur, et que l’endroit à corriger est le code du compilateur
    • Roc utilisait déjà Zig pour le runtime et la bibliothèque standard à l’époque où le compilateur était écrit en Rust
      Des composants comme le code de liaison externe et le ramasse-miettes nécessitent beaucoup d’unsafe même en Rust, ce qui compense en grande partie les avantages du Rust sûr
      On peut écrire uniquement le compilateur dans un langage sûr, mais les performances posent problème, et Zig comme le compilateur Roc utilisent largement une structure de tableaux (SoA) ainsi que des indices de tableau plutôt que des pointeurs
      En l’implémentant en Rust, on finit par contourner le vérificateur d’emprunts, ce qui fait perdre les bénéfices de sécurité associés
      Les relations de durée de vie dans un compilateur sont étonnamment simples : à une étape, on peut allouer, générer et modifier des données dans une arène, les transmettre en lecture seule à l’étape suivante, puis jeter l’arène entière
      Les parties plus complexes sont la compilation incrémentale et l’édition de liens, où l’on lit l’état depuis le disque et modifie les binaires sur place ; ce processus peut provoquer une corruption d’état, des bugs, de mauvaises compilations ou des problèmes mémoire, mais c’est distinct de la sécurité mémoire du processus du compilateur lui-même
      La sécurité et la correction sont des notions bien plus larges que la sécurité mémoire interne d’un programme, surtout lorsqu’il s’agit d’effectuer de manière sûre et correcte des tâches difficiles à exprimer en Rust
  • Je suis content d’être présenté comme l’auteur de compact_arena, mais la façon dont cette bibliothèque utilise unsafe est mal décrite
    L’objectif de compact_arena est de garantir qu’on ne puisse pas mal utiliser new en Rust sûr, afin de rendre l’indexation sûre
    La macro mk_arena peut être appelée depuis du code Rust sûr, même à l’intérieur d’une boucle où l’on ne connaît pas le nombre d’arènes nécessaires

    • Je n’ai peut-être pas compris l’explication selon laquelle new est utilisé de manière sûre, mais la section sur la sécurité de https://docs.rs/compact_arena/0.5.0/… indique que le tag passé au constructeur est la base de la technique d’indexation, et que si cette valeur est utilisée avec une autre arène, les indices des deux arènes peuvent se mélanger, entraînant un accès hors limites et un comportement indéfini
      Mon point n’est pas l’appel à new() lui-même, mais le fait que le risque apparaît lorsqu’on indexe une valeur créée avec new() ; selon moi, l’endroit à auditer et à marquer unsafe est donc l’appel d’indexation effectif
      Il est toutefois possible que j’aie mal compris la conception
  • Pour que Python prenne en compte du nouveau code, il faut redémarrer le programme, et à ma connaissance le hot reloading nécessite des extensions non standard et une écriture du code prudente
    Le hot reloading est plus courant dans les environnements de développement fondés sur une image, comme Lisp ou Smalltalk, ou encore avec Erlang

    • Avec importlib.reload(), le hot reloading est aussi possible en Python, mais je ne sais pas à quel point c’est répandu
  • Je me demande s’il est possible de réduire le temps de build si l’on n’utilise pas cargo build
    Il n’est pas certain que configurer Bazel représente plus ou moins de travail que de porter vers Zig, et bien sûr le temps de build n’était pas la seule raison du changement de langage

 
GN⁺ 4 시간 전
Commentaires sur Hacker News
  • L’ensemble de l’article est correct, mais j’ai du mal à accepter l’idée que, dans des compilateurs qui génèrent du code machine comme Roc ou rustc, les opérations non sûres pour la mémoire constituent une grande partie du travail
    Les patchs binaires à l’exécution ou le rechargement de code nécessitent peut-être du code non sûr, mais dans un processus classique de génération d’un exécutable, il n’y a pas de raison intrinsèque pour que la génération de code machine soit elle-même non sûre
    Il est plus probable de trouver du code non sûr dans le runtime du langage

    • D’accord. Générer du code machine consiste simplement à écrire des octets, et le danger potentiel n’apparaît qu’au moment d’exécuter ce code machine
      En pratique, cependant, beaucoup de compilateurs génèrent puis exécutent directement ce code machine, donc l’expression « une grande partie du travail » a été utilisée dans ce sens, mais un compilateur n’est pas obligé de faire les deux
      Je pensais aussi à l’évaluation de code utilisateur au moment de la compilation, comme avec const fn en Rust ou des expressions que Roc peut remonter au niveau supérieur, ainsi qu’à l’exécution de tests et à l’inspection de leur sortie pour décider quoi afficher
    • On détourne souvent le fait que même les langages à mémoire sûre ont des blocs de code non sûrs pour en conclure : « alors à quoi bon la sûreté mémoire ? »
      C’est un peu comme dire que, puisqu’on peut quand même se cogner la tête en portant une ceinture de sécurité, la ceinture est juste pénible et qu’il ne faut pas l’utiliser
    • Il n’est pas intrinsèquement nécessaire d’avoir des opérations non sûres pour générer du code machine, mais je pense qu’on voit ici des priorités différentes
      Dans du code poussé à l’extrême pour les performances, tout change, des structures de données et des algorithmes jusqu’aux stratégies d’allocation mémoire, et TigerBeetle est connu pour allouer toute la mémoire d’un coup au démarrage
      Le compilateur Roc cherche lui aussi un compromis proche de Zig, donc il est naturel d’y retrouver plusieurs motifs communs
    • Dire que la génération de code machine nécessite des opérations non sûres est clairement faux. Un compilateur peut travailler uniquement avec des structures de données totalement abstraites, et l’endroit où des opérations réellement non sûres pourraient devenir nécessaires se rapproche davantage du linker
    • J’ai du mal à comprendre dans quelle partie d’un compilateur des opérations mémoire non sûres seraient réellement nécessaires pour produire du code machine
  • Je ne vois aucun élément étayant l’affirmation selon laquelle ReleaseSafe détecte à l’exécution les use-after-free
    J’ai parcouru la documentation de Zig sur les vérifications de sûreté mémoire à l’exécution, mais je n’ai rien trouvé sur le sujet en cherchant use-after-free, UaF ou safety-checked, et même en utilisant DebugAllocator dans une build release, cela ne permet pas de les détecter de façon fiable
    J’ai résumé cela ici : https://landaire.net/memory-safety-by-default-is-non-negotia...

    • Ayant beaucoup écrit de code Zig, je peux dire que même si une telle fonctionnalité existait, elle n’a jamais vraiment bien fonctionné dans mon code
      C’est un jugement formé avant l’ère de l’IA, en écrivant moi-même le code ; avec une combinaison Zig + LLM, les choses pourraient peut-être être différentes, mais plus je lis l’article, plus j’y vois d’affirmations formulées de manière étrange, ce qui le rend difficile à croire
      Cela ressemble moins à une évaluation technique sincère qu’à un texte cherchant à justifier une polémique préexistante, mais j’aime les articles et les langages un peu atypiques, et je me méfie aussi de l’emballement excessif autour de l’IA, donc j’essaie de lui accorder le bénéfice du doute
    • De ce que je sais, ReleaseSafe ajoute surtout des vérifications de bornes et déclenche des paniques sur du code déclaré inatteignable
      Zig ne semble pas offrir de sûreté mémoire temporelle (temporal memory safety)
  • Il est intéressant qu’OCaml, pourtant mûr, flexible et suffisamment expressif pour valider des prototypes, n’ait pas été retenu comme langage d’implémentation final
    Je me demande aussi si les builds incrémentales de Zig sont réellement sensiblement plus rapides que celles de dune, et alors que la compilation croisée est présentée comme un avantage, cela n’a même pas été abordé dans « pourquoi Zig ? »
    Je me demande à quel point un contrôle fin de la mémoire est vraiment crucial pour un compilateur, et à partir de quel jalon des mainteneurs décident de basculer vers un autre langage, comme Rust et WASM qui avaient commencé en OCaml

    • Rust a quitté OCaml en décidant de se réécrire en Rust. L’article le suggère aussi comme un moment fréquent pour réécrire l’ensemble d’un projet, et je suis d’accord avec cette idée
    • L’un des objectifs centraux de Roc est la vitesse du compilateur. J’imagine qu’OCaml a été écarté parce que ce n’est pas un langage de programmation système
  • Les builds incrémentales de Zig sont clairement une fonctionnalité décisive, et je comprends le choix de changer de langage pour l’obtenir à court terme
    Mais à moyen terme, j’espère que Rust aura lui aussi quelque chose de comparable dans un avenir proche
    Je veux de la vitesse, mais pas avancer vite pour finalement me tirer une balle dans le pied, et je construis moi-même un langage combinant la sûreté de Rust, les fonctionnalités de Zig et un runtime de Go sans garbage collector

    • Des builds Rust rapides sont déjà en cours dans https://rust-lang.github.io/rust-project-goals/2026/roadmap-..., et la plupart des objectifs de cette page visent cette année
    • Il est fort possible que les temps de compilation de Rust s’améliorent bien avant que Zig ne devienne nettement plus sûr
    • Plutôt que d’attendre que le compilateur Rust devienne plus rapide, je me demande s’il ne vaudrait pas mieux ajouter à Zig une sorte de borrow checker
      Cela semble plus réaliste, et même faisable en espace utilisateur
    • Je me demande quel est l’avantage particulier du runtime de Go, en dehors du garbage collector
    • J’utilise parfois mes tokens Codex restants sur https://github.com/ityonemo/clr pour essayer de combiner la sûreté de Rust et les fonctionnalités de Zig
  • Le choix du langage d’implémentation du compilateur aurait été bien plus convaincant s’il avait reposé sur une véritable comparaison scientifique
    Cela semble partir de l’hypothèse non vérifiée selon laquelle un compilateur haute performance nécessite un langage système bas niveau (https://www.roc-lang.org/faq#self-hosted-compiler), pour conclure que la seule alternative à Rust serait Zig
    Les performances d’un compilateur sont dominées par les algorithmes, et même avec le même algorithme un langage managé rapide reste en général à moins d’un facteur deux sur le temps d’exécution, alors que l’écart de performance dû aux différences d’algorithmes n’a pas vraiment de limite
    Zig constitue lui-même un contre-exemple à la théorie selon laquelle écrire un compilateur dans un langage bas niveau le rendrait plus rapide, et les environ 15 000 lignes par seconde de Roc ne sont pas particulièrement rapides. On trouve des sources indiquant qu’en 1998 déjà, un compilateur ML traitait 3 000 lignes par seconde (https://flint.cs.yale.edu/cs421/case-for-ml.html)
    Il serait peut-être plus utile pour l’avenir d’arrêter le travail actuel sur le compilateur et de construire un compilateur auto-hébergé pour un sous-ensemble aussi petit que possible de Roc, faisable en moins de 10 000 lignes
    Cela permettrait de tester plusieurs implémentations à l’échelle de 10 000 lignes plutôt que sur une implémentation de 300 000 lignes, et de vérifier si un langage bas niveau est réellement nécessaire pour atteindre les objectifs de performance
    Le processus d’auto-hébergement ferait apparaître les fonctionnalités de Roc qui comptent réellement, conduirait aussi à écrire davantage de code Roc, et l’amélioration des fonctionnalités générales nécessaires au compilateur profiterait également aux applications en aval

    • Exiger de la science ici est lui aussi assez catégorique et ne s’appuie pas sur beaucoup de preuves empiriques
      Le simple fait que ML se compilait rapidement dans les années 1990 ne suffit pas à juger la vitesse de compilation de Roc aujourd’hui. La conception du langage impose de fortes contraintes sur les algorithmes nécessaires, et le matériel moderne est lui aussi bien plus complexe
      Roc semble avoir un certain degré de surcharge, ainsi que des algorithmes sophistiqués pour éviter d’allouer les closures sur le tas, et ce type d’exigences peut créer une complexité algorithmique incompressible
      Une fois la limite de l’optimisation algorithmique atteinte, il ne reste plus qu’à réduire les facteurs constants et les langages de plus haut niveau avec gestion mémoire imposent clairement une borne inférieure à la réduction possible
      J’ai déjà vu des cas où le contrôle direct de la disposition mémoire dans du vrai code apportait un gain de performance supérieur à 10x, et dans l’industrie du jeu ce genre de travail occupe parfois une grande partie d’une carrière. L’idée qu’un algorithme astucieux ferait disparaître tous les problèmes de performance est assez éloignée de la réalité
  • Les builds Rust sont l’une des grandes sources de gaspillage d’espace disque sur presque tous les ordinateurs, et quand on compile plusieurs bibliothèques on accumule vite des dizaines de Go
    Il est possible de configurer un dossier de build global pour réutiliser les dépendances entre projets, mais quelle que soit la solution, il serait préférable qu’elle fasse partie du comportement par défaut

    • C’est une architecture qui échange de l’espace disque contre de la vitesse et, dans certains cas, utiliser encore plus d’espace peut accélérer davantage
      Cela dit, il faut un garbage collection du cache, et un nouveau travail d’organisation des artefacts intermédiaires de build pour faciliter cela est en phase finale
    • C’est un contraste amusant quand on pense aux critiques permanentes sur la taille de node_modules dans l’écosystème JavaScript
      Même dans un projet Tauri où le code backend est bien plus petit que le frontend, les artefacts de build Rust font 9 Go, alors que node_modules ne pèse que 550 Mo
  • Je comprends Roc comme un langage de script embarqué dans l’ABI C, et je suis curieux de connaître ses cas d’usage réels
    Cherche-t-il à concurrencer WASM dans un environnement de plugins fournissant une grande plateforme Roc, et dans ce cas pourquoi les développeurs d’applications exposeraient-ils une couche Roc plutôt que WASM, qui permet aux auteurs de plugins d’utiliser le langage de leur choix ?
    Si c’est plutôt un langage au niveau applicatif reposant sur une petite plateforme Roc, je me demande aussi s’il vise à concurrencer Gleam pour le code HTTP côté serveur et Elm pour le code client

    • J’aime particulièrement comprendre à quels types d’applications servent les langages peu familiers comme les langages fonctionnels, mais même après avoir lu roc-lang.org et la FAQ, je n’ai pas réussi à cerner l’usage concret de Roc
  • Le temps de compilation est un facteur gravement sous-estimé. Devoir attendre 10 minutes pour un build C++ est ma plus grande frustration, et cela casse complètement l’état de flow en développement

    • J’utilise Rust-Analyzer dans VS Code pour l’analyse syntaxique et l’autocomplétion, mais il est beaucoup trop lent parce qu’il exécute le compilateur à chaque sauvegarde de fichier pour mettre les informations à jour
      Passer d’un fichier .rs à un fichier .ts donne presque l’impression d’avoir changé d’ordinateur
  • Je n’utilise pas Zig directement, mais certaines possibilités m’enthousiasment beaucoup
    Un nouveau jeu écrit en Zig, des logiciels distribués dont TigerBeetle a déjà montré le potentiel, et le domaine de la robotique qui m’intéresse personnellement, me paraissent particulièrement prometteurs