6 formas de mejorar el rendimiento en python


El proyecto de este post los puedes descargar pulsando AQUI.

En este artículo vamos a describir una serie de recomendaciones y buenas prácticas para programar en Python (versión 2.7). Algunas son aplicables a cualquier lenguaje de programación, y otras consisten en programar en un estilo más “a lo python” (lo que se denomina Pythonic) cuando sea posible.

Estas recomendaciones son aplicables a:

  1. Listas de compresión
  2. Generadores
  3. Comprobar con estructuras de datos que hagan hashes de los datos.
  4. Strings
  5. Salidas intermedias
  6. Caches

Listas de comprensión

Script: 01 – comprehensions.py

Las listas por comprensión (Comprehensions) es una forma de crear listas en python a partir de generadores (otras listas, diccionarios, conjuntos, etc). Como vamos a ver, no resuelven un problema nuevo (el ejemplo malo os resultara familiar), pero nos da una nueva sintaxis que hace que python los ejecute más rápido.

Forma Buena Forma Mala
def good_list():
    my_list = [value for value in range(elements)]
def bad_list():
    my_list = []
    for value in range(elements):
        my_list.append(value)

Como podemos observar lo que estamos haciendo en ambos casos es rellenar una lista con los números desde 0 hasta elements-1. La primera sería una forma clásica de resolver el problema, nos definimos una lista , y la vamos llenando con los elementos.  La segunda es una forma de hacerlo como se debe hacer en python. Si ejecutamos esto n veces midiendo los tiempos de ejecución y hacemos la media, obtenemos los siguientes resultados (para n = 3):

Buena (seg) 0.0160256226858 1,68 veces más rápido
Mala (seg) 0.0268766085307

Como vemos es más rápido si lo hacemos de la manera que python nos proporciona. Alguno puede estar pensando que es una forma de transformar bucles for en esta forma que nos da python, y en parte así es, con la salvedad de que toda lista por comprensión puede ser convertida en un bucle for, pero no todo bucle for puede ser transformado en esta clase de listas. Las listas de comprensión nos permiten hacer operaciones con los valores, como podría ser hacer el cuadrado de los elementos:

def square_good_list():
    my_list = [value * value for value in range(elements)]

O también añadir elementos que cumplen una cierta condición, como podrían ser los elementos pares:

def good_list_validation():
    my_list = [value for value in range(elements)
        if value % 2 == 0]

Generadores

Script: 02 – generator.py

Los generadores, como su nombre indica sirven para generar secuencias de valores. Las secuencias se van generando poco a poco. Aquí el rendimiento tiene dos dimensiones: el tiempo de computación y el uso de la memoria.

Los generadores al trabajar con series muy grandes se hacen indispensables ya que si queremos generar todos los números primos y devolverlos, sería imposible, ya que nos quedaríamos sin memoria. Sin embargo, con un generador podemos ir generándolos cada vez que lo necesitemos y no nos quedaríamos sin memoria.

range es un generador en python 3, no en python 2, en python 2 tenemos xrange. Para ver mas clara la diferencia:

  • Si en python2 hacemos range(3), la función va a generar todos los elementos [0,1,2] y los va a devolver, mientras que xrange(3) nos va a devolver un elemento cuando lo pidamos (cada vez que iteremos)
  • Xrange siempre usa la misma cantidad de memoria.

Como comentamos, la forma buena de definir un generador en python 2 es con xrange

Forma Buena Forma Mala
def good_generator():
    return (x*2 for x in xrange(elements))
def bad_generator():
    return (x*2 for x in range(elements))

También podemos definir nuestro propio generador de la siguiente manera:

def generator(element_number, start_value=0):
    while start_value < element_number:
        yield start_value
        start_value += 1

Con el decorador @profile podemos ver la memoria que está usando, para ello necesitamos tener memory_profiler instalado, podemos hacerlo con:

pip install -U memory_profiler
Line #    Mem usage    Increment   Line Contents

================================================

   17     12.7 MiB       0.0 MiB    @profile

   18                              def my_generator():

   19     12.7 MiB       0.0 MiB        return (x*2 for x in generator(elements))

Line #    Mem usage    Increment   Line Contents

================================================

   22     12.7 MiB       0.0 MiB    @profile

   23                              def my_generator_testing():

   24     12.7 MiB       0.0 MiB        for loop_count in my_generator():

   25     12.7 MiB       0.0 MiB            foo = loop_count + 3

Line #    Mem usage    Increment   Line Contents

================================================

   27     12.7 MiB      0.0 MiB   @profile

   28                             def good_generator():

   29     12.7 MiB      0.0 MiB       return (x*2 for x in xrange(elements))

Line #    Mem usage    Increment   Line Contents

================================================

   32     12.7 MiB      0.0 MiB   @profile

   33                             def good_generator_testing():

   34     12.7 MiB      0.0 MiB       for loop_count in good_generator():

   35     12.7 MiB      0.0 MiB           foo = loop_count + 3

Line #    Mem usage    Increment   Line Contents

================================================

   37     12.7 MiB      0.0 MiB   @profile

   38                             def bad_generator():

   39     43.7 MiB     31.0 MiB       return (x*2 for x in range(elements))

Line #    Mem usage    Increment   Line Contents

================================================

   42     12.7 MiB      0.0 MiB   @profile

   43                             def bad_generator_testing():

   44     43.7 MiB     31.0 MiB       for loop_count in bad_generator():

   45     43.7 MiB      0.0 MiB           foo = loop_count + 3
Buena (seg) 0.0160256226858 1,68 veces más rápido
Mala (seg) 0.0268766085307
Propio (seg) 0,130264997482 1,74 veces más rápido

El tiempo es ligeramente inferior, pero no es realmente relevante, lo más relevante en este ejemplo es el aumento de memoria de 13MB a 31MB solo con 1000000 elementos enteros. De hecho, al ser tamaños que entran en memoria, podemos ver que la forma “mala” es mas rapida ya que una vez lo tiene todo en memoria puede acceder más rápidamente. Los tiempos están cogidos sin el decorador @profile, por lo que si lo ejecutáis con el puesto, os dará tiempo mayores.

Comprobar con estructuras de datos que hagan hashes de los datos

Script: 03 – membership_checks.py

Siempre que nos vemos usando la palabra in, va a ser mucho más eficiente si la usamos con un set, que con una lista. La razón de esto es que los sets calculan el hash de los elementos que contiene, por lo que para saber si un elemento está en el set, calcula el hash del elemento y mira a ver si lo tiene. Esto tiene un tiempo fijo. En una lista el intérprete tiene que ir elemento a elemento mirando si es el mismo elemento que queremos saber esta en la lista.

No olvidemos que un conjunto (set) almacena los elementos de forma única y desordenada, por lo que puede que no se adapte a nuestro problema. Nos definimos una lista y un set para comprobarlo:

my_set = {value for value in range(elements)}
my_list = [value for value in range(elements)]

start = time.time()
999999 in my_set
end = time.time()
good_time = end - start

start = time.time()
999999 in my_list
end = time.time()
bad_time = end - start
Buena (seg) 0,0000040531158 4260 veces más rápido
Mala (seg) 0,0172669887543

Como vemos al ser el último elemento, el tiempo de la lista es mayor que el del set:

start = time.time()
5 in my_set
end = time.time()
good_time = end - start

start = time.time()
5 in my_list
end = time.time()
bad_time = end - start
Buena (seg) 0,0000019073486 2 veces más lento
Mala (seg) 0,0000009536743

En cambio si buscamos al principio de la lista es más rápido buscar en la lista

Strings

Script: 04 – strings.py

Esta sección aplica a muchos lenguajes de programación, entre ellos a Java.  Los Strings son objetos inmutables, lo que significa que si hacemos concatenaciones con el operador ‘+’ estamos creando objetos nuevos, por lo que no es una buena idea concatenar muchos para no crear objetos nuevos con cada concatenación. Los strings siempre hay que manipularlos con funciones diseñadas para ello, como por ejemplo join.

Forma Buena Forma Mala
def good_string_joiner():
    ''.join(examples)
			
def bad_string_joiner():
    final_string = ''
    for c in examples:
        final_string += c

El resultado de los tiempos de ejecución son:

Buena (seg) 0,00900101661682 5,89 veces más rápido
Mala (seg) 0,0530691146851

Si queremos insertar strings en mitad de una cadena, la forma más limpia es usar un placeholder, y el operador % por ejemplo:

print ("Ms. %s! My %s crawled in my %s and then I %s it."
       "Can I have another one?" % ("Hoover", "worm", "mouth", "ate"))

Otra opción es la función format, que además nos permite definir el orden de sustitución.

print ("Ms. {}! My {} crawled in my {} and then I {} it."
       "Can I have another one?".format("Hoover", "worm", "mouth", "ate"))

print ("Ms. {0}! My {2} crawled in my {1} and then I {3} it."
       "Can I have another one?".format("Hoover", "worm", "mouth", "ate"))

Como resultado tenemos:

Ms. Hoover! My worm crawled in my mouth and then I ate it.Can I have another one?
Ms. Hoover! My worm crawled in my mouth and then I ate it.Can I have another one?
Ms. Hoover! My mouth crawled in my worm and then I ate it.Can I have another one?

Salidas intermedias

Script: 05 – intermediate.py

Este es otro problema que no es específico de python. Cada vez que hacemos un print, estamos haciendo una llamada al sistema operativo, y estas llamadas son síncronas, por lo que hasta que el sistema operativo no acaba, nosotros no podemos continuar. Es muy común que los usemos para hacer debugging, lo cual no es una buena práctica, para debuggear tenemos los debuggers!

Forma Buena Forma Mala
def good_intermediate():
    for loop_count in range(elements):
        print ('Hi how are you')
			
def bad_intermediate():
    for loop_count in range(elements):
        print ('Hi')
        print ('How')
        print ('Are')
        print ('You')

El resultado de los tiempos de ejecución son:

Caches

Script: 06 – caches.py

Las cachés son algo muy útil cuando tenemos que hacer cálculos complejos que se pueden repetir. O, por ejemplo, si tenemos que acceder mucho a una base de datos y queremos evitarnos esa latencia. Si no es complicado el cálculo o la latencia es pequeña, no interesa almacenar estos resultados ya que las caches no tienen un coste cero. Hay que ver qué política de eliminación tienen, hay que añadir los elementos, consultar esos elementos para ver si ya lo tenemos o no, etc. python 3 tiene su sistema de cache implementado; para python 2 hay muchas alternativas, aquí usaremos repoze.lru que podemos instalar con:

pip install repoze.lru

En este caso usaremos el decorador @lru_cache donde le decimos el tamaño de cache. Esta función tiene un sleep para simular que es una operación costosa

@lru_cache(maxsize=16)
def cached_expensive_function(value):
    time.sleep(0.1)
    return value * 2

def non_cached_expensive_function(value):
    time.sleep(0.1)
    return value * 2
Buena (seg) 0,802917957306 124 veces más rápido
Mala (seg) 100,161109209

Si esa función que no es costosa (le quitamos el sleep), vemos que la cache se vuelve contraproducente:

@lru_cache(maxsize=16)
def cached_inexpensive_function(value):
    return value * 2

def non_cached_inexpensive_function(value):
    return value * 2
Buena (seg) 0,0012629032135 5,75 veces más lento
Mala (seg) 0,0002191066741

Referencias

Para realizar este tutorial hemos tomado como referencia el siguiente artículo: https://www.packtpub.com/books/content/7-tips-python-performance

Comparte esta entrada en:
Safe Creative #1401310112503
6 formas de mejorar el rendimiento en python por "www.jarroba.com" esta bajo una licencia Creative Commons
Reconocimiento-NoComercial-CompartirIgual 3.0 Unported License.
Creado a partir de la obra en www.jarroba.com

Deja un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Uso de cookies

Este sitio web utiliza cookies para que usted tenga la mejor experiencia de usuario. Si continúa navegando está dando su consentimiento para la aceptación de las mencionadas cookies y la aceptación de nuestra política de cookies

ACEPTAR
Aviso de cookies