Title: Optimizando código en Python

Date: 2020-01-25
Content:

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.

El indexado es más rápido que el método get en los diccionarios

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:

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

Los print son el infierno del rendimiento

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

  1. https://docs.python.org/3.8/library/functools.html
  2. https://docs.python.org/3.8/library/profile.html
  3. https://twitter.com/raymondh/status/1205969258800275456
  4. https://martinheinz.dev/blog/13

Actualizaciones

28-02-2020: Arreglados algunos typos. Gracias VMS.