Des articles approfondis sur les technologies qui façonnent l'avenir.

Comment les JIT rendent les langages dynamiques rapides

Comment les compilateurs JIT optimisent Ruby, Python et JavaScript en éliminant les opérations redondantes, avec des exemples concrets de YJIT et ZJIT.

Une gemme Ruby entrant dans une machine lumineuse et ressortant en comète de lumière filante

Ruby a la réputation d'être lent. Python aussi. JavaScript aussi, du moins jusqu'à ce que V8 le rende assez rapide pour faire tourner des charges côté serveur. L'histoire de la façon dont les langages dynamiques deviennent rapides est celle de la compilation JIT (Just-In-Time), et c'est l'un des domaines les plus fascinants de l'informatique appliquée. Le dernier chapitre en date : ZJIT de Ruby supprime les chargements et stockages d'objets redondants au niveau de la représentation intermédiaire, la même classe d'optimisation qui a rendu TurboFan de V8 si efficace.

Si vous vous êtes déjà demandé pourquoi votre code Ruby tourne à une fraction de la vitesse du C, ou comment JavaScript est devenu assez rapide pour faire tourner VS Code, la réponse tient à ce que font réellement les compilateurs JIT, et à ce qui rend l'optimisation des langages dynamiques fondamentalement plus difficile que celle des langages statiques.

Le problème fondamental des langages dynamiques

Quand un compilateur C voit a + b, il connaît les types de a et b à la compilation. S'il s'agit d'entiers, il émet une seule instruction ADD. S'il s'agit de flottants, il émet une addition en virgule flottante. Le processeur exécute cette instruction en un cycle. Il n'y a aucune ambiguïté, aucune décision à prendre à l'exécution.

Quand un interpréteur Ruby voit a + b, il ne sait presque rien. a peut être un entier, un flottant, une chaîne, un tableau ou n'importe quel objet qui définit une méthode +. L'interpréteur doit : vérifier le type de a, trouver la méthode + pour ce type, vérifier le type de b, éventuellement convertir les types, gérer les cas limites (débordement, objets gelés), et enfin effectuer l'opération. Ce simple + peut impliquer des dizaines d'instructions, de lectures en mémoire et de branchements.

# What the programmer writes:
def sum(a, b)
a + b
end
# What the interpreter actually has to do (pseudocode):
def sum(a, b)
method = lookup_method(a.class, :+)      # Hash lookup
raise NoMethodError unless method         # Branch
if a.is_a?(Integer) && b.is_a?(Integer)   # Type checks
result = integer_add(a.value, b.value)  # Actual math
if overflow?(result)                     # Overflow check
result = promote_to_bignum(result)    # Bignum promotion
end
elsif a.is_a?(Float) || b.is_a?(Float)
result = float_add(to_float(a), to_float(b))
elsif a.is_a?(String)
result = string_concat(a, b.to_s)
else
result = call_method(method, a, [b])    # Generic dispatch
end
result
end

Ce surcoût (vérification de type, recherche de méthode, dispatch) est le prix de la dynamicité. La souplesse d'écrire a + b et de la voir fonctionner pour des entiers, des flottants, des chaînes et des objets personnalisés coûte cher à l'exécution.

Comment la compilation JIT riposte

Le travail d'un compilateur JIT consiste à observer ce que fait réellement le programme à l'exécution, puis à générer du code machine optimisé à partir de ces observations. L'idée clé : bien que du code Ruby puisse opérer sur n'importe quel type, en pratique, un site d'appel donné voit presque toujours les mêmes types.

Si sum(a, b) a été appelé 10 000 fois et que a et b ont toujours été des entiers, le JIT peut générer du code machine spécialisé qui suppose qu'ils le resteront. Il émet une seule instruction d'addition entière, accompagnée d'un « garde », c'est-à-dire une vérification de type rapide qui bascule vers le chemin lent si l'hypothèse est un jour violée.

Interpreter execution of sum(a, b) where a and b are Integers:
1. Load a from stack
2. Check if a is an object reference
3. Load a's class pointer
4. Look up :+ in method table (hash lookup)
5. Check method visibility
6. Load b from stack
7. Check if b is an object reference
8. Check if b is compatible type
9. Unbox a's integer value
10. Unbox b's integer value
11. Perform addition
12. Check for overflow
13. Box result as new Integer object
14. Return result
JIT-compiled version (after profiling shows a,b are always Integers):
1. Guard: check a is Integer (bail if not)
2. Guard: check b is Integer (bail if not)
3. ADD instruction
4. Guard: check no overflow (bail if overflow)
5. Return result

C'est un processus de 14 étapes réduit à 5, où les étapes 1, 2 et 4 sont de simples comparaisons. Le calcul proprement dit, l'ADD, prend un cycle CPU. C'est ainsi que JavaScript est passé de « trop lent pour quoi que ce soit de sérieux » à « assez rapide pour faire tourner un IDE complet ».

Le parcours JIT de Ruby : de MJIT à YJIT, puis ZJIT

L'histoire de la compilation JIT dans Ruby illustre à quel point ce problème est difficile. Ruby a connu plusieurs implémentations JIT, chacune avec une approche différente.

MJIT (Ruby 2.6, 2018) traduisait le bytecode Ruby en code C, puis appelait GCC ou Clang pour le compiler. Le code obtenu était bien optimisé, mais le temps de démarrage était catastrophique : compiler du C prend des secondes, pas des millisecondes. Au moment où le code compilé était prêt, le programme avait souvent déjà terminé son exécution.

YJIT (Ruby 3.1, 2022) est la contribution de Shopify, écrite d'abord en C puis réécrite en Rust. YJIT utilise une technique appelée « lazy basic block versioning » : il compile le code un bloc de base à la fois, uniquement quand ce code est réellement exécuté, et crée des versions spécialisées selon les types observés. On obtient un démarrage rapide (des millisecondes, pas des secondes) avec de bonnes performances de pointe. YJIT améliore généralement les performances de Ruby de 15 à 30 % sur des charges réelles comme les applications Rails.

ZJIT est la prochaine évolution, et c'est là que les choses deviennent vraiment intéressantes. ZJIT introduit une représentation intermédiaire (IR), une représentation structurée du programme entre le bytecode et le code machine. Cette IR permet d'appliquer des optimisations classiques des compilateurs, que l'approche directe bytecode vers code machine de YJIT ne permettait pas facilement.

Éliminer les chargements et stockages redondants

L'optimisation précise que ZJIT vient d'intégrer, à savoir supprimer les chargements et stockages d'objets redondants, semble obscure, mais son impact est énorme. Voici pourquoi.

Les objets Ruby stockent leurs variables d'instance dans une table de propriétés. Chaque fois que vous lisez @name, l'interpréteur charge la valeur depuis la table de propriétés de l'objet en mémoire. Chaque fois que vous écrivez @name = value, il écrit dans cette table. Dans une méthode qui accède plusieurs fois à la même variable d'instance, l'interpréteur la recharge à chaque fois depuis la mémoire, car dans le cas général, quelque chose a pu modifier la valeur entre deux lectures.

class Rectangle
def area
@width * @height
end
def perimeter
# @width and @height are each loaded TWICE from memory
2 * (@width + @height)
end
def scale(factor)
@width = @width * factor    # Load @width, multiply, store @width
@height = @height * factor  # Load @height, multiply, store @height
self
end
end
# In a tight loop, these redundant loads add up:
rects.each { |r| r.scale(2).perimeter }

Avec une IR, ZJIT peut effectuer une élimination de chargements : si @width a déjà été chargé et que rien ne l'a modifié depuis, il réutilise la valeur depuis un registre au lieu de la relire en mémoire. Il peut aussi effectuer une élimination d'écritures : si vous écrivez deux fois de suite dans @width sans que personne ne le lise entre les deux, la première écriture peut être supprimée.

Ces optimisations sont le strict minimum pour les compilateurs de langages statiques : GCC et LLVM les pratiquent depuis des décennies. Mais les appliquer à un langage dynamique est bien plus difficile à cause de l'aliasing. En Ruby, l'appel de n'importe quelle méthode peut potentiellement modifier les variables d'instance de n'importe quel objet (via instance_variable_set, method_missing ou les hooks de trace). Le JIT doit prouver qu'entre deux lectures de @width, rien n'a pu le changer, ce qui exige d'analyser ce que chaque opération intermédiaire pourrait faire.

L'avantage de l'IR

La raison pour laquelle ZJIT a introduit une IR (et pourquoi TurboFan de V8, les DFG/FTL de JavaScriptCore et C2 de HotSpot utilisent eux aussi des IR) est que cela rend ces optimisations composables. Une IR est essentiellement un graphe d'opérations sur lequel les optimisations s'appliquent comme des transformations de graphe.

  • Repliement de constantes : si les deux opérandes d'une addition sont des constantes connues, remplacer l'opération par son résultat. 2 + 3 devient 5 à la compilation.
  • Élimination de code mort : si le résultat d'une opération n'est jamais utilisé, la supprimer entièrement.
  • Élimination des sous-expressions communes : si le même calcul apparaît deux fois, le calculer une seule fois et réutiliser le résultat.
  • Élimination de chargements et de stockages : supprimer les opérations mémoire redondantes, comme décrit plus haut.
  • Analyse d'échappement : si un objet est créé et ne quitte jamais la méthode courante, l'allouer sur la pile au lieu du tas (ou supprimer complètement l'allocation).
  • Inlining : remplacer un appel de méthode par le corps de la méthode, ce qui ouvre davantage d'opportunités aux autres optimisations.

Ces optimisations se cumulent. Inliner une méthode expose ses opérations au contexte de l'appelant, ce qui peut révéler des valeurs constantes, permettre le repliement de constantes, rendre du code mort, lequel est ensuite éliminé. Une seule décision d'inlining peut ainsi entraîner la suppression de dizaines d'opérations.

Le filet de sécurité de la désoptimisation

Tout ce que fait un compilateur JIT est spéculatif. Il part du principe que les types ne changeront pas, que les méthodes ne seront pas redéfinies et que le monkey-patching n'invalidera pas son code optimisé. Quand ces hypothèses tombent, le JIT doit « désoptimiser » : jeter le code optimisé et revenir à l'interpréteur.

La désoptimisation est l'une des parties les plus délicates de la conception d'un JIT. Le code optimisé peut avoir éliminé des variables locales, réordonné des opérations ou inliné des appels profondément imbriqués. Pour revenir à l'interpréteur, le JIT doit reconstruire son état (toutes les variables locales, la pile d'appels, le compteur de programme) à partir de ce que le code optimisé a sous la main. Cela exige de maintenir des métadonnées (appelées « on-stack replacement » ou cartes OSR) qui font correspondre les états du code optimisé aux états de l'interpréteur.

Quand la désoptimisation survient fréquemment, un phénomène appelé « deopt thrashing », les performances peuvent être pires qu'une interprétation pure. Le JIT passe son temps à compiler du code optimisé, à l'exécuter brièvement, à le désoptimiser, puis à recommencer. V8 gère cela en comptant les désoptimisations et finit par renoncer à optimiser une fonction donnée. YJIT adopte une approche plus simple : il génère plusieurs versions de chaque chemin d'exécution pour différentes combinaisons de types, ce qui réduit le besoin de désoptimiser, au prix de davantage de code généré.

Pourquoi cela compte au-delà de Ruby

Le parcours JIT de Ruby reflète ce qui se passe dans tous les langages dynamiques. Le JIT copy-and-patch de Python (intégré dans CPython 3.13) est la première étape vers une véritable compilation JIT pour Python. LuaJIT est remarquablement rapide depuis des années grâce à un JIT à traçage agressif. Le JIT de PHP 8.0+ utilise LLVM pour ses optimisations fondées sur une IR.

Le schéma est cohérent : commencer par un interpréteur, ajouter du profilage pour comprendre le comportement à l'exécution, compiler les chemins chauds avec une spécialisation des types, introduire une IR pour les optimisations classiques, puis affiner. Chaque langage fait face aux mêmes défis (dispatch dynamique, objets mutables, eval, monkey-patching) et arrive à des solutions similaires.

Pour les développeurs qui utilisent ces langages, la conclusion pratique est que l'écart de performance entre langages dynamiques et statiques se réduit. Il ne disparaîtra jamais complètement : les vérifications de type et les gardes ont un coût, et la désoptimisation est un surcoût inhérent. Mais un JIT bien optimisé peut se rapprocher à 2-5x du code C équivalent pour les charges de calcul, ce qui est « assez rapide » pour la grande majorité des applications.

La conclusion plus subtile : écrivez du code simple et direct. Les compilateurs JIT optimisent les schémas prévisibles. Les sites d'appel monomorphes (où une méthode reçoit toujours les mêmes types) s'optimisent bien. Les sites polymorphes (où les types varient) sont plus difficiles. Les sites mégamorphes (des dizaines de types) peuvent ne jamais être optimisés. Le code simple à comprendre pour un humain est généralement simple à optimiser pour un JIT, ce qui est un bel alignement d'intérêts.