Python : optimisation et couverture de code
Il est courant de vérifier si nos tests couvrent bien notre code, et en Python on utilise généralement coverage pour ça.
Cependant, il faut savoir que les optimisations de l’interpréteur peuvent
interférer, même si elles n’ont pas été activées explicitement via
l’option -O.
Démonstration
Le code suivant est une version simplifiée d’un code plus complexe où j’ai rencontré ce problème1 :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | #!/usr/bin/env python # coding: utf-8 def fetch(lst): for x in lst: print(x) try: if x & 1: raise Exception('error') except: print('x is odd') else: continue print('nothing to do') fetch([1, 2]) |
Si l’on exécute ce code, avec coverage run foo.py on obtient la
trace suivante :
1
x is odd
nothing to do
2
On constate que chaque ligne est exécutée au moins une fois :
-
1:printde la ligne 6. -
x is odd:printde la ligne 11, ce qui signifie que l’on est passé par leifde ligne 8 et que l’on a levé une exception à la ligne 9. -
nothing to do:printde la ligne 14. -
2:printde la ligne 6, étant donné qu’il n’y a pas de « nothing to do » on en conclut que l’on est passé par lecontinuede la ligne 13 (et que l’on a sauté leprintde la ligne 14).
Cependant, si l’on exécute coverage report -m on obtient le
résultat suivant :
Name Stmts Miss Cover Missing
--------------------------------------
foo.py 11 1 91% 13
D’après coverage, nous ne sommes pas passé par la ligne 13 !
Impossible, car si c’était le cas, nous aurions vu « nothing to do » après le
« 2 ».
Pour aller encore plus loin dans le « fun », on peut modifier le code en
déplaçant le continue à l’intérieur du try, après la
levée d’exception :
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 | #!/usr/bin/env python # coding: utf-8 def fetch(lst): for x in lst: print(x) try: if x & 1: raise Exception('meh!') continue except: print('x is odd') print('nothing to do') fetch([1, 2]) |
Nous avons le même comportement, car coverage run foo.py produit
exactement la même trace :
1
x is odd
nothing to do
2
Par contre, cette fois ci coverage confirme bien que chaque ligne
a été exécutée :
Name Stmts Miss Cover Missing
--------------------------------------
ok2.py 11 0 100%
Étrange n’est-ce pas ?
Sous le capot
Dans ce genre de cas, il peut être intéressant de voir le bytecode
généré par l’interpréteur. Pour ce faire, nous allons utiliser le
module dis (que j’avais déjà
utilisé ici) afin de comparer
le bytecode des deux fonctions et essayer de comprendre l’origine de
cette différence.
Version qui fonctionne
On va commencer par analyser la version qui fonctionne.
5 0 SETUP_LOOP 72 (to 74)
2 LOAD_FAST 0 (lst)
4 GET_ITER
>> 6 FOR_ITER 64 (to 72)
8 STORE_FAST 1 (x)
6 10 LOAD_GLOBAL 0 (print)
12 LOAD_FAST 1 (x)
14 CALL_FUNCTION 1
16 POP_TOP
7 18 SETUP_EXCEPT 22 (to 42)
8 20 LOAD_FAST 1 (x)
22 LOAD_CONST 1 (1)
24 BINARY_AND
26 POP_JUMP_IF_FALSE 36
9 28 LOAD_GLOBAL 1 (Exception)
30 LOAD_CONST 2 ('meh!')
32 CALL_FUNCTION 1
34 RAISE_VARARGS 1
10 >> 36 CONTINUE_LOOP 6
38 POP_BLOCK
40 JUMP_FORWARD 20 (to 62)
11 >> 42 POP_TOP
44 POP_TOP
46 POP_TOP
12 48 LOAD_GLOBAL 0 (print)
50 LOAD_CONST 3 ('x is odd')
52 CALL_FUNCTION 1
54 POP_TOP
56 POP_EXCEPT
58 JUMP_FORWARD 2 (to 62)
60 END_FINALLY
13 >> 62 LOAD_GLOBAL 0 (print)
64 LOAD_CONST 4 ('nothing to do')
66 CALL_FUNCTION 1
68 POP_TOP
70 JUMP_ABSOLUTE 6
>> 72 POP_BLOCK
>> 74 LOAD_CONST 0 (None)
76 RETURN_VALUE
Petite parenthèse sur le format de sortie de dis :
- première colonne : numéro de ligne pour la première instruction de chaque ligne (une ligne de code Python étant composée de plusieurs instructions).
-
deuxième colonne :
-->pour indiquer l’instruction en cours d’exécution. -
troisième colonne :
>>pour indiquer que l’instruction est une cible de saut (i.e. une autre instruction peut sauter à cet endroit). - quatrième colonne : offset de l’instruction (souvent utilisé comme cible de saut).
- cinquième colonne : nom de l’instruction.
- sixième colonne : arguments de l’instruction.
- septième colonne : informations supplémentaires sur les arguments.
Sachant cela, on voit bien que notre continue ligne 10 est
traduit en un CONTINUE_LOOP (et deux autres instructions pour
sortir du try et retourner au début de la boucle).
Version problèmatique
Maintenant, voyons voir le bytecode de la version problèmatique :
5 0 SETUP_LOOP 72 (to 74)
2 LOAD_FAST 0 (lst)
4 GET_ITER
>> 6 FOR_ITER 64 (to 72)
8 STORE_FAST 1 (x)
6 10 LOAD_GLOBAL 0 (print)
12 LOAD_FAST 1 (x)
14 CALL_FUNCTION 1
16 POP_TOP
7 18 SETUP_EXCEPT 20 (to 40)
8 20 LOAD_FAST 1 (x)
22 LOAD_CONST 1 (1)
24 BINARY_AND
26 POP_JUMP_IF_FALSE 36
9 28 LOAD_GLOBAL 1 (Exception)
30 LOAD_CONST 2 ('meh!')
32 CALL_FUNCTION 1
34 RAISE_VARARGS 1
>> 36 POP_BLOCK
38 JUMP_ABSOLUTE 6
10 >> 40 POP_TOP
42 POP_TOP
44 POP_TOP
11 46 LOAD_GLOBAL 0 (print)
48 LOAD_CONST 3 ('x is odd')
50 CALL_FUNCTION 1
52 POP_TOP
54 POP_EXCEPT
56 JUMP_FORWARD 4 (to 62)
58 END_FINALLY
13 60 JUMP_ABSOLUTE 6
14 >> 62 LOAD_GLOBAL 0 (print)
64 LOAD_CONST 4 ('nothing to do')
66 CALL_FUNCTION 1
68 POP_TOP
70 JUMP_ABSOLUTE 6
>> 72 POP_BLOCK
>> 74 LOAD_CONST 0 (None)
76 RETURN_VALUE
La première chose que l’on remarque, c’est l’absence
d’instruction CONTINUE_LOOP : à la place nous avons
un JUMP_ABSOLUTE à la ligne 13.
La seconde chose que l’on remarque c’est que cette instruction
JUMP_ABSOLUTE n’est pas atteignable :
-
si la condition du
ifest fausse (position 26), on saute à la position 36 qui fait un saut à la position 6. -
si la condition du
ifest vraie, on va lancer une exception et sauter à la position 40 (cf. leSETUP_EXCEPTde la position 18), puis exécuter les instructions jusqu’au saut de la position 56 qui nous amène à la position 62.
Dans les deux cas, on ne passe pas par la position 60, et comme aucune
instruction ne saute à cette position (elle n’est pas marqué
avec >>) et bien cette instruction n’est jamais exécutée.
Hypothèse
Si l’on devait générer le code nous-même en faisant du « mot à mot », on produirait probablement la séquence suivante :
-
on commence par traduire le
ifde la ligne 8 en utilisant unPOP_JUMP_IF_FALSEqui saute à la fin du bloctrysur unPOP_BLOCK(position 36) pour sortir du bloc. -
à la suite de ce
POP_BLOCKon ajoute un saut jusqu’aucontinuede la ligne 13/position 60 (probablement en utilisant unJUMP_FORWARD). -
enfin, le
continuede la ligne 13 est représenté par unCONTINUE_LOOPqui saute au début de la boucle à la position 6 (comme dans l’exemple qui fonctionne).
Maintenant, si on prend un peu de recul et que l’on décrit les choses de manière plus abstraite cela donne :
-
saute vers A si la condition est fausse (
POP_JUMP_IF_FALSEvers lePOP_BLOCK). -
saute vers B (
JUMP_FORWARDvers lecontinue). -
saute vers C (
CONTINUE_LOOPvers le début de la boucle).
On remarque que les deux dernières étapes sont des sauts inconditionnels, par conséquent l’étape 2 est superflue : on peut sauter de A vers C directement.
On peut donc raisonnablement penser que CPython sait reconnaître ce motif de « deux sauts inconditionnels consécutifs » et le remplace par un unique saut vers la destination finale.
Ce genre d’optimisations où l’on remplace une suite
d’instructions A par une autre suite
d’instructions B fonctionnellement équivalente (mais plus
efficace) est très commune et s’appelle une
peephole optimization.
Et c’est bien ce que semble faire CPython en sautant directement au début de
la boucle depuis la fin du bloc try, ignorant totalement notre
bloc else au passage (qui ne contient rien d’autre qu’un saut
inconditionnel).
Cela semble être confirmé par le fait que si l’on rajoute
un print dans le else (avant
le continue) alors le bloc else et
son continue sont maintenant exécutés. En effet, dans le cas
contraire le print ne serait pas exécuté et cela provoquerait une
différence de comportement observable qui rendrait l’optimisation caduque.
Confirmation de l’hypothèse
Bon, on a maintenant une hypothèse plutôt solide, et après quelques recherches on tombe sur :
-
ce rapport de bogue
chez
pytest-covqui nous apprend que la bibliothèquecoverageserait à l’origine du problème. - il s’avère que le lien donné pointe sur un doublon.
-
en remontant au
bogue d’origine
(on remarquera le titre qui mentionne
continue) on découvre que le problème ne vient pas decoveragelui-même mais de l’interpréteur Python (CPython en l’occurrence) qui est capable de remplacer un saut vers uncontinuepar un saut vers le début de la boucle directement (au passage, on y apprend aussi que cette optimisation est toujours activée).
Pour ceux qui veulent suivre l’évolution de ce problème, un bogue à été ouvert pour l’interpréteur.
Cela vient donc confirmer notre hypothèse : l’optimiseur intervient sur les instructions générées, ce qui affecte la couverture du code source.
Conclusion
La première leçon que l’on peut en tirer c’est que le code que l’on voit ne correspond pas forcément au code qui est exécuté. C’est bien connu dans les langages tel que le C par exemple, mais c’est plus surprenant en Python.
Deuxième point important : ce problème est simple à résoudre car « il
suffit de » réécrire le code en utilisant la version « qui fonctionne », et
c’est même plutôt bénéfique étant donné qu’elle est aussi plus lisible que le
code d’origine. En effet, l’utilisation du else dans une
construction try/except est spécifique à Python et donc
probablement méconnue.
Au passage, ces constructions « bizarres » à base
de try, else et continue, en plus
d’être parfois difficile à déchiffrer, ne sont pas toujours bien supportées :
pendant longtemps il a été impossible d’avoir un
continue à l’intérieur d’un bloc try (c’est
maintenant possible). Et aujourd’hui
encore2 il n’est pas possible
d’avoir un continue dans un bloc finally : c’est une
erreur de syntaxe !
En conclusion, il vaut mieux écrire du code simple et quand quelque chose de bizarre se produit il ne faut pas hésiter à regarder sous le capot pour en comprendre les raisons.
-
le code du
elsedans la constructiontry/except/elseest exécuté seulement si aucune exception n’a été levée à l‘intérieur dutry.↩ -
c’est maintenant possible en Python 3.8, qui est sorti entre le début de la rédaction de cet article et sa publication.↩