O desempenho do interpretador com tail call no Python 3.14
(blog.nelhage.com)- O novo interpretador com tail call do CPython pareceu inicialmente trazer um ganho médio de 10~15% no pyperformance, mas, ao ajustar a linha de base, a melhora real encolhe para cerca de 1~5%, dependendo da configuração
- O grande salto estava mais próximo de um desvio de uma regressão no LLVM 19 do que de um efeito exclusivo da nova implementação; a variável principal foi que o Clang 19 não conseguiu reproduzir corretamente o despacho por computed goto existente
- No Intel Raptor Lake i5-13500, o build com clang19 foi 1,09x mais lento que o com clang18, e o clang19.tc foi 1,03x mais rápido; já no Apple M1 MacBook Air, o clang19 foi 1,12x mais lento e até o clang19.tc ficou em um nível de 1,00x mais lento
- Por causa do limite de tail duplication no LLVM, o número de saltos indiretos caiu de 332 no clang18 para 3 no clang19, fazendo com que a estrutura pretendida do interpretador baseado em computed goto praticamente desaparecesse
- A abordagem com tail call continua sendo uma melhoria relevante, mas uma estratégia como
musttail, que exige explicitamente a aplicação de uma otimização pelo compilador, pode ser mais robusta para código sensível a desempenho
O efeito da linha de base que parecia ganho de desempenho
- O projeto CPython integrou há cerca de um mês uma nova estratégia de implementação para o interpretador de bytecode
- Os resultados iniciais mostravam um ganho médio de 10~15% de desempenho em várias plataformas e no benchmark pyperformance
- Análises posteriores mostraram que esse grande avanço era, em grande parte, resultado de desviar acidentalmente de uma regressão no LLVM 19
- Em comparação com GCC, clang-18 ou LLVM 19 com certas flags de ajuste, o ganho cai para algo em torno de 1~5%
- O interpretador com tail call realmente melhora a velocidade, mas o avanço é mais modesto do que os números iniciais sugeriam
- Se você compilou com clang-19 ou versões posteriores, é possível que o caminho antigo estivesse de fato 10~15% mais lento
- Simon Willison reproduziu um ganho de 10% ao comparar um build do
python-build-standalonecom o Python 3.13
- Simon Willison reproduziu um ganho de 10% ao comparar um build do
Configuração dos benchmarks e números principais
- Vários builds do CPython foram comparados em um servidor Intel e em um Apple M1 MacBook Air
- O servidor Intel era um Raptor Lake i5-13500 operado pela Hetzner
- Todos os builds usaram LTO e PGO
- A reprodução dos builds usou uma configuração do nix
- Os alvos de comparação foram os seguintes
clang18: Clang 18.1.8, computed gotogcc: GCC 14.2.1, computed goto, apenas no Intelclang19: Clang 19.1.7, computed gotoclang19.tc: Clang 19.1.7, novo interpretador com tail callclang19.taildup: Clang 19.1.7, computed goto com flag de ajuste-mllvmpara contornar a regressão
- Os resultados médios do pyperformance usando
clang18como linha de base foram os seguintes- Raptor Lake i5-13500:
clang19: 1,09x mais lentoclang19.taildup: 1,01x mais rápidoclang19.tc: 1,03x mais rápidogcc: 1,02x mais rápido
- Apple M1 MacBook Air:
clang19: 1,12x mais lentoclang19.taildup: 1,02x mais lentoclang19.tc: 1,00x mais lento
- Raptor Lake i5-13500:
- O interpretador com tail call mostrou algum ganho em relação ao clang-18, mas menor do que a perda de desempenho causada pela migração para o clang-19
clang18.tcnão pôde ser medido- Isso porque o interpretador com tail call depende de uma funcionalidade de compilador nova no Clang 19
- Essa limitação exigiu mais combinações de benchmark para entender a situação
A regressão no LLVM 19 que desmontou o dispatch
- Interpretadores tradicionais de bytecode processam opcodes com um
switchdentro de um loopwhile- O compilador normalmente transforma esse
switchem uma tabela de saltos com salto indireto
- O compilador normalmente transforma esse
- Há muito tempo se sabe que duplicar a lógica de despacho dentro do corpo de cada opcode acelera esse tipo de interpretador
- Em vez de voltar ao início do loop no fim de cada opcode, cada um passa a ter sua própria lógica para decodificar a próxima instrução e indexar a tabela de saltos
- Compiladores C oferecem a possibilidade de obter o endereço de labels e usá-los como computed goto, e o CPython usava esse modelo de loop do interpretador antes do trabalho com tail call
- O Clang/LLVM, por razões de desempenho do compilador, mescla internamente vários
gotode computed goto em uma única instruçãoindirectbrdo LLVM- Depois, na geração de código, executa tail duplication para copiar de volta a lógica de desvio em cada ponto
- Esse fluxo é documentado em alto nível em um post antigo do blog do LLVM
- O LLVM 19 introduziu um limite no pass de tail duplication para evitar aumentos severos no tempo de compilação ou no uso de memória em certos casos
- No CPython, esse limite fez o Clang deixar os saltos de despacho no estado mesclado
- Como resultado, o objetivo da implementação baseada em computed goto foi praticamente anulado
- Esse problema já havia sido identificado antes em implementações de outras linguagens com loops de interpretador parecidos, mas não se sabia que também afetava o CPython
- Ao desmontar o código objeto e contar os saltos indiretos, a diferença aparece diretamente
_PyEval_EvalFrameDefaultno build clang18:jmp *332 vezes_PyEval_EvalFrameDefaultno build clang19:jmp *3 vezes
A posição ambígua do computed goto
- Que a mudança na lógica de tail duplication causou a regressão foi confirmado pelo fato de que, após a correção, o desempenho do nível do clang-18 foi restaurado
- Ainda assim, a magnitude da regressão não fica totalmente explicada
- Historicamente, já se citou que a duplicação do despacho de opcode acelera interpretadores em algo entre 20% e 100%
- Em processadores modernos, com preditores de desvio melhores, trabalhos mais recentes mostram ganhos menores, na faixa de 2~4%
- O Python também oferece, por opção de configuração, um interpretador antigo com um único
switchclang18.nocg: 1,01x mais rápido que clang18clang19.nocg: 1,02x mais lento que clang18clang19: 1,09x mais lento que clang18
- O fato de
clang19.nocgter sido mais rápido queclang19é uma reviravolta adicional- O Clang 18, ou o Clang 19 com as flags adequadas, também duplica a lógica de despacho no interpretador baseado em
switch
- O Clang 18, ou o Clang 19 com as flags adequadas, também duplica a lógica de despacho no interpretador baseado em
- A comparação do número de saltos indiretos também mostra essa diferença
clang18: 332clang18.nocg: 306clang19.nocg: 3clang19: 3
- No Clang moderno, o interpretador inteiro com computed goto pode ser uma complexidade desnecessária
- Isso porque o compilador consegue fazer a mesma transformação até em código baseado em
switch - Por outro lado, o próprio computed goto não foi suficiente para garantir essa transformação
- Isso porque o compilador consegue fazer a mesma transformação até em código baseado em
- O GCC 14.2.1 não duplicou o
switch, mas ao usar computed goto implementou o comportamento desejado
Correção e formas de contorno
- O pull request 114990 do LLVM foi integrado logo após a publicação do texto e corrigiu a regressão
- Benchmarks anteriores à integração já confirmavam que essa correção restaurava o desempenho esperado
- Nas releases anteriores à correção, é possível ajustar o limiar de interrupção do tail duplication usando a opção de tuning adicionada pelo PR que introduziu a regressão
- No clang-19, definir esse limite como um valor muito alto pode restaurar um comportamento parecido
- Em builds com LTO, passar essa opção é mais complicado
- O tail duplication acontece durante a geração de código, e em builds com LTO essa geração ocorre no momento da linkedição, não na compilação
- Por isso, a flag precisa ser passada não só ao compilador, mas também ao
lld
- Um exemplo de configuração usada foi passar
-mllvm -tail-dup-pred-size=5000emOPTeLDFLAGSna etapa de./configure
O problema da linha de base revelado pelo benchmarking
- Benchmarks conseguem medir com precisão a diferença de desempenho entre builds específicos, mas expandir esse resultado para “ganho geral de desempenho” exige hipóteses adicionais
- Os benchmarks do interpretador com tail call mostraram resultados 10~15% mais rápidos que o interpretador anterior com computed goto, mas a linha de base era complexa demais para generalizar essa conclusão
- Em trabalho de desempenho, com o que você está comparando é repetidamente uma questão difícil
- Mesmo com entendimento teórico da melhor abordagem conhecida, ajustar corretamente sistema operacional, opções do compilador e flags é outro problema
- Benchmarks públicos feitos em hardware antigo ou em escalas difíceis de reproduzir podem não ser adequados para comparação direta
- Em artigos de machine learning, ao se afirmar uma melhora algorítmica, muitas vezes a primeira pergunta importante não é “o que foi feito”, mas “com qual linha de base foi comparado”
- Resultados impressionantes podem surgir facilmente quando a comparação é feita com uma linha de base mal ajustada
Compiladores otimizadores e musttail
- O caso do computed goto mostra que as expectativas sobre compiladores otimizadores podem entrar em conflito
- O compilador deve respeitar a intenção do programador e preservar o mesmo comportamento
- Ao mesmo tempo, também precisa fazer transformações complexas e pouco intuitivas para tornar o código mais rápido
- O clang-19 compilou corretamente o interpretador com computed goto do ponto de vista do comportamento do programa, mas produziu uma saída totalmente diferente da intenção de otimização
- Outras versões do Clang aplicam a mesma otimização pretendida até mesmo a um interpretador simples baseado em
switch() - No nível do código-fonte, computed goto e, no nível do código de máquina, duplicação do despacho parecem conceitos quase ortogonais
- Como o resultado da execução é o mesmo, as ferramentas atuais têm dificuldade para representar essa diferença de forma consistente
- O interpretador com tail call se baseia no atributo
musttailmusttailnão altera o comportamento observável tradicional do programa, mas é mais próximo de uma conversa com o otimizador- O compilador precisa ser capaz de aplicar uma otimização específica e, se isso não acontecer, a compilação deve falhar
- Esse tipo de abordagem pode se tornar um estilo mais robusto para escrever código sensível a desempenho mesmo com a evolução dos compiladores
- Também vale considerar se um atributo hipotético como
[[clang::musttailduplicate]]poderia substituir o computed goto no loopwhiledo interpretador
A reprodutibilidade e os limites oferecidos pelo nix
- O
nixfoi de grande ajuda para gerenciar vários builds do interpretador Python- Durante o experimento, foram compiladas e medidas dezenas de versões do interpretador Python com quatro compiladores (
gcc,clang-18,clang-19,clang-20) e várias combinações de flags
- Durante o experimento, foram compiladas e medidas dezenas de versões do interpretador Python com quatro compiladores (
- Com
nix, foi possível manter essas versões em paralelo de forma reproduzível e isolada- Era possível saber com confiança de qual compilador e de quais flags cada build vinha
- A definição da matriz de builds também pôde ser gerenciada com abstrações curtas
- Também foi possível compilar um LLVM customizado com o patch de correção do bug e usá-lo para gerar builds do Python com cerca de 10 linhas de código
- Ainda assim, há desvantagens
- O
nixdifere do modo comum de uso de software, então é difícil descartar totalmente a possibilidade de que essas diferenças tenham influenciado os benchmarks ou as conclusões - Por exemplo, o nix compila projetos com certas flags de hardening por padrão, e isso levou à descoberta inicial de que essas flags afetavam desproporcionalmente o interpretador com tail call
- O
- O Nix é forte em escalabilidade e customização, mas encontrar a forma de aplicar certas customizações exigiu muita tentativa e erro e exploração do código-fonte do
nixpkgs
1 comentários
Opiniões no Hacker News
Sou o autor do PR que colocou o interpretador de chamadas de cauda no CPython
Antes de tudo, agradeço ao Nelson, que passou quase um mês descobrindo a causa desse problema
Nem eu, e provavelmente nem a equipe do CPython, esperávamos que houvesse um bug desses no compilador usado como linha de base; cometi um grande erro e estou muito envergonhado e arrependido
Também publiquei um pedido de desculpas: https://fidget-spinner.github.io/posts/apology-tail-call.htm...
Foi anunciado como melhoria de desempenho de 10–15%, mas em compiladores sem o bug parece estar mais perto de 1–5%; e esses números nem estavam completamente errados, apenas eram verdadeiros sob certas condições
Você criou a melhoria, mediu e teve o PR revisado, então fez o que precisava fazer. Acontece que a versão do clang usada na medição causou uma interpretação equivocada dos números, mas isso parece um erro razoável em que qualquer um poderia cair
Ainda assim, você trouxe uma melhoria de desempenho significativa e também encontrou uma regressão no compilador; comparado a isso, os números incorretos parecem algo menor. Também não está claro quem foi realmente prejudicado por isso, então não me parece algo que exigisse um pedido de desculpas
Se 3–5% se mantiverem em um sistema antigo como o interpretador Python, isso por si só já é um grande feito, então dá para se orgulhar bastante
Depois de uns 30 anos, passei a desconfiar, de início, de melhorias de desempenho significativas em sistemas que existem há muito tempo, especialmente quando passam de 1%
Melhorias reais existem, mas não são comuns; muitas vezes, apenas deslocaram o tempo para outro lugar e isso não aparece no benchmark. Além disso, benchmarks são feitos em ambientes controlados para isolar o efeito, mas software real roda em VMs ou desktops junto com todo tipo de outras coisas
Vi muitas vezes uma melhoria que parecia claramente grande em um ambiente isolado desaparecer, ou ficar negativa, ao entrar em produção
No CPython isso é ainda mais difícil porque ele precisa mirar vários ambientes, e não há um único alvo de produção para dizer “se não ficou mais rápido em produção, então não ficou realmente mais rápido”. Tentar melhorar desempenho nesse mundo é realmente difícil
No fim, ajuste e medição de desempenho são muito difíceis, e a única coisa de que talvez valha se desculpar é ter aprendido esse fato
Espero que você não tenha medo de errar. Todo mundo erra mesmo. Basta dizer, como agora, “parece que estragamos isso”, e descobrir como lidar com o problema e como evitá-lo no futuro
[1] Isso é comum não só em desempenho, mas também em processos humanos. Por exemplo, uma equipe de ferramenta de revisão de código pode dizer “reduzimos o tempo de revisão de código em 15% e aceleramos o fluxo de trabalho de todos”, quando na verdade criou mais trabalho em outra parte do sistema, o fluxo geral não ficou mais rápido, e os 15% foram apenas movidos para um lugar que não foi medido
Em tese, com esse grafo de fluxo de controle e esse perfil, o compilador deveria ter informação suficiente para gerar código ótimo para um interpretador tradicional baseado em
switch(). Mas, na prática, quando uma função fica tão grande e interligada assim, você acaba brigando com o compiladorEle faz spill de variáveis importantes que você queria manter em registradores, puxa para cima manipulações de stack frame que você queria encolher em torno de chamadas de fallback, e mescla caminhos de código idênticos que você queria separar por causa da previsão de desvios. Pode parecer tocar piano usando luvas
Aqui também aconteceu exatamente essa “mesclagem de caminhos de código idênticos”, e o compilador “com bug” mesclou os caminhos iguais, piorando o desempenho
O compilador “corrigido” não faz mais isso, mas essa correção, no fim, está mais para um ajuste de heurísticas internas do compilador. Não há garantia de que este compilador, ou outros, continuem mantendo heurísticas favoráveis a nós no futuro
Já o interpretador de chamadas de cauda permite expressar no próprio interpretador o padrão de código de máquina desejado. Usando os atributos
musttail,noinlineepreserve_nonejuntos, dá para restringir o problema de modo a ficar muito menos sujeito às heurísticas do otimizadorPor isso, o benefício do interpretador de chamadas de cauda vai além de uma simples melhoria de desempenho de 3–5%; em alguns compiladores, pode ser uma melhoria de desempenho confiável ainda maior
Será que os benchmarks poderiam ser melhorados para evitar algo parecido?
[0] https://github.com/faster-cpython/benchmarking-public
Fazer benchmarking direito é absurdamente difícil. Há fatores demais que enganam as pessoas.
Recentemente, achei que tinha encontrado uma forma de tornar certo algoritmo cerca de 15% mais rápido. Pelo menos era isso que todos os benchmarks diziam.
Mas, quando dupliquei a função mais rápida dentro do harness de teste e, na prática, nem a chamei — chamando apenas a versão original mais lenta —, ela ainda assim ficou 15% mais rápida. Ou seja, código que nem sequer era executado tornou o código original mais rápido.
Naturalmente, era um problema de layout de código e memória: alguma coisa se moveu e passou a se encaixar melhor no cache da CPU.
É realmente difícil saber se o ganho de velocidade veio porque o código ficou de fato “melhor” ou porque, em algum lugar, você teve a sorte de conseguir um alinhamento melhor.
Casey Muratori está escrevendo uma série muito interessante sobre esse tema no Substack.
Parabéns ao autor por investigar a fundo e revelar o que estava acontecendo de fato. O interpretador com chamadas de cauda do Python 3.14 ainda é uma boa melhoria, e alguns pontos percentuais de ganho em um runtime de linguagem são conquistas difíceis.
Só não era aquele almoço grátis mágico de 15%.
Mais importante: este caso mostra muito bem a importância do rigor em benchmarking e de testar em vários ambientes. Também revelou um bug de compilador que pode beneficiar todo mundo.
É o tipo de análise aprofundada que faz a gente revalidar a próxima grande alegação de ganho de desempenho. Uma pergunta para refletir é: entre os muitos resultados de “X% mais rápido” por aí, quantos na verdade são artefatos de benchmark ou regressões desconhecidas?
Como podemos evitar melhor essas armadilhas daqui para frente?
O compilador em si não é benchmarkado? Os benchmarks existentes do lado do compilador ou do Python não usavam esse compilador?
Este é um bom exemplo de como é inadequado dizer que C é “próximo da máquina” ou “assembly portável”. Otimizadores modernos mudam a lógica agressivamente quando não há efeito observável.
O texto também dizia que “o clang-19 compila o interpretador com computed goto de forma ‘correta’, no sentido de que o binário resultante produz todos os valores esperados, mas, ao mesmo tempo, essa saída contraria completamente a intenção da otimização. Além disso, outras versões do compilador aplicam otimizações ao interpretador ‘ingênuo’ baseado em
switch(), implementando exatamente a mesma otimização que nós ‘pretendíamos’ ao reescrever o código-fonte”.Em C, dá para confiar que
a += 1incrementa um valor numérico, mas a mesma expressão em C++ pode alocar memória, desenrolar a pilha de chamadas ou fazer coisas desconhecidas. Da mesma forma,a = "a"em C é uma simples atribuição de ponteiro, enquanto em C++ pode envolver alocação de memória etc.A expressão “C é assembly portável” não significa que cada instrução seja compilada diretamente para uma instrução de máquina equivalente.
Não surpreende que, ao mexer na estrutura de laços, o compilador tenha feito com que o interpretador com chamadas de cauda inteiro não fosse tão eficaz quanto anunciado.
Implementações paranoicas de interpretadores às vezes voltam a escrever assembly diretamente. O LuaJIT é famoso por ter implementado um sistema de macros para tornar uma implementação de loop em assembly muito eficiente portável entre arquiteturas. É por isso que mexer com esse tipo de coisa também é divertido.
Alguns anos atrás, também escrevi um texto e testes sobre formas populares de implementar loops de interpretador:
https://github.com/vkazanov/bytecode-interpreters-post
Preditores de desvios modernos conseguem prever quase perfeitamente até mesmo um único salto indireto, desde que o trecho de execução seja longo o suficiente e o comportamento do próprio código interpretado seja estável.
Há um artigo que estudou isso tanto em hardware real quanto em um preditor de desvios simulado específico: https://inria.hal.science/hal-01100647/document
Os experimentos que fiz neste projeto também sustentam anedoticamente a mesma conclusão. Não consegui incluir no texto, mas examinei alguns interpretadores com contadores de hardware da CPU e
perf stat, e erros de predição de desvios não apareceram como fator dominante.Avaliar a performance de builds do Python é extremamente difícil. Há técnicas de build demais que podem melhorar o desempenho.
Recentemente, o pessoal da astral também esbarrou nesse problema ao mostrar que os builds do conda-forge eram visivelmente mais rápidos do que a maioria:
https://github.com/astral-sh/python-build-standalone/pull/54...
Fico curioso para saber como o interpretador com chamadas de cauda se comporta quando usado junto com outras otimizações de build já existentes.
O autor tentou combinações de vários compiladores e flags de otimização usando um algoritmo genético.
Discussões relacionadas:
https://docs.python.org/3.14/whatsnew/3.14.html#whatsnew314-... --> https://news.ycombinator.com/item?id=42999672 (66 pontos | 25 dias atrás | 22 comentários)
https://blog.reverberate.org/2025/02/10/tail-call-updates.ht... --> https://news.ycombinator.com/item?id=43076088 (124 pontos | 18 dias atrás | 92 comentários)
Bom artigo. Um detalhe me chamou atenção
Em um dos artigos citados, https://simonwillison.net/2025/Feb/13/python-3140a5/, ele escreveu que “o 3.14.0a5 foi 1,12x mais rápido que o 3.13 no benchmark, no meu M2 MacBook Pro extremamente sobrecarregado”
Essa parte é bem confusa. Quer dizer que ele rodou o benchmark enquanto o computador estava sobrecarregado com outros processos? Então o resultado não fica completamente pouco confiável?
Eu achava que benchmarks desse tipo eram executados em ambientes muito controlados para eliminar variáveis externas
Para o lado do CPython, veja https://github.com/faster-cpython/benchmarking-public
Algumas pessoas aqui chamam 10% de “grande” e 1% de “normal”, mas uma otimização como inlining parcial do Fibonacci duplamente recursivo pode reduzir exponencialmente a carga de trabalho e o tempo reais
Com argumentos de dois dígitos, pode passar de 10x, ou seja, milhares por cento. Estritamente falando, é exponencial em relação à diferença de profundidade da recursão, não ao tamanho do problema [1]
Compiladores C também podem reagir de forma muito sensível a métricas de inlining de código, então se esse enorme ganho de velocidade aparece de fato pode depender muito da forma do código
Portanto, parte do problema é que as CPUs ficaram muito sofisticadas e complexas, mas outro lado é que os compiladores além de
-O0ou-O1também ficaram sofisticados e complexosO artigo é bom e vale a leitura, mas também é um entre muitos exemplos de como a interação entre duas coisas complexas pode produzir resultados muito surpreendentes. Isso também vale fora da computação
As pessoas têm uma forte tendência a simplificar demais essa lição, não importa quantas vezes ela se repita
Além disso, o artigo usa pelo menos duas CPUs, Intel e Apple M1, e dois compiladores, gcc e clang, mas ambientes reais de distribuição podem ter muito mais gerações e implementações de Intel, AMD e ARM, além de outros compiladores. Ou seja, ele amostra só uma parte minúscula da complexidade total
Para ser mais científico, especialmente em diferenças como “1,01x”, a medição de tempo deveria ter algum tipo de barra de erro. O desvio padrão da média, ou, nesses casos, talvez o desvio padrão do valor mínimo seja melhor [2]
Para reduzir o erro de medição, provavelmente também seria necessário fixar o agendamento em núcleos de CPU pelo SO
[1] https://stackoverflow.com/questions/360748/computational-com...
[2] https://github.com/c-blake/bu/blob/main/doc/tim.md
Recentemente fiz benchmarks do Python 3.9 ao 3.13, e até o 3.11 ele continuou melhorando
Mas Python 3.12 e 3.13 ficaram cerca de 10% mais lentos que o 3.11
Achei que meu benchmark próprio talvez não fosse bom o suficiente, mas mesmo assim implantei em um serviço crítico, e as métricas coletadas mostraram a mesma mudança
Alguém passou pelo mesmo problema?
[0]: https://github.com/python/cpython/issues/123540