1 points par GN⁺ 3 시간 전 | 1 commentaires | Partager sur WhatsApp
  • Jam est un langage encore avant la v1.0 qui vise à combiner sûreté, faible courbe d’apprentissage et hautes performances, tout en conservant la prise en main immédiate des langages de la famille C et sans GC
  • Son cœur repose sur les mutable value semantics et un système de drop à la Rust, où le compilateur gère ownership, borrow et nettoyage automatique sans exposer de références ni de syntaxe de lifetime dans le code utilisateur
  • Son modèle d’initialisation évite à la fois undefined et l’initialisation implicite à zéro ; l’initialisation différée et les out-parameters sont gérés avec Maybe(T) et l’analyse de unsafeAssumeInit()
  • export expose les fonctions Jam via une ABI C et les struct Jam sont conçues avec un layout compatible C, afin de réduire le besoin de shim unsafe séparé ou d’annotations repr
  • Le compilateur est encore au stade bootstrap, implémenté en C++, non public, avec un plan d’open source après avoir construit 108 projets distincts en Jam

Positionnement du langage Jam

  • Jam est encore avant la v1.0 et, même si les mécanismes décrits fonctionnent dans le compilateur, certains détails peuvent encore changer avant stabilisation
  • L’objectif est de créer un langage sûr qui garde une sensation de langage de la famille C facile à comprendre immédiatement, comme Go, Zig ou le C moderne, tout en réduisant les classes de bugs du C
  • La conception s’articule autour de deux axes
    • les Mutable value semantics de Racordon, Abrahams et al. 2022
    • le drop system de Rust
  • Le point de départ est l’idée que, dans une équipe réelle aux niveaux d’expérience variés, les membres les moins expérimentés risquent davantage de faire des erreurs ; le langage doit donc bloquer plus de fautes avant même la revue de code

Différences avec Rust, Zig et C++

  • Rust a une philosophie de sûreté forte, mais l’écart entre « savoir un peu utiliser Rust » et « être productif avec Rust » peut être important, ce qui rend la courbe d’apprentissage lourde pour une équipe
  • Zig offre un petit langage très proche de C, avec une surface réduite et un modèle mental immédiat, mais ce n’est pas un langage sûr au niveau du langage lui-même
    • la lecture de mémoire non initialisée, le nettoyage manuel et la prévention des use-after-free ne sont pas imposés au niveau du langage
    • les gros projets de production en Zig ou en C++ dépendent fortement d’outils de vérification comme Valgrind, AddressSanitizer ou le fuzzing
  • À l’ère de l’IA, une grande partie du code de production est écrite ou ébauchée non par des humains mais par des outils, et le goulot d’étranglement se déplace de l’écriture du code vers la revue de code
    • le volume de code augmente tandis que la surface de revue reste plate, donc le compilateur doit attraper davantage de bugs

Système de drop automatique

  • Dans Jam, un binding possède sa valeur, et quand un binding d’un type ayant un drop sort de sa portée, le compilateur synthétise l’appel au drop
  • Dans l’exemple du type File, on déclare fn drop(self: mut File) et, dans useFile(), on écrit seulement const f: File = { fd: 7 };
    • pas de nettoyage explicite, pas de defer, pas de marqueur de fin de lifetime
    • dans l’IR LLVM, un call void @__drop_File(ptr %1) est généré juste avant ret
  • Le nom manglé __drop_File évite les collisions entre fonctions de drop de plusieurs types au niveau LLVM
  • self: mut File est abaissé en paramètre pointeur, et le site d’appel transmet directement l’adresse du binding
  • En Zig, il faut écrire explicitement defer f.deinit() pour le même nettoyage
    • si cette ligne est supprimée, l’appel deinit disparaît aussi de l’IR
    • une fuite de descripteur de fichier survient quand le programmeur oublie le nettoyage
  • Le RAII de C++ exécute aussi automatiquement le destructeur en sortie de portée, mais Jam adopte le modèle de drop plus simple de Rust
    • l’idée est d’éviter la complexité de C++ autour des règles of 0/3/5, des destructeurs virtuels, des exceptions dans les constructeurs, des exceptions dans les destructeurs, de std::exit, std::abort, longjmp ou des signaux
    • Jam a une seule fonction de drop par type, exécutée à chaque sortie de portée

Initialisation et Maybe(T)

  • Dans Jam, il n’existe pas de valeur undefined, et on ne peut pas déclarer un binding sans valeur
    • chaque var et chaque const doivent avoir un véritable initializer
    • pour une struct, on calcule d’abord les valeurs des champs, puis on construit le littéral de struct avant de l’affecter au binding
  • Zig autorise var f: File = undefined; return f.fd;, ce qui peut lire des déchets de pile à l’exécution
    • en mode Debug, un remplissage 0xaa est utilisé pour rendre les erreurs visibles
    • en mode Release, cela devient des octets arbitraires
  • Go initialise tous les var à zéro pour éviter ces lectures de déchets, mais cela a un coût même pour les champs aussitôt réécrits
  • Jam évite à la fois undefined et le zéro implicite
  • Pour l’initialisation différée et les out-parameters, Jam utilise Maybe(T)
    • empty() crée un slot dont le contenu n’a pas encore de sens
    • write() remplit ce slot
    • unsafeAssumeInit() extrait la valeur
  • Un passage de lint suit si le slot a été écrit, et si l’analyseur ne peut pas prouver l’initialisation, un appel à unsafeAssumeInit() est rejeté avec une erreur de compilation
    • le préfixe unsafe reste un point d’ancrage que les relecteurs humains et les outils IA peuvent retrouver via grep

Sortie de portée, return, break, continue

  • Le compilateur suit une pile de portées de drop et pousse une nouvelle portée à chaque frontière de bloc lexical
  • À la fin d’un bloc ou juste avant d’en sortir via une branche, il émet les drops des bindings de cette portée
    • les bindings dans if, else, les branches de match, le corps de while ou de for sont drop à la fin de leur bloc
    • un return dans un bloc imbriqué drop d’abord toutes les portées actives, de la plus interne à la plus externe, avant le ret effectif
    • break et continue drop les portées ouvertes dans le corps de boucle avant de sortir de la boucle ou de passer à l’itération suivante
  • Dans l’exemple de break imbriqué, outer est drop à la fin de l’itération 0 et, sur le chemin du break à l’itération 1, inner est drop puis outer

Modes de paramètres et suppression des références first-class

  • Lors d’un appel de fonction, c’est le mode de paramètre qui détermine si un binding est drop
  • Le mode par défaut est un borrow en lecture seule
    • le callee lit la valeur et le binding du caller reste initialisé
    • aucun drop n’a lieu au retour de l’appel
  • mut est un borrow exclusif en lecture-écriture
    • le binding du caller reste initialisé après l’appel
  • Seul move consomme la valeur
    • le callee reçoit la possession et le drop a lieu à la fin du callee
    • le binding du caller passe en état Uninit après l’appel, et toute lecture devient une erreur de compilation
  • Il n’y a pas de marqueur au site d’appel : f(x) a la même forme dans tous les modes
  • Jam n’a pas de type référence first-class
    • on ne peut pas stocker un borrow dans une variable, le retourner, ni le conserver dans un champ de struct
    • les borrows de paramètres n’existent que pendant la frame d’appel et expirent au retour
    • c’est pourquoi il n’y a pas besoin d’annotations de lifetime : il n’y a aucun lifetime à y attacher
  • L’API des collections reste elle aussi orientée valeur
    • v[i] = x est désucré en v.setAt(i, x)
    • let y = v[i] appelle le getter v.at(i) qui renvoie l’élément par valeur
  • La vérification d’exclusivité au site d’appel inspecte le recouvrement des chemins dans l’ensemble des borrows créés par les arguments
    • swap(p.x, p.y) est autorisé car ce sont deux sous-chemins disjoints
    • moveX(p, p.x) produit une erreur car p et p.x se recouvrent

ABI C et FFI

  • L’ABI native de Rust est instable, donc dès qu’on franchit une frontière de distribution, il faut réencoder sous forme C
    • le déréférencement de pointeur brut est unsafe
    • l’ownership est transféré manuellement avec Box::into_raw et Box::from_raw
    • pour passer une struct par valeur, il faut une annotation supplémentaire comme #[repr(C)]
    • des outils comme cbindgen et abi_stable existent pour réduire le travail manuel à cette frontière
  • Jam considère qu’en l’absence de références first-class, de lifetime et de layout niche-packed, une valeur Jam reste value-shaped all the way down
    • les struct Jam sont déjà conçues avec un layout compatible C
  • export expose une fonction Jam avec un nom simple non manglé selon la convention d’appel C
    • export fn counterAdd(c: mut Counter, n: i64) i64 peut être appelé depuis C comme int64_t counterAdd(Counter *c, int64_t n);
    • le paramètre mut Counter est abaissé en Counter * pointant vers un stockage possédé par le caller
  • Le corps de fonction côté Jam reste du Jam ordinaire, donc les règles de drop, l’analyse d’initialisation et l’exclusivité au site d’appel continuent de s’appliquer
  • Dans le sens vers C, on déclare la signature C avec extern
    • une fonction extern suit littéralement l’ABI C
    • la mécanique des modes de paramètres ne s’applique pas au-delà de cette frontière
    • on peut passer un buffer à C via un pointeur brut, et Jam ne vérifie pas ce que C fait de ce pointeur
  • Ce que Jam cherche à offrir, c’est un côté Jam safe by default tout en permettant d’exposer une bibliothèque Jam via une ABI C sans devoir créer une API miroir unsafe ou une couche de shim séparée

Pattern matching

  • Le match de Jam utilise la forme Pattern Block et n’emploie pas =>
    • la valeur testée utilise des parenthèses, comme dans match (opcode)
    • _ est la branche catch-all
    • les branches sont évaluées de haut en bas, premier match gagné, sans fallthrough implicite
  • Le principal cas d’usage est le dispatcher d’opcodes d’un émulateur Game Boy
    • il dispatch 256 opcodes de base et 256 opcodes préfixés
  • Le langage prend aussi en charge le matching sur payload d’enum
    • le pattern de variant matche le tag et lie les champs du payload à de nouvelles variables locales dans la branche
    • le compilateur vérifie l’exhaustivité sur l’ensemble des variants
    • si on ajoute un nouveau variant, chaque site de match qui ne le gère pas échoue à la compilation
  • match fonctionne aussi comme expression
    • chaque bloc de branche produit la valeur de son expression finale
    • toutes les branches doivent produire le même type
    • le match doit être exhaustif
  • En interne, chaque match passe par un pipeline d’arbre de décision fondé sur Luc Maranget 2008
    • une cascade de littéraux entiers peut être repliée par LLVM simplifycfg en switch et table de saut quand c’est rentable

Conception du compile time

  • Le pipeline de compilation de Rust passe par plusieurs IR et étapes d’analyse
    • tokens → AST → HIR → THIR → MIR → monomorphization → LLVM IR → machine code
    • la résolution de traits est un problème de recherche, et le borrow checking est une analyse de régions à l’échelle de la fonction
    • la monomorphization augmente le volume de code avant LLVM
  • Le pipeline de Jam est conçu pour être plus court
    • tokens → AST → AstGen → JIR → codegen → LLVM IR → machine code
    • il n’utilise qu’un seul IR typé : JIR
  • JIR est typé dès sa création par AstGen
    • Jam considère ne pas avoir de comptime-as-values imposant un lowering non typé
    • le placement des drops, la vérification init-before-use et la règle d’exclusivité au site d’appel sont effectués comme des passes locales de dataflow sur JIR
  • Comme chaque binding a son annotation de type, la charge liée à l’inférence de types globale et à la recherche ouverte de traits est plus faible
  • L’AST et JIR sont des structures de données plates
    • de petits nœuds à taille fixe sont rangés dans des tableaux contigus
    • on utilise des index plutôt que des pointeurs, et les payloads trop volumineux sont stockés dans des side pools
    • le compilateur parcourt ainsi des tableaux cache-friendly au lieu de suivre des arbres alloués sur le heap
  • Côté backend, c’est LLVM qui domine encore le temps d’optimisation en build release
    • un split est prévu avec Cranelift pour les builds debug et LLVM pour les builds release
    • Cranelift est sur la roadmap mais n’est pas encore terminé
  • Le compilateur actuel est encore une implémentation C++ en phase de bootstrap du langage, et il n’existe pas encore de benchmark de temps de build digne d’être cité
    • les affirmations sur le compile time relèvent donc de choix de conception, pas de mesures publiées

Performances runtime et exemples

  • L’objectif est que Jam atteigne les performances de Rust et Zig
  • Jam n’a ni GC, ni runtime de mémoire managée, ni header par allocation
    • le codegen produit un IR LLVM direct
  • Le projet ne considère pas encore avoir atteint le niveau de Rust et Zig
    • Rust et Zig bénéficient d’un long travail sur les intrinsèques spécifiques aux cibles dans la bibliothèque standard, les indices d’auto-vectorisation, les conteneurs sensibles à l’allocator, l’optimisation des hot paths et le réglage des passes LLVM
    • Jam devra faire le même type de travail pour combler les 10 à 30 % finaux
  • Sur les workloads mesurés jusqu’ici, l’écart semble relever d’un petit facteur constant plutôt que d’une autre classe de performance
  • Une démo de Tetris en terminal a été écrite en Jam

Plan de publication et travaux restants

  • Jam n’est pas encore public
    • le compilateur existe et fonctionne, mais n’a pas encore été diffusé plus largement
  • Pour un usage quotidien, les travaux suivants sont en cours
    • surface stable
    • package manager
    • LSP
    • formatter
    • le reste du tooling
  • Plusieurs sujets feront l’objet d’articles séparés
    • le système de modes de paramètres
    • la règle d’exclusivité
    • les génériques
    • le comptime de Jam
    • la bibliothèque standard
    • les systèmes d’allocator
    • le modèle de panic
    • l’exploration de MLIR pour une pipeline de génération de code GPU
    • le travail sur l’ABI Rust pour la FFI
    • Cranelift
    • la voie vers un compilateur self-hosted
  • Le plan open source est de publier Jam après avoir créé 108 projets distincts avec
    • le nombre 108 est un jalon arbitraire inspiré des 108 Stars of Destiny de Suikoden 2
    • pour l’instant, le langage est diffusé à un petit groupe d’utilisateurs, avec l’idée d’élargir l’accès à mesure que le tooling suivra
  • Un accès anticipé est disponible via la beta list sur jamlang.org

1 commentaires

 
GN⁺ 3 시간 전
Avis sur Lobste.rs
  • La résolution des traits est un problème de recherche. Le borrow checking est une analyse à l’échelle de toute la fonction. La monomorphisation augmente la quantité de code avant même que LLVM, l’étape la plus lente, ne le voie…

    Ce genre de texte généré par LLM fait quelque chose dont les ingénieurs, en particulier les jeunes, devraient se méfier : remplacer des données quantitatives par une prose qualitative et plausible.
    Convaincre par un récit est plus facile, pour l’auteur comme pour le lecteur, que de collecter et d’analyser des chiffres solides. Le cerveau humain aime les histoires, et elles fonctionnent d’autant mieux qu’elles sont simples et nettes. Les données réelles reflètent souvent un monde complexe, plein de nuances dès qu’on prend la peine de les examiner.
    Il suffit de comparer avec ce billet quantitatif sur le profilage du compilateur Rust, écrit par un contributeur de rustc.

    • Je me demande déjà s’il faut prendre ce projet au sérieux.
    • J’ai particulièrement aimé la formulation « remplacer les données quantitatives par une prose qualitative/évocatrice ».
      Un bon texte technique peut, et devrait, contenir les deux lorsque c’est pertinent, mais il ne doit pas manquer ce qu’il faut vraiment transmettre. Après avoir dirigé une grosse organisation d’assurance qualité, j’ai compris à quel point la rédaction technique est difficile, et il faut se méfier de la manière dont ce problème peut encore empirer avec la facilité d’accès aux LLM.
  • La différence essentielle avec Zig, c’est qu’il y a drop, et qu’il n’y a pas undefined, ce composant particulier si facile à mal utiliser ?

    Il n’y a pas undefined et toutes les valeurs doivent être initialisées, mais Maybe(T).empty() renvoie une valeur dont le contenu n’a « pas encore de sens », et si on appelle juste après unsafeAssumeInit(), cela semble pouvoir renvoyer une valeur poubelle. Ce n’est donc pas de la sûreté au sens où Rust traite unsafe comme une contamination nécessitant un unsafe { .. } explicite.

    L’exemple qui montre la sûreté et la fonctionnalité drop est ce code :

    const File = struct {  
        fd: i32,  
        fn drop(self: mut File) {  
            close(self.fd);  
        }  
    };
    
    export fn useFile() i32 {  
        const f: File = { fd: 7 };  
        return f.fd;  
    }  
    

    Si je ne me trompe pas, ce n’est pas sûr, non ? Même en mettant de côté l’allocation manuelle du descripteur de fichier, cela appelle close(7) puis renvoie 7. Comme il n’y a pas de suivi des durées de vie, l’utilisateur n’a aucun moyen d’exprimer que la durée de vie du descripteur de fichier se termine avant le retour de useFile().

    Dans l’exemple d’ABI, quand export fn counterAdd(c: mut Counter, n: i64) i64 { .. } devient int64_t counterAdd(Counter *c, int64_t n);, comment exprime-t-on si c peut être NULL ou non ? Rust a une ABI définie pour cela, et on peut écrire aussi bien extern "C" fn counterAdd(c: &mut Counter, n: i64) -> i64 que extern "C" fn counterAdd(c: Option<&mut Counter>, n: i64) -> i64.

    La version Rust n’a pas non plus besoin de unsafe. On peut définir l’API avec des références. Ironiquement, le seul endroit qui pourrait nécessiter unsafe dans Rust moderne est #[no_mangle], devenu #[unsafe(no_mangle)], mais l’exemple est curieusement construit pour utiliser des pointeurs bruts côté Rust.

    Cet autre exemple, plus loin :

    extern fn snprintf(buf: *mut[] u8, size: u64, fmt: *const[] u8, ...) i32;
    
    fn render(value: i32) i32 {  
        var buf: [16]u8 = [0; 16];  
        return snprintf(&buf[0], 16, "n=%d", value);  
    }  
    

    Il ne devrait pas y avoir un unsafe quelque part ici ? Comme snprintf reçoit des pointeurs bruts, si l’on suit la règle évoquée plus haut selon laquelle les opérations unsafe doivent pouvoir être repérées par leur nom, il devrait y avoir quelque chose comme unsafeSnprintf et une redéfinition du symbole.

    « Un indice honnête : sur une ligne extern, on parle à C, et les règles de C l’emportent », hmm

    • Je l’ai lu de la même façon. Cela dit, ce n’est pas différent du .as_raw_fd() de Rust, qui présente le même problème de sûreté.
  • Rien de ce qui rend l’ABI de Rust instable n’existe dans Jam. Pas de références de première classe, pas de durées de vie, pas de layouts niche-packed à effacer.

    C’est une mauvaise compréhension de la stabilité FFI de la bibliothèque standard Rust. Les références partagées, les références mutables, Box et leurs variantes Option ont toutes une ABI définie et stable. Toute la procédure Box::into_raw/from_raw de l’exemple est donc inutile.
    Les durées de vie n’existent tout simplement pas au niveau binaire. Si l’on choisit de définir une ABI stable pour une énumération, l’optimisation par niche est désactivée.

    La raison pour laquelle la plupart des types ne définissent pas d’ABI stable, c’est que souvent on n’en veut pas, car cela empêcherait de modifier leur structure interne.

  • Jam n’a pas encore été rendu public. Le compilateur existe et s’exécute, mais nous repoussons une publication plus large pendant que nous travaillons sur ce qui le rend agréable à utiliser au quotidien — une surface stable, un gestionnaire de paquets, un LSP, un formateur, et tous les autres outils que l’on ne remarque que lorsqu’ils manquent…

    Je ne comprends pas ce choix. Il y a une grande différence entre « publier » quelque chose d’incomplet et simplement ouvrir le code source. Si c’est de toute façon prévu plus tard, quel mal y a-t-il à le rendre public pendant la construction du projet ?
    L’avantage, c’est que les personnes à qui la direction plaît peuvent l’essayer elles-mêmes et peut-être même contribuer. Bien sûr, à « l’ère de l’IA », il n’est pas évident que ces contributions soient un bénéfice net. Cela permet aussi de mieux comprendre ce qui est en cours de construction et d’évaluer les arguments expliquant pourquoi c’est formidable. Sans cela, le projet devient beaucoup moins intéressant.

    En plus, il y a des gens qui n’utilisent aucun de ces outils. Dans mon équipe actuelle, nous n’arrivons même pas à nous mettre d’accord sur l’adoption d’un formateur automatique, mais à part cela elle est excellente. Retarder l’ouverture pendant qu’on construit ces outils ne change donc pas grand-chose.

  • Les gens essaient sans cesse de créer un « Rust sans durées de vie pénibles », et échouent sans cesse. Un autre commentaire a abordé l’un des modes d’échec : le problème de renvoyer une partie d’une valeur qui a été drop vient du fait qu’on ne peut pas renvoyer de référence. Un autre problème classique est celui-ci :

    let mut arr = vec![1];  
    let x = &arr[0];  
    arr.push(2);  
    // Que se passe-t-il si l’on utilise `x` ?  
    

    Il y a trois réponses :

    1. On le refuse. Pour cela, il faut une certaine forme de notion d’emprunt. En général, c’est partagé XOR mutable, mais avec seulement du mutable c’est peu pratique, et avec seulement du partagé ce n’est pas sûr
    2. On l’autorise. Parce qu’il n’existe pas de référence via une autre variable et que tout est géré par GC ou par pointeurs à comptage de références
    3. On l’autorise et cela provoque un comportement indéfini à l’exécution

    Chacune de ces trois options a ses bonnes raisons, mais Jam semble vouloir être dans le cas 1 comme Rust, tout en étant en réalité dans le cas 2 à cause de sa sémantique de valeur. Si cela signifie que tout est copié, cela risque d’empêcher d’écrire des structures de données à la fois sûres et efficaces

    • Je trouve qu’Inko s’en sort plutôt bien. Bien sûr, il faut mettre de côté mon biais évident, mais il y a aussi des compromis propres à ce langage
      En particulier, si l’on abandonne le vérificateur d’emprunts, il devient beaucoup plus difficile de prendre en charge des types alloués sur la pile sans introduire plusieurs indices. Par exemple, on peut copier lors de l’emprunt ; Inko comme Swift font tous deux cela
    • Je ne connais pas bien Jam, mais la sémantique de valeur mutable à la Hylo possède une forme d’emprunt appelée subscripts. Elle se situe donc davantage dans un entre-deux
    • La première question qui m’est venue en lisant cette partie a été : « sans références ni annotations de durée de vie, comment stocke-t-on une référence dans une structure ? »
      En regardant la référence du langage, il n’y a pas de références, mais il existe des pointeurs mut et const, et je n’ai rien trouvé sur leur sûreté
  • L’un des grands éléments qui font que Zig est Zig, c’est l’absence de RAII, et Rust, c’est le vérificateur d’emprunts. Or le point où aboutissent ces choix de conception, à savoir « RAII sans références », je ne vois pas vraiment qui en a besoin
    Cela dit, je pense qu’il y a de la place pour expérimenter dans cette niche, et je vois ces tentatives d’un bon œil. Simplement, cette approche ne me semble pas être la bonne

    La direction à laquelle je pense beaucoup ces derniers temps est une combinaison du comptime de Zig, de permissions de référence à la Pony, du traitement des durées de vie comme des valeurs au moment de la compilation, et du branding des durées de vie sur les allocateurs
    Ce que j’en espère, c’est ajouter la sûreté des références à la stratégie d’allocateurs de Zig, et obtenir des durées de vie qui nécessitent très peu d’annotations

  • Les nouveaux langages, c’est bien, mais je n’aime pas que tout devienne un frontend pour LLVM. Je sais que les backends sont difficiles, mais j’aimerais parfois qu’il y ait aussi d’autres options

  • Ça sonne presque comme Swift