Olá pessoal. Hoje queremos compartilhar outra tradução preparada às vésperas do lançamento do curso
Python Developer . Vamos lá!

Eu usei o Python com mais frequência do que qualquer outra linguagem de programação nos últimos 4-5 anos. Python é a linguagem predominante para compilações no Firefox, testing e a ferramenta CI. Mercurial também é escrito principalmente em Python. Também escrevi muitos dos meus projetos de terceiros.
Durante meu trabalho, adquiri um pouco de conhecimento sobre o desempenho do Python e suas ferramentas de otimização. Neste artigo, eu gostaria de compartilhar esse conhecimento.
Minha experiência com Python está relacionada principalmente ao interpretador CPython, especialmente ao CPython 2.7. Nem todas as minhas observações são universais para todas as distribuições do Python ou para aquelas que têm as mesmas características em versões semelhantes do Python. Vou tentar mencionar isso durante a narrativa. Lembre-se de que este artigo não é uma visão geral detalhada do desempenho do Python. Só vou falar sobre o que me deparei por conta própria.
A carga devido às peculiaridades de iniciar e importar módulos
Iniciar o interpretador Python e importar os módulos é um processo bastante longo quando se trata de milissegundos.
Se você precisar iniciar centenas ou milhares de processos Python em qualquer um dos seus projetos, esse atraso em milissegundos se transformará em um atraso de vários segundos.
Se você usar o Python para fornecer ferramentas da CLI, a sobrecarga poderá causar um congelamento perceptível ao usuário. Se você precisar das ferramentas da CLI instantaneamente, a execução do interpretador Python a cada chamada tornará mais difícil obter essa ferramenta complexa.
Eu já escrevi sobre esse problema. Algumas das minhas anotações anteriores falam sobre isso, por exemplo,
em 2014 ,
em maio de 2018 e
outubro de 2018 .
Não há muitas coisas que você pode fazer para reduzir o atraso na inicialização: corrigir esse caso refere-se à manipulação do interpretador Python, pois é ele quem controla a execução do código, o que leva muito tempo. A melhor coisa que você pode fazer é desativar a importação do módulo do
site nas chamadas para evitar a execução de código Python extra na inicialização. Por outro lado, muitos aplicativos usam a funcionalidade do módulo site.py, para que você possa usá-lo por sua conta e risco.
Também devemos considerar o problema de importar módulos. Qual a vantagem do interpretador Python se ele não processar nenhum código? O fato é que o código é disponibilizado ao intérprete com mais frequência através do uso de módulos.
Para importar módulos, você precisa executar várias etapas. E em cada um deles existe uma fonte potencial de cargas e atrasos.
Um certo atraso ocorre devido à pesquisa de módulos e à leitura de seus dados. Como demonstrei com o
PyOxidizer , substituindo a pesquisa e o carregamento de um módulo de um sistema de arquivos por uma solução arquitetonicamente mais simples, que consiste na leitura de dados do módulo de uma estrutura de dados na memória, você pode importar a biblioteca padrão do Python para 70-80% do tempo inicial da solução para esta tarefa. Ter um módulo por arquivo do sistema de arquivos aumenta a carga no sistema de arquivos e pode diminuir a velocidade de um aplicativo Python durante os primeiros milissegundos críticos de execução. Soluções como o PyOxidizer podem ajudar a evitar isso. Espero que a comunidade Python veja esses custos da abordagem atual e esteja considerando a transição para os mecanismos de distribuição dos módulos, que não dependem tanto dos arquivos individuais no módulo.
Outra fonte de custos adicionais de importação para um módulo é a execução de código nesse módulo durante a importação. Alguns módulos contêm partes do código em uma área fora das funções e classes do módulo, que são executadas quando o módulo é importado. A execução desse código aumenta o custo da importação. Solução alternativa: não execute todo o código no momento da importação, apenas execute-o se necessário. O Python 3.7 suporta o módulo
__getattr__
, que será chamado se o atributo de um módulo não for encontrado. Isso pode ser usado para preencher lentamente os atributos do módulo no primeiro acesso.
Outra maneira de se livrar da desaceleração da importação é importar preguiçosamente o módulo. Em vez de carregar o módulo diretamente durante a importação, você registra um módulo de importação personalizado que retorna um stub. Quando você acessa esse stub pela primeira vez, ele carrega o módulo real e “muda” para se tornar este módulo.
Você pode salvar dezenas de milissegundos com aplicativos que importam várias dezenas de módulos se você ignorar o sistema de arquivos e evitar a execução de partes desnecessárias do módulo (os módulos geralmente são importados globalmente, mas apenas determinadas funções do módulo são usadas).
A importação lenta de módulos é uma coisa frágil. Muitos módulos possuem modelos com as seguintes coisas:
try: import foo
;
except ImportError:
Um importador preguiçoso de módulo nunca pode lançar um ImportError, porque, se o fizer, terá que procurar no sistema de arquivos por um módulo para descobrir se ele existe em princípio. Isso adicionará carga extra e aumentará o tempo gasto, para que importadores preguiçosos não façam isso em princípio! Este problema é bastante irritante. Importador de módulos preguiçosos O Mercurial processa uma lista de módulos que não podem ser importados preguiçosamente e deve ignorá-los. Outro problema é a sintaxe
from foo import x, y
, que também interrompe a importação do módulo lento, nos casos em que foo é um módulo (em oposição a um pacote), pois o módulo ainda deve ser importado para retornar uma referência a x e y.
O PyOxidizer possui um conjunto fixo de módulos conectados ao binário, para que possa ser eficaz no aumento do ImportError. O módulo __getattr__ do Python 3.7 fornece flexibilidade adicional para os importadores preguiçosos do módulo. Espero integrar um importador lento e confiável no PyOxidizer para automatizar alguns processos.
A melhor solução para evitar o início do intérprete e causar atrasos é iniciar o processo em segundo plano no Python. Se você iniciar o processo Python como um processo daemon, digamos para um servidor da Web, poderá fazê-lo. A solução que a Mercurial oferece é iniciar um processo em segundo plano que forneça um
protocolo de servidor de comando . hg é o executável C (ou agora Rust), que se conecta a esse processo em segundo plano e envia um comando. Para encontrar uma abordagem para o servidor de comando, você precisa fazer muito trabalho, é extremamente instável e tem problemas de segurança. Estou pensando na idéia de entregar um servidor de comando usando o PyOxidizer para que o arquivo executável tenha suas vantagens, e o problema do custo da solução de software foi resolvido com a criação do projeto do PyOxidizer.
Atraso na Chamada de Função
Chamar funções no Python é um processo relativamente lento. (Essa observação é menos aplicável ao PyPy, que pode executar o código JIT.)
Vi dezenas de correções para o Mercurial, o que tornou possível alinhar e combinar o código de forma a evitar carga desnecessária ao chamar funções. No atual ciclo de desenvolvimento, foram feitos alguns esforços para reduzir o número de funções chamadas ao atualizar a barra de progresso. (Utilizamos barras de progresso para quaisquer operações que possam levar algum tempo, para que o usuário entenda o que está acontecendo). Obter os resultados da chamada de
funções e evitar pesquisas simples entre
funções economiza dezenas de centenas de milissegundos quando executado, quando falamos de um milhão de execuções, por exemplo.
Se você possui loops estreitos ou funções recursivas no Python, onde centenas de milhares ou mais chamadas de função podem ocorrer, você deve estar ciente da sobrecarga de chamar uma função individual, pois isso é de grande importância. Lembre-se de funções simples incorporadas e da capacidade de combinar funções para evitar sobrecarga.
Sobrecarga de pesquisa de atributo
Esse problema é semelhante ao overhead devido a uma chamada de função, pois o significado é quase o mesmo!
A localização de atributos de resolução no Python pode ser lenta. (E, novamente, no PyPy, isso é mais rápido). No entanto, lidar com esse problema é o que costumamos fazer no Mercurial.
Digamos que você tenha o seguinte código:
obj = MyObject() total = 0 for i in len(obj.member): total += obj.member[i]
Omita que existem maneiras mais eficientes de escrever este exemplo (por exemplo,
total = sum(obj.member)
) e observe que o loop precisa definir obj.member a cada iteração. O Python possui um mecanismo relativamente sofisticado para definir
atributos . Para tipos simples, pode ser rápido o suficiente. Mas para tipos complexos, esse acesso ao atributo pode chamar automaticamente
__getattr__
,
__getattribute__
, vários métodos
dunder
e até
@property
definidas pelo usuário. Isso é semelhante a uma pesquisa rápida de um atributo que pode fazer várias chamadas de função, o que levará a uma carga extra. E essa carga pode ser agravada se você usar coisas como
obj.member1.member2.member3
, etc.
Cada definição de atributo causa uma carga extra. E como quase tudo no Python é um dicionário, podemos dizer que toda pesquisa de atributo é uma pesquisa de dicionário. A partir de conceitos gerais sobre estruturas básicas de dados, sabemos que a pesquisa de dicionário não é tão rápida quanto, digamos, a pesquisa de índice. Sim, é claro que existem alguns truques no CPython que podem se livrar da sobrecarga devido a pesquisas no dicionário. Mas o principal tópico que quero abordar é que qualquer pesquisa de atributo é um possível vazamento de desempenho.
Para loops apertados, especialmente aqueles que potencialmente excedem centenas de milhares de iterações, é possível evitar essas despesas gerais mensuráveis para localizar atributos atribuindo um valor a uma variável local. Vejamos o seguinte exemplo:
obj = MyObject() total = 0 member = obj.member for i in len(member): total += member[i]
Obviamente, isso só pode ser feito com segurança se não for substituído em um ciclo. Se isso acontecer, o iterador manterá um link para o elemento antigo e tudo poderá explodir.
O mesmo truque pode ser realizado ao chamar o método do objeto. Em vez disso
obj = MyObject() for i in range(1000000): obj.process(i)
Você pode fazer o seguinte:
obj = MyObject() fn = obj.process for i in range(1000000:) fn(i)
Também é importante notar que, no caso em que a pesquisa de atributo precisa chamar um método (como no exemplo anterior), o Python 3.7 é relativamente
mais rápido que nas versões anteriores. Mas tenho certeza de que aqui a carga excessiva está conectada, em primeiro lugar, à chamada de função e não à carga na pesquisa de atributo. Portanto, tudo funcionará mais rápido se você abandonar a pesquisa extra por atributos.
Finalmente, como uma pesquisa de atributo chama uma função para isso, pode-se dizer que a pesquisa de atributo geralmente é menos um problema do que uma carga devido a uma chamada de função. Normalmente, para observar mudanças significativas na velocidade, é necessário eliminar muitas pesquisas de atributos. Nesse caso, assim que você der acesso a todos os atributos dentro do loop, você poderá falar sobre 10 ou 20 atributos apenas no loop antes de chamar a função. E loops com apenas milhares ou menos de dezenas de milhares de iterações podem fornecer rapidamente centenas de milhares ou milhões de pesquisas de atributos. Então tenha cuidado!
Carregamento de objeto
Do ponto de vista do interpretador Python, todos os valores são objetos. No CPython, cada elemento é uma estrutura PyObject. Cada objeto controlado pelo intérprete está no heap e possui sua própria memória contendo a contagem de referência, o tipo de objeto e outros parâmetros. Cada objeto é descartado pelo coletor de lixo. Isso significa que cada novo objeto adiciona sobrecarga devido à contagem de referências, coleta de lixo, etc. (E, novamente, o PyPy pode evitar esse fardo desnecessário, pois “se prende com mais cuidado” à vida útil dos valores de curto prazo.)
Geralmente, quanto mais valores únicos e objetos Python você criar, mais lento o trabalho para você.
Digamos que você itere sobre uma coleção de um milhão de objetos. Você chama uma função para coletar esse objeto em uma tupla:
for x in my_collection: a, b, c, d, e, f, g, h = process(x)
Neste exemplo,
process()
retornará uma tupla de 8 tuplas. Não importa se destruímos o valor de retorno ou não: essa tupla exige a criação de pelo menos 9 valores em Python: 1 para a própria tupla e 8 para seus membros internos. Bem, na vida real, pode haver menos valores se
process()
retornar uma referência a um objeto existente. Ou, pelo contrário, pode haver mais se seus tipos não forem simples e exigirem muitos PyObjects para representar. Eu só quero dizer que, sob o capô do intérprete, existe um verdadeiro malabarismo de objetos para a apresentação completa de certas construções.
Pela minha própria experiência, posso dizer que essas despesas gerais são relevantes apenas para operações que proporcionam ganhos de velocidade quando implementadas em um idioma nativo, como C ou Rust. O problema é que o intérprete do CPython simplesmente não consegue executar o bytecode tão rápido que a carga extra devido ao número de objetos é importante. Em vez disso, é mais provável que você reduza o desempenho chamando uma função ou através de cálculos pesados, etc. antes que você possa perceber a carga extra devido a objetos. Existem, é claro, algumas exceções, a saber, a construção de tuplas ou dicionários de vários valores.
Como um exemplo concreto de sobrecarga, você pode citar o Mercurial com código C que analisa estruturas de dados de baixo nível. Para maior velocidade de análise, o código C executa uma ordem de magnitude mais rápido que o CPython. Porém, assim que o código C cria PyObject para representar o resultado, a velocidade cai várias vezes. Em outras palavras, a carga envolve a criação e o gerenciamento de elementos Python para que eles possam ser usados no código.
Uma maneira de contornar esse problema é produzir menos elementos em Python. Se você precisar se referir a um único elemento, inicie a função e retorne-a, e não uma tupla ou um dicionário de N elementos. No entanto, não pare de monitorar a possível carga devido a chamadas de função!
Se você possui muito código que funciona com rapidez suficiente usando a API CPython C e elementos que precisam ser distribuídos entre módulos diferentes, sem tipos Python que representam dados diferentes como estruturas C e já tenha compilado código para acessar essas estruturas em vez de passar pela API C do CPython. Ao evitar a API CPython C para acessar dados, você se livra de muita carga extra.
Tratar elementos como dados (em vez de ter funções para acessar tudo em uma linha) seria a melhor abordagem para um pythonist. Outra solução alternativa para o código já compilado é instanciar preguiçosamente o PyObject. Se você criar um tipo personalizado em Python (PyTypeObject) para representar elementos complexos, precisará definir os
campos tp_members ou
tp_getset para criar funções C personalizadas para procurar o valor do atributo. Se você, por exemplo, escreve um analisador e sabe que os clientes só terão acesso a um subconjunto dos campos analisados, é possível criar rapidamente um tipo que contém dados brutos, retornar esse tipo e chamar uma função C para procurar atributos do Python que processam o PyObject. Você pode até atrasar a análise até que a função seja chamada para economizar recursos se a análise nunca for necessária! Essa técnica é bastante rara, porque requer a criação de código não trivial, mas fornece um resultado positivo.
Determinação preliminar do tamanho da coleção
Isso se aplica à CPython C API.
Ao criar coleções, como listas ou dicionários, use
PyList_New()
+
PyList_SET_ITEM()
para preencher uma nova coleção se seu tamanho já estiver definido no momento da criação. Isso determinará previamente o tamanho da coleção para poder conter um número finito de elementos nela. Isso ajuda a ignorar a verificação de um tamanho de coleção suficiente ao inserir itens. Ao criar uma coleção de milhares de itens, você economiza alguns recursos!
Usando cópia zero na API C
A API Python C realmente gosta de criar cópias de objetos em vez de retornar referências a eles. Por exemplo,
PyBytes_FromStringAndSize () copia
char*
na memória reservada pelo Python. Se você fizer isso para um grande número de valores ou grandes dados, poderemos falar sobre gigabytes de E / S de memória e a carga associada no alocador.
Se você precisar escrever código de alto desempenho sem uma API C, familiarize-se com o
protocolo de buffer e tipos relacionados, como
memoryview .Buffer protocol
é incorporado aos tipos Python e permite que os intérpretes convertam o tipo de / para bytes. Ele também permite que o interpretador de código C receba um descritor
void*
de um determinado tamanho. Isso permite que você associe qualquer endereço na memória ao PyObject. Muitas funções que trabalham com dados binários aceitam de forma transparente qualquer objeto que implemente o
buffer protocol
. E se você quiser aceitar qualquer objeto que possa ser considerado como bytes, precisará usar
unidades do formato s*
,
y*
ou
w*
ao receber argumentos de função.
Usando o
buffer protocol
, você oferece ao intérprete a melhor oportunidade disponível para usar operações de
zero-copy
e se recusa a copiar bytes extras na memória.
Ao usar tipos no Python do formulário
memoryview
, você também permitirá que o Python acesse os níveis de memória por referência, em vez de fazer cópias.
Se você tiver gigabytes de código que passam pelo seu programa Python, o uso perspicaz dos tipos Python que suportam cópia zero salvará você das diferenças de desempenho. Uma vez eu notei que o
python-zstandard se mostrou mais rápido do que qualquer ligação Python LZ4 (embora devesse ser o contrário), pois usei muito o
buffer protocol
e evitei E / S de memória excessiva no
python-zstandard
!
Conclusão
Neste artigo, procurei falar sobre algumas das coisas que aprendi ao otimizar meus programas Python por vários anos. Repito e digo que não é de forma alguma uma visão abrangente dos métodos de melhoria de desempenho do Python. Admito que provavelmente uso o Python mais exigente que outros, e minhas recomendações não podem ser aplicadas a todos os programas.
Você não deve, de maneira alguma, corrigir massivamente o seu código Python e remover, por exemplo, a busca por atributos depois de ler este artigo . Como sempre, quando se trata de otimização de desempenho, primeiro corrija onde o código é especialmente lento.
py-spy Python. , , Python, . , , , !
, Python . , , Python - . Python – PyPy, . Python . , Python , . , « ». , , , Python, , , .
;-)