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 :
On constate que chaque ligne est exécutée au moins une fois :
-
1
:print
de la ligne 6. -
x is odd
:print
de la ligne 11, ce qui signifie que l’on est passé par leif
de ligne 8 et que l’on a levé une exception à la ligne 9. -
nothing to do
:print
de la ligne 14. -
2
:print
de la ligne 6, étant donné qu’il n’y a pas de « nothing to do » on en conclut que l’on est passé par lecontinue
de la ligne 13 (et que l’on a sauté leprint
de la ligne 14).
Cependant, si l’on exécute coverage report -m
on obtient le
résultat suivant :
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 :
Par contre, cette fois ci coverage
confirme bien que chaque ligne
a été exécutée :
É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.
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 :
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
if
est fausse (position 26), on saute à la position 36 qui fait un saut à la position 6. -
si la condition du
if
est vraie, on va lancer une exception et sauter à la position 40 (cf. leSETUP_EXCEPT
de 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
if
de la ligne 8 en utilisant unPOP_JUMP_IF_FALSE
qui saute à la fin du bloctry
sur unPOP_BLOCK
(position 36) pour sortir du bloc. -
à la suite de ce
POP_BLOCK
on ajoute un saut jusqu’aucontinue
de la ligne 13/position 60 (probablement en utilisant unJUMP_FORWARD
). -
enfin, le
continue
de la ligne 13 est représenté par unCONTINUE_LOOP
qui 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_FALSE
vers lePOP_BLOCK
). -
saute vers B (
JUMP_FORWARD
vers lecontinue
). -
saute vers C (
CONTINUE_LOOP
vers 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-cov
qui nous apprend que la bibliothèquecoverage
serait à 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 decoverage
lui-même mais de l’interpréteur Python (CPython en l’occurrence) qui est capable de remplacer un saut vers uncontinue
par 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
else
dans la constructiontry
/except
/else
est 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.↩