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 *