grim7reaper

Un artisan du code

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 :

Exemple minimal reproduisant le problème
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 :

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 :

Version alternative
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 :

  1. 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).
  2. deuxième colonne : --> pour indiquer l’instruction en cours d’exécution.
  3. troisième colonne : >> pour indiquer que l’instruction est une cible de saut (i.e. une autre instruction peut sauter à cet endroit).
  4. quatrième colonne : offset de l’instruction (souvent utilisé comme cible de saut).
  5. cinquième colonne : nom de l’instruction.
  6. sixième colonne : arguments de l’instruction.
  7. 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 :

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 :

Maintenant, si on prend un peu de recul et que l’on décrit les choses de manière plus abstraite cela donne :

  1. saute vers A si la condition est fausse (POP_JUMP_IF_FALSE vers le POP_BLOCK).
  2. saute vers B (JUMP_FORWARD vers le continue).
  3. 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 :

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.


  1. le code du else dans la construction try/except/else est exécuté seulement si aucune exception n’a été levée à l‘intérieur du try.

  2. 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.