4 pontos por GN⁺ 2024-03-20 | 1 comentários | Compartilhar no WhatsApp
  • 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 .so fornece uma única função _PG_jit_provider_init, que inicializa três callbacks
    • compile_expr
    • release_context
    • reset_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 evalfunc pelo código gerado, em vez do interpretador do PostgreSQL
  • 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 CONST declara op como ExprEvalStep externo e deixa, no arquivo .o compilado, uma lacuna para o endereço de op
  • 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 where com 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.sh antes
  • 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

 
GN⁺ 2024-03-20
Comentários do Hacker News
  • A cache e reutilização de consultas compiladas, uma forma de tornar o compilador JIT do LLVM mais fácil de usar, na verdade já foi implementada no LLVM há anos :) https://github.com/llvm/llvm-project/commit/a98546ebcd2a692e...
    • Sim, eu deveria ter sido mais claro: o problema não está no LLVM, mas no PostgreSQL
      Como o compilador JIT precisa injetar endereços de memória diretamente, o código gerado fica vinculado àquela consulta e àquele processo
    • Como o PG usa um processo por conexão e o código JIT do LLVM é fixo por processo, nem todas as conexões do DB conseguem compartilhar o código
      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
  • Sou o autor. Obrigado por publicar no Hacker News; se houver perguntas, vou tentar responder o máximo possível
    • Fico curioso se há uma diferença fundamental entre copy-and-patch tendo C como alvo e o que um compilador faz quando mira uma representação intermediária
      A compilação tradicional também parece um “copy-and-patch” que usa alguma linguagem intermediária que não é C
    • É sempre bom ver melhorias de desempenho no Postgres. Fico curioso para saber quanto tempo o LLVM leva em consultas reais e como o LLVM está configurado
      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 -O0 mirando 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 abordagens
      Alé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
    • Fico curioso se copy-and-patch é mesmo uma ideia nova, ou se é uma ideia antiga com um nome novo
      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 memcpy de trechos de código executável gerados pelo compilador
      A 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
    • Parece um tema muito bom para abordar na pgconf.eu em junho. A pgcon foi transferida para Vancouver
      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
  • Ainda me surpreende que o PostgreSQL ainda não tenha cache de consultas/planos de execução
    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
    • O PG também tem uma forma de cache de planos de execução. Em prepared statements, se ele conclui que os valores reais dos parâmetros não têm grande impacto sobre o plano de execução, o PG usa um “generic plan” e reutiliza o mesmo plano de execução para todas as execuções do mesmo prepared statement
      Veja “notes” em https://www.postgresql.org/docs/current/sql-prepare.html
    • Eu também pensava assim originalmente, mas depois de implementar isso eu mesmo, entendi que seria muito difícil
      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
    • A cache de planos de execução da Oracle costumava dar bastante dor de cabeça no passado quando o otimizador não conseguia enxergar os parâmetros de prepared statements
      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 de x na tabela
      No 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
  • Na época do 386, havia o conceito de código automodificável em assembly
    É 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