Chronique de la réécriture du compilateur Roc de Rust vers Zig
(rtfeldman.com)- 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=sizefait 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.rocgé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
- Après avoir lancé un serveur avec
-
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
- L’interpolation de chaînes comme
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
cargode 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
- Les builds
-
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
unsafesur 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,unsafen’é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
- Le compilateur Rust existant, sur 300 000 lignes, contenait environ 1 200 usages de
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
ReleaseSafede Zig et les vérifications de type Fil-C peuvent détecter - Le mode
ReleaseSafede 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
ReleaseFastdé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
unsafeen 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
unsafeet 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 à
unsafeexistent 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
- TigerBeetle utilise
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 checketroc 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
- Dans les messages d’erreur de
- 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 -fincrementalreconstruit 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
-fincrementaldans 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,
-fincrementalne 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
- 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
roc checkenregistre 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 testmet 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_arenade 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_arenamarque donc aussiSmallArena::newcommeunsafe - 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
unsafesont petites et isolées, mais dans un cas comme Roc oùunsafeest répandu, cette hypothèse ne tient plus
Un écosystème Zig adapté à Roc
- Bun tirait parti de
Dropde 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
Dropimplicite - 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
initetdefer deinit, et la moindre erreur fait échouer le test pour fuite mémoire
- Le
comptimede Zig recoupe le polymorphisme paramétrique et ad hoc, mais ces deux formes de polymorphisme restent regrettées- Le trait
Allocatorde Rust peut recevoirself - Une implémentation comme
ArenaAllocatordans Zig doit recevoir un pointeuranyopaquepuis le caster vers son propre type
- Le trait
- 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 unsafeet 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 blocsunsafe- 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.zigde 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
- Les outils intégrés de Zig et
- 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
comptimeou de simples fonctions - Il permet un contrôle fin de la disposition des données
- Des types entiers non puissances de deux comme
u7ouu5peuvent ê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
- Des types entiers non puissances de deux comme
- 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
Resultpar défaut de Rust,anyhowetthiserror - Pour la propagation des erreurs, la syntaxe postfixée
?de Rust est préférée autryde 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
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
Le mode
checkde Rust et le LSP sont plus rapides qu’une recompilation complète, mais ce n’est pas au même niveauAvec la combinaison
--watch -fincremental, on peut recompiler à chaque sauvegarde et obtenir un feedback très rapide et fréquentJ’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
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
Lorsqu’on modifie ou ajoute une fonction, il cherche une zone vide suffisamment grande dans la section
.textde l’exécutable de sortie et y écrit le nouveau code machineS’il n’y a pas assez d’espace, il peut devoir agrandir
.textet déplacer d’autres données, mais il amortit le coût en agrandissant les sections de manière exponentielleSi 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
unsafedans 300 000 lignes de Rust est plus élevé que ce à quoi je m’attendaisPar exemple, Inko ne compte que 162 expressions
unsafe { ... }et 87 fonctionsunsafeexposées au code généré via l’ABI C, pour environ 88 000 lignes de Rust au totalCela 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 comparaisonDe mon côté, je ne marque comme
unsafeque 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 panicLe chiffre selon lequel
unsafeapparaît 40 000 fois dans la bibliothèque standard et le compilateur Rust est inexactIl 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 compilateurIl 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 impliciteCe compilateur est environ 10 fois plus petit que rustc, mais son usage de
unsafeest 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 rustcNous n’avons pas analysé séparément le
unsafelié à 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 deunsafeque rustc, à cause de notre architecture fondée sur le caching et sur l’utilisation d’indices plutôt que de pointeursNotre 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
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 fnde 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’utilisateurQue 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
Des composants comme le code de liaison externe et le ramasse-miettes nécessitent beaucoup d’
unsafemême en Rust, ce qui compense en grande partie les avantages du Rust sûrOn 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 utiliseunsafeest mal décriteL’objectif de
compact_arenaest de garantir qu’on ne puisse pas mal utilisernewen Rust sûr, afin de rendre l’indexation sûreLa macro
mk_arenapeut ê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écessairesnewest 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éfiniMon 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 avecnew(); selon moi, l’endroit à auditer et à marquerunsafeest donc l’appel d’indexation effectifIl 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
importlib.reload(), le hot reloading est aussi possible en Python, mais je ne sais pas à quel point c’est répanduJe me demande s’il est possible de réduire le temps de build si l’on n’utilise pas
cargo buildIl 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
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 travailLes 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
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 fnen 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 afficherC’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
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
Je ne vois aucun élément étayant l’affirmation selon laquelle
ReleaseSafedétecte à l’exécution les use-after-freeJ’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,UaFousafety-checked, et même en utilisantDebugAllocatordans une build release, cela ne permet pas de les détecter de façon fiableJ’ai résumé cela ici : https://landaire.net/memory-safety-by-default-is-non-negotia...
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
ReleaseSafeajoute surtout des vérifications de bornes et déclenche des paniques sur du code déclaré inatteignableZig 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
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
Cela semble plus réaliste, et même faisable en espace utilisateur
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
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
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
node_modulesdans l’écosystème JavaScriptMê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_modulesne pèse que 550 MoJe 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
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
Passer d’un fichier
.rsà un fichier.tsdonne presque l’impression d’avoir changé d’ordinateurJe 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