El artículo que sigue es una traducción de un artículo escrito en la página web Python Module of the Week, que es una especie de recopilatorio de artículos sobre módulos de Python escritos por Doug Hellman. Sus textos se publican bajo la licencia Creative Commons By-Nc-Sa ( como los míos, por si alguien no lo había notado todavía con el logo de la página ).

Python Bytecode Disassembler ( dis )

El módulo que trataremos se llama dis y su principal utilidad es convertir código objeto a una representación de bytecode que sea entendible para los seres humanos (o almenos para aquellos que hayan perdido un poco de su tiempo en intentar entender éstas cosas). Éste texto está indicado para versiones de Python iguales o superiores a la versión 1.4, por lo que no tendréis ningún problema (ahora todo el casi mundo usa versiones iguales o superiores a la 2.4).

El módulo dis incluye funciones para desensamblar bytecode de Python (que se genera durante la interpretación del código para acelerar el funcionamiento de los scripts).Observar el código bytecode ejecutado por el intérprete es una buena forma de optimizar a mano bucles y otras secuencias de código. También es útil para encontrar condiciones de carrera en aplicaciones multihilo ya que mirando el bytecode se puede ver en qué "momento" es más probable que haya un cambio de hilo.

Desensamblado básico

La función dis.dis() muestra por pantalla la representación del desensamblado de código fuente Python (módulo, clase, método, función, o código objeto). Podemos desensamblar código como el siguiente:

1
2
3
4
#!/usr/bin/env python
# encoding: utf-8
 
my_dict = { 'a':1 }

ejecutando dis desde la línea de comandos. La salida está organizada en columnas con el número de línea original del código fuente, la “dirección” dentro del código objeto, el nombre de opcode y los argumentos pasados al opcode.

1
2
3
4
5
6
7
8
$ python -m dis codigo.py
  4           0 BUILD_MAP                1
              3 LOAD_CONST               0 (1)
              6 LOAD_CONST               1 ('a')
              9 STORE_MAP
             10 STORE_NAME               0 (my_dict)
             13 LOAD_CONST               2 (None)
             16 RETURN_VALUE

En este caso el código se traduce a 5 operaciones para inicializar el diccionario (crearlo y llenarlo), luego guarda los resultados en una variable global. Como el intérprete de Python está basado en un esquema de pila, los primeros pasos consisten en poner las constantes en la pila siguiendo el orden correcto con la operación LOAD_CONST, y luego usar STORE_MAP para sacar la clave y el valor que se añadirán al diccionario (no nos olvidemos de que antes se ha hecho la operación BUILD_MAP, los valores añadidos entre la ejecución de BUILD_MAP y STORE_MAP serán las claves y los valores del diccionario que estamos creando). El objeto resultante se enlaza con el nombre "my_dict" con la operación STORE_NAME.

Desensamblando funciones

Desafortunadamente desensamblar el módulo entero no lo hace con las funciones que hay en él automáticamente. Por ejemplo, si desensamblamos éste módulo:

1
2
3
4
5
6
7
8
9
10
#!/usr/bin/env python
# encoding: utf-8
 
def f(*args):
    nargs = len(args)
    print nargs, args
 
if __name__ == '__main__':
    import dis
    dis.dis(f)

los resultados muestran como se carga el código objeto en la pila y luego se salta dentro de la función (LOAD_CONST, MAKE_FUNCTION), pero el cuerpo de la función no está.

1
2
$ python -m dis dis_function.py
  4           0 LOAD_CONST               0 (<code>) 3 MAKE_FUNCTION 0 6 STORE_NAME 0 (f) 8 9 LOAD_NAME 1 (__name__) 12 LOAD_CONST 1 ('__main__') 15 COMPARE_OP 2 (==) 18 JUMP_IF_FALSE 29 (to 50) 21 POP_TOP 9 22 LOAD_CONST 2 (-1) 25 LOAD_CONST 3 (None) 28 IMPORT_NAME 2 (dis) 31 STORE_NAME 2 (dis) 10 34 LOAD_NAME 2 (dis) 37 LOAD_ATTR 2 (dis) 40 LOAD_NAME 0 (f) 43 CALL_FUNCTION 1 46 POP_TOP 47 JUMP_FORWARD 1 (to 51) >> 50 POP_TOP >> 51 LOAD_CONST 3 (None) 54 RETURN_VALUE </code>

Para ver dentro de la función tenemos que pasarla como argumento a dis.dis().

1
2
3
4
5
6
7
8
9
10
11
12
13
$ python dis_function.py
  5           0 LOAD_GLOBAL              0 (len)
              3 LOAD_FAST                0 (args)
              6 CALL_FUNCTION            1
              9 STORE_FAST               1 (nargs)
 
  6          12 LOAD_FAST                1 (nargs)
             15 PRINT_ITEM
             16 LOAD_FAST                0 (args)
             19 PRINT_ITEM
             20 PRINT_NEWLINE
             21 LOAD_CONST               0 (None)
             24 RETURN_VALUE

Clases

Se pueden pasar también clases a la función dis, en este caso todos sus métodos son desensamblados a la vez.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env python
# encoding: utf-8
 
import dis
 
class MyObject(object):
    """Example for dis."""
 
    CLASS_ATTRIBUTE = 'some value'
 
    def __init__(self, name):
        self.name = name
 
    def __str__(self):
        return 'MyObject(%s)' % self.name
 
dis.dis(MyObject)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ python dis_class.py
Disassembly of __init__:
 12           0 LOAD_FAST                1 (name)
              3 LOAD_FAST                0 (self)
              6 STORE_ATTR               0 (name)
              9 LOAD_CONST               0 (None)
             12 RETURN_VALUE
 
Disassembly of __str__:
 15           0 LOAD_CONST               1 ('MyObject(%s)')
              3 LOAD_FAST                0 (self)
              6 LOAD_ATTR                0 (name)
              9 BINARY_MODULO
             10 RETURN_VALUE

Desensamblando para debuggear

A veces puede ser útil ver qué bytecode causó el problema cuando se está debuggeando una excepción.  Hay un par de formas de desensamblar el código que encierra el error.

La primera forma consiste en usar dis.dis() dentro del intérprete interactivo para que analize la última excepción ocurrida. Si no se le pasa ningún argumento a dis, ésta busca la última excepción ocurrida y muestra el desensamblado de la parte "más alta" de la pila que la causó.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ python
Python 2.6.2 (r262:71600, Apr 16 2009, 09:17:39)
[GCC 4.0.1 (Apple Computer, Inc. build 5250)] on darwin
Type "help", "copyright", "credits" or "license" for more information.
>>> import dis
>>> j = 4
>>> i = i + 4
Traceback (most recent call last):
  File "", line 1, in
NameError: name 'i' is not defined
>>> dis.distb()
  1 -->       0 LOAD_NAME                0 (i)
              3 LOAD_CONST               0 (4)
              6 BINARY_ADD
              7 STORE_NAME               0 (i)
             10 LOAD_CONST               1 (None)
             13 RETURN_VALUE
>>>

Notad la flecha --> indicando el opcode que causó el error. La variable “i” no está definida, por lo que el valor asociado con el nombre no puede ser cargado en la pila.

Desde tu propio código puedes mostrar por pantalla información sobre el traceback pasándolodirectamente como argumento a dis.distb(). En este ejemplo hay una excepción DivideByZero, pero como la fórmula tiene dos partes, no está claro cual de los elementos es el cero.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#!/usr/bin/env python
# encoding: utf-8
 
i = 1
j = 0
k = 3
 
# ... many lines removed ...
 
try:
    result = k * (i / j) + (i / k)
except:
    import dis
    import sys
    exc_type, exc_value, exc_tb = sys.exc_info()
    dis.distb(exc_tb)

El valor incorrecto es fácil de detectar cuando está cargado en la pila dentro del desensamblado. La operación incorrecta está remarcada con la flecha -->, y sólo tenemos que mirar unas cuantas líneas hacia arriba para encontrar dónde se ha cargado el valor 0 en la pila.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
$ python dis_traceback.py
  4           0 LOAD_CONST               0 (1)
              3 STORE_NAME               0 (i)
 
  5           6 LOAD_CONST               1 (0)
              9 STORE_NAME               1 (j)
 
  6          12 LOAD_CONST               2 (3)
             15 STORE_NAME               2 (k)
 
 10          18 SETUP_EXCEPT            26 (to 47)
 
 11          21 LOAD_NAME                2 (k)
             24 LOAD_NAME                0 (i)
             27 LOAD_NAME                1 (j)
    -->      30 BINARY_DIVIDE
             31 BINARY_MULTIPLY
             32 LOAD_NAME                0 (i)
             35 LOAD_NAME                2 (k)
             38 BINARY_DIVIDE
             39 BINARY_ADD
             40 STORE_NAME               3 (result)
             43 POP_BLOCK
             44 JUMP_FORWARD            65 (to 112)
 
 12     >>   47 POP_TOP
             48 POP_TOP
             49 POP_TOP
 
 13          50 LOAD_CONST               3 (-1)
             53 LOAD_CONST               4 (None)
             56 IMPORT_NAME              4 (dis)
             59 STORE_NAME               4 (dis)
 
 14          62 LOAD_CONST               3 (-1)
             65 LOAD_CONST               4 (None)
             68 IMPORT_NAME              5 (sys)
             71 STORE_NAME               5 (sys)
 
 15          74 LOAD_NAME                5 (sys)
             77 LOAD_ATTR                6 (exc_info)
             80 CALL_FUNCTION            0
             83 UNPACK_SEQUENCE          3
             86 STORE_NAME               7 (exc_type)
             89 STORE_NAME               8 (exc_value)
             92 STORE_NAME               9 (exc_tb)
 
 16          95 LOAD_NAME                4 (dis)
             98 LOAD_ATTR               10 (distb)
            101 LOAD_NAME                9 (exc_tb)
            104 CALL_FUNCTION            1
            107 POP_TOP
            108 JUMP_FORWARD             1 (to 112)
            111 END_FINALLY
        >>  112 LOAD_CONST               4 (None)
            115 RETURN_VALUE

Análisis de rendimiento en bucles

Además de para localizar errores, dis también puede ayudar a encontrar problemas de rendimiento en nuestro código. Examinar el código desensamblado es especialmente útil con pequeños bucles en los que el número de líneas de código Python es pequeño pero éstas se  ejecutan lentamente ya que se traducen a un conjunto ineficiente de bytecodes. Veremos como el desensamblado nos ayuda a examinar unas pocas implementaciones de una clase, Dictionary, que lee un conjunto de palabras y las agrupa por su primera letra.

Antes de nada, la aplicación que usaremos para hacer los tests:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import dis
import sys
import timeit
 
module_name = sys.argv[1]
module = __import__(module_name)
Dictionary = module.Dictionary
 
dis.dis(Dictionary.load_data)
print
t = timeit.Timer(
    'd = Dictionary(words)',
    """from %(module_name)s import Dictionary
words = [l.strip() for l in open('/usr/share/dict/words', 'rt')]
    """ % locals()
    )
iterations = 10
print 'TIME: %0.4f' % (t.timeit(iterations)/iterations)

Podemos usar dis_test_loop.py para ejecutar cada versión de la clase Dictionary que hagamos.

Una implementación sencilla de la classe Dictionary puede ser algo así:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python
# encoding: utf-8
 
class Dictionary(object):
 
    def __init__(self, words):
        self.by_letter = {}
        self.load_data(words)
 
    def load_data(self, words):
        for word in words:
            try:
                self.by_letter[word[0]].append(word)
            except KeyError:
                self.by_letter[word[0]] = [word]

La salida muestra que esta versión ha tomado 0.1074 segundos para cargar las 234936 palabras en mi copia de  /usr/share/dict/words en OS X [Recordad que es una traducción y no lo hice directamente yo ésto]. No está demasiado mal, pero como podemos ver en el desensamblado de abajo, el bucle. está haciendo más trabajo del necesario. Tal como entra en el bucle en el opcode 13, se instala el contexto de una excepción (SETUP_EXCEPT). Entonces usa 6 opcodes para encontrar self.by_letter[word[0]] antes de añadir la palabra a la lista. Si se lanza una excepción porque word[0] todavía no está en el diccionario, el manejador de excepciones hace otra vez el mismo trabajo para  determinar word[0] (3 opcodes) y inicializa self.by_letter[word[0]] como una nueva lista que contiene la palabra.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
$ python dis_test_loop.py dis_slow_loop
 11           0 SETUP_LOOP              84 (to 87)
              3 LOAD_FAST                1 (words)
              6 GET_ITER
        >>    7 FOR_ITER                76 (to 86)
             10 STORE_FAST               2 (word)
 
 12          13 SETUP_EXCEPT            28 (to 44)
 
 13          16 LOAD_FAST                0 (self)
             19 LOAD_ATTR                0 (by_letter)
             22 LOAD_FAST                2 (word)
             25 LOAD_CONST               1 (0)
             28 BINARY_SUBSCR
             29 BINARY_SUBSCR
             30 LOAD_ATTR                1 (append)
             33 LOAD_FAST                2 (word)
             36 CALL_FUNCTION            1
             39 POP_TOP
             40 POP_BLOCK
             41 JUMP_ABSOLUTE            7
 
 14     >>   44 DUP_TOP
             45 LOAD_GLOBAL              2 (KeyError)
             48 COMPARE_OP              10 (exception match)
             51 JUMP_IF_FALSE           27 (to 81)
             54 POP_TOP
             55 POP_TOP
             56 POP_TOP
             57 POP_TOP
 
 15          58 LOAD_FAST                2 (word)
             61 BUILD_LIST               1
             64 LOAD_FAST                0 (self)
             67 LOAD_ATTR                0 (by_letter)
             70 LOAD_FAST                2 (word)
             73 LOAD_CONST               1 (0)
             76 BINARY_SUBSCR
             77 STORE_SUBSCR
             78 JUMP_ABSOLUTE            7
        >>   81 POP_TOP
             82 END_FINALLY
             83 JUMP_ABSOLUTE            7
        >>   86 POP_BLOCK
        >>   87 LOAD_CONST               0 (None)
             90 RETURN_VALUE
 
TIME: 0.1074

Una técnica para elimnar la excepción es rellenar self.by_letter con una lista para cada letra del alfabeto antes de empezar a llenar el diccionario. Esto significa que siempre podremos hacer la operación append satisfactoriamente sin necesidad de manejar ninguna excepción.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python
# encoding: utf-8
 
import string
 
class Dictionary(object):
 
    def __init__(self, words):
        self.by_letter = dict( (letter, [])
                                for letter in string.letters)
        self.load_data(words)
 
    def load_data(self, words):
        for word in words:
            self.by_letter[word[0]].append(word)

El cambio reduce el número de opcodes aproximadamente a la mitad, pero solo se reduce el tiempo a 0.0984 segundos. Obviamente el manejo de la excepción añadía un cierto overhead pero tampoco demasiado.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
$ python dis_test_loop.py dis_faster_loop
 14           0 SETUP_LOOP              38 (to 41)
              3 LOAD_FAST                1 (words)
              6 GET_ITER
        >>    7 FOR_ITER                30 (to 40)
             10 STORE_FAST               2 (word)
 
 15          13 LOAD_FAST                0 (self)
             16 LOAD_ATTR                0 (by_letter)
             19 LOAD_FAST                2 (word)
             22 LOAD_CONST               1 (0)
             25 BINARY_SUBSCR
             26 BINARY_SUBSCR
             27 LOAD_ATTR                1 (append)
             30 LOAD_FAST                2 (word)
             33 CALL_FUNCTION            1
             36 POP_TOP
             37 JUMP_ABSOLUTE            7
        >>   40 POP_BLOCK
        >>   41 LOAD_CONST               0 (None)
             44 RETURN_VALUE
 
TIME: 0.0984

Podemos optimizar aún más el rendimiento moviendo el acceso a self.by_letter fuera del bucle (dado que el valor no cambia en ningún momento).

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#!/usr/bin/env python
# encoding: utf-8
 
import collections
 
class Dictionary(object):
 
    def __init__(self, words):
        self.by_letter = collections.defaultdict(list)
        self.load_data(words)
 
    def load_data(self, words):
        by_letter = self.by_letter
        for word in words:
            by_letter[word[0]].append(word)

Los opcodes 0-6 ahora encuentran el valor de self.by_letter y lo guardan como la variable local by_letter. Usar variables locales solo requiere un opcode en vez de 2 (en la posición 22 se usa LOAD_FAST para almacenar dictionary en la pila). Después de este cambio el tiempo de ejecución se reduce a 0.0842 segundos.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ python dis_test_loop.py dis_fastest_loop
 13           0 LOAD_FAST                0 (self)
              3 LOAD_ATTR                0 (by_letter)
              6 STORE_FAST               2 (by_letter)
 
 14           9 SETUP_LOOP              35 (to 47)
             12 LOAD_FAST                1 (words)
             15 GET_ITER
        >>   16 FOR_ITER                27 (to 46)
             19 STORE_FAST               3 (word)
 
 15          22 LOAD_FAST                2 (by_letter)
             25 LOAD_FAST                3 (word)
             28 LOAD_CONST               1 (0)
             31 BINARY_SUBSCR
             32 BINARY_SUBSCR
             33 LOAD_ATTR                1 (append)
             36 LOAD_FAST                3 (word)
             39 CALL_FUNCTION            1
             42 POP_TOP
             43 JUMP_ABSOLUTE           16
        >>   46 POP_BLOCK
        >>   47 LOAD_CONST               0 (None)
             50 RETURN_VALUE
 
TIME: 0.0842

Una mayor optimización sugerida por Brandon Rhodes es eliminar la versión Python del bucle por completo. Si usamos groupby() del módulo itertools para agrupar la entrada, la iteración es movida a código C. Podemos hacer ésto porque sabemos que la entrada está ordenada. En caso de no saber si está ordenada o no, deberíamos ordenarla por si acaso.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#!/usr/bin/env python
# encoding: utf-8
 
import operator
import itertools
 
class Dictionary(object):
 
    def __init__(self, words):
        self.by_letter = {}
        self.load_data(words)
 
    def load_data(self, words):
        # Arrange by letter
        grouped = itertools.groupby(words, key=operator.itemgetter(0))
        # Save arranged sets of words
        self.by_letter = dict((group[0][0], group) for group in grouped)

La versión con itertools solo tarda 0.0543 segundos en ejecutarse, más o menos la mitad del tiempo original.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
$ python dis_test_loop.py dis_eliminate_loop
 15           0 LOAD_GLOBAL              0 (itertools)
              3 LOAD_ATTR                1 (groupby)
              6 LOAD_FAST                1 (words)
              9 LOAD_CONST               1 ('key')
             12 LOAD_GLOBAL              2 (operator)
             15 LOAD_ATTR                3 (itemgetter)
             18 LOAD_CONST               2 (0)
             21 CALL_FUNCTION            1
             24 CALL_FUNCTION          257
             27 STORE_FAST               2 (grouped)
 
 17          30 LOAD_GLOBAL              4 (dict)
             33 LOAD_CONST               3 (<code> at 0x7e7b8, file "/Users/dhellmann/Documents/PyMOTW/dis/PyMOTW/dis/dis_eliminate_loop.py", line 17>) 36 MAKE_FUNCTION 0 39 LOAD_FAST 2 (grouped) 42 GET_ITER 43 CALL_FUNCTION 1 46 CALL_FUNCTION 1 49 LOAD_FAST 0 (self) 52 STORE_ATTR 5 (by_letter) 55 LOAD_CONST 0 (None) 58 RETURN_VALUE TIME: 0.0543 </code>

Referencias:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
$ python dis_test_loop.py dis_eliminate_loop
 15           0 LOAD_GLOBAL              0 (itertools)
              3 LOAD_ATTR                1 (groupby)
              6 LOAD_FAST                1 (words)
              9 LOAD_CONST               1 ('key')
             12 LOAD_GLOBAL              2 (operator)
             15 LOAD_ATTR                3 (itemgetter)
             18 LOAD_CONST               2 (0)
             21 CALL_FUNCTION            1
             24 CALL_FUNCTION          257
             27 STORE_FAST               2 (grouped)
 
 17          30 LOAD_GLOBAL              4 (dict)
             33 LOAD_CONST               3 (<code object <genexpr> at 0x7e7b8, file "/Users/dhellmann/Documents/PyMOTW/dis/PyMOTW/dis/dis_eliminate_loop.py", line 17>)
             36 MAKE_FUNCTION            0
             39 LOAD_FAST                2 (grouped)
             42 GET_ITER
             43 CALL_FUNCTION            1
             46 CALL_FUNCTION            1
             49 LOAD_FAST                0 (self)
             52 STORE_ATTR               5 (by_letter)
             55 LOAD_CONST               0 (None)
             58 RETURN_VALUE
 
TIME: 0.0543