Un CPU n’exécute que son propre code machine. Un programme écrit en C, en Python ou en JavaScript doit y arriver d’une façon ou d’une autre, et les langages empruntent trois grandes voies :
- Compiler à l’avance (AOT, ahead of time) : traduire tout le programme en code machine avant son exécution. C’est le cas de C, C++, Rust, Go et Swift.
- Interpréter : exécuter un programme qui lit le vôtre et l’exécute pas à pas. C’est le cas de CPython, via du bytecode.
- Compiler à la volée (JIT, just in time) : commencer par interpréter, observer ce que fait réellement le programme, et compiler en code machine ses parties chaudes pendant qu’il tourne. C’est le cas des moteurs JavaScript, des machines virtuelles Java et .NET, de PyPy et de LuaJIT.
La voie est une propriété de l’implémentation, pas du langage : il existe des interpréteurs C et des compilateurs Java à l’avance. Mais chaque langage a sa voie habituelle, qui détermine la vitesse de démarrage, la vitesse d’exécution, et ce qu’il reste à lire pour qui analyse le binaire.
La compilation à l’avance
Un compilateur lit tout le programme, le vérifie, l’optimise, et écrit du code machine. Le simulateur de ce site contient un petit compilateur C. La démo ci-dessous compile une fonction C en direct et s’ouvre au niveau du C. Zoomez pour voir l’assembleur produit, puis encore plus bas jusqu’aux octets et aux micro-opérations :
- int total(int n) {
- int s = 0;
- for (int i = 0; i < n; i++)
- s = s + i * 2;
- return s;
- }
- int main() {
- return total(5);
- }
Le programme renvoie 20 après 81 instructions. Le compilateur du simulateur est volontairement simple, comme gcc -O0 : chaque variable vit sur la pile, et le code correspond ligne à ligne au C.
Un vrai compilateur avec optimisations fait bien plus. Voici la boucle s = (s + i * 2) % 1000003 compilée par clang en -O2 pour x86-64, réduite au cœur de la boucle :
.LBB0_8:
add rsi, rcx ; s += i*2 (rcx contient i*2)
mov rax, rsi
imul r8 ; multiplication par une constante « magique »…
add rdx, rsi
mov rax, rdx
shr rax, 63
sar rdx, 19 ; …et décalage : rdx = s / 1000003
add rdx, rax
imul rax, rdx, 1000003
sub rsi, rax ; s -= (s / 1000003) * 1000003, soit s %= 1000003
… ; la même chose pour le i suivant : la boucle est déroulée deux fois
add rcx, 4
add r9, -2
jne .LBB0_8
Il ne reste aucune instruction de division. Le % est devenu une multiplication par une constante précalculée et quelques décalages, bien plus rapides, et la boucle traite deux itérations par tour. Un compilateur à l’avance peut passer des secondes à trouver ce genre d’astuces, puisqu’il ne tourne qu’une fois, avant la livraison du programme.
La compilation à l’avance donne un démarrage instantané, une vitesse prévisible, et un programme qui n’a besoin d’aucun environnement d’exécution. Les coûts : un binaire par plateforme, une étape de compilation entre chaque modification et chaque exécution, et aucune connaissance de la façon dont le programme sera réellement utilisé.
L’interprétation et le bytecode
Un interpréteur ne traduit pas votre programme en code machine. Il est un programme en code machine qui exécute le vôtre. La plupart des interpréteurs modernes ne travaillent pas directement sur le texte source : ils le compilent d’abord en bytecode, un jeu d’instructions compact pour une machine imaginaire, puis exécutent une boucle qui lit et exécute une instruction de bytecode à la fois.
Voici ce que CPython 3.14 produit pour la même boucle (avec dis.dis) :
L1: FOR_ITER 18 (to L2)
STORE_FAST 2 (i)
LOAD_FAST_BORROW_LOAD_FAST_BORROW 18 (s, i)
LOAD_SMALL_INT 2
BINARY_OP 5 (*)
BINARY_OP 13 (+=)
STORE_FAST 1 (s)
JUMP_BACKWARD 20 (to L1)
C’est une machine à pile : les LOAD empilent des valeurs, BINARY_OP en dépile deux et empile le résultat. Chaque instruction de bytecode est traitée par un morceau de code C dans l’interpréteur. Ce code décode l’instruction, vérifie le type de ses opérandes — * ne fait pas la même chose sur des entiers, des flottants ou des chaînes —, manipule des entiers stockés comme objets dans le tas, et met à jour des compteurs de références. Un seul BINARY_OP peut coûter des dizaines d’instructions machine là où la boucle C compilée en utilisait une ou deux.
Les interpréteurs démarrent instantanément, exécutent le même bytecode sur toutes les plateformes, et rendent faciles les fonctionnalités dynamiques — types qui changent, eval, fonctions redéfinies à l’exécution. Le prix, c’est la vitesse.
La compilation à la volée
Un JIT combine les deux. V8, le moteur JavaScript de Chrome et de Node.js, compile d’abord une fonction en bytecode pour son interpréteur Ignition. La même boucle, écrite en JavaScript :
LdaZero
Star0 ; s = 0
LdaZero
Star1 ; i = 0
Ldar a0
TestLessThan r1, [0] ; i < n ?
JumpIfFalse [23]
Ldar r1
MulSmi [2], [2] ; i * 2
Add r0, [1] ; + s
…
Inc [3] ; i++
JumpLoop [24], [0], [4]
Les nombres entre crochets sont des emplacements de feedback. Pendant son exécution, l’interpréteur note ce qu’il observe à chaque opération — « ce Add a toujours reçu deux petits entiers » — et compte combien de fois chaque fonction et chaque boucle tourne. Quand du code devient chaud, V8 le compile en code machine en arrière-plan. Avec node --trace-opt, on peut le voir se produire sur cette boucle :
[marking … <JSFunction total> for optimization to MAGLEV, … reason: hot and stable]
[completed compiling … <JSFunction total> (target MAGLEV) OSR - took … 0.094 … ms]
[completed compiling … <JSFunction total> (target TURBOFAN_JS) OSR - took … 0.725 … ms]
Il y a deux étages : Maglev, un compilateur intermédiaire rapide, puis Turbofan, le compilateur optimisant complet. OSR (on-stack replacement) signifie que V8 a remplacé la boucle déjà en cours dans l’interpréteur par la version compilée, sans attendre le prochain appel de la fonction.
Le code optimisé repose sur la spéculation. Comme le feedback indiquait que s et i étaient toujours de petits entiers, Turbofan produit des instructions machine entières ordinaires, avec une vérification rapide que l’hypothèse tient toujours. Si une chaîne ou un très grand nombre apparaît un jour, la vérification échoue et le moteur désoptimise : il jette le code compilé et revient à l’interpréteur, qui sait traiter tous les cas. Un compilateur à l’avance ne peut pas faire de tels paris ; un JIT le peut, parce qu’il peut toujours revenir en arrière.
La même boucle, de trois façons
Le même calcul — 50 millions d’itérations de s = (s + i * 2) % 1000003 — sur l’Apple M2 Ultra où ce chapitre a été écrit. Les quatre versions affichent le même résultat, 22650 :
| Implémentation | Temps |
|---|---|
C, compilé à l’avance (clang -O2) | 0,17 s |
| JavaScript, V8 avec son JIT (Node.js 25) | 0,25 s |
JavaScript, interpréteur V8 seul (node --jitless) | 0,67 s |
| Python, interpréteur de bytecode CPython 3.14 | 3,3 à 3,7 s |
Le JIT amène JavaScript à environ 1,5 fois le temps du C sur cette boucle. Le même moteur limité à son interpréteur est presque 3 fois plus lent. CPython, qui exécute son bytecode sans JIT (la 3.13 en a ajouté un expérimental, désactivé par défaut), met environ 20 fois plus de temps que le C. Ces chiffres valent pour une boucle numérique serrée sur une machine : les vrais programmes passent du temps dans les entrées-sorties, les bibliothèques et la mémoire, et les écarts y sont en général bien plus faibles. Le code Python numérique évite l’interpréteur en appelant des bibliothèques comme NumPy, dont les boucles internes sont du C compilé.
Choisir, et brouiller les frontières
| À l’avance | Interpréteur | JIT | |
|---|---|---|---|
| Démarrage | instantané | instantané | rapide, puis montée en régime |
| Vitesse maximale | la plus haute | la plus basse | proche de l’AOT sur le code chaud |
| Portabilité | un binaire par plateforme | le même code partout | le même code partout |
| Mémoire | la plus faible | faible | plus grande (compilateur, code, profils) |
| Ce que voit un rétro-ingénieur | du code machine dépouillé | du bytecode, facile à décompiler | du bytecode plus du code généré à l’exécution |
Les vrais systèmes mélangent les trois :
- Java compile en bytecode à l’avance (les fichiers
.class), puis la JVM l’interprète et le compile à la volée. Lenative-imagede GraalVM peut à la place tout compiler à l’avance. - Android compile le bytecode des applications en partie à l’avance lors de l’installation, et le reste à la volée.
- WebAssembly est un bytecode portable de bas niveau que les navigateurs compilent en code machine au chargement. L’émulateur de CPU de ce site est écrit en Rust, compilé en WebAssembly, et transformé en code natif par votre navigateur.
La frontière entre compiler et interpréter tient moins du mur que du curseur : combien de traduction a lieu avant l’exécution du programme, et combien pendant. Le chapitre suivant, du code source au binaire qui s’exécute, suit pas à pas la voie de la compilation à l’avance.
À retenir
- Le CPU n’exécute que du code machine. Les langages y arrivent en compilant à l’avance, en interprétant, ou en compilant à la volée : une propriété de l’implémentation, pas du langage.
- Les compilateurs optimisants transforment profondément le code avant sa livraison : un
%devient une multiplication et des décalages, et les boucles sont déroulées. - Les interpréteurs exécutent en général du bytecode pour une machine à pile virtuelle. Chaque instruction passe par des vérifications de type et une répartition : souples, portables et lents.
- Les JIT profilent le programme et compilent le code chaud pendant l’exécution, en spéculant sur les types et en désoptimisant quand un pari échoue. V8 passe par Ignition → Maglev → Turbofan, et remplace même des boucles en cours (OSR).
- Mesuré sur une boucle : C 0,17 s, JavaScript avec son JIT 0,25 s, le même moteur en interprétation 0,67 s, CPython 3,3 à 3,7 s.