Como construir um Arnês de Benchmarking Personalizado em Rust


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:

Cargo.toml
[[bench]]
harness = false

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:

game
├── Cargo.lock
├── Cargo.toml
└── benches
└── play_game.rs
└── src
└── lib.rs

Em seguida, precisamos informar ao cargo bench sobre nosso crate de benchmark personalizado play_game. Então, atualizamos nosso arquivo Cargo.toml:

Cargo.toml
[[bench]]
name = "play_game"
harness = false

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 a 100 (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:

src/lib.rs
pub fn play_game(n: u32, print: bool) {
let result = fizz_buzz_fibonacci(n);
if print {
println!("{result}");
}
}
fn fizz_buzz_fibonacci(n: u32) -> String {
if is_fibonacci_number(n) {
"Fibonacci".to_string()
} else {
match (n % 3, n % 5) {
(0, 0) => "FizzBuzz".to_string(),
(0, _) => "Fizz".to_string(),
(_, 0) => "Buzz".to_string(),
(_, _) => n.to_string(),
}
}
}
fn is_fibonacci_number(n: u32) -> bool {
let (mut previous, mut current) = (0, 1);
while current < n {
let next = previous + current;
previous = current;
current = next;
}
current == n
}

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:

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"

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:

benches/play_game.rs
#[global_allocator]
static ALLOC: dhat::Alloc = dhat::Alloc;

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.

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}

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 CustomBenchmarks:

benches/play_game.rs
#[derive(Debug)]
struct CustomBenchmark {
name: &'static str,
benchmark_fn: fn() -> dhat::HeapStats,
}
inventory::collect!(CustomBenchmark);

Crie uma Função de Benchmark

Agora, podemos criar uma função de benchmark que joga o jogo FizzBuzzFibonacci:

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}

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 personalizado dhat::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 a 100, inclusivamente.
  • Para cada número, chame play_game, com print definido como false.
  • Retorne nossas estatísticas de alocação de heap como dhat::HeapStats.

🐰 Definimos print como false para a função play_game. Isso impede que play_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:

  1. Nos importamos com os recursos necessários para imprimir no padrão de saída?
  2. Imprimir no padrão de saída é uma possível fonte de ruído?

Para nosso exemplo, optamos por:

  1. Não, não nos importamos com a impressão no padrão de saída.
  2. 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.

benches/play_game.rs
fn bench_play_game() -> dhat::HeapStats {
let _profiler = dhat::Profiler::builder().testing().build();
std::hint::black_box(for i in 1..=100 {
play_game(i, false);
});
dhat::HeapStats::get()
}
inventory::submit!(CustomBenchmark {
name: "bench_play_game",
benchmark_fn: bench_play_game
});

Se tivéssemos mais de um benchmark, repetiríamos o mesmo processo:

  1. Criar uma função de benchmark.
  2. Criar um CustomBenchmark para a função de benchmark.
  3. Registrar o CustomBenchmark na coleção inventory.

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!

Cargo.toml
[dev-dependencies]
dhat = "0.3"
inventory = "0.3"
serde_json = "1.0"

Em seguida, implementaremos um método para CustomBenchmark executar sua função de benchmark e depois retornar os resultados como JSON BMF.

benches/play_game.rs
impl CustomBenchmark {
fn run(&self) -> serde_json::Value {
let heap_stats = (self.benchmark_fn)();
let measures = serde_json::json!({
"Final Blocks": {
"value": heap_stats.curr_blocks,
},
"Final Bytes": {
"value": heap_stats.curr_bytes,
},
"Max Blocks": {
"value": heap_stats.max_blocks,
},
"Max Bytes": {
"value": heap_stats.max_bytes,
},
"Total Blocks": {
"value": heap_stats.total_blocks,
},
"Total Bytes": {
"value": heap_stats.total_bytes,
},
});
let mut benchmark_map = serde_json::Map::new();
benchmark_map.insert(self.name.to_string(), measures);
benchmark_map.into()
}
}

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.

benches/play_game.rs
fn main() {
let mut bmf = serde_json::Map::new();
for benchmark in inventory::iter::<CustomBenchmark> {
let mut results = benchmark.run();
bmf.append(results.as_object_mut().unwrap());
}
let bmf_str = serde_json::to_string_pretty(&bmf).unwrap();
std::fs::write("results.json", &bmf_str).unwrap();
println!("{bmf_str}");
}

Execute o Arnês de Benchmark Personalizado

Tudo está preparado agora. Finalmente podemos executar nosso arnês de benchmark personalizado.

Terminal window
cargo bench

A saída tanto para o padrão quanto para um arquivo chamado results.json deve se parecer com isto:

{
"bench_play_game": {
"Current Blocks": {
"value": 0
},
"Current Bytes": {
"value": 0
},
"Max Blocks": {
"value": 1
},
"Max Bytes": {
"value": 9
},
"Total Blocks": {
"value": 100
},
"Total Bytes": {
"value": 662
}
}
}

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:

Terminal window
bencher run --file results.json "cargo bench"

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

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.

🤖 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.