💾 Archived View for blog.kirbylife.dev › post › optimizando-codigo-en-python-6 captured on 2024-12-17 at 11:27:26. Gemini links have been rewritten to link to archived content
⬅️ Previous capture (2024-09-29)
-=-=-=-=-=-=-
Publicado el: 2020-01-25
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.
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.
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 | +----------+-------+-------+
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.
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 +.
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.
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.
**28-02-2020**: Arreglados algunos typos. Gracias VMS.