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 la tecnología, que se abre paso más profundamente en nuestras vidas. La tecnología hace la vida más fácil y conveniente y es capaz de 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 las aplicaciones están aumentando más de lo que nuestro hardware puede seguir. Para combatir esto, las personas han ideado muchas estrategias para utilizar los recursos de manera más eficiente: aplicaciones en contenedores, reactivas (asincrónicas), etc.

    Sin embargo, el primer paso que debemos tomar, y con 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 impulsar el rendimiento y mejorar la utilización de los recursos informáticos disponibles.

    Problema con el rendimiento

    A medida que las soluciones de software escalan, el rendimiento se vuelve más crucial y los problemas se vuelven más importantes y visibles. Cuando escribimos código en nuestro localhost, es fácil pasar por alto 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 simultáneos, los problemas se vuelven más elaborados.

    La lentitud es uno de los principales problemas que surgen 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 demasiadas. 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 usabilidad 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 funcione sin problemas. Por ejemplo, si la administración de la memoria no se maneja bien, el programa terminará requiriendo más memoria, lo que resultará en costos de actualización o fallas frecuentes.

    La inconsistencia y la salida errónea es otro resultado de programas mal optimizados. Estos puntos resaltan la necesidad de optimizar los programas.

    Por qué y cuándo optimizar

    Cuando se crea para un 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 o solicitudes simultáneas mientras mantiene fácilmente el nivel de rendimiento en términos de velocidad.

    Esto conduce a la satisfacción general del cliente, ya que el uso no se ve afectado. Esto también conduce a menos dolores de cabeza cuando una aplicación falla en medio de la noche y su gerente enojado lo llama para solucionarlo instantáneamente.

    Los recursos informáticos son costosos y la optimización puede resultar útil para reducir los costos operativos en términos de almacenamiento, memoria o potencia informática.

    Pero, ¿cuándo optimizamos?

    Es importante tener en cuenta que la optimización puede afectar negativamente la legibilidad y el mantenimiento del código base haciéndolo más complejo. Por tanto, es importante considerar el resultado de la optimización frente a la deuda técnica que generará.

    Si estamos construyendo sistemas grandes que esperan mucha 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 potencia informática o memoria, la optimización será de gran ayuda para garantizar que podamos arreglárnoslas con los recursos disponibles.

    Perfilado

    Antes de que podamos optimizar nuestro código, tiene que estar funcionando. De esta manera podemos saber cómo funciona y utiliza los recursos. Y esto nos lleva a la primera regla de optimización: no lo hagas .

    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 optimizarla.

    La elaboración de perfiles implica el escrutinio de nuestro código y el análisis de su rendimiento para identificar cómo funciona nuestro código en diversas situaciones y áreas de mejora, si es necesario. Nos permitirá identificar la cantidad de tiempo que tarda 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 llevar mucho tiempo y, si se hace manualmente, es posible que se pasen por alto algunos problemas que afectan el rendimiento. A tal efecto, las diversas herramientas que pueden ayudar a crear perfiles de código de forma más rápida y eficiente incluyen:

    • PyCallGraph : 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 Python.
    • gProf2dot , que es una biblioteca que visualiza la salida de los perfiladores en un gráfico de puntos.

    La creación de perfiles nos ayudará a identificar áreas para optimizar en nuestro código. Analicemos cómo elegir la estructura de datos o el flujo de control adecuados 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 el 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.

    La creación de perfiles puede ser de gran ayuda para identificar la mejor estructura de datos para usar en diferentes puntos de nuestro código Python. ¿Estamos haciendo muchas inserciones? ¿Eliminamos con frecuencia? ¿Buscamos artículos constantemente? Estas preguntas pueden ayudarnos a elegir la estructura de datos correcta para la necesidad y, en consecuencia, dar como resultado un código Python optimizado.

    El uso de tiempo y memoria se verá muy afectado por nuestra elección de 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.

    Para comprensiones de bucle vs lista

    Los bucles son comunes cuando se desarrolla en Python y pronto se encontrará con listas por comprensión, que son una forma concisa de crear nuevas listas que también admiten condiciones.

    Por ejemplo, si queremos obtener una lista de los cuadrados de todos los números pares en un cierto rango usando 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 comprensión de la lista es más corta y concisa, pero ese no es el único truco bajo la manga. También son notablemente más rápidos en tiempo de ejecución que los bucles for. Usaremos el módulo Timeit que proporciona una forma de cronometrar pequeños fragmentos de código Python.

    Pongamos la comprensión de la lista contra el forciclo equivalente y veamos cuánto tiempo 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 toma menos tiempo que el forciclo. 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 toda la diferencia necesaria para ahorrar algo de tiempo.

    Si aumentamos el rango de cuadrados de 10 a 100, la diferencia se vuelve 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:

    Tras un examen más detenido, todavía podemos ver que la herramienta cProfile informa que nuestra comprensión de listas lleva menos tiempo de ejecución que nuestra implementación de bucle for, como habíamos establecido anteriormente. cProfile muestra todas las funciones llamadas, el número de veces que se han llamado y la cantidad de tiempo que toma cada una.

    Si nuestra intención es reducir el tiempo que tarda nuestro código en ejecutarse, entonces List Comprehension sería una mejor opción que usar For Loop. El efecto de tal decisión para optimizar nuestro código será mucho más claro a mayor escala y muestra cuán importante, pero también fácil, puede ser optimizar el código.

    Pero, ¿y si nos preocupa el uso de nuestra memoria? La comprensión de una lista requeriría más memoria para eliminar elementos de una lista que un ciclo normal. Una lista por comprensión siempre crea una nueva lista en la memoria una vez completada, por lo que para eliminar elementos de una lista, se crearía una nueva lista. Mientras que, para un bucle for normal, podemos usar list.remove()o list.pop()para modificar la lista original en lugar de crear una nueva en la memoria.

    Nuevamente, en scripts a pequeña escala, puede que no haga 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á usar la memoria extra guardada para otras operaciones.

    Listas vinculadas

    Otra estructura de datos que puede resultar útil para ahorrar memoria es la Lista Vinculada. Se diferencia de una matriz normal en que cada elemento o node tiene un enlace o puntero al siguiente node de la lista y no requiere una asignación de memoria contigua.

    Una matriz requiere que la memoria necesaria para almacenarla y sus elementos se asignen por adelantado y esto puede resultar bastante caro o un desperdicio cuando no se conoce de antemano el tamaño de la matriz.

    Una lista vinculada le permitirá asignar memoria según sea necesario. Esto es posible porque los nodes de la lista vinculada se pueden almacenar en diferentes lugares de la memoria, pero se unen en la lista vinculada mediante 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 ubicación de los elementos en la memoria. La creación de perfiles adecuada le ayudará a identificar si necesita una mejor gestión de la memoria o del tiempo para decidir si utilizar una lista vinculada o una matriz como su elección de la estructura de datos al optimizar su código.

    Rango vs XRange

    Cuando se trata de bucles en Python, a veces necesitaremos generar una lista de enteros para ayudarnos a ejecutar bucles for. Las funciones rangey xrangese 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 el sentido de que no es la lista final. Nos da la capacidad de generar los valores en la lista final esperada según sea necesario durante el tiempo de ejecución mediante una técnica conocida como «rendimiento».

    El hecho de que la xrangefunción no devuelva la lista final la convierte en la opción más eficiente en memoria para generar grandes listas de enteros con fines de bucle.

    Si necesitamos generar una gran cantidad de enteros para su uso, xrangedebería ser nuestra opción de referencia para este propósito ya que usa menos memoria. Si usamos la rangefunción en su lugar, será necesario crear la lista completa de enteros y esto consumirá 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 de enteros usando rangey xrange. El 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 mucha memoria, pero ¿qué pasa con el tiempo de búsqueda de elementos? Calculamos 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 lleva más tiempo encontrar un elemento en él. Dada la situación y los recursos disponibles, podemos elegir entre rangeo en xrangefunción del aspecto que estemos buscando. Esto reitera la importancia de la creación de perfiles en la optimización de nuestro código Python.

    Nota: xrange está en desuso en Python 3 y la rangefunción ahora puede ofrecer la misma funcionalidad. Los generadores todavía están disponibles en Python 3 y pueden ayudarnos a ahorrar memoria de otras formas, como por ejemplo, comprensiones o expresiones de generador .

    Conjuntos

    Cuando trabajamos 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 Python Sets. Son como listas pero no permiten que se almacenen duplicados en ellas. Los conjuntos también se utilizan para eliminar de forma eficaz los duplicados de las listas y son más rápidos que crear una nueva lista y completarla a partir 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 verifica su presencia.

    Esto podría ser útil al filtrar 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 mejorado que viene con Sets es significativo.

    Es posible que esto no ocurra con frecuencia, pero puede marcar una gran diferencia cuando se solicita. La elaboración de perfiles adecuada puede ayudarnos a identificar tales situaciones y puede marcar la diferencia en el rendimiento de nuestro código.

    Concatenación de cadenas

    Las cadenas son inmutables de forma predeterminada 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 usa 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 objetos String nuevos en la memoria, por lo tanto, la utilización inadecuada de la memoria.

    También podemos usar el operador concatenar +=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 múltiples cadenas, la forma ideal de concatenarlas es mediante el .join()método.

    Creemos una lista de mil palabras y comparemos cómo se comparan .join()el +=operador y el operador:

    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 ordenado y 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 a medida que escala. A través del módulo Timeit y el generador de perfiles cProfile, pudimos saber qué implementación toma menos tiempo para ejecutarse y la respaldamos 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 tener más cuidado.

    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 que podría terminar siendo más costosa de mantener o dificultar la comprensión del código.

     

    Etiquetas:

    Deja una respuesta

    Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *