Optimización del rendimiento de Python

Optimizaremos patrones y procedimientos comunes en la programación de Python en un esfuerzo por aumentar el rendimiento y mejorar la utilización de los recursos informáticos disponibles.

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 camino cada vez más en nuestras vidas. La tecnología hace la vida más fácil y cómoda 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 poderosas y la optimización del código nunca ha sido tan crucial.

Los requisitos de rendimiento de las aplicaciones están aumentando más de lo que nuestro hardware puede soportar. Para combatir esto, las personas han ideado muchas estrategias para utilizar los recursos de manera más eficiente: contenedorización, aplicaciones reactivas (asincrónicas), 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 de código. Necesitamos escribir código que funcione mejor y utilice menos recursos informáticos.

En este artículo, optimizaremos patrones y procedimientos comunes en la programación de Python en un esfuerzo por aumentar el rendimiento y mejorar la utilización de los recursos informáticos disponibles.

Problema con el rendimiento

A medida que escalan las soluciones de software, el rendimiento se vuelve más crucial y los problemas se vuelven más grandes y visibles. Cuando estamos escribiendo 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 complicados.

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 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 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 bloqueos frecuentes.

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

Por qué y cuándo optimizar {#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 puede manejar una gran cantidad de usuarios o solicitudes concurrentes 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 genera menos dolores de cabeza cuando una aplicación falla en medio de la noche y su gerente enojado lo llama para solucionarlo al instante.

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 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 al hacerlo más complejo. Por lo 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 contribuirá en gran medida a 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 cómo 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 prematura es la raíz de todos los males."

La solución tiene que funcionar para que sea optimizada.

La creació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 el 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 o no nuestro código.

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. En este sentido, las diversas herramientas que pueden ayudar a perfilar la codificación de manera más rápida y eficiente incluyen:

  • PyCallGraph - que crea visualizaciones de gráficos de llamadas que representan relaciones de llamada entre subrutinas para el código de Python.
  • cPerfil, 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 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 correctos puede ayudar a que nuestro código Python funcione mejor.

Elección de estructuras de datos y flujo de control {#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? ¿Estamos borrando con frecuencia? ¿Estamos constantemente buscando artículos? 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 tiempo y el uso de la memoria se verán muy afectados 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.

For Loop vs Comprensiones de listas

Los bucles son comunes cuando se desarrolla en Python y muy pronto se encontrará con listas de 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 el bucle for:

1
2
3
4
new_list = []
for n in range(0, 10):
    if n % 2 == 0:
        new_list.append(n**2)

Una versión Comprensión de lista del bucle sería simplemente:

1
new_list = [ n**2 for n in range(0,10) if n%2 == 0]

La lista de comprensión 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 Cronométralo que proporciona una forma de cronometrar pequeños fragmentos de código Python.

Pongamos la comprensión de la lista contra el bucle for equivalente y veamos cuánto tarda cada uno en lograr el mismo resultado:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$ 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 bucle for. En el código a pequeña escala, esto puede no marcar 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 hace más evidente:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ 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 perfilador que viene con Python y si lo usamos para perfilar nuestro código:

cprofile analysis

Tras un examen más detallado, aún podemos ver que la herramienta cProfile informa que nuestra Comprensión de listas requiere menos tiempo de ejecución que nuestra implementación de For Loop, como habíamos establecido anteriormente. cProfile muestra todas las funciones llamadas, la cantidad de veces que han sido llamadas y la cantidad de tiempo que lleva cada una.

Si nuestra intención es reducir el tiempo que tarda nuestro código en ejecutarse, entonces la Comprensión de lista sería una mejor opción que usar For Loop. El efecto de tal decisión de 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 estamos preocupados por el uso de nuestra memoria? La comprensión de una lista requeriría más memoria para eliminar elementos de una lista que un bucle normal. Una lista por comprensión siempre crea una nueva lista en la memoria al finalizar, 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.

Una vez más, en secuencias de comandos a pequeña escala, es posible que no haga mucha diferencia, pero la optimización funciona bien a mayor escala y, en esa situación, el ahorro de memoria será bueno y nos permitirá usar la memoria adicional guardada para otras operaciones.

Listas enlazadas

Otra estructura de datos que puede venir bien para lograr el ahorro de 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 de la lista y no requiere una asignación de memoria contigua.

Una matriz requiere que la memoria requerida para almacenarla y sus elementos se asigne 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 vinculada le permitirá asignar memoria según sea necesario. Esto es posible porque los nodos de la lista enlazada se pueden almacenar en diferentes lugares de la memoria, pero se unen en la lista enlazada a través de punteros. Esto hace que las listas enlazadas sean mucho más flexibles en comparación con las matrices.

La advertencia con una lista enlazada 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 lo ayudará a identificar si necesita una mejor memoria o administración del tiempo para decidir si usar una lista enlazada o una matriz como su elección de estructura de datos al optimizar su código.

Rango frente a XRange

Cuando se trata de bucles en Python, a veces necesitaremos generar una lista de números enteros para ayudarnos a ejecutar bucles for. Las funciones range y xrange se utilizan para este efecto.

Su funcionalidad es la misma pero son diferentes en que range devuelve un objeto list pero xrange devuelve un objeto xrange.

¿Qué significa esto? Un objeto xrange es un generador en el sentido de que no es la lista final. Nos brinda la capacidad de generar los valores en la lista final esperada según sea necesario durante el tiempo de ejecución a través de una técnica conocida como "rendimiento".

El hecho de que la función xrange no devuelva la lista final la convierte en la opción más eficiente en términos de memoria para generar listas enormes de enteros con fines de bucle.

Si necesitamos generar una gran cantidad de enteros para usar, xrange debería ser nuestra opción de acceso para este propósito, ya que usa menos memoria. Si usamos la función rango en su lugar, será necesario crear la lista completa de enteros y esto hará que la memoria sea intensiva.

Exploremos esta diferencia en el consumo de memoria entre las dos funciones:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$ 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 range y xrange. El tipo de objeto creado por la función rango es una Lista que consume 8000072 bytes de memoria mientras que el objeto xrange consume solo 40 bytes de memoria.

La función xrange nos ahorra memoria, mucha, pero ¿qué pasa con el tiempo de búsqueda de elementos? Vamos a cronometrar el tiempo de búsqueda de un entero en la lista generada de enteros usando Timeit:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
$ 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

xrange puede consumir menos memoria pero lleva más tiempo encontrar un elemento en ella. Dada la situación y los recursos disponibles, podemos elegir range o xrange dependiendo del aspecto que busquemos. Esto reitera la importancia de la creación de perfiles en la optimización de nuestro código Python.

Nota: xrange está obsoleto en Python 3 y la función range ahora puede tener la misma funcionalidad. Los generadores aún están disponibles en Python 3 y pueden ayudarnos a ahorrar memoria de otras formas, como Generador de Comprensiones o Expresiones.

Conjuntos

Al trabajar 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 almacenar duplicados en ellas. Los conjuntos también se usan para eliminar de manera eficiente 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 los valores únicos.

Comparemos las dos operaciones:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
$ 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 mucho más rápido que crear manualmente una lista y agregar elementos mientras se verifica la presencia.

Esto podría ser útil al filtrar las entradas para un concurso de obsequios, donde debemos filtrar las entradas duplicadas. Si tarda 2 segundos en filtrar 120 entradas, imagine filtrar 10 000 entradas. En tal escala, el rendimiento mucho mayor que viene con Sets es significativo.

Es posible que esto no ocurra con frecuencia, pero puede marcar una gran diferencia cuando se le solicita. La creació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 varias situaciones.

Podemos usar + (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 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 usando el método .join().

Vamos a crear una lista de mil palabras y comparar cómo se comparan los operadores .join() y +=:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
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:

1
2
3
4
5
6
7
$ 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 método .join() no solo es más ordenado y legible, sino que también es significativamente más rápido que el operador de concatenación cuando se unen 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, hemos podido determinar qué implementación requiere menos tiempo para ejecutarse y lo respaldamos con las cifras. Las estructuras de datos y las estructuras de flujo de control que usamos 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.