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:
- Listas de compresión
- Generadores
- Comprobar con estructuras de datos que hagan hashes de los datos.
- Strings
- Salidas intermedias
- 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.
Más información sobre Generadores en Python y ejemplos en el artículo que le dedicamos pinchando aquí.
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
Ramon Admin:
Gracias por aclararme el asunto, en el sentido que existe una alternativa que debería marchar mas rápido. Como principiante en esto, estudiaré el asunto pues eso de:
for (String query : queries) {
stat.addBatch(query);
}
stat.executeBatch();
No me queda claro en una primera pasada. Si puedes recomendarme un sitio donde estudiarlo seria estupendo.
Gracias de nuevo
Realmente es sencillo, tanto como la forma execute(query). Batch significa «Lotes», lo que indica que con addBatch(query) se van introduciendo en el objeto de la clase Statement todas las consultas que quieras que se ejecuten a la vez y cuando ya tengas todas introducidas en el objeto de la clase Statement, las ejecutas todas juntas con executeBatch()
Tienes más información en https://docs.oracle.com/javase/7/docs/api/java/sql/Statement.html
Dionisio: Excelente aporte para los que estamos empezando, y para los otros también supongo
Algo que no he podido encontrar y que es imprescindible para el manejo de tablas de datos es la forma optima de insertar registros
Actualmente tengo la forma clásica:
cursor = connection.cursor()
cursor.execute(«insert into mitabla,… etc.
pero va extremadamente lento para unos miseros 155 registros. Te agradecería alguna ayuda
Buenas Gabriel.
Para muchas filas mejor ejecuta las consultas SQL «Insert» como Batch. Ejemplo:
Connection con = ...
Statement stat = con.createStatement();
List queries = new List ();
queries.add("INSERT INTO Tabla (columna1, columna2) VALUES ('valor1', 'valor2')");
// Insertar todas las filas necesarias ...
for (String query : queries) {
stat.addBatch(query);
}
stat.executeBatch();
stat.close();
con.close();
O con entidades y sentencias preparadas:
Connection con = ...
PreparedStatement stat = con.prepareStatement("INSERT INTO Tabla (columna1, columna2) VALUES (?, ?)");
// Listado con todos los objetos de mi entidad ... entidades = ...
List
for (MiEntidad entidad : entidades) {
stat.setString(1, entidad.getValorDeMiEntidad());
stat.setString(2, entidad.getOtroValorDeMiEntidad());
stat.addBatch();
}
stat.executeBatch();
stat.close();
con.close();