Compilation JIT

Le JIT compile les chemins numériques chauds de la VM en code machine. Son but n'est pas de remplacer la VM : il accélère une trace tant que ses hypothèses restent vraies, puis rend la main à l'interpréteur quand une garde échoue.

Une compilation par traces

Catnip enregistre l'exécution réelle d'une boucle ou d'une fonction, produit une trace linéaire, puis la confie à Cranelift. Les branches froides restent hors de la trace ; des gardes vérifient à l'entrée et aux sorties latérales que les types, la fonction appelée et le contrôle de flux correspondent encore au profil observé.

Mermaid diagram dev__JIT--m001 Mermaid diagram dev__JIT--m001

Une fois un site compilé ou refusé après tentative, la VM ne le recompile pas continuellement. Elle évite ainsi qu'un programme instable consacre son temps à retracer les mêmes instructions.

La trace JIT observe un trajet réel et lui construit un raccourci. Les autres routes restent ouvertes.

L'enregistrement garde une suite de TraceOp, les types observés et les offsets de reprise. Cranelift ne reçoit donc pas le bytecode Catnip brut : le traceur a déjà retiré le dispatch, fixé les types scalaires et explicité les sorties qui retournent à la VM. Une compilation réussie installe ensemble le pointeur natif et les gardes qui décrivent ses hypothèses.

Références :

Pourquoi Cranelift

Cranelift fournit un backend Rust, une compilation courte et plusieurs architectures cibles. Catnip lui délègue la sélection et l'émission d'instructions, mais garde la construction des traces, les gardes et la restauration de l'état VM.

Cranelift n'est pas formellement prouvé dans ce dépôt. Les preuves Coq portent sur les modèles Catnip décrits dans Preuves Coq, pas sur le backend natif.

Détection des chemins chauds

Les seuils courants sont :

  • 100 passages dans le même corps de boucle ;
  • 100 appels de la même fonction.

Une boucle courte reste interprétée. Une boucle longue commence dans la VM, atteint le seuil, puis continue en natif si sa trace est admissible. Une fonction est identifiée par son bytecode, son nom et son arité ; les appels récursifs simples peuvent devenir des appels natifs.

Le seuil est un compromis : compiler trop tôt paie Cranelift pour du code rarement réutilisé ; compiler trop tard laisse du travail répétitif dans le dispatch.

Le détecteur continue de compter les passages après un échec de garde pour les statistiques, sans réarmer la compilation du même site. Une alternance de types ne déclenche donc pas une succession de traces concurrentes.

Spécialisation et gardes

Le JIT travaille sur des scalaires numériques :

  • entiers courts et booléens ;
  • flottants inline ;
  • opérations arithmétiques et comparaisons prises en charge ;
  • quelques builtins purs : abs, bool, float, int, max, min, round.

Les opcodes typés produits par l'analyse sémantique donnent directement le type de l'opération. Dans les autres cas, la trace enregistre le type observé dans chaque slot. Le code natif reçoit des valeurs déboxées et ne refait pas le dispatch dynamique à chaque instruction.

À l'entrée, la VM vérifie chaque slot tracé contre sa garde. Les slots non tracés doivent au minimum rester des scalaires numériques si la trace risque de les restaurer : écraser une valeur heap sans exécuter son protocole de libération casserait le contrat de propriété. Dans une fonction compilée, un argument qui ne satisfait pas la signature native retombe avant l'appel sur le chemin interprété.

Garde stricte des flottants

Un slot flottant compilé doit contenir un f64 inline. Le prédicat d'entrée est donc is_bare_float, pas le prédicat plus large utilisé par l'arithmétique générique. Ce dernier accepte aussi un NaN Python conservé derrière un handle pour préserver son identité ; bitcaster ce handle vers f64 produirait un NaN silencieux et perdrait sa propriété.

Le même prédicat strict est utilisé à l'entrée de la trace, dans les fast paths flottants et par les callbacks de déboxing appelés depuis le code machine. Un NaN Python reste interprété ; un NaN produit par un calcul inline peut suivre le chemin numérique.

Le seul motif binaire que le NaN-boxing craint est celui que le FPU fabrique tout seul.

Refus plutôt que sémantique différente

Une trace est abandonnée quand le backend ne peut pas préserver le comportement de la VM. Ce refus garde le programme correct, au prix de rester interprété.

La division vraie en est le cas principal. Catnip doit lever ZeroDivisionError sur un diviseur nul ; une division flottante native produirait inf, tandis qu'une division entière peut trapper ou tronquer différemment. La trace qui contient cette opération est donc refusée tant qu'un side-exit complet n'en préserve pas le contrat.

Le modulo entier est compilé avec deux corrections :

  • une garde sur zéro déopte vers la VM, qui lève l'exception ;
  • le reste tronqué natif est ajusté pour suivre le modulo plancher de Python quand les signes diffèrent.

Un conflit entre le type déduit d'un slot et la valeur qu'une instruction veut y stocker refuse également la trace. Le codegen ne panique pas et ne bitcaste pas une valeur sous le mauvais type.

Un compilateur qui ne connaît pas le type d'un slot peut demander une garde ou renoncer. Le processus n'est pas une troisième réponse.

Débordement entier

Les additions, soustractions et multiplications natives vérifient que leur résultat reste dans la plage SmallInt. Un débordement suit un side-exit : la VM rejoue l'itération et effectue la promotion BigInt.

Le side-exit restaure l'état du début de l'itération, pas l'état partiellement écrit au moment de l'échec. La VM peut alors rejouer l'itération entière sans appliquer deux fois les stores exécutés avant la garde. Une sortie normale, elle, écrit l'état final de la boucle.

Les branches de sortie écrivent uniquement les slots effectivement modifiés. Les autres conservent leur Value originale et son propriétaire. Lorsqu'un slot heap est remplacé après retour du natif, la VM libère l'ancienne valeur avant d'installer le mot reboxé.

Le code natif a commencé l'itération ; l'interpréteur va la recommencer. La mémoire revient donc au point de départ.

Fonctions pures inlinées

Un appel @pure dans une boucle chaude peut être remplacé par le corps de la fonction dans la trace. Catnip inline les builtins purs pris en charge et les fonctions utilisateur dont le corps est disponible.

L'inlining fige ce corps au moment de la compilation. Une garde d'identité vérifie donc que la liaison désigne toujours la même fonction :

  • garde par nom pour une fonction englobante ou globale ;
  • garde par slot pour une fonction tenue dans un local.

Si la fonction est réassignée, la trace déopte. Une réassignation observée pendant l'enregistrement fait refuser la trace.

Une fonction inlinée promet d'être encore elle-même au passage suivant. La garde signe le reçu.

SSA et variables de boucle

Cranelift représente son IR en SSA. Les valeurs modifiées d'une itération à l'autre deviennent des paramètres explicites du bloc de boucle :

entry(initial_values) → loop(parameters)
loop(updated_values) → loop(parameters)
loop(final_values) → exit

Cette forme évite de dépendre d'une inférence implicite quand un slot est lu puis redéfini dans le corps. L'ordre des slots est fixé avant l'émission, de sorte que toutes les arêtes passent les paramètres dans le même ordre.

Références :

  • Cytron et al. (1991), Efficiently Computing Static Single Assignment Form and the Control Dependence Graph ;
  • Braun et al. (2013), Simple and Efficient Construction of Static Single Assignment Form.

Le JIT opère sur un tableau de mots NaN-boxés. À l'entrée, il déboxe les seuls slots tracés. À la sortie, il reboxe les valeurs modifiées et laisse intactes les autres valeurs du frame, notamment les objets heap que la trace n'a pas le droit d'écraser.

Cache

Le cache persiste les traces et les stencils Cranelift sous le cache Catnip. Sa clé comprend :

  • une version de format et de compilation ;
  • le hash du bytecode et de ses constantes ;
  • l'offset du site dans le CodeObject.

Le couple (hash, offset) est aussi la clé en mémoire. L'offset seul serait insuffisant dans une REPL ou un hôte qui réutilise la VM : deux programmes sans rapport peuvent avoir une boucle au même emplacement.

Le hash empêche deux programmes qui se croisent au même offset de se prendre l'un pour l'autre.

Au premier passage, une trace compatible peut être chargée avant le warmup. Sans entrée, la VM attend le seuil, trace, compile puis écrit le cache par fichier temporaire et renommage atomique. Les entrées d'une ancienne version ne sont pas relues et sont nettoyées au démarrage.

Un stencil est du code machine que le processus suivant exécute, donc son emplacement compte autant que son contenu : sans dossier personnel déterminable, le cache n'a pas de répertoire et toute lecture, écriture ou suppression devient inerte — jamais un chemin dérivé du répertoire de travail (voir CACHE).

Le hash couvre les instructions, leurs arguments et le pool de constantes. Il est calculé une fois par CodeObject, puis transporté avec le frame courant lors des appels et retours. Deux boucles au même offset mais avec une constante différente ne partagent donc pas une trace.

Deux niveaux sont distingués :

  • la trace sérialisée décrit les opérations, types et gardes ;
  • le stencil natif contient du code non relocalisé et sa table de relocations.

Le pointeur de fonction final reste propre au processus. Chaque worker applique ses relocations et installe son code localement. Des workers qui écrivent la même clé produisent une trace équivalente ; l'écriture atomique suffit, sans verrou global.

Le préfixe du stencil incorpore la version des opcodes et un sel du compilateur. Une modification du layout de trace ou de la sémantique du codegen augmente ce sel : l'ancienne entrée devient inaccessible avant sa désérialisation.

Limites et fallback

Le JIT refuse notamment :

  • les appels Python externes hors builtins explicitement supportés ;
  • l'I/O, la réflexion et les valeurs non numériques ;
  • les handlers d'exception dans la trace ;
  • une structure, un module ou un BigInt déjà présent au moment de la chauffe ;
  • une branche ou un store dont les types contredisent le profil.

Une garde qui échoue ou une trace refusée retourne à la VM sans modifier le résultat attendu. L'enregistrement d'une trace abandonnée est réinitialisé pour ne pas empêcher une boucle suivante d'être compilée.

Les opcodes de gestion d'exception arrêtent l'enregistrement. Une boucle qui contient un try reste donc dans la VM ; l'abandon de ce site n'empêche pas les sites suivants de la même exécution d'atteindre leur propre seuil.

Les speedups dépendent du temps passé dans le calcul compilable :

Charge Effet attendu
longue boucle entière gain majeur après warmup
longue boucle flottante gain important si les valeurs restent inline
récursion numérique simple gain modéré, selon le corps
I/O ou appels Python peu ou pas de gain

Les ratios publiés doivent préciser la frontière et le warmup. Le protocole est dans Benchmarking.

Activation

Le pipeline Python (catnip et Catnip()) laisse le JIT désactivé par défaut ; un pragma, la configuration ou l'argument de construction l'active pour le script concerné :

catnip -o jit script.cat
CATNIP_OPTIMIZE=jit:on catnip script.cat
Catnip(jit=True)

Contrairement à optimize= et tco=, jit= n'est pas un override : un pragma("jit", ...) dans le fichier le reprend. Mesurer le JIT à partir d'un hôte suppose donc un programme sans ce pragma, ou une relecture du drapeau après parse() — c'est ce que fait bench/.

pragma("jit", True)

Le binaire Rust catnip-run suit l'autre profil : le JIT y est actif par défaut et --no-jit le désactive.

Un fichier peut désactiver le JIT indépendamment du profil de l'hôte :

pragma("jit", False)

La désactivation est utile pour comparer la VM, isoler une divergence ou rendre un profil entièrement interprété.