Como fazer benchmark de código Rust com Criterion
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!
Escreva FizzBuzz em Rust
Para escrevermos testes de desempenho, precisamos de algum código-fonte para testar. Para começar, vamos escrever um programa muito simples, FizzBuzz.
As regras para o FizzBuzz são as seguintes:
Escreva um programa que imprima os inteiros de
1
a100
(inclusive):
- Para múltiplos de três, imprima
Fizz
- Para múltiplos de cinco, imprima
Buzz
- Para múltiplos de três e cinco, imprima
FizzBuzz
- Para todos os outros, imprima o número
Existem muitas maneiras de escrever o FizzBuzz. Então vamos seguir com o meu favorito:
- Crie uma função
main
- Itere de
1
a100
inclusivamente. - Para cada número, calcule o módulo (resto depois da divisão) para ambos
3
e5
. - Faça correspondência de padrões nos dois restos.
Se o resto é
0
, então o número é múltiplo do fator dado. - Se o resto é
0
para ambos3
e5
então imprimaFizzBuzz
. - Se o resto é
0
apenas para3
então imprimaFizz
. - Se o resto é
0
apenas para5
então imprimaBuzz
. - Caso contrário, apenas imprima o número.
Siga Passo a Passo
Para acompanhar este tutorial passo a passo, você precisa instalar Rust.
🐰 O código fonte para esta postagem está disponível no GitHub
Com Rust instalado, você pode então abrir uma janela de terminal e digitar: cargo init game
Em seguida, navegue para o diretório game
recém-criado.
Você verá um diretório chamado src
com um arquivo chamado main.rs
:
Substitua o conteúdo dele pela implementação FizzBuzz acima. Depois, execute cargo run
.
A saída deve ser parecida com:
🐰 Boom! Você está arrasando na entrevista de programação!
Um novo arquivo Cargo.lock
deve ter sido gerado:
Antes de prosseguir, é importante discutir as diferenças entre micro-benchmarking e macro-benchmarking.
Micro-Benchmarking vs Macro-Benchmarking
Existem duas categorias importantes de benchmarks de software: micro-benchmarks e macro-benchmarks.
Os micro-benchmarks operam em um nível semelhante aos testes unitários.
Por exemplo, um benchmark para uma função que determina Fizz
, Buzz
, ou FizzBuzz
para um número individual seria um micro-benchmark.
Os macro-benchmarks operam em um nível semelhante aos testes de integração.
Por exemplo, um benchmark para uma função que executa o jogo inteiro de FizzBuzz, de 1
a 100
, seria um macro-benchmark.
Em geral, é melhor testar no menor nível de abstração possível. No caso dos benchmarks, isso os torna mais fáceis de manter, e ajuda a reduzir a quantidade de ruído nas medições. No entanto, assim como ter alguns testes de ponta a ponta pode ser muito útil para verificar se todo o sistema se junta conforme esperado, ter macro-benchmarks pode ser muito útil para garantir que os caminhos críticos através do seu software permaneçam com bom desempenho.
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.
Todos os três são suportados pelo Bencher. Então, por que escolher o Criterion? Criterion é o padrão de facto para realização de benchmark na comunidade Rust. Eu sugeriria o uso do Criterion para fazer benchmark da latência do seu código. Ou seja, o Criterion é ótimo para medir o tempo de relógio.
Refatorando o FizzBuzz
Para testar nosso aplicativo FizzBuzz, precisamos desacoplar nossa lógica da função main
do programa.
Os bancos de teste não conseguem benchmarkar a função main
. Para fazer isso, precisamos fazer algumas alterações.
Em src
, crie um novo arquivo chamado lib.rs
:
Adicione o seguinte código em lib.rs
:
play_game
: Recebe um número inteiro não assinadon
, chamafizz_buzz
com aquele número, e seprint
fortrue
imprime o resultado.fizz_buzz
: Recebe um número inteiro não assinadon
e executa a lógica real deFizz
,Buzz
,FizzBuzz
, ou número retornando o resultado como uma string.
Em seguida, atualize main.rs
para ter esta aparência:
game::play_game
: Importaplay_game
do pacotegame
que acabamos de criar comlib.rs
.main
: O ponto de entrada principal em nosso programa que percorre os números de1
a100
inclusos e chamaplay_game
para cada número, comprint
definido comotrue
.
Benchmarking do FizzBuzz
Para fazer benchmark do nosso código, precisamos criar um diretório benches
e adicionar um arquivo para conter nossos benchmarks, play_game.rs
:
Dentro de play_game.rs
adicione o seguinte código:
- Importe o executor de benchmark
Criterion
. - Importe a função
play_game
da nossa crategame
. - Crie uma função chamada
bench_play_game
que recebe uma referência mutável paraCriterion
. - Use a instância
Criterion
(c
) para criar um benchmark chamadobench_play_game
. - Em seguida, use o executor de benchmark (
b
) para executar nosso macro-benchmark várias vezes. - Execute nosso macro-benchmark 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
.
Agora precisamos configurar a crate game
para executar nossos benchmarks.
Adicione o seguinte ao final do seu arquivo Cargo.toml
:
criterion
: Adicionecriterion
como uma dependência de desenvolvimento, pois estamos usando apenas para testes de performance.bench
: Registreplay_game
como um benchmark e definaharness
comofalse
, pois usaremos o Criterion como nossa ferramenta de benchmarking.
Agora estamos prontos para fazer benchmark do nosso código, execute cargo bench
:
🐰 Vamos agitar a centrífuga! Conseguimos nossas primeiras métricas de benchmark!
Finalmente, podemos descansar nossas cansadas cabeças de desenvolvedores… Brincadeira, nossos usuários querem um novo recurso!
Escreva FizzBuzzFibonacci em Rust
Nossos Indicadores Chave de Desempenho (KPIs) estão em baixa, então nosso Gerente de Produto (PM) quer que adicionemos um novo recurso. Após muitas discussões e várias entrevistas com usuários, decidiu-se que o bom e velho FizzBuzz não é suficiente. As crianças de hoje querem um novo jogo, FizzBuzzFibonacci.
As regras para FizzBuzzFibonacci são as seguintes:
Escreva um programa que imprime os números inteiros de
1
a100
(inclusive):
- Para múltiplos de três, imprima
Fizz
- Para múltiplos de cinco, imprima
Buzz
- Para múltiplos de ambos três e cinco, imprima
FizzBuzz
- Para números que fazem parte da sequência de Fibonacci, apenas imprima
Fibonacci
- Para todos os outros, imprima o número
A Sequência de Fibonacci é uma série na qual cada número é a soma dos dois números precedentes.
Por exemplo, começando com 0
e 1
o próximo número na sequência de Fibonacci seria 1
.
Seguido por: 2
, 3
, 5
, 8
e assim por diante.
Números que fazem parte da Sequência de Fibonacci são conhecidos como números de Fibonacci. Então, teremos que escrever uma função que detecte números de Fibonacci.
Existem muitas maneiras de escrever a sequência de Fibonacci e, da mesma forma, muitas maneiras de detectar um número de Fibonacci. Então, vamos com a minha favorita:
- Crie uma função chamada
is_fibonacci_number
que recebe um número inteiro sem sinal e retorna um booleano. - Itere para todos os números de
0
ao nosso número específicon
inclusive. - Inicialize nossa sequência Fibonacci começando com
0
e1
como os númerosanterior
eatual
respectivamente. - Itere enquanto o número
atual
for menor que a iteração atuali
. - Adicione o número
atual
eanterior
para obter o númeropróximo
. - Atualize o número
anterior
para o númeroatual
. - Atualize o número
atual
para o númeropróximo
. - Uma vez que
atual
for maior ou igual ao número especificon
, nós sairemos do loop. - Verifique se o número
atual
é igual ao número especificadon
e, se for, retornetrue
. - Caso contrário, retorne
false
.
Agora precisaremos atualizar nossa função fizz_buzz
:
- Renomeie a função
fizz_buzz
parafizz_buzz_fibonacci
para torná-la mais descritiva. - Chame nossa função auxiliar
is_fibonacci_number
. - Se o resultado de
is_fibonacci_number
fortrue
retorneFibonacci
. - Se o resultado de
is_fibonacci_number
forfalse
, execute a mesma lógicaFizz
,Buzz
,FizzBuzz
, ou número retornando o resultado.
Como renomeamos fizz_buzz
para fizz_buzz_fibonacci
, também precisamos atualizar nossa função play_game
:
Ambas as funções main
e bench_play_game
podem permanecer exatamente as mesmas.
Benchmarking do FizzBuzzFibonacci
Agora podemos reexecutar nosso benchmark:
Oh, interessante! O Criterion nos informa que a diferença entre o desempenho dos nossos jogos FizzBuzz e FizzBuzzFibonacci é +568.69%
.
Seus números serão um pouco diferentes dos meus.
No entanto, a diferença entre os dois jogos provavelmente está na faixa de 5x
.
Isso me parece bom! Especialmente por adicionar um recurso tão sofisticado quanto Fibonacci ao nosso jogo.
As crianças vão adorar!
Expandindo FizzBuzzFibonacci em Rust
Nosso jogo é um sucesso! As crianças realmente adoram jogar FizzBuzzFibonacci.
Tanto que a direção quer uma sequência.
Mas vivemos em um mundo moderno, precisamos de Receita Anual Recorrente (ARR) e não de compras únicas!
A nova visão para o nosso jogo é que ele seja aberto, sem mais viver entre os limites de 1
e 100
(mesmo que inclusivo).
Não, estamos partindo para novas fronteiras!
As regras para o Open World FizzBuzzFibonacci são as seguintes:
Escreva um programa que aceite qualquer número inteiro positivo e imprima:
- Para múltiplos de três, imprima
Fizz
- Para múltiplos de cinco, imprima
Buzz
- Para múltiplos de ambos três e cinco, imprima
FizzBuzz
- Para números que fazem parte da sequência de Fibonacci, apenas imprima
Fibonacci
- Para todos os outros, imprima o número
Para que nosso jogo funcione para qualquer número, precisaremos aceitar um argumento de linha de comando.
Atualize a função main
para ficar assim:
- Colete todos os argumentos (
args
) passados para o nosso jogo a partir da linha de comando. - Pegue o primeiro argumento passado para o nosso jogo e analise-o como um inteiro não assinado
i
. - Se a análise falhar ou nenhum argumento for passado, use por padrão o nosso jogo com
15
como entrada. - Finalmente, jogue nosso jogo com o novo inteiro não assinado
i
analisado.
Agora podemos jogar nosso jogo com qualquer número!
Use cargo run
seguido de --
para passar argumentos para o nosso jogo:
E se omitirmos ou fornecermos um número inválido:
Nossa, que teste completo! O CI passou. Nossos chefes estão entusiasmados. Vamos lançá-lo! 🚀
O Fim
🐰 … o fim da sua carreira talvez?
Brincadeira! Tudo está pegando fogo! 🔥
Bem, a princípio, tudo parecia estar indo bem. Então, às 02:07 da madrugada de sábado, meu pager disparou:
📟 Seu jogo está pegando fogo! 🔥
Após sair da cama às pressas, tentei descobrir o que estava acontecendo. Eu tentei pesquisar nos logs, mas era difícil porque tudo continuava travando. Finalmente, encontrei o problema. As crianças! Elas adoravam tanto nosso jogo que jogavam até chegar a um milhão! Num lampejo de brilhantismo, adicionei dois novos benchmarks:
- Um micro-benchmark
bench_play_game_100
para jogar o jogo com o número cem (100
) - Um micro-benchmark
bench_play_game_1_000_000
para jogar o jogo com o número um milhão (1_000_000
)
Quando executei, obtive isto:
Aguarde… aguarde…
O quê! 403,57 ns
x 1,000
deveria ser 403.570 ns
e não 9,596,800 ns
(9.5968 ms
x 1_000_000 ns/1 ms
) 🤯
Mesmo que eu tenha meu código da sequência de Fibonacci funcionando corretamente, devo ter algum bug de desempenho nele.
Corrigindo FizzBuzzFibonacci em Rust
Vamos dar outra olhada naquela função is_fibonacci_number
:
Agora que estou pensando em desempenho, percebo que tenho um loop extra desnecessário.
Podemos nos livrar completamente do loop for i in 0..=n {}
e
apenas comparar o valor atual
com o número dado (n
) 🤦
- Atualize sua função
is_fibonacci_number
. - Inicialize nossa sequência de Fibonacci começando com
0
e1
como os númerosanterior
eatual
, respectivamente. - Itere enquanto o número
atual
for menor que o número dadon
. - Adicione o número
anterior
eatual
para obter o númeropróximo
. - Atualize o número
anterior
para o númeroatual
. - Atualize o número
atual
para o númeropróximo
. - Uma vez que
atual
seja maior ou igual ao número dadon
, sairemos do loop. - Verifique se o número
atual
é igual ao número dadon
e retorne esse resultado.
Agora vamos reexecutar esses benchmark e ver como nos saímos:
Oh, uau! Nosso benchmark bench_play_game
voltou para algo próximo de onde estava para o FizzBuzz original.
Eu queria lembrar exatamente qual era esse score. Mas já se passaram três semanas.
Meu histórico de terminal não vai tão longe.
E o Criterion só compara com o resultado mais recente.
Mas acho que está perto!
O benchmark bench_play_game_100
está quase 10x para baixo, -93.950%
.
E o benchmark bench_play_game_1_000_000
está mais de 10,000x para baixo! 9,596,800 ns
para 30.403 ns
!
Nós até maximizamos o medidor de mudança do Criterion, que só vai até -100.000%
!
🐰 Ei, pelo menos pegamos este bug de desempenho antes de ir para a produção… ah, certo. Esqueça…
Detecte Regressões de Desempenho em CI
Os executivos não ficaram felizes com a enxurrada de críticas negativas que nosso jogo recebeu devido ao meu pequeno bug de desempenho. Eles me disseram para não deixar isso acontecer de novo, e quando perguntei como, eles simplesmente me disseram para não fazê-lo novamente. Como eu deveria gerenciar isso‽
Felizmente, encontrei esta incrível ferramenta open source chamada Bencher. Existe um nível gratuito super generoso, então posso apenas usar Bencher Cloud para meus projetos pessoais. E no trabalho, onde tudo precisa estar em nossa nuvem privada, comecei a usar Bencher Auto-Hospedado.
Bencher tem adaptadores integrados, por isso é fácil de integrar ao CI. Após seguir o guia Rápido Início, consegui executar meus benchmarks e rastreá-los com o Bencher.
Usando este incrível dispositivo de viagem no tempo que um simpático coelho me deu, consegui voltar ao passado e reviver o que teria acontecido se estivéssemos usando o Bencher desde o início. Você pode ver onde fizemos pela primeira vez o push da implementação bugada de FizzBuzzFibonacci. Imediatamente recebi falhas no CI como um comentário na minha solicitação de pull. No mesmo dia, corrigi o bug de desempenho, eliminando aquele loop extra e desnecessário. Sem incêndios. Apenas usuários felizes.
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.