Ce que le compilateur écrit
Ce que ce chapitre apporte
- Lire le bytecode d'une fonction Python et dire ce que chaque instruction fait de la pile.
- Distinguer une machine à pile d'une machine à registres, et nommer ce que chacune laisse implicite.
- Traduire une petite fonction Python en RISC-V, et comparer le nombre d'instructions des deux formes.
- Reconnaître dans un assembleur les choix d'un compilateur : une variable gardée en registre, un calcul sorti d'une boucle, une instruction supprimée.
- Expliquer pourquoi le code observé dans un débogueur ne correspond pas ligne à ligne au code écrit.
Neuf chapitres durant, les programmes de ce module ont été écrits à la main, instruction par instruction. Cela pourrait donner l'impression que l'assembleur est un langage d'exception, réservé à des situations rares. C'est l'inverse : toute ligne de Python exécutée ce matin est passée par une étape du même genre. CPython ne lit pas le texte d'une fonction à chaque appel ; il le traduit une fois pour toutes en une suite d'instructions élémentaires, qu'une machine imaginaire exécute ensuite. Cette machine imaginaire n'a pas de registres nommés : elle empile et dépile. Ce chapitre met les deux traductions côte à côte, celle que Python produit tout seul et celle qu'il faudrait écrire pour une machine à registres, et montre où passe la différence.
Une fonction Python n'est pas exécutée telle qu'elle est écrite
Soit la plus petite fonction utile qui soit : elle retire une correction fixe à une mesure.
Le module dis de la bibliothèque standard affiche ce que l'interpréteur exécute réellement. La sortie ci-dessous vient de CPython 3.14.7 ; les noms d'instructions changent d'une version à l'autre, et une version plus ancienne en donnerait d'autres.
1 RESUME 0
2 LOAD_FAST_BORROW 0 (mesure)
LOAD_SMALL_INT 4
BINARY_OP 10 (-)
RETURN_VALUE
La colonne de gauche est le numéro de ligne du fichier source. Le reste est une suite d'instructions, et elle se lit exactement comme un programme de ce module : une opération élémentaire par ligne, exécutées dans l'ordre. RESUME est une formalité d'entrée de fonction, sans effet observable ici.
Les trois instructions qui comptent méritent d'être lues avec attention, parce qu'il leur manque quelque chose. LOAD_FAST_BORROW 0 charge la variable locale numéro 0, c'est-à-dire mesure. La charge où ? L'instruction ne le dit pas. LOAD_SMALL_INT 4 charge la constante quatre. Au même endroit non nommé. BINARY_OP 10 (-) fait une soustraction, mais ne nomme ni ses deux sources ni sa destination.
Une machine à pile est une machine dont les instructions ne nomment pas leurs opérandes : elles les prennent au sommet d'une pile, et y déposent leur résultat. BINARY_OP - retire les deux valeurs du sommet, calcule leur différence et la repose. L'endroit où se trouvent les opérandes est donc toujours le même, et n'a pas besoin d'être écrit.
C'est là toute la différence avec ce que les neuf chapitres précédents ont montré. sub t0, a0, a1 nomme trois cases. BINARY_OP - n'en nomme aucune. Les deux instructions font la même soustraction.
Le prix d'une pile, mesuré
Rien n'empêche de faire tourner une machine à pile sur une machine à registres : il suffit de se donner une pile, et RISC-V en a une depuis le chapitre 8. La figure suivante traduit les quatre instructions Python ci-dessus, une par une, sans rien simplifier. Chaque commentaire porte le nom de l'instruction Python qu'il réalise.
programme
- 0x0addi sp, sp, -4
- 0x4sw a0, 0(sp)
- 0x8li t0, 4addi t0, zero, 4
- 0xcaddi sp, sp, -4
- 0x10sw t0, 0(sp)
- 0x14lw t1, 0(sp)
- 0x18lw t0, 4(sp)
- 0x1caddi sp, sp, 4
- 0x20sub t0, t0, t1
- 0x24sw t0, 0(sp)
- 0x28lw a0, 0(sp)
- 0x2caddi sp, sp, 4
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| a0 | 25 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Douze instructions, et sp qui remonte et redescend six fois. Sur une machine à registres, la même fonction s'écrit en une seule instruction, addi a0, a0, -4, qui ne touche ni la mémoire ni la pile. L'écart n'est pas une curiosité : c'est exactement ce que fait un interpréteur Python à chaque tour de boucle, et c'est une des raisons pour lesquelles une boucle Python est lente comparée à la même boucle compilée.
La pile n'est pourtant pas une erreur de conception. Une instruction qui ne nomme aucun registre tient dans un octet, là où une instruction RISC-V en occupe quatre ; un bytecode compact se transporte mieux, et surtout il se produit sans avoir à répondre à la question la plus pénible de la compilation : quelle valeur mérite de rester dans un registre, et laquelle doit retourner en mémoire. Une machine à pile n'a pas à en décider, puisqu'elle n'a pas de registres à distribuer.
Trois lignes Python, trois lignes d'assembleur
La fonction suivante calcule la même chose que l'exemple du chapitre 1, avec des étapes intermédiaires nommées.
Son bytecode montre une seconde famille d'instructions, celles qui rangent.
1 RESUME 0
2 LOAD_FAST_BORROW_LOAD_FAST_BORROW 1 (mesure, offset)
BINARY_OP 10 (-)
STORE_FAST 2 (ecart)
3 LOAD_FAST_BORROW_LOAD_FAST_BORROW 34 (ecart, ecart)
BINARY_OP 0 (+)
STORE_FAST 3 (double)
4 LOAD_FAST_BORROW 3 (double)
LOAD_SMALL_INT 2
BINARY_OP 10 (-)
RETURN_VALUE
STORE_FAST 2 retire la valeur du sommet de la pile et la range dans la case locale numéro 2. Les variables locales, elles, sont bien numérotées : ce sont des cases fixes, désignées par un indice, et non par leur nom, qui n'existe plus qu'entre parenthèses pour la lisibilité de l'affichage. La pile ne sert donc qu'aux valeurs en cours de calcul, jamais au stockage durable.
Un détail mérite d'être relevé, parce qu'il annonce tout le reste du chapitre. LOAD_FAST_BORROW_LOAD_FAST_BORROW n'est pas une instruction d'un manuel : c'est deux chargements fusionnés en un seul, décidés par le compilateur de CPython parce qu'ils se suivaient. Le texte source ne le demandait pas, et le programmeur ne l'a pas écrit. C'est une optimisation, visible à l'œil nu dans la sortie de dis.
La traduction vers une machine à registres n'a besoin d'aucune pile, parce qu'il y a assez de registres pour tenir les trois étapes.
programme
- 0x0sub t0, a0, a1
- 0x4add t1, t0, t0
- 0x8addi a0, t1, -2
registres
| t0 | 0 | |
| t1 | 0 | |
| a0 | 25 | |
| a1 | 4 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Trois instructions RISC-V pour trois lignes de Python, contre dix instructions de bytecode pour les mêmes trois lignes. La correspondance n'est presque jamais aussi nette, mais l'ordre de grandeur, lui, est fidèle : là où la machine à registres range un résultat intermédiaire dans t0 et l'y laisse, la machine à pile l'empile, le dépile, le range dans sa case locale et ira l'y rechercher.
Une machine à pile rend la position des opérandes implicite et leur ordre explicite. Une machine à registres fait l'inverse : elle nomme chaque case, et n'impose aucun ordre d'arrivée. Aucune des deux n'est plus vraie que l'autre ; le processeur physique a des registres, l'interpréteur n'en a pas besoin, et le compilateur est ce qui traduit de l'une vers l'autre.
Le compilateur choisit, et pas comme un humain
Le cas intéressant n'est pas la ligne isolée, c'est la boucle, parce que c'est là que se joue tout ce qu'un compilateur sait faire.
1 RESUME 0
2 LOAD_SMALL_INT 0
STORE_FAST 1 (total)
3 LOAD_SMALL_INT 1
STORE_FAST 2 (i)
4 L1: LOAD_FAST_BORROW_LOAD_FAST_BORROW 32 (i, n)
COMPARE_OP 58 (bool(<=))
POP_JUMP_IF_FALSE 20 (to L2)
NOT_TAKEN
5 LOAD_FAST_BORROW_LOAD_FAST_BORROW 18 (total, i)
BINARY_OP 0 (+)
STORE_FAST 1 (total)
6 LOAD_FAST_BORROW 2 (i)
LOAD_SMALL_INT 1
BINARY_OP 0 (+)
STORE_FAST 2 (i)
JUMP_BACKWARD 25 (to L1)
7 L2: LOAD_FAST_BORROW 1 (total)
RETURN_VALUE
Deux choses sautent aux yeux, et les deux ont été rencontrées au chapitre 6. JUMP_BACKWARD est un saut arrière, et il est la boucle : l'interpréteur ne connaît pas plus le mot while que le processeur. POP_JUMP_IF_FALSE est le saut conditionnel qui en sort. La structure est celle d'un programme RISC-V, au vocabulaire près.
La troisième chose demande d'y regarder à deux fois : à chaque tour, total est rechargé de sa case locale, additionné, puis rangé dans sa case locale. Pareil pour i. Six accès à des cases nommées par tour de boucle, pour deux additions. Rien dans le texte Python ne demande cela ; c'est la conséquence directe du modèle à pile, où une valeur qui doit survivre à la fin d'une expression n'a pas d'autre endroit où se tenir.
La même fonction dans un langage système
Voici la même chose en C, langage que ce cours ne demande pas de connaître. Chaque ligne est commentée, et rien n'est à en retenir sinon la forme générale.
Un compilateur peut traduire ce texte de deux façons très différentes, et le choix ne dépend pas du programme mais du niveau d'optimisation demandé. Les deux figures suivantes donnent ces deux traductions, écrites à la main pour ce cours.
La première suit l'habitude du bytecode : total et i vivent en mémoire, et chaque tour va les y chercher.
programme
- 0x0la t3, totallui t3, 1
- 0x4addi zero, zero, 0
- 0x8la t4, ilui t4, 1
- 0xcaddi t4, t4, 4
- 0x10lw t0, 0(t4)
- 0x14bgt t0, a0, finblt a0, t0, fin
- 0x18lw t1, 0(t3)
- 0x1cadd t1, t1, t0
- 0x20sw t1, 0(t3)
- 0x24lw t0, 0(t4)
- 0x28addi t0, t0, 1
- 0x2csw t0, 0(t4)
- 0x30j bouclejal zero, boucle
- 0x34lw a0, 0(t3)
registres
| sp | 0x2000 | |
| t0 | 0 | |
| t1 | 0 | |
| a0 | 5 | |
| t3 | 0 | |
| t4 | 0 |
mémoire
| 0x1000 | total | 0 |
| 0x1004 | i | 1 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Un détail de cette figure mérite un mot au passage : la colonne des instructions réelles montre que la t3, total produit un lui suivi d'un addi zero, zero, 0, c'est-à-dire d'un nop. L'adresse visée tombe pile sur un multiple de 4096, la seconde moitié du raccourci n'a donc rien à ajouter, et l'assembleur laisse une instruction sans effet plutôt que de décaler tout ce qui suit.
La seconde traduction garde les deux variables dans des registres, et ne touche plus la mémoire du tout.
programme
- 0x0li t0, 0addi t0, zero, 0
- 0x4li t1, 1addi t1, zero, 1
- 0x8bgt t1, a0, finblt a0, t1, fin
- 0xcadd t0, t0, t1
- 0x10addi t1, t1, 1
- 0x14j bouclejal zero, boucle
- 0x18mv a0, t0addi a0, t0, 0
registres
| t0 | 0 | |
| t1 | 0 | |
| a0 | 5 |
Rien n'a encore été exécuté. Le compteur ordinal pointe la première instruction, en 0x0.
Les deux figures donnent 15, ce qui était attendu. Elles ne mettent pas le même temps à y arriver : cinquante-deux pas pour la première, vingt-quatre pour la seconde. Le corps de boucle passe de neuf instructions à quatre, parce que six accès mémoire ont disparu. Aucune ligne du programme source n'a changé ; seule la question « où vit cette variable » a reçu une autre réponse.
C'est, très exactement, ce qu'un compilateur fait en passant d'un niveau d'optimisation à un autre. Sans optimisation, il range chaque variable locale dans la pile et la relit à chaque usage, parce que c'est simple, régulier, et que le débogueur peut alors montrer la valeur de n'importe quelle variable à n'importe quel instant. Avec optimisation, il garde en registre ce qui sert souvent, et la variable, en tant qu'emplacement mémoire, cesse d'exister.
Un débogueur qui affiche <optimized out> à la place d'une valeur ne s'est pas trompé : la variable demandée n'est nulle part, parce qu'elle n'a jamais été rangée. Elle a vécu dans un registre, ce registre a été réutilisé, et il n'existe aucun endroit où aller chercher son ancienne valeur. C'est la raison pour laquelle une compilation de mise au point et une compilation de production ne se déboguent pas de la même façon.
Ce que le compilateur s'autorise encore
Garder une variable en registre est le choix le plus visible, mais ce n'est pas le seul. Trois autres reviennent constamment, et se reconnaissent dans un désassemblage.
Le calcul sorti de la boucle : une expression dont les sources ne changent pas d'un tour à l'autre est calculée une fois avant d'entrer. Le programme source la répète à chaque tour, l'assembleur produit ne la contient qu'une fois, et le compte des instructions ne correspond plus au compte des lignes.
Le calcul fait d'avance : quand toutes les sources d'un calcul sont connues au moment de la compilation, le résultat est écrit directement dans le programme. Une fonction qui ne fait qu'additionner deux constantes peut se réduire à ranger la somme, et l'addition disparaît du binaire.
Le réordonnancement : l'ordre des instructions produites n'est pas celui des lignes écrites. Un compilateur peut avancer un chargement mémoire de plusieurs instructions, pour que la valeur soit arrivée quand elle sera utilisée, tant que cela ne change rien au résultat observé par ce programme, seul. Ce dernier mot compte : la garantie porte sur un fil d'exécution isolé, et cesse dès que deux traitements partagent une donnée.
Un compilateur, et le processeur après lui, réordonnent librement tant qu'un observateur unique ne peut pas s'en apercevoir. Deux traitements qui lisent et écrivent la même case n'entrent pas dans cette hypothèse, et c'est précisément pour cela qu'un compteur partagé incrémenté sans précaution donne un résultat faux. La condition de course part de ce constat, sur un cas où une seule ligne de code se révèle être trois instructions séparables.
Savoir lire une trace
Un dernier usage, moins spectaculaire mais plus fréquent : lire ce qu'un outil affiche quand un programme s'est arrêté brutalement. Une trace d'exécution ne donne ni les noms de variables ni les lignes de code, mais des adresses, des noms de fonctions et des décalages. Trois habitudes suffisent à en tirer quelque chose.
D'abord, une adresse de la forme nom_de_fonction+0x2c indique une position à l'intérieur d'une fonction, mesurée en octets depuis son début. Divisée par quatre sur une machine RISC-V, elle donne un rang d'instruction, pas un numéro de ligne.
Ensuite, le registre qui contient l'adresse de retour dit d'où l'appel venait. Le chapitre 9 a montré que cette adresse est rangée sur la pile dès qu'une fonction en appelle une autre : c'est ce chaînage que les outils remontent pour reconstituer la suite des appels.
Enfin, une adresse invraisemblable, très petite ou manifestement pas un multiple de quatre, n'est pas un mystère mais un indice : quelque chose a écrit là où il ne fallait pas, et le chapitre 9 a montré exactement comment une écriture trop longue dans une variable locale atteint l'adresse de retour posée juste à côté.
1.Que fait BINARY_OP - dans le bytecode de CPython ?
2.Pourquoi la traduction fidèle d'une machine à pile vers RISC-V demande-t-elle beaucoup plus d'instructions ?
3.Dans le bytecode, à quoi sert STORE_FAST ?
4.Deux traductions de la même boucle prennent cinquante-deux pas et vingt-quatre pas. Qu'est-ce qui les sépare ?
5.Un débogueur affiche <optimized out> au lieu de la valeur d'une variable. Que s'est-il passé ?
6.Sur quelle hypothèse repose la liberté qu'a un compilateur de réordonner des instructions ?
7.LOAD_FAST_BORROW_LOAD_FAST_BORROW apparaît dans la sortie de dis alors que le programme Python ne contient rien de tel. Pourquoi ?
La méthode
- Désassembler avant de supposer :
dis.dis(fonction)sur une fonction Python donne la suite réellement exécutée, en quelques secondes, sans outil extérieur. - Lire le bytecode comme une pile : suivre à la main ce que chaque instruction empile et dépile, et vérifier qu'il ne reste qu'une valeur au moment du
RETURN_VALUE. - Repérer les sauts avant tout le reste :
JUMP_BACKWARDmarque une boucle,POP_JUMP_IF_FALSEsa condition de sortie. La structure du programme s'en déduit sans lire les calculs. - Compter les accès mémoire d'un corps de boucle plutôt que ses lignes, puisque c'est ce compte qui sépare deux traductions du même code.
- Vérifier la version de l'outil avant de citer une sortie de
dis: les noms d'instructions changent d'une version de CPython à l'autre, et une sortie recopiée d'ailleurs peut être invérifiable. - Se méfier de la correspondance ligne à ligne quand l'optimisation est active : une variable peut avoir disparu, un calcul avoir été sorti d'une boucle, et un point d'arrêt tomber ailleurs que là où il est posé.
Synthèse
- Le bytecode de CPython est une suite d'instructions élémentaires pour une machine à pile : ses opérations ne nomment pas leurs opérandes, qu'elles prennent et reposent au sommet d'une pile.
- Une machine à registres fait l'inverse : elle nomme chaque case et n'impose pas d'ordre d'arrivée. Traduire fidèlement quatre instructions de bytecode vers RISC-V coûte douze instructions, là où la même soustraction tient en une seule.
- La même fonction se traduit de deux façons selon que ses variables vivent en mémoire ou en registre : cinquante-deux pas contre vingt-quatre pour la boucle du chapitre, à résultat identique.
- Un compilateur décide seul de ce qui reste en registre, de ce qui sort d'une boucle, de ce qui se calcule d'avance et de l'ordre des instructions produites. Aucune de ces décisions n'apparaît dans le texte source.
- Une variable gardée en registre n'a pas d'adresse, ce qui explique le
<optimized out>d'un débogueur et la différence de confort entre une compilation de mise au point et une compilation de production. - La liberté de réordonner repose sur l'hypothèse d'un fil d'exécution unique, et cesse de valoir dès que deux traitements partagent une donnée.
- Lire une trace d'exécution demande trois réflexes : interpréter
fonction+0x2ccomme un décalage en octets, remonter les adresses de retour empilées, et reconnaître une adresse invraisemblable comme la marque d'une écriture hors limites.
Ce chapitre a montré une seconde façon d'écrire les mêmes calculs, celle de la machine à pile. Un autre alphabet en montre une troisième, celle des processeurs qui équipent la plupart des machines de bureau, et apprend à la lire sans la parler.