Desde o início, eu sabia que o Bencher Perf API
seria um dos endpoints mais exigentes em termos de desempenho.
Acredito que o principal motivo pelo qual muitas pessoas tiveram que reinventar a roda de acompanhamento de benchmarks
é que as ferramentas disponíveis no mercado não lidam com a alta dimensionalidade necessária.
Por “alta dimensionalidade”, eu quero dizer ser capaz de acompanhar o desempenho ao longo do tempo e em várias dimensões:
Branches, Ambientes de Teste, Benchmarks e Medidas.
Essa capacidade de segmentar e cruzar cinco dimensões diferentes leva a um modelo muito complexo.
Devido a essa complexidade inerente e à natureza dos dados,
eu considerei usar um banco de dados de séries temporais para o Bencher.
No final, porém, optei por usar o SQLite.
Eu concluí que era melhor fazer coisas que não escalonam
do que gastar tempo extra aprendendo uma arquitetura de banco de dados totalmente nova que talvez não ajudasse de fato.
Com o tempo, as demandas sobre a Bencher Perf API também aumentaram.
Originalmente, você tinha que selecionar todas as dimensões que queria plotar manualmente.
Isso criava muita fricção para os usuários obterem um gráfico útil.
Para resolver isso, eu adicionei uma lista dos Relatórios mais recentes às Páginas de Desempenho,
e por padrão, o Relatório mais recente era selecionado e plotado.
Isso significa que se houvesse 112 benchmarks no Relatório mais recente, então todos os 112 seriam plotados.
O modelo também ficou ainda mais complicado com a capacidade de acompanhar e visualizar Limites de Limiar.
Com isso em mente, fiz algumas melhorias relacionadas ao desempenho.
Uma vez que o Gráfico de Desempenho precisa do Relatório mais recente para começar a plotar,
eu refatorei a API de Relatórios para obter os dados de resultado de um Relatório em uma única chamada ao banco de dados, em vez de iterar.
O período de tempo para a consulta padrão do Relatório foi definido para quatro semanas, em vez de ser ilimitado.
Eu também limitei drasticamente o escopo de todos os handles do banco de dados, reduzindo a contenção de bloqueios.
Para ajudar a comunicar aos usuários, eu adicionei um indicador de status giratório tanto para o Gráfico de Desempenho quanto para as abas de dimensão.
Eu também tive uma tentativa frustrada no último outono de usar uma consulta composta para obter todos os resultados do Perf em uma única query,
em vez de usar um loop aninhado quádruplo.
Isso me levou a atingir o limite de recursão do sistema de tipos do Rust,
transbordando repetidamente a pilha,
sofrendo com tempos de compilação insanos (muito mais longos que 38 segundos),
e finalmente chegando a um beco sem saída no limite máximo de número de termos em uma instrução select composta do SQLite.
Com tudo isso na bagagem, eu sabia que realmente precisava me aprofundar aqui
e vestir as calças de engenheiro de desempenho.
Eu nunca havia perfilado um banco de dados SQLite antes,
e, honestamente, nunca havia perfilado nenhum banco de dados antes.
Agora espere um minuto, você pode estar pensando.
Meu perfil no LinkedIn diz que fui “Administrador de Banco de Dados” por quase dois anos.
E eu nunca profilei um banco de dados‽
Sim. Essa é uma história para outra hora, suponho.
De ORM para Consulta SQL
O primeiro desafio que encontrei foi extrair a consulta SQL do meu código Rust.
Eu uso o Diesel como o mapeador objeto-relacional (ORM) para o Bencher.
O Diesel cria consultas parametrizadas.
Ele envia a consulta SQL e seus parâmetros de ligação separadamente para o banco de dados.
Isto é, a substituição é feita pelo banco de dados.
Portanto, o Diesel não pode fornecer uma consulta completa ao usuário.
O melhor método que encontrei foi usar a função diesel::debug_query para saída da consulta parametrizada:
E então limpando manualmente e parametrizando a consulta em um SQL válido:
Se você conhece uma maneira melhor, por favor me avise!
Esta é a maneira que o mantenedor do projeto sugeriu no entanto,
então eu simplesmente prossegui com ela.
Agora que eu tinha uma consulta SQL, eu finalmente estava pronto para… ler uma enorme quantidade de documentação.
Planejador de Consultas do SQLite
O site do SQLite possui uma ótima documentação para o seu Planejador de Consultas.
Ele explica exatamente como o SQLite executa a sua consulta SQL,
e ensina quais índices são úteis e quais operações ficar de olho, como varreduras completas de tabela.
Para ver como o Planejador de Consultas executaria minha consulta Perf,
eu precisei adicionar uma nova ferramenta ao meu arsenal: EXPLAIN QUERY PLAN
Você pode tanto prefixar sua consulta SQL com EXPLAIN QUERY PLAN
ou executar o comando .eqp on antes da sua consulta.
De qualquer maneira, eu obtive um resultado que se parece com isso:
Nossa!
Há muito aqui.
Mas as três grandes coisas que me saltaram aos olhos foram:
O SQLite está criando uma view materializada instantaneamente que varre a inteira tabela boundary
O SQLite está então varrendo a inteira tabela metric
O SQLite está criando dois índices instantaneamente
E quão grandes são as tabelas metric e boundary?
Bem, elas acontecem de ser as duas maiores tabelas,
já que é onde todas as Métricas e Limites são armazenadas.
Já que esta foi a minha primeira experiência com ajuste de desempenho no SQLite,
eu queria consultar um especialista antes de fazer quaisquer mudanças.
SQLite Expert
SQLite possui um modo “expert” experimental que pode ser ativado com o comando .expert.
Ele sugere índices para consultas; então, decidi experimentar.
Eis o que ele sugeriu:
Definitivamente, isso é uma melhoria!
Ele eliminou a varredura na tabela metric e ambos os índices criados em tempo de execução.
Sinceramente, eu não teria chegado aos dois primeiros índices por conta própria.
Obrigado, SQLite Expert!
Agora, a única coisa que resta eliminar é aquela maldita visualização materializada criada em tempo de execução.
Visão Materializada
Quando adicionei a capacidade de rastrear e visualizar Limites de Limiares no ano passado,
eu tinha uma decisão a tomar no modelo de banco de dados.
Existe um relacionamento de 1-para-0/1 entre uma Métrica e seu Limite correspondente.
Isso significa que uma Métrica pode se relacionar a zero ou um Limite, e um Limite só pode se relacionar a uma Métrica.
Então, eu poderia simplesmente expandir a tabela metric para incluir todos os dados de boundary com todos os campos relacionados a boundary sendo nulos.
Ou eu poderia criar uma tabela boundary separada com uma chave estrangeira UNIQUE para a tabela metric.
Para mim, a última opção pareceu muito mais limpa, e eu imaginei que sempre poderia lidar com quaisquer implicações de desempenho mais tarde.
Estas foram as consultas efetivas usadas para criar as tabelas metric e boundary:
E acontece que “mais tarde” chegou.
Eu tentei simplesmente adicionar um índice para boundary(metric_id), mas isso não ajudou.
Eu acredito que o motivo tem a ver com o fato de que a consulta de Perf está se originando da tabela metric
e porque essa relação é 0/1, ou de outra forma, nula, ela tem que ser escaneada (O(n))
e não pode ser buscada (O(log(n))).
Isso me deixou com uma opção clara.
Eu precisava criar uma visão materializada que achatasse a relação metric e boundary
para evitar que o SQLite tenha que criar uma visão materializada na hora.
Esta é a consulta que usei para criar a nova visão materializada metric_boundary:
Com essa solução, estou trocando espaço por desempenho de execução.
Quanto espaço?
Surpreendentemente, apenas cerca de um aumento de 4%, mesmo que esta visão seja para as duas maiores tabelas no banco de dados.
Melhor de tudo, isso me permite ter meu bolo e comê-lo também no meu código-fonte.
Criar uma visão materializada com Diesel foi surpreendentemente fácil.
Eu apenas tive que usar as mesmas macros que o Diesel usa quando gerando meu esquema normal.
Com isso dito, aprendi a apreciar muito mais o Diesel ao longo dessa experiência.
Veja Bug Bônus para todos os detalhes suculentos.
Conclusão
Com os três novos índices e uma view materializada adicionados, é isso que o Planejador de Consultas agora mostra:
Olhe todas essas belas SEARCHes, todas com índices existentes! 🥲
E após implantar minhas alterações em produção:
Agora era hora do teste final.
Quão rápido a página de Perf do Rustls carrega?
Aqui, eu até te dou uma âncora. Clique nela e depois atualize a página.
Bencher é um conjunto de ferramentas de benchmarking contínuas.
Já teve algum impacto de regressão de desempenho nos seus usuários?
Bencher poderia ter prevenido isso.
Bencher permite que você detecte e previna regressões de desempenho antes que cheguem à produção.
Execute: Execute seus benchmarks localmente ou no CI usando suas ferramentas de benchmarking favoritas. O CLI bencher simplesmente envolve seu harness de benchmark existente e armazena seus resultados.
Rastreie: Acompanhe os resultados de seus benchmarks ao longo do tempo. Monitore, consulte e faça gráficos dos resultados usando o console web do Bencher baseado na branch de origem, testbed e medida.
Capture: Capture regressões de desempenho no CI. Bencher usa análises personalizáveis e de última geração para detectar regressões de desempenho antes que elas cheguem à produção.
Pelos mesmos motivos que os testes de unidade são executados no CI para prevenir regressões de funcionalidades, benchmarks deveriam ser executados no CI com o Bencher para prevenir regressões de desempenho. Bugs de desempenho são bugs!
Já estou utilizando o Bencher com o Bencher,
mas todos os adaptadores de harness de benchmark existentes são para harnesses de micro-benchmarking.
A maioria dos harnesses HTTP são realmente harnesses para testes de carga,
e testes de carga são diferentes de benchmarking.
Além disso, não estou procurando expandir o Bencher para testes de carga tão cedo.
Esse é um caso de uso muito diferente que exigiria considerações de design muito distintas,
como aquele banco de dados de séries temporais, por exemplo.
Mesmo se eu tivesse implementado testes de carga,
realmente precisaria estar executando contra uma nova extração de dados de produção para que isso fosse identificado.
As diferenças de desempenho para essas mudanças foram insignificantes com meu banco de dados de teste.
Clique para visualizar os resultados de benchmark do banco de dados de teste
Antes:
Após índices e visão materializada:
Tudo isso me leva a crer que devo criar um micro-benchmark
que rode contra o endpoint da API Perf e utilizar os resultados com o Bencher.
Isso vai exigir um banco de dados de teste considerável
para garantir que esse tipo de regressão de desempenho seja capturado em CI.
Eu criei um issue de acompanhamento para este trabalho, caso você queira seguir o andamento.
Isso tudo me fez pensar:
E se você pudesse fazer teste de snapshot do plano de consulta do seu banco de dados SQL?
Ou seja, você poderia comparar seus planos de consulta do banco de dados atual e candidato.
Testes do plano de consulta SQL seriam como um benchmarking baseado em contagem de instrução para bancos de dados.
O plano de consulta ajuda a indicar que pode haver um problema com o desempenho em tempo de execução,
sem precisar realmente fazer o benchmark da consulta ao banco de dados.
Eu criei um issue de acompanhamento para isso também.
Por favor, sinta-se livre para adicionar um comentário com pensamentos ou qualquer trabalho anterior que você conheça!
Bônus Bug
Eu originalmente encontrei um bug no meu código de visão materializada.
A consulta SQL parecia com isso:
Você vê o problema? Não. Eu também não!
O problema está justamente aqui:
Na verdade, deveria ser:
Eu estava tentando ser muito esperto,
e na minha estrutura de visão materializada Diesel eu permiti essa junção:
Eu assumi que essa macro era de alguma forma inteligente o suficiente
para relacionar o alert.boundary_id ao metric_boundary.boundary_id.
Mas, infelizmente, não era.
Parece que ela apenas escolheu a primeira coluna de metric_boundary (metric_id) para relacionar com alert.
Uma vez que descobri o bug, foi fácil de corrigir.
Eu só tive que usar uma junção explícita na consulta Perf:
🐰 Isso é tudo, pessoal!
🤖 Este documento foi gerado automaticamente pelo OpenAI GPT-4. Pode não ser preciso e pode conter erros. Se você encontrar algum erro, abra um problema no GitHub.