Desenvolvido um novo compilador JIT para PostgreSQL
(pinaraf.info)- O motor JIT experimental pg-copyjit foca em gerar código “rápido o suficiente” até para consultas curtas no PostgreSQL, com custo de geração menor que o do LLVM
- As estimativas de custo do PostgreSQL não correspondem diretamente ao tempo real de execução, então o JIT LLVM, com alto custo de otimização, pode acabar sendo desvantajoso em consultas curtas
- A abordagem copy-and-patch pré-compila stencils feitos em C e, na execução, copia e aplica patches nos trechos necessários para executá-los como se fossem novas funções
- O pg-copyjit se conecta por meio da interface de provider JIT do PostgreSQL e, ao encontrar um opcode ainda não implementado, faz fallback automático para o interpretador do PostgreSQL
- No momento, está em fase de prova de conceito, funcionando em PostgreSQL 16 e AMD64; a geração de código leva algumas centenas de microssegundos, mas build, documentação e suporte ainda não estão prontos
Onde o pg-copyjit quer atuar
- O pg-copyjit é um motor JIT experimental para tornar o servidor PostgreSQL mais rápido
- O código atual está mais próximo de algo para hackers experientes testarem do que de algo pronto para servidores de produção
- O feedback esperado são resultados interessantes de experimentos, casos de ganho de desempenho e ideias de implementação; ainda não é uma fase para aceitar downtime em aplicações críticas de negócio
Por que o LLVM JIT pesa no PostgreSQL
- O PostgreSQL já tem um compilador JIT baseado em LLVM, introduzido por Andres Freund
- O LLVM pode gerar código eficiente, mas, na forma como é usado no PostgreSQL, os custos de compilação JIT e otimização são altos
- Sem usar o otimizador, ele pode ficar pior do que não compilar; usando o otimizador, o custo pode aumentar ainda mais
- Para decidir se o JIT deve ser aplicado, usa-se a estimativa de custo da consulta
- O custo no PostgreSQL é um valor para comparar consultas entre si, não uma medida direta do tempo real de execução
- Uma consulta com custo 100 pode levar 1 segundo, e outra com custo 1000 pode terminar em 100 ms
- Se a consulta ficar 10 ms mais rápida, mas a otimização custar 50 ms, o tempo total de execução sai perdendo
- Uma forma de tornar o LLVM JIT mais útil seria armazenar em cache e reutilizar consultas compiladas, mas isso não é uma implementação pequena
Estrutura da abordagem copy-and-patch
- Copy-and-patch é uma forma de construir compiladores JIT apresentada em um artigo de 2021, e também é usada no motor JIT do Python 3.13
- A unidade central é o stencil escrito em C
- Um stencil é uma função com lacunas
- Ele é pré-compilado com clang
- O suporte a gcc ainda está em espera
- No momento da compilação, os stencils necessários são encadeados, as lacunas são preenchidas e então o fluxo salta para a nova função “compilada”
- O fluxo básico é simples
- Copiar o stencil para uma nova região de memória
- Aplicar patch nos valores necessários
- Executar o código resultante
- Há espaço para otimizações adicionais
- Pré-calcular valores que podem ser resolvidos em tempo de compilação
- Dividir loops em vários stencils para fazer unroll
- Combinar vários stencils em um meta-stencil para otimizar tudo de uma vez
Como integrar ao PostgreSQL
- O JIT do PostgreSQL tem uma estrutura extensível de providers
- O
.sofornece uma única função_PG_jit_provider_init, que inicializa três callbackscompile_exprrelease_contextreset_after_error
- O callback principal é o
compile_expr- A entrada é um ponteiro de expressão
ExprState*composto por opcodes - Ele compila os opcodes da forma desejada
- Marca o código gerado como executável
- Troca
evalfuncpelo código gerado, em vez do interpretador do PostgreSQL
- A entrada é um ponteiro de expressão
- Ao encontrar um opcode não implementado, é possível fazer fallback automático para o interpretador do PostgreSQL
O processo de compilação do pg-copyjit
- O algoritmo copy-and-patch do pg-copyjit ainda é uma forma simples, com apenas algumas pequenas otimizações
- Para cada opcode, o compilador verifica uma coleção de stencils
- Se houver um stencil correspondente ao opcode, ele é anexado ao código em geração
- Se não houver stencil, a compilação é interrompida e o interpretador do PostgreSQL assume a execução
- Depois de anexar o stencil, cada lacuna recebe patch com os valores necessários
- O stencil do opcode
CONSTdeclaraopcomoExprEvalStepexterno e deixa, no arquivo.ocompilado, uma lacuna para o endereço deop - A coleção de stencils preserva essas informações de relocation, e o compilador JIT injeta o endereço da estrutura do opcode atual para produzir código executável
- O build primeiro gera os stencils em um único arquivo
.o, depois extrai dele o código assembly e as relocations e converte tudo em estruturas utilizáveis em C
Estado da implementação e desempenho
- No início, o código assembly foi extraído manualmente para fazer funcionar os três opcodes necessários para
SELECT 42; - Depois, foi criado um script DirtyPython para automatizar a extração do assembly e, em poucas horas, foram adicionados os recursos abaixo
- Chamadas de função
- Consultas de tabela única
- Tipos de dados mais complexos
- Algumas otimizações
- O estado confirmado no momento é o seguinte
- Funciona no PostgreSQL 16
- É esperado que também funcione em versões anteriores, mas o ambiente confirmado é o PostgreSQL 16
- A única arquitetura suportada é AMD64
- Há planos para adicionar suporte a ARM64
- Alvos como POWER64 e S390x também interessam, mas podem exigir patches no compilador e acesso a essas máquinas
- Os números de desempenho ainda vêm de um estado quase sem otimização
- A geração de código termina em algumas centenas de microssegundos
- Já é algo utilizável até em consultas curtas
- Em
SELECT 42;, sem JIT leva 0,3 ms, com copyjit 0,6 ms, com LLVM sem otimização 1,6 ms e com LLVM otimizado 6,6 ms
- O LLVM pode gerar código extremamente rápido, mas o objetivo do pg-copyjit é gerar rapidamente código rápido o suficiente, então é difícil comparar as duas ferramentas de forma direta
- Duas consultas foram benchmarkadas em uma tabela simples sem índice de 90 mil linhas, e, quando há uma cláusula
wherecom trabalho de CPU, o desempenho melhora em relação ao interpretador - Os benchmarks foram feitos em um notebook, então a confiabilidade é limitada; no futuro, devem ser feitos testes mais adequados em um desktop
- Mesmo com poucos opcodes implementados, já é possível executar qualquer consulta
- O motor JIT emite mensagens sobre partes ainda não implementadas
- A execução real fica por conta do interpretador
Publicação do código e trabalho restante
- O código está disponível no GitHub em pg-copyjit
- No momento, o foco está mais no próprio código do que em organizar o histórico do git ou documentar
- Para fazer o build, ainda é preciso executar manualmente o arquivo
build-stencils.shantes - No estado atual, não há como oferecer suporte, então a documentação ainda não foi feita
- O trabalho restante é relativamente claro
- Implementar mais opcodes
- Explorar otimizações
- Melhorar a praticidade do build
- Organizar tudo para ficar em estado empacotável
- O script de build atualmente é específico para Debian e PostgreSQL 16
Casos de uso esperados e expansão para outras arquiteturas
- O objetivo é chegar a um nível em que possa ser empacotado com segurança e implantado no próprio servidor de produção
- Também existe a ideia de dividir o uso de JIT por tipo de servidor
- Em servidores GIS, onde as consultas justificam o custo de otimização, usar LLVM JIT
- Em bancos de dados de aplicações web, onde o tempo de consultas curtas importa, usar pg-copyjit
- Portar para outras arquiteturas também é um objetivo sério
- Há uma visão nostálgica da época de arquiteturas variadas como Alpha, Itanium, Sparc e M68k, e a posição é de não querer reforçar o problema de monocultura centrada em uma única arquitetura
1 comentários
Comentários do Hacker News
Como o compilador JIT precisa injetar endereços de memória diretamente, o código gerado fica vinculado àquela consulta e àquele processo
Os planos de execução também sofrem do mesmo problema, já que não são estruturas de dados em memória projetadas para serem compartilhadas entre processos diferentes
Bancos como o MSSQL têm uma arquitetura de processo único usando threads, então não têm esse problema; esse também é um dos motivos pelos quais conseguem lidar com mais conexões simultâneas sem um pooler externo
O MSSQL também consegue serializar planos de execução em uma representação que não fica presa ao processo e armazená-los no DB, o que pode ser usado em recursos como fixação de planos de execução
A compilação tradicional também parece um “copy-and-patch” que usa alguma linguagem intermediária que não é C
Por exemplo, coisas como quais passes e quais otimizações de backend são usadas
Na nossa experiência [1], se você desativar as otimizações e ajustar o pipeline de backend
-O0mirando o tempo de compilação, o LLVM também pode ficar bem rápido, mas ainda é 10 a 20 vezes mais lento do que outras abordagensAlém disso, nossa experiência é que o código gerado com copy-and-patch executa de forma consideravelmente lenta e é difícil de otimizar. Tentamos algumas coisas [2; Sec. 5], mas ainda há uma diferença grande. Para os resultados de avaliação em banco de dados, veja a Fig. 3
Também fico curioso se há números de queda no tempo de execução em comparação com o LLVM, e se há planos de implementar um JIT em múltiplos níveis que alterne dinamicamente do código compilado rapidamente para código otimizado pelo LLVM
[1]: https://home.in.tum.de/~engelke/pubs/2403-cgo.pdf
[2]: https://home.in.tum.de/~engelke/pubs/2403-cc.pdf
Por volta de 2010, quando eu estava aprendendo programação, especialmente interpretadores, achava que era um fato bem conhecido que, se usado com cuidado, dava para fazer
memcpyde trechos de código executável gerados pelo compiladorA grande armadilha na época era que o bit NX estava começando a se popularizar. Mesmo no Linux, a maioria ainda considerava distribuições de 32 bits algo óbvio, e havia gente que se surpreendia ao descobrir que a CPU tinha suporte a 64 bits
Mais tarde, cheguei a usar um netbook que não tinha nenhum suporte a código de 64 bits
Infelizmente, gastei tempo demais no resto do código e não consegui me aprofundar nisso o suficiente para criar algo utilizável
Infelizmente, a chamada para propostas de palestras já terminou, mas há uma parte de “unconference”. Só que os temas são definidos no local do evento, então não há garantia
Com uma cache, consultas executadas com frequência poderiam ser armazenadas e otimizadas de forma mais agressiva, então as duas abordagens parecem poder se encaixar bem, compensando o custo de compilação
Claro que isso, por si só, acrescentaria uma complexidade e uma dor de cabeça totalmente novas
Veja “notes” em https://www.postgresql.org/docs/current/sql-prepare.html
Simplificando bastante, ponteiros que apontam para partes da consulta vazam por todos os cantos do motor de execução
Para eliminá-los, seria necessária uma reformulação considerável, incluindo o motor de execução, o planner e sabe-se lá o que mais
Mesmo dentro de uma única sessão, duas consultas compiladas acabam tendo código compilado diferente por causa disso. Tanto o LLVM quanto o meu copyjit precisam injetar os endereços de várias structs no código assembly
Normalmente era preciso entrar manualmente e adicionar hints ou forçar um plano de execução
Até uma consulta simples como
SELECT * FROM t WHERE x = TRUE;podia virar um pesadelo dependendo da distribuição dos valores dexna tabelaNo Postgres quase não enfrentei esse tipo de problema, embora eu admita que nunca usei Postgres com prepared statements
Já vi consultas com planejamento lento (acima de 100 ms), então uma cache poderia ter sido útil, mas não me lembro de casos em que a otimização fosse realmente necessária
É parecido com os stencils apresentados aqui, mas como o código geralmente era uma única instância, era raro criar cópias
Por exemplo, o Doom para DOS também usava esse tipo de otimização. Era necessário para extrair desempenho suficiente dos loops de renderização apertados em CPUs antigas