Pourquoi LLVM pour TypeScript ?
Un compilateur ahead-of-time vit dans un régime différent d'un JIT. Un JIT compile pendant que l'utilisateur attend, donc la latence de compilation est la contrainte. Un compilateur AOT comme Perry compile une seule fois — sur la machine du développeur ou en CI — et le binaire est ensuite exécuté des millions de fois. Cette asymétrie est exactement l'endroit où un optimiseur lourd se rentabilise.
LLVM apporte deux décennies de travail de middle-end : la vectorisation de boucles, le déplacement de code invariant de boucle, la numérotation de valeurs globales, la propagation de constantes conditionnelles éparses, l'inlining agressif, l'analyse d'alias. Le travail de Perry est de fournir à cette machinerie une IR qu'elle peut réellement optimiser — ce qui est précisément le rôle des informations de type de TypeScript.
Le pipeline d'abaissement
Le code source est analysé avec SWC, puis abaissé vers une IR typée de haut niveau (HIR) où les décisions intéressantes ont lieu avant même que LLVM ne voie le code :
- Monomorphisation. Les fonctions et classes génériques sont spécialisées par instanciation concrète, la même stratégie qu'utilisent Rust et C++.
Stack<number>etStack<string>deviennent deux fonctions indépendantes et entièrement typées — si bien que l'optimiseur travaille avec des types concrets plutôt qu'un blob de dispatch générique, et les génériques ne coûtent rien à l'exécution. - Dispatch statique. Là où le type du receveur est connu à la compilation, les appels de méthode compilent vers des appels directs que LLVM peut inliner, pas des recherches dans une table de hachage.
- Accès direct aux champs. Les champs d'objet se résolvent en index à la compilation, si bien qu'une lecture de propriété est un chargement à décalage fixe — pas une recherche dans un dictionnaire.
NaN-boxing et abaissements en ligne
Là où les valeurs sont dynamiques, Perry utilise le NaN-boxing : chaque valeur est un mot de 64 bits. Les doubles sont stockés directement ; les objets, chaînes, booléens, null et undefined sont encodés dans les motifs de bits inutilisés d'un NaN silencieux IEEE 754. Les nombres sont à coût nul — aucun boxing, aucune allocation pour l'arithmétique.
Le piège est que les opérations sur des valeurs non numériques nécessitent des séquences de bits déballer-opérer-remballer. Si ces séquences vivent sous forme d'appels vers un runtime compilé séparément, LLVM voit des boîtes noires opaques et ne peut pas optimiser à travers elles. Perry émet donc les opérations chaudes — lectures de propriétés, dispatch de méthodes, allocation d'objets — sous forme d'IR LLVM en ligne que l'optimiseur peut fusionner et simplifier. L'allocation d'objets, par exemple, se compile en une allocation par décalage (bump allocation) en ligne et thread-local :
%off_ptr = getelementptr i8, ptr %state, i64 8
%offset = load i64, ptr %off_ptr ; current bump offset
%new_off = add i64 %offset, 96 ; headers + 8 fields
%sz_ptr = getelementptr i8, ptr %state, i64 16
%size = load i64, ptr %sz_ptr ; block capacity
%fits = icmp ule i64 %new_off, %size
br i1 %fits, label %fast, label %slowPourquoi pas Cranelift ?
Le premier backend de Perry était Cranelift — le générateur de code derrière wasmtime, conçu pour une compilation rapide et prévisible. C'était le bon point de départ, et il reste un excellent choix pour les JIT et les runtimes en bac à sable. Deux choses ont forcé le changement :
- Le plafond de l'optimiseur. Cranelift est délibérément un compilateur mono-niveau rapide : « du code correct rapidement », ce qui est le bon compromis pour un JIT et le mauvais pour un compilateur AOT dont l'argument de vente est la performance native maximale.
- arm64_32. L'Apple Watch utilise une ABI (instructions 64 bits, pointeurs 32 bits) que Cranelift ne supporte pas. Pour que watchOS existe en tant que cible, LLVM était requis — et maintenir deux backends signifiait deux ensembles de bugs, de tests et de bases de référence de performance.
La migration n'a pas été gratuite : la première release uniquement-LLVM a régressé certains benchmarks jusqu'à 70x parce que les opérations chaudes passaient initialement par des appels opaques à des helpers du runtime. Le rattrapage — abaissements en ligne, l'allocateur par décalage ci-dessus, de meilleures frontières d'inlining — a fait dépasser au backend les chiffres de Cranelift, et une fois stabilisé Perry battait Node.js sur chaque benchmark de sa suite, de 1,7x à 24,6x avec deux égalités (avril 2026). Le post-mortem complet vaut la lecture : De Cranelift à LLVM : comment Perry est devenu 24x plus rapide.
Aller plus loin
La page de fonctionnement interne du compilateur couvre le NaN-boxing, la monomorphisation et le dispatch statique plus en détail. Sur le blog, Tout optimiser : une semaine, 68 versions et une accélération JSON de 547x retrace le travail d'optimisation release par release, et GC générationnel, JSON paresseux et benchmarks qui résistent à l'examen explique comment fonctionne la méthodologie de benchmark (RUNS=11, médiane + p95). Pour la vue d'ensemble, commencez par l'aperçu du compilateur TypeScript natif.