Desenvolvendo programas Python extremamente rápidos

Os que odeiam o Python sempre dizem que uma das razões pelas quais eles não querem usar essa linguagem é porque o Python é lento. Mas o fato de um determinado programa, independentemente da linguagem de programação usada, poder ser considerado rápido ou lento, depende muito do desenvolvedor que o escreveu, de seu conhecimento e da capacidade de criar código otimizado e de alto desempenho.



O autor do artigo, que estamos publicando hoje, oferece para provar que aqueles que chamam Python de lento estão errados. Ele quer falar sobre como melhorar o desempenho dos programas Python e torná-los muito rápidos.

Medição de tempo e criação de perfil


Antes de começar a otimizar qualquer código, primeiro você precisa descobrir quais partes dele diminuem a velocidade do programa inteiro. Às vezes, um gargalo do programa pode ser óbvio, mas se o programador não souber onde está, ele poderá aproveitar algumas oportunidades para identificá-lo.

Abaixo está o código do programa, que utilizarei para fins de demonstração. É retirado da documentação do Python. Este código eleva e ao 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)) 

A maneira mais fácil de "criar um perfil" do código


Para começar, considere a maneira mais simples de criar um perfil do seu código. Por assim dizer, "criar perfil para os preguiçosos". Consiste em usar o comando de time Unix:

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

Esse perfil pode muito bem fornecer ao programador algumas informações úteis - no caso de ele precisar medir o tempo de execução de todo o programa. Mas geralmente isso não é suficiente.

O método de criação de perfil mais preciso


No outro extremo do espectro de métodos de criação de perfil de código está a ferramenta cProfile , que fornece ao programador, reconhecidamente, informações demais:

 ~ $ 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>) 

Aqui, executamos o script investigado usando o módulo cProfile e usamos o argumento de time . Como resultado, as linhas de saída são classificadas por tempo interno ( cumtime ). Isso nos dá muita informação. De fato, o que é mostrado acima é apenas cerca de 10% da saída do cProfile .

Após analisar esses dados, podemos ver que a função exp é o motivo da operação lenta do programa (isso é uma surpresa!). Depois disso, podemos fazer a criação de perfil de código usando ferramentas mais precisas.

O estudo de indicadores de desempenho temporários de uma função específica


Agora sabemos sobre o local do programa em que precisamos direcionar nossa atenção. Portanto, podemos decidir estudar a função lenta sem criar outro código de programa. Para fazer isso, você pode usar um decorador simples:

 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 pode ser aplicado à função a ser explorada:

 @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)) 

Agora, após o início do programa, receberemos as seguintes informações:

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

Aqui vale a pena prestar atenção exatamente a que horas planejamos medir. O pacote correspondente nos fornece indicadores como time.perf_counter e time.process_time . A diferença entre eles é que perf_counter retorna um valor absoluto, que inclui o tempo durante o qual o processo do programa Python não está em execução. Isso significa que esse indicador pode ser afetado pela carga no computador criada por outros programas. A process_time retorna apenas o tempo do usuário. Não inclui a hora do sistema. Isso nos fornece apenas informações sobre o tempo de execução do nosso processo.

Aceleração de código


E agora a parte divertida. Vamos trabalhar para acelerar o programa. Eu (na maioria das vezes) não mostrarei todos os tipos de hacks, truques e partes misteriosas de código aqui que magicamente resolvem problemas de desempenho. Basicamente, quero falar sobre idéias e estratégias comuns que, se usadas, podem ter um grande impacto no desempenho. Em alguns casos, estamos falando de um aumento de 30% na velocidade de execução do código.

▍Use os tipos de dados internos


O uso de tipos de dados internos é uma abordagem muito óbvia para acelerar o código. Os tipos de dados internos são extremamente rápidos, especialmente se você os comparar com tipos personalizados, como árvores ou listas vinculadas. O ponto aqui é principalmente que os mecanismos internos da linguagem são implementados usando C. Se você descreve algo usando Python, não pode atingir o mesmo nível de desempenho.

Aplicar cache (memoization) com lru_cache


O cache é uma abordagem popular para melhorar o desempenho do código. Eu já escrevi sobre ele, mas acho que vale a pena contar sobre ele aqui:

 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     

A função acima simula cálculos complexos usando time.sleep . Quando é chamado pela primeira vez com o parâmetro 1 , aguarda 2 segundos e retorna o resultado somente depois disso. Quando ela é chamada novamente com o mesmo parâmetro, verifica-se que o resultado de seu trabalho já está em cache. O corpo da função nessa situação não é executado e o resultado é retornado imediatamente. Aqui você pode encontrar exemplos de armazenamento em cache mais próximos da realidade.

Use variáveis ​​locais


Aplicando variáveis ​​locais, levamos em consideração a velocidade de pesquisa de uma variável em cada escopo. Estou falando especificamente sobre "todas as áreas de visibilidade", pois aqui tenho em mente não apenas uma comparação da velocidade do trabalho com variáveis ​​locais e globais. De fato, a diferença no trabalho com variáveis ​​é observada, digamos, entre variáveis ​​locais em uma função (a velocidade mais alta), atributos em nível de classe (por exemplo, self.name , isso já é mais lento) e entidades importadas globais como time.time (o mais lento desses três mecanismos).

Você pode melhorar o desempenho usando as seguintes abordagens para atribuir valores que podem parecer completamente desnecessários e inúteis para uma pessoa 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() 

▍ Quebra de código na função


Esse conselho pode parecer contrário ao senso comum, pois quando uma função é chamada, alguns dados são enviados para a pilha e o sistema está sob carga adicional processando a operação de retorno da função. No entanto, esta recomendação está relacionada à anterior. Se você apenas colocar todo o seu código em um arquivo sem gravá-lo como uma função, ele será executado muito mais devagar devido ao uso de variáveis ​​globais. Isso significa que o código pode ser acelerado simplesmente envolvendo-o na função main() e chamando-o uma vez:

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

AccessNão acesse atributos


Outro mecanismo que pode retardar um programa é o Operador de ponto ( . ), Usado para acessar os atributos dos objetos. Esta declaração chama uma __getattribute__ dicionário usando __getattribute__ , o que __getattribute__ um estresse extra no sistema. Como limitar o impacto no desempenho desse recurso 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) # ! 

Ew Cuidado com as cordas


As operações de string podem desacelerar bastante um programa se executadas em loops. Em particular, estamos falando sobre como formatar seqüências de caracteres usando %s e .format() . É possível substituí-los por alguma coisa? Se você olhar para um tweet recente de Raymond Hettinger, poderá ver que o único mecanismo que precisa ser usado nessas situações é o f-lines. Este é o método de formatação de string mais legível, conciso e mais rápido. Aqui, de acordo com esse tweet, há uma lista de métodos que podem ser usados ​​para trabalhar com strings - do mais rápido ao mais lento:

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

Saiba que os geradores também podem trabalhar rápido


Geradores não são os mecanismos que, por sua natureza, são rápidos. O fato é que eles foram criados para executar cálculos "preguiçosos", o que economiza não tempo, mas memória. No entanto, economizar memória pode levar à execução mais rápida dos programas. Como isso é possível? O fato é que, ao processar um grande conjunto de dados sem usar geradores (iteradores), os dados podem levar ao estouro do cache L1 do processador, o que desacelerará significativamente o processo de localização de valores na memória.

Quando se trata de desempenho, é muito importante se esforçar para garantir que o processador possa acessar rapidamente os dados que processa, para que eles fiquem o mais próximo possível dele. E isso significa que esses dados devem ser colocados no cache do processador. Esse problema foi tratado nesta apresentação por Raymond Hettinger.

Sumário


A primeira regra da otimização é que a otimização não é necessária. Mas se você não consegue otimizar a otimização, espero que as dicas que compartilhei o ajudem.

Caros leitores! Como você aborda a otimização do desempenho do seu código Python?

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


All Articles