Cinder é a versão de produção interna do CPython 3.10 voltada para o desempenho da Meta. Ele contém uma série de otimizações de desempenho, incluindo cache embutido de bytecode, avaliação rápida de corrotinas, um JIT de método por vez e um compilador de bytecode experimental que usa anotações de tipo para emitir bytecode especializado em tipo com melhor desempenho no JIT.
O Cinder está impulsionando o Instagram, onde começou, e é cada vez mais usado em cada vez mais aplicativos Python no Meta.
Para obter mais informações sobre CPython, consulte README.cpython.rst
.
Resposta curta: não.
Disponibilizamos o Cinder publicamente para facilitar a conversa sobre o potencial de upstreaming de parte desse trabalho para o CPython e para reduzir a duplicação de esforços entre as pessoas que trabalham no desempenho do CPython.
Cinder não é polido ou documentado para uso de outra pessoa. Não temos o desejo de que ele se torne uma alternativa ao CPython. Nosso objetivo ao disponibilizar esse código é um CPython unificado e mais rápido. Portanto, embora executemos o Cinder em produção, se você decidir fazê-lo, estará por conta própria. Não podemos nos comprometer a corrigir relatórios de bugs externos ou revisar solicitações pull. Garantimos que o Cinder seja suficientemente estável e rápido para nossas cargas de trabalho de produção, mas não oferecemos garantias sobre sua estabilidade, correção ou desempenho para quaisquer cargas de trabalho ou casos de uso externos.
Dito isto, se você tem experiência em tempos de execução de linguagem dinâmica e tem ideias para tornar o Cinder mais rápido; ou se você trabalha no CPython e deseja usar o Cinder como inspiração para melhorias no CPython (ou ajudar a fazer o upstream de partes do Cinder para o CPython), entre em contato; adoraríamos conversar!
O Cinder deve ser construído exatamente como o CPython; configure
e make -j
. No entanto, como a maior parte do desenvolvimento e uso do Cinder ocorre no contexto altamente específico do Meta, não o exercitamos muito em outros ambientes. Dessa forma, a maneira mais confiável de criar e executar o Cinder é reutilizar a configuração baseada no Docker do nosso fluxo de trabalho do GitHub CI.
Se você deseja apenas obter um Cinder funcional sem construí-lo sozinho, nossa imagem Runtime Docker será a mais fácil (não é necessário nenhum clone de repositório!):
docker run -it --rm ghcr.io/facebookincubator/cinder-runtime:cinder-3.10
Se você quiser construí-lo sozinho:
git clone https://github.com/facebookincubator/cinder
docker run -v "$PWD/cinder:/vol" -w /vol -it --rm ghcr.io/facebookincubator/cinder/python-build-env:latest bash
./configure && make
Esteja ciente de que o Cinder só é construído ou testado em Linux x64; qualquer outra coisa (incluindo macOS) provavelmente não funcionará. A imagem do Docker acima é baseada no Fedora Linux e construída a partir de um arquivo de especificação do Docker no repositório Cinder: .github/workflows/python-build-env/Dockerfile
.
Existem alguns novos alvos de teste que podem ser interessantes. make testcinder
é praticamente o mesmo que make test
exceto que ele pula alguns testes que são problemáticos em nosso ambiente de desenvolvimento. make testcinder_jit
executa o conjunto de testes com o JIT totalmente habilitado, então todas as funções são JIT. make testruntime
executa um conjunto de testes de unidade C++ gtest para o JIT. E make test_strict_module
executa um conjunto de testes para módulos estritos (veja abaixo).
Observe que essas etapas produzem um binário Cinder Python sem otimizações PGO/LTO habilitadas, portanto, não espere usar essas instruções para obter qualquer aceleração em qualquer carga de trabalho Python.
O Cinder Explorer é um playground ao vivo, onde você pode ver como o Cinder compila o código Python do código-fonte ao assembly – você pode experimentá-lo! Sinta-se à vontade para registrar solicitações de recursos e relatórios de bugs. Tenha em mente que o Cinder Explorer, como todo o resto, é "suportado" na base do melhor esforço.
O Instagram usa uma arquitetura de servidor web multiprocessos; o processo pai é iniciado, executa o trabalho de inicialização (por exemplo, carregamento de código) e bifurca dezenas de processos de trabalho para lidar com as solicitações do cliente. Os processos de trabalho são reiniciados periodicamente por vários motivos (por exemplo, vazamentos de memória, implantações de código) e têm uma vida útil relativamente curta. Neste modelo, o SO deve copiar a página inteira contendo um objeto que foi alocado no processo pai quando a contagem de referências do objeto for modificada. Na prática, os objetos alocados no processo pai sobrevivem aos trabalhadores; todo o trabalho relacionado à contagem de referências é desnecessário.
O Instagram tem uma base de código Python muito grande e a sobrecarga devido à cópia na gravação da contagem de referência de objetos de longa vida acabou sendo significativa. Desenvolvemos uma solução chamada "instâncias imortais" para fornecer uma maneira de excluir objetos da contagem de referência. Consulte Incluir/object.h para obter detalhes. Este recurso é controlado pela definição de Py_IMMORTAL_INSTANCES e é habilitado por padrão no Cinder. Esta foi uma grande vitória para nós na produção (cerca de 5%), mas torna o código linear mais lento. As operações de contagem de referência ocorrem com frequência e devem verificar se um objeto participa ou não da contagem de referência quando este recurso está habilitado.
"Shadowcode" ou "shadow bytecode" é a nossa implementação de um intérprete especializado. Ele observa casos específicos otimizáveis na execução de opcodes genéricos do Python e (para funções quentes) substitui dinamicamente esses opcodes por versões especializadas. O núcleo do shadowcode reside em Shadowcode/shadowcode.c
, embora as implementações para os bytecodes especializados estejam em Python/ceval.c
com o resto do loop eval. Os testes específicos do Shadowcode estão em Lib/test/test_shadowcode.py
.
É semelhante em espírito ao intérprete adaptativo especializado (PEP-659) que será integrado ao CPython 3.11.
O Instagram Server é uma carga de trabalho assíncrona, onde cada solicitação da web pode acionar centenas de milhares de tarefas assíncronas, muitas das quais podem ser concluídas sem suspensão (por exemplo, graças a valores memorizados).
Estendemos o protocolo vectorcall para passar um novo sinalizador, Ci_Py_AWAITED_CALL_MARKER
, indicando que o chamador está aguardando imediatamente esta chamada.
Quando usado com chamadas de função assíncronas que são imediatamente aguardadas, podemos avaliar imediatamente (ansiosamente) a função chamada, até a conclusão ou até sua primeira suspensão. Se a função for concluída sem suspensão, poderemos retornar o valor imediatamente, sem alocações extras de heap.
Quando usado com coleta assíncrona, podemos avaliar imediatamente (avidamente) o conjunto de aguardáveis passados, evitando potencialmente o custo de criação e agendamento de múltiplas tarefas para corrotinas que poderiam ser concluídas de forma síncrona, futuros concluídos, valores memorizados, etc.
Essas otimizações resultaram em uma melhoria significativa (~5%) na eficiência da CPU.
Isso é implementado principalmente em Python/ceval.c
, por meio de um novo sinalizador vectorcall Ci_Py_AWAITED_CALL_MARKER
, indicando que o chamador está aguardando imediatamente esta chamada. Procure usos da macro IS_AWAITED()
e deste sinalizador vectorcall.
O Cinder JIT é um JIT personalizado de método por vez implementado em C++. Ele é habilitado por meio do sinalizador -X jit
ou da variável de ambiente PYTHONJIT=1
. Ele suporta quase todos os opcodes Python e pode atingir melhorias de velocidade de 1,5 a 4x em muitos benchmarks de desempenho Python.
Por padrão, quando ativado, ele compilará em JIT todas as funções que já foram chamadas, o que pode tornar seu programa mais lento, e não mais rápido, devido à sobrecarga de funções raramente chamadas de compilação JIT. A opção -X jit-list-file=/path/to/jitlist.txt
ou PYTHONJITLISTFILE=/path/to/jitlist.txt
pode apontar para um arquivo de texto contendo nomes de funções totalmente qualificados (no formato path.to.module:funcname
ou path.to.module:ClassName.method_name
), um por linha, que deve ser compilado em JIT. Usamos esta opção para compilar apenas um conjunto de funções importantes derivadas de dados de perfil de produção. (Uma abordagem mais típica para um JIT seria compilar funções dinamicamente à medida que elas são chamadas com frequência. Ainda não valeu a pena implementar isso, já que nossa arquitetura de produção é um servidor web pré-fork, e para Por motivos de compartilhamento de memória, desejamos fazer toda a nossa compilação JIT antecipadamente no processo inicial, antes que os trabalhadores sejam bifurcados, o que significa que não podemos observar a carga de trabalho em processo antes de decidir quais funções compilar JIT.)
O JIT reside no diretório Jit/
e seus testes C++ residem em RuntimeTests/
(execute-os com make testruntime
). Existem também alguns testes Python para isso em Lib/test/test_cinderjit.py
; eles não pretendem ser exaustivos, já que executamos todo o conjunto de testes do CPython no JIT por meio de make testcinder_jit
; eles cobrem casos extremos JIT não encontrados no conjunto de testes CPython.
Consulte Jit/pyjit.cpp
para obter outras opções -X
e variáveis de ambiente que influenciam o comportamento do JIT. Há também um módulo cinderjit
definido nesse arquivo que expõe alguns utilitários JIT ao código Python (por exemplo, forçar a compilação de uma função específica, verificar se uma função está compilada, desabilitar o JIT). Observe que cinderjit.disable()
desativa apenas compilação futura; ele compila imediatamente todas as funções conhecidas e mantém as funções compiladas JIT existentes.
O JIT primeiro reduz o bytecode Python para uma representação intermediária de alto nível (HIR); isso é implementado em Jit/hir/
. HIR mapeia razoavelmente próximo ao bytecode Python, embora seja uma máquina de registro em vez de uma máquina de pilha, é um nível um pouco inferior, é digitado e alguns detalhes que são obscurecidos pelo bytecode Python, mas importantes para o desempenho (principalmente contagem de referência) são exposto explicitamente no HIR. O HIR é transformado no formato SSA, algumas passagens de otimização são executadas nele e, em seguida, as operações de contagem de referência são automaticamente inseridas nele de acordo com os metadados sobre a contagem de ref e os efeitos de memória dos opcodes HIR.
HIR é então reduzido para uma representação intermediária de baixo nível (LIR), que é uma abstração sobre montagem, implementada em Jit/lir/
. No LIR, fazemos a alocação de registros, algumas passagens adicionais de otimização e, finalmente, o LIR é reduzido para montagem (em Jit/codegen/
) usando a excelente biblioteca asmjit.
O JIT está em seus estágios iniciais. Embora ele já possa eliminar a sobrecarga do loop do interpretador e oferecer melhorias significativas de desempenho para muitas funções, apenas começamos a arranhar a superfície das possíveis otimizações. Muitas otimizações comuns do compilador ainda não foram implementadas. Nossa priorização de otimizações é em grande parte impulsionada pelas características da carga de trabalho de produção do Instagram.
Módulos estritos são algumas coisas reunidas em uma só:
1. Um analisador estático capaz de validar que a execução do código de nível superior de um módulo não terá efeitos colaterais visíveis fora desse módulo.
2. Um tipo StrictModule
imutável que pode ser usado no lugar do tipo de módulo padrão do Python.
3. Um carregador de módulo Python capaz de reconhecer módulos ativados no modo estrito (por meio de uma import __strict__
na parte superior do módulo), analisando-os para validar nenhum efeito colateral de importação e preenchendo-os em sys.modules
como um objeto StrictModule
.
Static Python é um compilador de bytecode que faz uso de anotações de tipo para emitir bytecode Python especializado em tipo e verificado por tipo. Usado junto com o Cinder JIT, ele pode oferecer desempenho semelhante ao MyPyC ou Cython em muitos casos, ao mesmo tempo que oferece uma experiência de desenvolvedor Python puro (sintaxe Python normal, sem etapa extra de compilação). Static Python mais Cinder JIT alcançam 18x o desempenho do CPython padrão em uma versão digitada do benchmark Richards. No Instagram, usamos com sucesso o Static Python em produção para substituir todos os módulos Cython em nossa base de código de servidor web principal, sem regressão de desempenho.
O compilador Static Python é construído sobre o módulo compiler
Python que foi removido da biblioteca padrão no Python 3 e desde então tem sido mantido e atualizado externamente; este compilador é incorporado ao Cinder em Lib/compiler
. O compilador Static Python é implementado em Lib/compiler/static/
, e seus testes estão em Lib/test/test_compiler/test_static.py
.
Classes definidas em módulos Python estáticos recebem automaticamente slots digitados (com base na inspeção de seus atributos de classe digitados e atribuições anotadas em __init__
), e carregamentos e armazenamentos de atributos em instâncias desses tipos usam novos opcodes STORE_FIELD
e LOAD_FIELD
, que no JIT se tornam diretos carrega/armazena de/para um deslocamento de memória fixo no objeto, sem nenhuma indireção de LOAD_ATTR
ou STORE_ATTR
. As classes também ganham vtables de seus métodos, para uso pelos opcodes INVOKE_*
mencionados abaixo. O suporte de tempo de execução para esses recursos está localizado em StaticPython/classloader.h
e StaticPython/classloader.c
.
Uma função estática do Python começa com um prólogo oculto que verifica se os tipos dos argumentos fornecidos correspondem às anotações de tipo e, caso contrário, gera TypeError
. Chamadas de uma função estática do Python para outra função estática do Python irão ignorar este opcode (já que os tipos já foram validados pelo compilador). Chamadas estáticas para estáticas também podem evitar grande parte da sobrecarga de uma chamada de função típica do Python. Emitimos um opcode INVOKE_FUNCTION
ou INVOKE_METHOD
que carrega consigo metadados sobre a função ou método chamado; isso mais módulos opcionalmente imutáveis (via StrictModule
) e tipos (via cinder.freeze_type()
, que atualmente aplicamos a todos os tipos em módulos estritos e estáticos em nosso carregador de importação, mas no futuro pode se tornar uma parte inerente do Static Python) e compilar O conhecimento em tempo real da assinatura do receptor nos permite (no JIT) transformar muitas chamadas de função Python em chamadas diretas para um endereço de memória fixo usando a convenção de chamada x64, com pouco mais sobrecarga do que uma chamada de função C.
O Python estático ainda é digitado gradualmente e suporta código que é apenas parcialmente anotado ou usa tipos desconhecidos, voltando ao comportamento dinâmico normal do Python. Em alguns casos (por exemplo, quando um valor de tipo estaticamente desconhecido é retornado de uma função com uma anotação de retorno), um opcode CAST
de tempo de execução é inserido, o que gerará TypeError
se o tipo de tempo de execução não corresponder ao tipo esperado.
Static Python também oferece suporte a novos tipos para inteiros de máquina, bools, duplos e vetores/matrizes. No JIT estes são tratados como valores unboxed e, por exemplo, a aritmética primitiva de números inteiros evita toda a sobrecarga do Python. Algumas operações em tipos internos (por exemplo, lista ou subscrito de dicionário ou len()
) também são otimizadas.
O Cinder suporta a adoção gradual de módulos estáticos por meio de um carregador de módulo estrito/estático que pode detectar automaticamente módulos estáticos e carregá-los como estáticos com compilação entre módulos. O carregador procurará as anotações import __static__
e import __strict__
no topo de um arquivo e compilará os módulos apropriadamente. Para ativar o carregador, você tem uma das três opções:
1. Instale explicitamente o carregador no nível superior do seu aplicativo por meio from cinderx.compiler.strict.loader import install; install()
.
PYTHONINSTALLSTRICTLOADER=1
em seu ambiente../python -X install-strict-loader application.py
. Alternativamente, você pode compilar todo o código estaticamente usando ./python -m compiler --static some_module.py
, que irá compilar o módulo como Python estático e executá-lo.
Consulte CinderDoc/static_python.rst
para obter documentação mais detalhada.