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:
#!/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.
$ 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:
#!/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á.
$ python -m dis dis_function.py
4 0 LOAD_CONST 0 ()
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
Para ver dentro de la función tenemos que pasarla como argumento a dis.dis().
$ 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.
#!/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)
$ 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ó.
$ 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.
#!/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.
$ 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:
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í:
#!/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.
$ 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.
#!/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.
$ 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).
#!/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.
$ 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.
#!/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.
$ 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 ( 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
Referencias:
$ 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