No es una sorpresa para nadie que Python no es de los lenguajes más rápidos en cuanto a operaciones por segundo se refiere. Parte de la culpa es del lenguaje, ya que al ser interpretado no puede tener un rendimiento igual a un lenguaje compilado, PEEEEEEERO otra gran parte de la culpa es de las personas que programan en dicho lenguaje. Esta entrada no se tratará directamente de como optimizar el código en general (eso podría ser en otra ocasión), sino que se tratará de ciertas cuestiones a tener en cuenta al momento de estar programando exclusivamente en Python. Tampoco se tocará la concurrencia y como podemos mejorar el rendimiento de nuestro código con ella.
* Anotaciones antes de comenzar
Para medir el tiempo se utilizará el siguiente decorador:
from cProfile import Profile
def explain_time(f):
def wrapper(*args, **kwargs):
profile = Profile()
profile.enable() # Comenzar conteo de operaciones
output = f(*args, **kwargs) # Ejecutar función
profile.disable() # Terminar conteo de operaciones
profile.print_stats()
return output
return wrapper
No entraré a explicarlo a profundidad, solo diré que es una manera mucho más detallada de saber cuanto tiempo le tomó a una función ejecutarse fijándose en cuanto tarda cada operación individualmente.
get
en los diccionarios
El indexado es más rápido que el método Hay 2 maneras de acceder a la información que contiene un diccionario, indexando el valor utilizando la sintaxis de corchetes []
y utilizando el método get
. Ambos ofrecen ventajas y desventajas.
La ventaja de utilizar get
es que el código se vuelve "tolerante" a fallas ya que, si no se encuentra registrado un valor con la llave que le hemos pasado, este no arrojará una excepción, sino que simplemente nos devolverá un None
por defecto, u otro valor que le pasemos como segundo parámetros.
d = { "a": -1 }
>>> d.get("a")
-1
>>> d.get("b")
^ ^
╚═╩═ Usando el método "get", si la llave no se encuentra en el diccionario
Retornará simplemente un "None"
╔═╦══════════════════════════════════╩═╝
v v
None
>>> d["a"]
-1
>>> d["b"]
^ ^
╚═╩═ Usando un indexado tradicional, si la llave no se encuentra
Arrojará una excepción
╔═╦═════════════════════╩═╩═╩═╩═╝
v v
---------------------------------------------------------------------------
KeyError Traceback (most recent call last)
<ipython-input-212-07abf0799e3f> in <module>
----> 1 d["b"]
KeyError: 'b'
Pero este post no se trata sobre código tolerante a fallas, sino que se trata de código rápido; Y en rapidez, el indexado tradicional le gana al método get
.
Y es que, si pones un indexado a competir contra get
, estos son los resultados:
# Prueba utilizando el indexado tradicional
@explain_time
def test_index():
d = { "a": -1 }
for _ in range(100000):
d["a"]
>>> test_index():
2 function calls in 0.007 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.007 0.007 0.007 0.007 <ipython-input-250-319a0e1f128d>:2(test_index)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
# Prueba utilizando el método "get"
@explain_time
def test_get():
d = { "a": -1 }
for _ in range(100000):
d.get("a")
>>> test_get()
100002 function calls in 0.023 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.012 0.012 0.023 0.023 <ipython-input-252-96210b127bf1>:1(test_get)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
100000 0.011 0.000 0.011 0.000 {method 'get' of 'dict' objects}
la diferencia es clara, ¿no?, el indexado tarda de 0.007 segundos haciendo 100000 búsquedas, a diferencia de get
que tarda 0.023 segundos en hacer la misma cantidad de búsquedas.
Aunque la diferencia sea clara, soy una persona de ciencia y eso me obliga a repetir la tarea varias veces para ver si el tiempo promedio nos sorprende.
Y los resultados para sorpresa de nadie son los siguientes:
N° | index | get |
---|---|---|
1 | 0.008 | 0.027 |
2 | 0.007 | 0.027 |
3 | 0.006 | 0.024 |
4 | 0.004 | 0.023 |
5 | 0.011 | 0.028 |
6 | 0.009 | 0.032 |
7 | 0.009 | 0.028 |
8 | 0.007 | 0.027 |
9 | 0.009 | 0.028 |
10 | 0.008 | 0.031 |
promedio | 0.007 | 0.027 |
print
son el infierno del rendimiento
Los Cada vez que nosotros interactuamos con un buffer, este consume cierto tiempo de CPU, incluyendo al buffer de estándar de salida (stdout
para los amigos). Osea, nuestros print
están haciendo lento nuestro código.
Justo por eso es una mala práctica hacer debuging a punta de print
's en lugar de herramientas dirigidas a ello, como logging
. En sí logging
no hará nuestro código más rápido, pero si que nos permite elegir hasta que grado de importancia hará o no la petición al buffer de salida.
Para ver esto es bastante sencillo, aquí los resultados:
# Prueba utilizando un "print" dentro de un ciclo
@explain_time
def test_print():
for _ in range(10000):
result = 10-5
print(f"{result}", end="")
>>> test_print()
55555...555
10002 function calls in 0.022 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.004 0.004 0.022 0.022 <ipython-input-293-35e61664f972>:1(test_print)
10000 0.017 0.000 0.017 0.000 {built-in method builtins.print}
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
# Prueba de ciclo sin llamadas a "print" dentro
@explain_time
def test_no_print():
for _ in range(10000):
result = 10-5
>>> test_no_print()
2 function calls in 0.000 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.000 0.000 <ipython-input-295-e65a75e6f856>:1(test_no_print)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
Aquí la diferencia es tan clara que ni siquiera haré tabla comparativa. El mismo for
con la misma operación matemática, tarda 0.022 segundos mostrando el resultado y 0.000 no mostrando el resultado.
Así que si están desarrollando el backend de una página web, quiten los prints de su código.
Concatenar strings de la manera incorrecta
Unir 2 strings es una tarea que debemos hacer prácticamente en todos los programas que hagamos... pero, ¿lo estamos haciendo de la manera más óptima?.
Existen múltiples formas para hacer esa labor, ya sea desde utilizando el operador +
, utlizar el método join
de un string, utilizando la manera de formatear un texto como en C con el verbo %s
o métodos de formateo más propias de Python como el método format
o los f-strings.
Bien, cada una de esas técnicas consumen más o menos recursos.
Como aquí iba a quedar muy largo todo el código que utilicé para medir los tiempos, dejo un enlace a un snippet por si les interesa verlo. De igual forma, aquí les dejo los resultados ordenados de peor a mejor:
técnica | tiempo |
---|---|
método "format" | 0.066 |
método "join" | 0.049 |
verbos C-like | 0.037 |
operador + | 0.029 |
f-strings | 0.024 |
Como podemos apreciar, los f-strings
son la manera más rápida para concatenar 2 strings... pero tienen un pequeño defecto, y es que no podemos declarar f-strings "al vuelo" ya que su poder radica justo en el hecho de que el interprete ya sabe de antemano que justo ahí tendrá que hacer un concatenado de strings. Así que si queremos unir 2 strings sobre la marcha, nuestra segunda mejor opción es usar el operador +.
Cachear resultados de funciones
Una de las bases que definen la programación funcional es "siempre que mandes llamar una función con los mismos parámetros, está te devolverá el mismo resultado", y en base a ella se creó el lru_cache
(least recently used).
El concepto es muy simple de entender. Sí nosotros mandamos llamar por primera vez una función, esta tiene que calcular el resultado para posteriormente retornarlo, si el resultado de la segunda vez que la mandemos llamar el resultado también debe ser el mismo, entonces, ¿Para qué volver a calcular el resultado si ya esa tarea la hicimos anteriormente?. Creo que con un ejemplo se entiende mejor.
from time import sleep
from functools import lru_cache
^ ^ ^ ^ ^
╚═╩═╩═╩═╩═ Se importa el decorador del paquete "functools"
╔═╦═╦═╦═╦═╦═ Le indicamos que puede guardar hasta un máximo de 100 resultados
v v v v v v
@lru_cache(maxsize=100)
def is_even(x):
sleep(2) <═ Simulando una tarea muy tardada
return x%2 == 0
@explain_time
def test_100_numbers():
for n in range(1, 101):
is_even(n)
>>> test_100_numbers()
202 function calls in 200.195 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
100 0.001 0.000 200.193 2.002 <ipython-input-334-f1ecb61062cb>:1(is_even)
1 0.002 0.002 200.195 200.195 <ipython-input-335-c237fa081463>:2(test_100_numbers)
100 200.192 2.002 200.192 2.002 {built-in method time.sleep}
^ ^
╚═╩═ Hace 100 llamadas a la función "sleep"
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
>>> test_100_numbers()
2 function calls in 0.000 seconds
Ordered by: standard name
ncalls tottime percall cumtime percall filename:lineno(function)
1 0.000 0.000 0.000 0.000 <ipython-input-335-c237fa081463>:2(test_100_numbers)
1 0.000 0.000 0.000 0.000 {method 'disable' of '_lsprof.Profiler' objects}
^ ^
╚═╩═ Las llamadas a "sleep" desaparecen
Como podemos apreciar, la primera vez que corremos el test que medirá si un número es par o no, a este le tomará 200 segundos (gracias al delay enorme que le pusimos a la función is_even
), pero la segunda vez que corremos el test esté es terminado de inmediato, esto gracias a que los resultados de la función quedaron cacheados en memoria y cuando se volvió a llamar la función, esta ya no se ejecutó como tal librándonos del delay.
Aunque esto pueda ser realmente bueno, hay que tener cuidado y pensar muy bien cual función puede ser cacheada y cual no, pero si utilizamos el caché a conciencia, nos traerá muchas bondades a nuestro código.
Conclusiones
Aunque Python no sea el lenguaje más rápido que pueda existir, su rendimiento en general no está nada mal y conociendo estos pequeños consejos, pueden escribir código con mejor rendimiento.
Por otra parte, el rendimiento no lo es todo en un lenguaje y si sacrificamos un poco de rendimiento por tener un código más limpio o un código más tolerante a fallas, pues es un sacrificio que vale la pena.
Fuentes
- https://docs.python.org/3.8/library/functools.html
- https://docs.python.org/3.8/library/profile.html
- https://twitter.com/raymondh/status/1205969258800275456
- https://martinheinz.dev/blog/13
Actualizaciones
28-02-2020: Arreglados algunos typos. Gracias VMS.