Optimización del rendimiento de Python

Introducción

Los recursos nunca son suficientes para satisfacer las crecientes necesidades en la mayoría de las industrias, y ahora especialmente en tecnología a medida que se abre camino en nuestras vidas. La tecnología hace la vida más fácil y cómoda, y puede evolucionar y mejorar con el tiempo.
Esta mayor dependencia de la tecnología se ha producido a expensas de los recursos informáticos disponibles. Como resultado, se están desarrollando computadoras más potentes y la optimización del código nunca ha sido más crucial.
Los requisitos de rendimiento de la aplicación están aumentando más de lo que nuestro hardware puede seguir. Para combatir esto, las personas han desarrollado muchas estrategias para utilizar los recursos de manera más eficiente: aplicaciones de contenedorización , reactivas (asíncronas) , etc.
Sin embargo, el primer paso que debemos tomar, y por mucho el más fácil de tomar en consideración, es la optimización del código . Necesitamos escribir código que funcione mejor y utilice menos recursos informáticos.
En este artículo, optimizaremos los patrones y procedimientos comunes en la programación de Python en un esfuerzo por mejorar el rendimiento y mejorar la utilización de los recursos informáticos disponibles.

Problema con el rendimiento

A medida que aumentan las soluciones de software, el rendimiento se vuelve más crucial y los problemas se vuelven más grandes y visibles. Cuando escribimos código en nuestro localhost, es fácil perder algunos problemas de rendimiento ya que el uso no es intenso. Una vez que se implementa el mismo software para miles y cientos de miles de usuarios finales concurrentes, los problemas se vuelven más complejos.
La lentitud es uno de los principales problemas que se deben presentar cuando se escala el software. Esto se caracteriza por un mayor tiempo de respuesta. Por ejemplo, un servidor web puede tardar más en servir páginas web o enviar respuestas a los clientes cuando las solicitudes son demasiado numerosas. A nadie le gusta un sistema lento, especialmente porque la tecnología está destinada a hacer que ciertas operaciones sean más rápidas y la facilidad de uso disminuirá si el sistema es lento.
Cuando el software no está optimizado para utilizar bien los recursos disponibles, terminará requiriendo más recursos para garantizar que se ejecute sin problemas. Por ejemplo, si la administración de la memoria no se maneja bien, el programa requerirá más memoria, lo que resultará en costos de actualización o fallos frecuentes.
La inconsistencia y la salida errónea es otro resultado de programas mal optimizados. Estos puntos resaltan la necesidad de optimización de programas.

Por qué y cuándo optimizar

Al construir para uso a gran escala, la optimización es un aspecto crucial del software a considerar. El software optimizado es capaz de manejar una gran cantidad de usuarios concurrentes o solicitudes al mismo tiempo que mantiene el nivel de rendimiento en términos de velocidad fácilmente.
Esto lleva a la satisfacción general del cliente ya que el uso no se ve afectado. Esto también provoca menos dolores de cabeza cuando una aplicación se bloquea en medio de la noche y su administrador enojado lo llama para que lo arregle instantáneamente.
Los recursos informáticos son costosos y la optimización puede ser útil para reducir los costos operativos en términos de almacenamiento, memoria o potencia de cómputo.
¿Pero cuándo optimizamos?
Es importante tener en cuenta que la optimización puede afectar negativamente la legibilidad y la capacidad de mantenimiento del código base al hacerlo más complejo. Por lo tanto, es importante considerar el resultado de la optimización contra la deuda técnica que generará.
Si estamos construyendo grandes sistemas que esperan una gran interacción por parte de los usuarios finales, entonces necesitamos que nuestro sistema funcione en el mejor estado y esto requiere optimización. Además, si tenemos recursos limitados en términos de capacidad de cálculo o memoria, la optimización ayudará en gran medida a asegurarnos de poder hacerlo con los recursos disponibles.

Perfilado

Antes de que podamos optimizar nuestro código, tiene que estar funcionando. De esta manera podemos saber cómo realiza y utiliza los recursos. Y esto nos lleva a la primera regla de la optimización: no .
Como dijo Donald Knuth, matemático, informático y profesor de la Universidad de Stanford:
"La optimización temprana es la raíz de todo mal."
La solución tiene que funcionar para que sea optimizada.
La elaboración de perfiles implica el escrutinio de nuestro código y el análisis de su rendimiento para identificar el rendimiento de nuestro código en diversas situaciones y áreas de mejora, si es necesario. Nos permitirá identificar la cantidad de tiempo que toma nuestro programa o la cantidad de memoria que utiliza en sus operaciones. Esta información es vital en el proceso de optimización, ya que nos ayuda a decidir si optimizar nuestro código o no.
La creación de perfiles puede ser una tarea desafiante y puede llevar mucho tiempo y, si se hace manualmente, se pueden pasar por alto algunos problemas que afectan el rendimiento. Para este efecto, las diversas herramientas que pueden ayudar a un perfil de código más rápido y más eficiente incluyen:
  • PyCallGraph - que crea visualizaciones de gráficos de llamadas que representan relaciones de llamadas entre subrutinas para el código Python.
  • cProfile - que describirá con qué frecuencia y durante cuánto tiempo se ejecutan varias partes del código de Python.
  • gProf2dot , que es una biblioteca que visualiza los perfiladores en un gráfico de puntos.
El perfil nos ayudará a identificar áreas para optimizar en nuestro código. Discutamos cómo elegir la estructura de datos correcta o el flujo de control puede ayudar a que nuestro código Python funcione mejor.

Elección de estructuras de datos y flujo de control

La elección de la estructura de datos en nuestro código o algoritmo implementado puede afectar el rendimiento de nuestro código Python. Si tomamos las decisiones correctas con nuestras estructuras de datos, nuestro código funcionará bien.
Los perfiles pueden ser de gran ayuda para identificar la mejor estructura de datos para usar en diferentes puntos de nuestro código Python. ¿Estamos haciendo un montón de inserciones? ¿Estamos eliminando con frecuencia? ¿Estamos constantemente buscando artículos? Estas preguntas pueden ayudarnos a guiarnos a elegir la estructura de datos correcta para la necesidad y, en consecuencia, dar como resultado un código Python optimizado.
El tiempo y el uso de la memoria se verán muy afectados por nuestra elección de la estructura de datos. También es importante tener en cuenta que algunas estructuras de datos se implementan de manera diferente en diferentes lenguajes de programación.

For Loop vs List Comprehensions

Los bucles son comunes cuando se desarrollan en Python y pronto encontrarán listas de comprensión, que son una forma concisa de crear nuevas listas que también soportan condiciones.
Por ejemplo, si queremos obtener una lista de los cuadrados de todos los números pares en un rango determinado utilizando for loop:
new_list = []  
for n in range(0, 10):  
    if n % 2 == 0:
        new_list.append(n**2)
Una List Comprehensionversión del bucle sería simplemente:
new_list = [ n**2 for n in range(0,10) if n%2 == 0]  
La lista de comprensión es más breve y concisa, pero ese no es el único truco bajo la manga. También son notablemente más rápidos en el tiempo de ejecución que para los bucles. Usaremos el módulo Timeit , que proporciona una forma de sincronizar pequeños bits de código Python.
Pongamos la lista de comprensión contra el forbucle equivalente y veamos cuánto tarda cada uno en lograr el mismo resultado:
import timeit

def for_square(n):  
    new_list = []
    for i in range(0, n):
        if i % 2 == 0:
            new_list.append(n**2)
    return new_list

def list_comp_square(n):  
    return [i**2 for i in range(0, n) if i % 2 == 0]

print("Time taken by For Loop: {}".format(timeit.timeit('for_square(10)', 'from __main__ import for_square')))

print("Time taken by List Comprehension: {}".format(timeit.timeit('list_comp_square(10)', 'from __main__ import list_comp_square')))  
Después de ejecutar el script 5 veces usando Python 2:
$ python for-vs-lc.py 
Time taken by For Loop: 2.56907987595  
Time taken by List Comprehension: 2.01556396484  
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.37083697319  
Time taken by List Comprehension: 1.94110512733  
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.52163410187  
Time taken by List Comprehension: 1.96427607536  
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.44279003143  
Time taken by List Comprehension: 2.16282701492  
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 2.63641500473  
Time taken by List Comprehension: 1.90950393677  
Si bien la diferencia no es constante, la comprensión de la lista lleva menos tiempo que el forbucle. En el código a pequeña escala, esto puede no hacer una gran diferencia, pero en la ejecución a gran escala, puede ser la diferencia necesaria para ahorrar algo de tiempo.
Si aumentamos el rango de cuadrados de 10 a 100, la diferencia se hace más evidente:
$ python for-vs-lc.py 
Time taken by For Loop: 16.0991549492  
Time taken by List Comprehension: 13.9700510502  
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 16.6425571442  
Time taken by List Comprehension: 13.4352738857  
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 16.2476081848  
Time taken by List Comprehension: 13.2488780022  
$ 
$ python for-vs-lc.py 
Time taken by For Loop: 15.9152050018  
Time taken by List Comprehension: 13.3579590321  
cProfile es un generador de perfiles que viene con Python y si lo usamos para perfilar nuestro código:
análisis de perfiles
Tras un examen más detenido, todavía podemos ver que la herramienta cProfile informa que nuestra Comprensión de Lista lleva menos tiempo de ejecución que nuestra implementación de For Loop , como hemos establecido anteriormente. cProfile muestra todas las funciones llamadas, la cantidad de veces que se han llamado y la cantidad de tiempo que ha tomado cada una.
Si nuestra intención es reducir el tiempo que tarda nuestro código en ejecutarse, entonces la Comprensión de la Lista sería una mejor opción en lugar de usar For Loop. El efecto de tal decisión de optimizar nuestro código será mucho más claro a una escala mayor y muestra cuán importante, pero también fácil, puede ser el código de optimización.
Pero, ¿y si nos preocupa nuestro uso de la memoria? Una comprensión de lista requeriría más memoria para eliminar elementos de una lista que un bucle normal. Una lista de comprensión siempre crea una nueva lista en la memoria al finalizar, por lo que para eliminar elementos de una lista, se creará una nueva lista. Mientras que, para un bucle normal, podemos usar list.remove()list.pop()para modificar la lista original en lugar de crear una nueva en la memoria.
Nuevamente, en los scripts a pequeña escala, puede que no haya mucha diferencia, pero la optimización es buena a mayor escala, y en esa situación, tal ahorro de memoria será bueno y nos permitirá utilizar la memoria adicional guardada para otras operaciones.

Listas enlazadas

Otra estructura de datos que puede ser útil para ahorrar memoria es la Lista enlazada . Se diferencia de una matriz normal en que cada elemento o nodo tiene un enlace o puntero al siguiente nodo en la lista y no requiere asignación de memoria contigua.
Una matriz requiere que la memoria requerida para almacenarla y sus elementos se asignen por adelantado y esto puede ser bastante costoso o un desperdicio cuando el tamaño de la matriz no se conoce de antemano.
Una lista enlazada le permitirá asignar memoria según sea necesario. Esto es posible porque los nodos de la lista enlazada se pueden almacenar en diferentes lugares en la memoria pero se unen en la lista enlazada a través de los punteros. Esto hace que las listas enlazadas sean mucho más flexibles en comparación con las matrices.
La advertencia con una lista vinculada es que el tiempo de búsqueda es más lento que el de una matriz debido a la colocación de los elementos en la memoria. El perfilado adecuado lo ayudará a identificar si necesita una mejor memoria o una mejor administración del tiempo para decidir si desea utilizar una Lista vinculada o una Matriz como su elección de la estructura de datos al optimizar su código.

Rango vs XRange

Al tratar con bucles en Python, a veces necesitaremos generar una lista de enteros para ayudarnos a ejecutar bucles for. Las funciones rangexrangese utilizan para este efecto.
Su funcionalidad es la misma pero son diferentes en que rangedevuelve un listobjeto pero xrangedevuelve un xrangeobjeto.
¿Qué significa esto? Un xrangeobjeto es un generador en que no es la lista final. Nos da la capacidad de generar los valores en la lista final esperada según se requiera durante el tiempo de ejecución a través de una técnica conocida como "rendimiento".
El hecho de que la xrangefunción no devuelva la lista final hace que sea la opción más eficiente en cuanto a memoria para generar enormes listas de enteros con fines de bucle.
Si necesitamos generar un gran número de enteros para usar, xrangedebe ser nuestra opción de acceso para este propósito, ya que utiliza menos memoria. Si usamos la rangefunción en su lugar, será necesario crear la lista completa de enteros y esto requerirá mucha memoria.
Exploremos esta diferencia en el consumo de memoria entre las dos funciones:
$ python
Python 2.7.10 (default, Oct 23 2015, 19:19:21)  
[GCC 4.2.1 Compatible Apple LLVM 7.0.0 (clang-700.0.59.5)] on darwin
Type "help", "copyright", "credits" or "license" for more information.  
>>> import sys
>>> 
>>> r = range(1000000)
>>> x = xrange(1000000)
>>> 
>>> print(sys.getsizeof(r))
8000072  
>>> 
>>> print(sys.getsizeof(x))
40  
>>> 
>>> print(type(r))
<type 'list'>  
>>> print(type(x))
<type 'xrange'>  
Creamos un rango de 1,000,000 enteros usando rangexrangeEl tipo de objeto creado por la rangefunción es el Listque consume 8000072 bytesmemoria, mientras que el xrangeobjeto solo consume 40 bytesmemoria.
La xrangefunción nos ahorra memoria, un montón, pero ¿qué pasa con el tiempo de búsqueda de elementos? Calculemos el tiempo de búsqueda de un entero en la lista generada de enteros usando Timeit:
import timeit

r = range(1000000)  
x = xrange(1000000)

def lookup_range():  
    return r[999999]

def lookup_xrange():  
    return x[999999]

print("Look up time in Range: {}".format(timeit.timeit('lookup_range()', 'from __main__ import lookup_range')))

print("Look up time in Xrange: {}".format(timeit.timeit('lookup_xrange()', 'from __main__ import lookup_xrange')))  
El resultado:
$ python range-vs-xrange.py 
Look up time in Range: 0.0959858894348  
Look up time in Xrange: 0.140854120255  
$ 
$ python range-vs-xrange.py 
Look up time in Range: 0.111716985703  
Look up time in Xrange: 0.130584001541  
$ 
$ python range-vs-xrange.py 
Look up time in Range: 0.110965013504  
Look up time in Xrange: 0.133008003235  
$ 
$ python range-vs-xrange.py 
Look up time in Range: 0.102388143539  
Look up time in Xrange: 0.133061170578  
xrangepuede consumir menos memoria pero toma más tiempo encontrar un elemento en ella. Dada la situación y los recursos disponibles, podemos elegir cualquiera rangexrangedependiendo del aspecto que vamos a buscar. Esto reitera la importancia de perfilar en la optimización de nuestro código Python.
Nota: xrange está en desuso en Python 3 y la rangefunción ahora puede servir la misma funcionalidad. Los generadores aún están disponibles en Python 3 y pueden ayudarnos a ahorrar memoria de otras formas, como las Comprensiones o Expresiones del Generador .

Conjuntos

Cuando se trabaja con listas en Python, debemos tener en cuenta que permiten entradas duplicadas. ¿Qué pasa si importa si nuestros datos contienen duplicados o no?
Aquí es donde entran los Conjuntos de Python . Son como listas pero no permiten que se dupliquen duplicados en ellas. Los conjuntos también se utilizan para eliminar de manera eficiente los duplicados de las listas y son más rápidos que crear una nueva lista y rellenarla de la que tiene duplicados.
En esta operación, puede pensar en ellos como un embudo o filtro que retiene los duplicados y solo deja pasar valores únicos.
Comparemos las dos operaciones:
import timeit

# here we create a new list and add the elements one by one
# while checking for duplicates
def manual_remove_duplicates(list_of_duplicates):  
    new_list = []
    [new_list.append(n) for n in list_of_duplicates if n not in new_list]
    return new_list

# using a set is as simple as
def set_remove_duplicates(list_of_duplicates):  
    return list(set(list_of_duplicates))

list_of_duplicates = [10, 54, 76, 10, 54, 100, 1991, 6782, 1991, 1991, 64, 10]

print("Manually removing duplicates takes {}s".format(timeit.timeit('manual_remove_duplicates(list_of_duplicates)', 'from __main__ import manual_remove_duplicates, list_of_duplicates')))

print("Using Set to remove duplicates takes {}s".format(timeit.timeit('set_remove_duplicates(list_of_duplicates)', 'from __main__ import set_remove_duplicates, list_of_duplicates')))  
Después de ejecutar el script cinco veces:
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.64614701271s  
Using Set to remove duplicates takes 2.23225092888s  
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.65356898308s  
Using Set to remove duplicates takes 1.1165189743s  
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.53129696846s  
Using Set to remove duplicates takes 1.15646100044s  
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.57102680206s  
Using Set to remove duplicates takes 1.13189387321s  
$ 
$ python sets-vs-lists.py 
Manually removing duplicates takes 2.48338890076s  
Using Set to remove duplicates takes 1.20611810684s  
Usar un conjunto para eliminar duplicados es consistentemente más rápido que crear manualmente una lista y agregar elementos mientras se verifica la presencia.
Esto podría ser útil cuando se filtran las entradas para un concurso de sorteos, donde deberíamos filtrar las entradas duplicadas. Si se necesitan 2 segundos para filtrar 120 entradas, imagine filtrar 10 000 entradas. En tal escala, el rendimiento enormemente aumentado que viene con los Conjuntos es significativo.
Esto puede no ocurrir comúnmente, pero puede hacer una gran diferencia cuando se le solicita. El perfilado adecuado puede ayudarnos a identificar tales situaciones y puede hacer toda la diferencia en el rendimiento de nuestro código.

Concatenacion de cuerdas

Las cadenas son inmutables por defecto en Python y, posteriormente, la concatenación de cadenas puede ser bastante lenta. Hay varias formas de concatenar cadenas que se aplican a diversas situaciones.
Podemos usar el +(más) para unir cadenas. Esto es ideal para algunos objetos String y no a escala. Si utiliza el +operador para concatenar varias cadenas, cada concatenación creará un nuevo objeto ya que las cadenas son inmutables. Esto dará como resultado la creación de muchos nuevos objetos String en la memoria, por lo tanto, la utilización incorrecta de la memoria.
También podemos usar el operador de concatenación +=para unir cadenas, pero esto solo funciona para dos cadenas a la vez, a diferencia del +operador que puede unir más de dos cadenas.
Si tenemos un iterador como una lista que tiene varias cadenas, la forma ideal de concatenarlas es mediante el uso del .join()método.
Creemos una lista de mil palabras y comparemos cómo se comparan .join()el +=operador y:
import timeit

# create a list of 1000 words
list_of_words = ["foo "] * 1000

def using_join(list_of_words):  
    return "".join(list_of_words)

def using_concat_operator(list_of_words):  
    final_string = ""
    for i in list_of_words:
        final_string += i
    return final_string

print("Using join() takes {} s".format(timeit.timeit('using_join(list_of_words)', 'from __main__ import using_join, list_of_words')))

print("Using += takes {} s".format(timeit.timeit('using_concat_operator(list_of_words)', 'from __main__ import using_concat_operator, list_of_words')))  
Después de dos intentos:
$ python join-vs-concat.py 
Using join() takes 14.0949640274 s  
Using += takes 79.5631570816 s  
$ 
$ python join-vs-concat.py 
Using join() takes 13.3542580605 s  
Using += takes 76.3233859539 s  
Es evidente que el .join()método no solo es más pulcro y más legible, sino que también es significativamente más rápido que el operador de concatenación al unir cadenas en un iterador.
Si está realizando muchas operaciones de concatenación de cadenas, disfrutar de los beneficios de un enfoque que es casi 7 veces más rápido es maravilloso.

Conclusión

Hemos establecido que la optimización del código es crucial en Python y también vimos la diferencia hecha a medida que se escala. A través del módulo Timeit y el perfil del perfil cProfile , hemos podido decir qué implementación tarda menos tiempo en ejecutarse y hacer una copia de seguridad con las cifras. Las estructuras de datos y las estructuras de flujo de control que utilizamos pueden afectar en gran medida el rendimiento de nuestro código y debemos ser más cuidadosos.
La creación de perfiles también es un paso crucial en la optimización del código, ya que guía el proceso de optimización y lo hace más preciso. Necesitamos estar seguros de que nuestro código funciona y es correcto antes de optimizarlo para evitar una optimización prematura, lo que podría resultar más costoso de mantener o hará que el código sea difícil de entender.

Acerca de: Programator

Somos Instinto Programador

0 comentarios:

Publicar un comentario

Dejanos tu comentario para seguir mejorando!

Con tecnología de Blogger.