Comment le code Python est exécuté
Un inconvénient possible des langages de haut niveau comme Python, c’est qu’il est difficile de comprendre ce qui se passe en coulisses quand le code s’exécute.
S’il est vrai que la plupart des développeurs Python ne se soucient sans doute pas des détails intimes de la machine virtuelle Python, quelques connaissances de base sur cette face cachée du langage peuvent toujours être utiles, surtout quand les performances entrent en jeu.
Python est un langage interprété (n’est-ce pas ?)
Contrairement aux langages de programmation « purs » comme le C ou le C++ (qui a dit l’assembleur ?), Python est un langage interprété, qui s’exécute sur une machine virtuelle (VM).
Je parle en fait de l’implémentation standard de Python, appelée CPython (à ne pas confondre avec « Cython », qui est autre chose), mais il existe d’autres versions de Python, comme PyPy ou Jython, avec des implémentations complètement différentes. Concentrons-nous tout de même sur CPython, puisque c’est ce que la plupart des développeurs Python utilisent par défaut.
(C)Python est donc un langage interprété, ce qui est la principale raison pour laquelle il est considéré comme un langage lent, comparé à des langages compilés comme le C, voire Java.
En réalité ce n’est pas tout à fait vrai : le code Python est toujours compilé, non pas en langage machine natif mais en bytecode, qui est à son tour interprété par la machine virtuelle Python. Le bytecode Python est la représentation interne d’un programme Python, et c’est ce que l’on trouve dans les fichiers .pyc, produits lorsqu’on exécute un script Python.
À la découverte du bytecode Python
Pour comprendre le bytecode Python, il faut d’abord comprendre comment fonctionne la VM Python.
La VM Python est une machine virtuelle à pile, ce qui signifie que sa mémoire de travail est organisée sous forme de pile.
Si vous avez déjà eu une calculatrice comme la HP-48, vous savez sans doute déjà ce qu’est une machine à pile. Avec la HP-48, pour faire un calcul simple comme 2 * (12 + 42), il fallait utiliser une syntaxe appelée RPN (notation polonaise inverse), et saisir quelque chose d’étrange comme 2 12 42 + *, ce qui signifie :
- Empiler « 2 »
- Empiler « 12 »
- Empiler « 42 »
- Additionner les deux nombres au sommet de la pile (c’est-à-dire 12 et 42), et les remplacer par le résultat (c’est-à-dire 54)
- Multiplier les deux nombres au sommet de la pile (c’est-à-dire 2 et 54), et les remplacer par le résultat
- À la fin, il ne reste que le résultat sur la pile (c’est-à-dire 108)
La VM Python fonctionne exactement de la même façon : le bytecode Python contient donc un ensemble d’instructions qui opèrent sur une pile.
Définissons une fonction Python triviale :
def f(x, y, z):
return x * (y + z)
Le bytecode produit par cette fonction peut être affiché grâce au module intégré dis :
>>> import dis
>>> dis.dis(f)
2 0 LOAD_FAST 0 (x)
3 LOAD_FAST 1 (y)
6 LOAD_FAST 2 (z)
9 BINARY_ADD
10 BINARY_MULTIPLY
11 RETURN_VALUE
Voici la signification de cet affichage du bytecode :
- La première colonne indique le numéro de ligne dans le code source.
- La deuxième colonne est l’adresse de l’instruction.
- La troisième colonne est le nom du code d’opération (ou opcode).
- La quatrième colonne est le paramètre de l’opération.
- La cinquième colonne (entre parenthèses) donne la signification du paramètre, par rapport au code source.
Comme vous pouvez le voir, ce bytecode est assez proche de l’exemple RPN ci-dessus :
- L’instruction
LOAD_FASTempile une variable (ici un paramètre de la fonction). - L’instruction
BINARY_ADDdépile deux valeurs du sommet de la pile, les additionne, et empile le résultat. - L’instruction
BINARY_MULTIPLYfait ce que vous devinez. - Et enfin,
RETURN_VALUErenvoie la valeur restante au sommet de la pile.
Il existe environ 120 instructions de bytecode différentes, qui sont listées dans la documentation de Python.
Voyons maintenant une fonction un peu plus complexe, qui affiche les n premiers entiers :
def print_seq(n):
for i in xrange(n):
print i
Le bytecode produit par cette fonction est le suivant :
2 0 SETUP_LOOP 25 (to 28)
3 LOAD_GLOBAL 0 (xrange)
6 LOAD_FAST 0 (n)
9 CALL_FUNCTION 1
12 GET_ITER
>> 13 FOR_ITER 11 (to 27)
16 STORE_FAST 1 (i)
3 19 LOAD_FAST 1 (i)
22 PRINT_ITEM
23 PRINT_NEWLINE
24 JUMP_ABSOLUTE 13
>> 27 POP_BLOCK
>> 28 LOAD_CONST 0 (None)
31 RETURN_VALUE
On comprend facilement son fonctionnement grâce à la documentation, mais voici quelques commentaires :
Il y a une colonne supplémentaire dans l’affichage, contenant le symbole >>. Elle indique que l’instruction est la destination d’un saut depuis une autre instruction.
CALL_FUNCTION 1 signifie qu’il y a une fonction à appeler avec un argument positionnel, qui se trouve au sommet de la pile. La fonction elle-même est une valeur sur la pile, empilée par l’instruction LOAD_GLOBAL.
On voit que print produit des instructions de bytecode dédiées (PRINT_ITEM et PRINT_NEWLINE), car c’est un mot-clé spécial, et non une vraie fonction.
On voit aussi qu’un return None est implicitement ajouté à la fin de la fonction, si elle n’a rien à renvoyer.
Dans les entrailles de la VM Python
Pour terminer ce tour d’horizon, regardons la machine virtuelle plus en détail.
Comme le nom « CPython » le laisse deviner, la VM Python est écrite en C, donc au bout du compte chaque instruction de bytecode est interprétée par un morceau de code C.
Le cœur de la VM est la fonction PyEval_EvalFrameEx, définie dans Python/ceval.c, qui exécute les instructions de bytecode. Cette fonction est en gros un grand switch sur l’opcode, et chaque instruction de bytecode est implémentée dans un bloc case.
Voici par exemple l’implémentation en C de l’instruction BINARY_ADD :
TARGET(BINARY_ADD) {
PyObject *right = POP();
PyObject *left = TOP();
PyObject *sum;
if (PyUnicode_CheckExact(left) &&
PyUnicode_CheckExact(right)) {
sum = unicode_concatenate(left, right, f, next_instr);
/* unicode_concatenate consumed the ref to v */
}
else {
sum = PyNumber_Add(left, right);
Py_DECREF(left);
}
Py_DECREF(right);
SET_TOP(sum);
if (sum == NULL)
goto error;
DISPATCH();
}
Ce code se contente de prendre les paramètres sur la pile, et d’appeler la fonction PyNumber_Add, définie ainsi :
#define NB_SLOT(x) offsetof(PyNumberMethods, x)
PyObject *
PyNumber_Add(PyObject *v, PyObject *w)
{
PyObject *result = binary_op1(v, w, NB_SLOT(nb_add));
if (result == Py_NotImplemented) {
PySequenceMethods *m = v->ob_type->tp_as_sequence;
Py_DECREF(result);
if (m && m->sq_concat) {
return (*m->sq_concat)(v, w);
}
result = binop_type_error(v, w, "+");
}
return result;
}
Comme Python est un langage dynamique, cette fonction ne fait aucune hypothèse sur le type des paramètres. Le vrai travail est délégué à une autre fonction, binary_op1, qui est une fonction générique pour effectuer une opération binaire sur une paire d’objets :
#define NB_BINOP(nb_methods, slot) \
(*(binaryfunc*)(& ((char*)nb_methods)[slot]))
static PyObject *
binary_op1(PyObject *v, PyObject *w, const int op_slot)
{
PyObject *x;
binaryfunc slotv = NULL;
binaryfunc slotw = NULL;
if (v->ob_type->tp_as_number != NULL)
slotv = NB_BINOP(v->ob_type->tp_as_number, op_slot);
if (w->ob_type != v->ob_type &&
w->ob_type->tp_as_number != NULL) {
slotw = NB_BINOP(w->ob_type->tp_as_number, op_slot);
if (slotw == slotv)
slotw = NULL;
}
if (slotv) {
if (slotw && PyType_IsSubtype(w->ob_type, v->ob_type)) {
x = slotw(v, w);
if (x != Py_NotImplemented)
return x;
Py_DECREF(x); /* can't do it */
slotw = NULL;
}
x = slotv(v, w);
if (x != Py_NotImplemented)
return x;
Py_DECREF(x); /* can't do it */
}
if (slotw) {
x = slotw(v, w);
if (x != Py_NotImplemented)
return x;
Py_DECREF(x); /* can't do it */
}
Py_RETURN_NOTIMPLEMENTED;
}
Et ce n’est pas fini ! La fonction d’addition proprement dite dépend du type des paramètres ; si on suppose que les paramètres sont des nombres flottants, l’addition est implémentée par la fonction suivante :
static PyObject *
float_add(PyObject *v, PyObject *w)
{
double a,b;
CONVERT_TO_DOUBLE(v, a);
CONVERT_TO_DOUBLE(w, b);
PyFPE_START_PROTECT("add", return 0)
a = a + b;
PyFPE_END_PROTECT(a)
return PyFloat_FromDouble(a);
}
On y est ! On voit enfin notre addition, faite par la ligne a = a + b !
Vous comprenez sans doute maintenant pourquoi le calcul numérique fait en Python pur est bien plus lent qu’en C, et pourquoi vous devriez utiliser numpy si les performances comptent pour vous !