Desarrollando programas Python extremadamente rápidos

Los que odian a Python siempre dicen que una de las razones por las que no quieren usar este lenguaje es porque Python es lento. Pero el hecho de que un determinado programa, independientemente del lenguaje de programación utilizado, pueda considerarse rápido o lento, depende en gran medida del desarrollador que lo escribió, de su conocimiento y de la capacidad de crear código optimizado y de alto rendimiento.



El autor del artículo, que publicamos hoy, ofrece probar que aquellos que llaman a Python lento están equivocados. Quiere hablar sobre cómo mejorar el rendimiento de los programas Python y hacerlos realmente rápidos.

Medición de tiempo y perfil


Antes de comenzar a optimizar cualquier código, primero debe averiguar qué partes de él ralentizan todo el programa. A veces, un cuello de botella del programa puede ser obvio, pero si el programador no sabe dónde está, puede aprovechar algunas oportunidades para identificarlo.

A continuación se muestra el código del programa, que utilizaré para fines de demostración. Está tomado de la documentación de Python. Este código eleva e al poder de x :

 # slow_program.py from decimal import * def exp(x):    getcontext().prec += 2    i, lasts, s, fact, num = 0, 0, 1, 1, 1    while s != lasts:        lasts = s        i += 1        fact *= i        num *= x        s += num / fact    getcontext().prec -= 2    return +s exp(Decimal(150)) exp(Decimal(400)) exp(Decimal(3000)) 

La forma más fácil de "perfilar" el código


Para comenzar, considere la forma más sencilla de perfilar su código. Por así decirlo, "perfilar para los perezosos". Consiste en usar el comando de time Unix:

 ~ $ time python3.8 slow_program.py real 0m11,058s user 0m11,050s sys 0m0,008s 

Tal perfil puede brindarle al programador información útil, en caso de que necesite medir el tiempo de ejecución de todo el programa. Pero generalmente esto no es suficiente.

El método de perfilado más preciso


En el otro extremo del espectro de métodos de creación de perfiles de código se encuentra la herramienta cProfile , que le da al programador, ciertamente, demasiada información:

 ~ $ python3.8 -m cProfile -s time slow_program.py         1297 function calls (1272 primitive calls) in 11.081 seconds   Ordered by: internal time   ncalls tottime percall cumtime percall filename:lineno(function)        3  11.079  3.693 11.079  3.693 slow_program.py:4(exp)        1  0.000  0.000 0.002  0.002 {built-in method _imp.create_dynamic}      4/1  0.000  0.000 11.081  11.081 {built-in method builtins.exec}        6  0.000  0.000 0.000  0.000 {built-in method __new__ of type object at 0x9d12c0}        6  0.000  0.000 0.000  0.000 abc.py:132(__new__)       23  0.000  0.000 0.000  0.000 _weakrefset.py:36(__init__)      245  0.000  0.000 0.000  0.000 {built-in method builtins.getattr}        2  0.000  0.000 0.000  0.000 {built-in method marshal.loads}       10  0.000  0.000 0.000  0.000 <frozen importlib._bootstrap_external>:1233(find_spec)      8/4  0.000  0.000 0.000  0.000 abc.py:196(__subclasscheck__)       15  0.000  0.000 0.000  0.000 {built-in method posix.stat}        6  0.000  0.000 0.000  0.000 {built-in method builtins.__build_class__}        1  0.000  0.000 0.000  0.000 __init__.py:357(namedtuple)       48  0.000  0.000 0.000  0.000 <frozen importlib._bootstrap_external>:57(_path_join)       48  0.000  0.000 0.000  0.000 <frozen importlib._bootstrap_external>:59(<listcomp>)        1  0.000  0.000 11.081  11.081 slow_program.py:1(<module>) 

Aquí ejecutamos el script investigado usando el módulo cProfile y usamos el argumento time . Como resultado, las líneas de salida se ordenan por tiempo interno ( cumtime ). Nos da mucha información. De hecho, lo que se muestra arriba es solo alrededor del 10% de la producción de cProfile .

Después de analizar estos datos, podemos ver que la función exp es la razón del lento funcionamiento del programa (¡eso es una sorpresa!). Después de eso, podemos hacer perfiles de código utilizando herramientas más precisas.

El estudio de los indicadores temporales de desempeño de una función específica.


Ahora sabemos sobre el lugar del programa donde necesitamos dirigir nuestra atención. Por lo tanto, podemos decidir estudiar la función lenta sin perfilar otro código de programa. Para hacer esto, puede usar un decorador simple:

 def timeit_wrapper(func):    @wraps(func)    def wrapper(*args, **kwargs):        start = time.perf_counter() #       time.process_time()        func_return_val = func(*args, **kwargs)        end = time.perf_counter()        print('{0:<10}.{1:<8} : {2:<8}'.format(func.__module__, func.__name__, end - start))        return func_return_val    return wrapper 

Este decorador se puede aplicar a la función a explorar:

 @timeit_wrapper def exp(x):    ... print('{0:<10} {1:<8} {2:^8}'.format('module', 'function', 'time')) exp(Decimal(150)) exp(Decimal(400)) exp(Decimal(3000)) 

Ahora, después de comenzar el programa, recibiremos la siguiente información:

 ~ $ python3.8 slow_program.py module   function time __main__ .exp : 0.003267502994276583 __main__ .exp : 0.038535295985639095 __main__ .exp : 11.728486061969306 

Aquí vale la pena prestar atención a exactamente a qué hora planeamos medir. El paquete correspondiente nos proporciona indicadores como time.perf_counter y time.process_time . La diferencia entre ellos es que perf_counter devuelve un valor absoluto, que incluye el tiempo durante el cual el proceso del programa Python no se está ejecutando. Esto significa que este indicador puede verse afectado por la carga en la computadora creada por otros programas. La process_time solo devuelve el tiempo del usuario. No incluye la hora del sistema. Esto solo nos brinda información sobre el tiempo de ejecución de nuestro proceso.

Aceleración de código


Y ahora para la parte divertida. Trabajemos para acelerar el programa. No voy a mostrar (en su mayor parte) todo tipo de trucos, trucos y misteriosos códigos aquí que resuelven mágicamente los problemas de rendimiento. Básicamente quiero hablar sobre ideas y estrategias comunes que, si se usan, pueden tener un gran impacto en el rendimiento. En algunos casos, estamos hablando de un aumento del 30% en la velocidad de ejecución del código.

▍Utilice los tipos de datos incorporados


El uso de tipos de datos integrados es un enfoque muy obvio para acelerar el código. Los tipos de datos integrados son extremadamente rápidos, especialmente si los compara con tipos personalizados como árboles o listas vinculadas. El punto aquí es principalmente que los mecanismos integrados del lenguaje se implementan usando C. Si describe algo usando Python, no puede lograr el mismo nivel de rendimiento.

▍Aplicar almacenamiento en caché (memorización) con lru_cache


El almacenamiento en caché es un enfoque popular para mejorar el rendimiento del código. Ya escribí sobre él, pero creo que vale la pena contarlo sobre él aquí:

 import functools import time #   12   @functools.lru_cache(maxsize=12) def slow_func(x):    time.sleep(2) #       return x slow_func(1) # ...  2     slow_func(1) #    -   ! slow_func(3) # ...   2     

La función anterior simula cálculos complejos utilizando time.sleep . Cuando se llama por primera vez con el parámetro 1 , espera 2 segundos y devuelve el resultado solo después de eso. Cuando vuelve a llamarla con el mismo parámetro, resulta que el resultado de su trabajo ya está almacenado en caché. El cuerpo de la función en esta situación no se ejecuta y el resultado se devuelve inmediatamente. Aquí puede encontrar ejemplos de almacenamiento en caché más cercanos a la realidad.

▍Utilizar variables locales


Aplicando variables locales, tenemos en cuenta la velocidad de búsqueda de una variable en cada ámbito. Estoy hablando específicamente de "todas las áreas de visibilidad", ya que aquí tengo en mente no solo una comparación de la velocidad del trabajo con variables locales y globales. De hecho, la diferencia en el trabajo con variables incluso se observa, por ejemplo, entre variables locales en una función (la velocidad más alta), atributos de nivel de clase (por ejemplo, self.name , esto ya es más lento) y entidades globales importadas como time.time (la mayoría lento de estos tres mecanismos).

Puede mejorar el rendimiento utilizando los siguientes enfoques para asignar valores que pueden parecer completamente innecesarios e inútiles para una persona desinformada:

 #  #1 class FastClass:    def do_stuff(self):        temp = self.value #           for i in range(10000):            ... #      `temp` #  #2 import random def fast_function():    r = random.random    for i in range(10000):        print(r()) #   `r()` ,     random.random() 

▍ Código de ajuste en función


Este consejo puede parecer contrario al sentido común, ya que cuando se llama a una función, algunos datos se envían a la pila y el sistema está bajo carga adicional procesando la operación de retorno de la función. Sin embargo, esta recomendación está relacionada con la anterior. Si solo coloca todo su código en un archivo sin escribirlo como una función, se ejecutará mucho más lento debido al uso de variables globales. Esto significa que el código se puede acelerar simplemente envolviéndolo en la función main() y llamándolo una vez:

 def main():    ... #  ,     main() 

▍No acceda a los atributos


Otro mecanismo que puede ralentizar un programa es el operador punto ( . ), Que se utiliza para acceder a los atributos de los objetos. Esta declaración invoca una __getattribute__ diccionario usando __getattribute__ , lo que __getattribute__ un esfuerzo adicional en el sistema. ¿Cómo limitar el impacto en el rendimiento de esta función de Python?

 # : import re def slow_func():    for i in range(10000):        re.findall(regex, line) # ! # : from re import findall def fast_func():    for i in range(10000):        findall(regex, line) # ! 

▍ Cuidado con las cuerdas


Las operaciones de cadena pueden ralentizar enormemente un programa si se ejecutan en bucles. En particular, estamos hablando de formatear cadenas usando %s .format() . ¿Es posible reemplazarlos con algo? Si observa un tweet reciente de Raymond Hettinger, puede ver que el único mecanismo que debe usarse en tales situaciones son las líneas f. Este es el método de formateo de cadenas más legible, conciso y rápido. Aquí, de acuerdo con ese tweet, hay una lista de métodos que se pueden usar para trabajar con cadenas, desde el más rápido hasta el más lento:

 f'{s} {t}' # ! s + ' ' + t ' '.join((s, t)) '%s %s' % (s, t) '{} {}'.format(s, t) Template('$s $t').substitute(s=s, t=t) # ! 

▍ Sepa que los generadores también pueden funcionar rápido


Los generadores no son aquellos mecanismos que, por su naturaleza, son rápidos. El hecho es que fueron creados para realizar cálculos "perezosos", lo que no ahorra tiempo, sino memoria. Sin embargo, ahorrar memoria puede hacer que los programas se ejecuten más rápido. ¿Cómo es esto posible? El hecho es que al procesar un gran conjunto de datos sin usar generadores (iteradores), los datos pueden provocar un desbordamiento de la caché L1 del procesador, lo que ralentizará significativamente el proceso de búsqueda de valores en la memoria.

Cuando se trata del rendimiento, es muy importante esforzarse por garantizar que el procesador pueda acceder rápidamente a los datos que procesa, de modo que estén lo más cerca posible de ellos. Y esto significa que dichos datos deben colocarse en la memoria caché del procesador. Este problema se aborda en esta presentación de Raymond Hettinger.

Resumen


La primera regla de optimización es que la optimización no es necesaria. Pero si no puede prescindir de la optimización, espero que los consejos que he compartido le ayuden con esto.

Estimados lectores! ¿Cómo aborda la optimización del rendimiento de su código Python?

Source: https://habr.com/ru/post/483678/


All Articles