Le compilateur JIT de Python arrive enfin
Python 3.15 intègre un JIT copy-and-patch. Fonctionnement, gains de performance attendus et pourquoi CPython a tant tardé.

Python est « trop lent » depuis qu'il existe. La réponse classique de la communauté Python — « utiliser des extensions C pour les boucles critiques » — a toujours été une concession : le modèle d'exécution par défaut du langage est fondamentalement limité. CPython interprète le bytecode instruction par instruction, chaque instruction étant dispatchée via une instruction switch. C'est simple, portable et facile à déboguer. C'est aussi environ 100 fois plus lent que du C compilé pour les calculs intensifs.
Python 3.15 change la donne. Après des années de travaux expérimentaux, le compilateur JIT copy-and-patch est livré comme fonctionnalité activée par défaut. Il ne rendra pas Python aussi rapide que du C — rien ne le fera, sauf la compilation statique — mais les premiers benchmarks montrent des gains de 15 à 30 % sur du code réel, avec des améliorations bien plus importantes sur certains motifs. Pour un langage dont la stratégie de performance se résume à « réécrire le chemin critique en C » depuis 30 ans, un JIT qui accélère sensiblement le code Python pur est une vraie étape.
Pourquoi CPython n'a jamais eu de JIT
Ce n'est pas faute d'avoir essayé. PyPy dispose d'un JIT depuis plus d'une décennie et exécute régulièrement du code Python 5 à 10 fois plus vite que CPython. Mais PyPy est une implémentation distincte avec son propre runtime, et elle n'a jamais atteint la part de marché de CPython, car l'écosystème des extensions C — NumPy, pandas, scikit-learn, tout ce qui fait de Python le langage de la data science — est lié à l'API C de CPython.
Intégrer un JIT dans CPython lui-même a été évoqué et tenté à plusieurs reprises. Les difficultés sont bien documentées. L'architecture de CPython rend la compilation JIT ardue : le bytecode est typé dynamiquement (le JIT a besoin d'informations de type pour générer du code efficace, et Python ne les fournit pas statiquement), l'API C permet au code C de manipuler directement les objets Python d'une façon qui casse les hypothèses du JIT, et le ramasse-miettes à comptage de références de l'interpréteur crée une surcharge de comptabilité qu'un JIT ne peut pas facilement éliminer.
Les tentatives précédentes — Unladen Swallow (Google, 2009), Pyston (Dropbox, 2014) — ont essayé de greffer une compilation JIT basée sur LLVM sur CPython. Les deux ont constaté que le surcoût de compilation de LLVM était trop élevé pour les charges de travail typiques de Python. LLVM est conçu pour la compilation anticipée de grosses bases de code ; l'utiliser pour compiler à la volée de courtes fonctions Python ajoute des millisecondes de compilation pour des microsecondes d'exécution. La compilation coûtait plus cher que l'accélération.
Copy-and-Patch : un JIT d'un autre genre
La technique copy-and-patch, présentée dans un article de recherche en 2021, adopte une approche fondamentalement différente de la compilation JIT. Au lieu de traduire le bytecode en représentation intermédiaire et d'appliquer des passes d'optimisation (l'approche LLVM), copy-and-patch travaille avec des modèles de code précompilés.
Voici l'idée : pour chaque instruction de bytecode (LOAD_FAST, BINARY_ADD, CALL_FUNCTION, etc.), le compilateur précompile une implémentation C en code machine, avec des « trous » réservés aux données propres à chaque variable — allocations de registres, valeurs constantes, décalages mémoire. À l'exécution, la compilation JIT se résume à : copier le modèle précompilé, puis remplir les trous avec les valeurs spécifiques à cette fonction. Pas de passes d'optimisation, pas d'algorithmes d'allocation de registres, pas de sélection d'instructions. Juste un memcpy et des patchs.
Traditional JIT pipeline:
Python bytecode
→ Parse into IR
→ Type inference
→ Optimization passes (CSE, DCE, loop unrolling, inlining...)
→ Register allocation
→ Instruction selection
→ Machine code
Total: 1-100ms per function
Copy-and-patch pipeline:
Python bytecode
→ For each instruction, copy pre-compiled template
→ Fill in holes (constants, offsets)
→ Done — machine code ready
Total: 1-100μs per function (1000x faster compilation)
Le compromis porte sur la qualité du code. LLVM produit du code machine hautement optimisé. Copy-and-patch produit du code machine qui est essentiellement une version compilée de la boucle de l'interpréteur — chaque instruction de bytecode reste un modèle distinct, avec très peu d'optimisation entre instructions. Le code généré est meilleur que l'interprétation (plus de surcoût de dispatch, plus de switch, meilleure prédiction de branchement) mais moins bon que ce que produirait un compilateur optimisant complet.
Pour Python, ce compromis est excellent. Les fonctions Python sont généralement courtes, appelées très souvent, et ne prennent chacune que quelques microsecondes. Un JIT qui compile en microsecondes et accélère l'exécution de 20 à 30 % vaut mieux qu'un JIT qui compile en millisecondes et accélère de 200 % — car le surcoût de compilation de la première approche est amorti presque immédiatement.
Ce qui gagne en vitesse
Le JIT n'accélère pas magiquement tout le code Python de la même façon. Pour comprendre ce qui en profite le plus, il faut comprendre sur quoi l'interpréteur passe son temps.
Surcoût du dispatch de bytecode. Dans l'interpréteur, chaque instruction de bytecode demande : récupérer le prochain opcode, le décoder, puis sauter au gestionnaire via une instruction switch. Ce surcoût peut représenter 30 à 50 % du temps d'exécution dans les boucles serrées. Le JIT l'élimine entièrement : les instructions sont compilées en code machine séquentiel avec des sauts directs.
Opérations spécialisées par type. Python 3.11 a introduit l'interpréteur adaptatif spécialisant, qui remplace les opérations génériques par des versions propres à un type, après avoir observé les types réellement utilisés. BINARY_ADD devient BINARY_ADD_INT lorsqu'il voit deux entiers. Le JIT compile ces instructions spécialisées en code machine efficace — une addition d'entiers devient une seule instruction add au lieu d'un appel de fonction.
Prédiction de branchement. La boucle de dispatch centrale de l'interpréteur — un switch avec des centaines de cas — est un cauchemar pour le prédicteur de branchements du processeur. Le JIT la remplace par un flux de contrôle direct que le CPU prédit correctement. Sur les processeurs modernes où une mauvaise prédiction coûte 15 à 20 cycles, cela explique à lui seul une part importante de l'accélération.
Ce qui n'accélère pas : les appels aux extensions C (NumPy, pandas), les opérations d'E/S (réseau, disque), les opérations dominées par l'allocation mémoire (création de millions de petits objets). Si votre programme Python passe 95 % de son temps dans des extensions C et 5 % en Python pur, le JIT accélère ces 5 % — mesurable, mais pas transformateur.
Le pipeline de spécialisation
Le JIT ne travaille pas seul. C'est l'étape finale d'un pipeline de performance commencé avec l'interpréteur spécialisant de Python 3.11 et poursuivi par les améliorations incrémentales de 3.12 à 3.14.
- Niveau 0 : interpréteur. Tout code commence ici. Interprétation standard du bytecode avec spécialisation adaptative. Après plusieurs appels d'une fonction, les instructions les plus sollicitées sont remplacées par des versions spécialisées par type.
- Niveau 1 : bytecode compilé à la volée. Le JIT copy-and-patch compile le bytecode spécialisé en code machine. Cela élimine le surcoût de dispatch et permet des optimisations de base comme le pliage de constantes et l'élimination de code mort au sein des modèles compilés.
- Niveau 2 (à venir) : optimisation par traces. Enregistrer les traces d'exécution des chemins chauds et compiler des traces entières — à travers les frontières de fonctions — en code machine optimisé. C'est prévu mais pas encore livré.
Cette approche par niveaux ressemble à celle de YJIT de Ruby et d'autres runtimes de langages modernes. On commence par une interprétation rapide, on passe à une compilation rapide quand le code devient chaud, et on réserve les optimisations coûteuses aux chemins les plus chauds. C'est la même intuition fondamentale : la plupart du code ne vaut pas la peine d'être optimisé, alors autant consacrer le budget de compilation à celui qui tourne le plus.
Impact sur la mémoire et le démarrage
Les compilateurs JIT consomment de la mémoire pour le code compilé. Le surcoût mémoire du JIT copy-and-patch reste modeste : le code compilé est plus volumineux que le bytecode mais plus petit que ce que produisent les JIT basés sur LLVM (il n'y a pas de gonflement dû aux optimisations). L'implémentation actuelle utilise environ 1,5 à 3 fois la mémoire du bytecode qu'elle remplace, et ne compile que les fonctions appelées assez fréquemment pour en tirer profit.
Le temps de démarrage pose problème pour les scripts Python éphémères. Le JIT ajoute un surcoût pour charger la bibliothèque de modèles et préparer l'infrastructure de compilation. Pour les scripts qui tournent moins d'une seconde, ce surcoût peut dépasser le gain. CPython y remédie en ne compilant les fonctions qu'après un certain nombre d'appels : les scripts éphémères restent dans l'interpréteur et ne paient pas la taxe JIT.
C'est configurable. Le drapeau -X jit contrôle le comportement du JIT, et des variables d'environnement ajustent le seuil de compilation. Pour les fonctions serverless et les outils en ligne de commande où le démarrage compte, vous pouvez relever le seuil ou désactiver complètement le JIT. Pour les serveurs de longue durée et les scripts de traitement de données où la performance en régime établi compte, les valeurs par défaut conviennent très bien.
Ce que cela change pour l'écosystème Python
Le JIT ne modifie pas la place de Python dans la hiérarchie des performances — C, Rust, Go et Java restent nettement plus rapides pour les calculs intensifs. Ce qui change, c'est le seuil à partir duquel les développeurs Python doivent recourir à ces alternatives.
Un gain de 20 à 30 % sur du Python pur signifie que certaines charges de travail qui nécessitaient auparavant des extensions C ou une réécriture tournent désormais assez vite en Python pur. Des scripts de traitement de données qui prenaient 10 minutes en prennent 7. Des serveurs web qui traitaient 1000 requêtes par seconde en traitent 1300. Ces chiffres ne sont pas révolutionnaires, mais ils font la différence entre « Python est assez rapide » et « il faut réécrire ça en Go ».
Plus important encore, l'infrastructure JIT pose les bases d'optimisations futures. L'approche copy-and-patch peut être étendue avec de meilleurs modèles, davantage de spécialisation et, à terme, une compilation par traces. Chaque version de Python pourra livrer de meilleurs modèles sans changer l'architecture fondamentale du JIT. Les 20 à 30 % de la 3.15 constituent un plancher, pas un plafond.
Après trois décennies comme l'un des langages les plus populaires et, pour certains, les plus lents, CPython investit enfin sérieusement dans la performance. Le JIT ne convaincra pas les partisans du « Python est trop lent » — rien ne le fera, car pour certaines charges Python est vraiment trop lent, JIT ou pas. Mais pour la grande majorité du code Python, où la vitesse d'exécution était « correcte mais pas top », le JIT la rapproche de « vraiment bien ». C'est plus important qu'il n'y paraît.


