Como construir um Arnês de Benchmarking Personalizado em Rust
Everett Pompeii
O que é Benchmarking?
Benchmarking é a prática de testar o desempenho do seu código para ver quão rápido (latência) ou quanto (throughput) trabalho ele pode executar. Este passo frequentemente negligenciado no desenvolvimento de software é crucial para criar e manter um código rápido e performático. O benchmarking fornece as métricas necessárias para que os desenvolvedores compreendam o desempenho do seu código sob várias cargas de trabalho e condições. Pelas mesmas razões que você escreve testes unitários e de integração para evitar regressões de funcionalidades, você deve escrever benchmarks para evitar regressões de desempenho. Bugs de desempenho são bugs!
Benchmarking em Rust
As três opções populares para benchmarking em Rust são: libtest bench, Criterion, e Iai.
libtest é o framework embutido de testes unitários e benchmarking do Rust.
Embora faça parte da biblioteca padrão do Rust, o libtest bench ainda é considerado instável,
portanto, está disponível apenas em versões do compilador nightly
.
Para funcionar no compilador Rust estável,
um harness de benchmarking separado
precisa ser usado.
Nenhum dos dois está sendo desenvolvido ativamente, no entanto.
O harness de benchmarking mais popular dentro do ecossistema Rust é o Criterion.
Ele funciona tanto em versões estáveis quanto em versões nightly
do compilador Rust,
e se tornou o padrão de facto dentro da comunidade Rust.
O Criterion também possui muito mais recursos em comparação com o libtest bench.
Uma alternativa experimental ao Criterion é o Iai, do mesmo criador do Criterion. No entanto, ele usa contagem de instruções em vez de tempo real: instruções da CPU, acessos L1, acessos L2 e acessos à RAM. Isso permite benchmarking de tiro único, uma vez que essas métricas devem permanecer quase idênticas entre as execuções.
Com tudo isso em mente, se você está procurando medir o tempo de execução do seu código, é recomendável usar o Criterion. Se você está procurando medir o desempenho do seu código no CI com runners compartilhados, pode valer a pena conferir o Iai. Note, no entanto, que o Iai não é atualizado há mais de 3 anos. Portanto, você pode considerar usar Iai-Callgrind em vez disso.
Mas e se você não quiser medir o tempo de execução ou a contagem de instruções? E se você quiser rastrear um benchmark completamente diferente‽ Felizmente, o Rust torna extremamente fácil criar um conjunto de testes de desempenho personalizado.
Como o cargo bench
Funciona
Antes de construir um mecanismo de benchmarking personalizado,
precisamos entender como funcionam os benchmarks em Rust.
Para a maioria dos desenvolvedores Rust, isso significa executar o comando cargo bench
.
O comando cargo bench
compila e executa seus benchmarks.
Por padrão, cargo bench
tentará usar o mecanismo de bench integrado (mas instável) libtest.
O libtest bench então percorrerá seu código e executará todas as funções anotadas com o atributo #[bench]
.
Para usar um mecanismo de benchmarking personalizado, precisamos informar ao cargo bench
para não usar o libtest bench.
Use uma Harness de Benchmarking Personalizada com cargo bench
Para fazer com que cargo bench
não use libtest bench,
precisamos adicionar o seguinte ao nosso arquivo Cargo.toml
:
Infelizmente, não podemos usar o atributo #[bench]
com nossa harness de benchmarking personalizada.
Talvez um dia em breve, mas não hoje.
Em vez disso, precisamos criar um diretório separado chamado benches
para armazenar nossos benchmarks.
O diretório benches
é para benchmarks
o que o diretório tests
é para testes de integração.
Cada arquivo dentro do diretório benches
é tratado como um crate separado.
Portanto, o crate que está sendo benchmarked deve ser um crate de biblioteca.
Ou seja, ele deve ter um arquivo lib.rs
.
Por exemplo, se tivermos um crate de biblioteca básico chamado game
então poderíamos adicionar um arquivo de benchmark personalizado chamado play_game
ao diretório benches
.
Nossa estrutura de diretórios ficaria assim:
Em seguida, precisamos informar ao cargo bench
sobre nosso crate de benchmark personalizado play_game
.
Então, atualizamos nosso arquivo Cargo.toml
:
Escreva Código para Benchmark
Antes de podermos escrever um teste de desempenho, precisamos ter algum código de biblioteca para benchmark. Para nosso exemplo, vamos jogar o jogo FizzBuzzFibonacci.
As regras para FizzBuzzFibonacci são as seguintes:
Escreva um programa que imprime os inteiros de
1
a100
(inclusive):
- Para múltiplos de três, imprime
Fizz
- Para múltiplos de cinco, imprime
Buzz
- Para múltiplos de três e cinco, imprime
FizzBuzz
- Para números que fazem parte da sequência de Fibonacci, imprime apenas
Fibonacci
- Para todos os outros, imprime o número
Esta é a aparência da nossa implementação em src/lib.rs
:
Criando um Harness de Benchmarking Personalizado
Vamos criar um harness de benchmarking personalizado dentro de benches/play_game.rs
.
Este harness de benchmarking personalizado irá medir alocações no heap
usando o crate dhat-rs
.
dhat-rs
é uma ferramenta fantástica para rastrear alocações no heap em programas Rust
criada pelo especialista em performance Rust Nicholas Nethercote.
Para nos ajudar a gerenciar nossas funções de benchmark,
usaremos o crate inventory
do incrivelmente prolífico David Tolnay.
Vamos adicionar dhat-rs
e inventory
ao nosso arquivo Cargo.toml
como dev-dependencies
:
Crie um Allocator Personalizado
Como nosso conjunto de benchmarks personalizado medirá as alocações no heap,
precisaremos usar um allocator de heap personalizado.
O Rust permite configurar um allocator de heap global personalizado
usando o atributo #[global_allocator]
.
Adicione o seguinte ao topo de benches/play_game.rs
:
Isso diz ao Rust para usar dhat::Alloc
como nosso allocator de heap global.
🐰 Você só pode definir um allocator de heap global por vez. Se você quiser alternar entre múltiplos allocators globais, eles precisam ser gerenciados por meio de compilação condicional com [features do Rust].
Criar um Coletor de Benchmark Personalizado
Para criar um mecanismo de benchmarking personalizado,
precisamos de uma maneira de identificar e armazenar nossas funções de benchmark.
Usaremos uma struct, adequadamente chamada CustomBenchmark
, para encapsular cada função de benchmark.
Um CustomBenchmark
tem um nome e uma função de benchmark que retorna dhat::HeapStats
como sua saída.
Em seguida, usaremos o crate inventory
para criar uma coleção para todos os nossos CustomBenchmark
s:
Crie uma Função de Benchmark
Agora, podemos criar uma função de benchmark que joga o jogo FizzBuzzFibonacci:
Linha por linha:
- Crie uma função de benchmark que corresponda à assinatura usada em
CustomBenchmark
. - Crie um
dhat::Profiler
em modo de teste, para coletar resultados do nosso alocador global personalizadodhat::Alloc
. - Execute nossa função
play_game
dentro de uma “caixa preta” para que o compilador não otimize nosso código. - Itere de
1
a100
, inclusivamente. - Para cada número, chame
play_game
, comprint
definido comofalse
. - Retorne nossas estatísticas de alocação de heap como
dhat::HeapStats
.
🐰 Definimos
false
para a funçãoplay_game
. Isso impede queplay_game
imprima no padrão de saída. Parametrizar suas funções de biblioteca desta maneira pode torná-las mais adequadas para benchmark. No entanto, isso significa que talvez não estejamos fazendo benchmark da biblioteca exatamente da mesma maneira que ela é usada em produção.Neste caso, temos que nos perguntar:
- Nos importamos com os recursos necessários para imprimir no padrão de saída?
- Imprimir no padrão de saída é uma possível fonte de ruído?
Para nosso exemplo, optamos por:
- Não, não nos importamos com a impressão no padrão de saída.
- Sim, é uma fonte muito provável de ruído.
Portanto, omitimos a impressão no padrão de saída como parte deste benchmark. Fazer benchmark é difícil, e muitas vezes não há uma resposta certa para perguntas como essas. Depende.
Registrar a Função de Benchmark
Com nossa função de benchmark escrita, precisamos
criar um CustomBenchmark
e registrá-lo em nossa coleção de benchmarks usando inventory
.
Se tivéssemos mais de um benchmark, repetiríamos o mesmo processo:
- Criar uma função de benchmark.
- Criar um
CustomBenchmark
para a função de benchmark. - Registrar o
CustomBenchmark
na coleçãoinventory
.
Criar um Executor de Benchmark Personalizado
Finalmente, precisamos criar um executor para nosso medidor de benchmark personalizado. Um medidor de benchmark personalizado é, na verdade, apenas um binário que executa todos os nossos benchmarks e relata os resultados. O executor de benchmark é o que orquestra tudo isso.
Queremos que nossos resultados sejam gerados no Formato de Métrica Bencher (BMF) JSON.
Para conseguir isso, precisamos adicionar uma dependência final,
a crate serde_json
de… você adivinhou, David Tolnay!
Em seguida, implementaremos um método para CustomBenchmark
executar sua função de benchmark
e depois retornar os resultados como JSON BMF.
Os resultados JSON BMF contêm seis Medidas para cada benchmark:
- Blocos Finais: Número final de blocos alocados quando o benchmark terminou.
- Bytes Finais: Número final de bytes alocados quando o benchmark terminou.
- Máximo de Blocos: Número máximo de blocos alocados ao mesmo tempo durante a execução do benchmark.
- Máximo de Bytes: Número máximo de bytes alocados ao mesmo tempo durante a execução do benchmark.
- Total de Blocos: Número total de blocos alocados durante a execução do benchmark.
- Total de Bytes: Número total de bytes alocados durante a execução do benchmark.
Finalmente, podemos criar uma função main
para executar todos os benchmarks na nossa coleção inventory
e gerar os resultados como JSON BMF.
Execute o Arnês de Benchmark Personalizado
Tudo está preparado agora. Finalmente podemos executar nosso arnês de benchmark personalizado.
A saída tanto para o padrão quanto para um arquivo chamado results.json
deve se parecer com isto:
Os números exatos que você vê podem ser um pouco diferentes com base na arquitetura do seu computador. Mas o importante é que você tenha ao menos alguns valores para as últimas quatro métricas.
Acompanhe Resultados Personalizados de Benchmark
A maioria dos resultados de benchmark são efêmeros. Eles desaparecem assim que o seu terminal alcança seu limite de rolagem. Alguns frameworks de benchmark permitem que você armazene os resultados em cache, mas isso dá muito trabalho para implementar. E mesmo assim, só poderíamos armazenar nossos resultados localmente. Por sorte, nosso framework personalizado de benchmark vai funcionar com o Bencher! Bencher é um conjunto de ferramentas contínuas de benchmark que nos permite acompanhar os resultados de nossos benchmarks ao longo do tempo e detectar regressões de desempenho antes que elas cheguem à produção.
Uma vez que você tenha configurado o Bencher Cloud ou o Bencher Self-Hosted, você pode acompanhar os resultados do nosso framework personalizado de benchmark executando:
Você também pode ler mais sobre como acompanhar benchmarks personalizados com o Bencher e o adaptador de benchmark JSON.
Conclusão
Começamos este post analisando os três frameworks de benchmarking mais populares no ecossistema Rust: libtest bench, Criterion e Iai. Mesmo que eles cubram a maioria dos casos de uso, às vezes você pode precisar medir algo além do tempo de relógio ou contagem de instruções. Isso nos levou a criar um framework de benchmarking personalizado.
Nosso framework de benchmarking personalizado mede alocações de heap usando dhat-rs
.
As funções de benchmark foram coletadas usando inventory
.
Quando executados, nossos benchmarks geram resultados no formato JSON do Bencher Metric Format (BMF).
Podemos então usar o Bencher para rastrear os resultados dos nossos benchmarks personalizados ao longo do tempo
e detectar regressões de desempenho no CI.
Todo o código fonte deste guia está disponível no GitHub.
Bencher: Benchmarking Contínuo
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!
Comece a capturar regressões de desempenho no CI — experimente o Bencher Cloud gratuitamente.