Bem-vindo à aula sobre benchmark e profiling em Rust. Quando escrevemos software, muitas vezes precisamos garantir que ele seja rápido e eficiente. No entanto, otimizar sem medir é como atirar no escuro: você pode gastar tempo em lugares que não importam. Nesta aula, vamos aprender a usar ferramentas como criterion para criar benchmarks precisos e confiáveis, e ferramentas de profiling para identificar gargalos reais. Vamos também discutir a importância de evitar otimizações prematuras e como tomar decisões baseadas em dados.

O ecossistema Rust oferece um suporte excelente para análise de desempenho. Com o crate criterion, você pode escrever benchmarks que são executados de forma isolada, com estatísticas robustas e detecção de regressões. Para profiling, ferramentas como perf (no Linux) e flamegraph fornecem visualizações claras de onde o tempo está sendo gasto. Vamos explorar cada uma dessas ferramentas e como integrá-las ao seu fluxo de trabalho.

criterion (visão geral)

O crate criterion é a biblioteca padrão de fato para benchmarks em Rust. Ele oferece uma API simples para definir benchmarks, coleta estatísticas detalhadas (como média, mediana, desvio padrão) e até mesmo detecta regressões de desempenho ao longo do tempo. Diferente de benchmarks ingênuos que apenas medem o tempo de execução, o criterion executa cada benchmark várias vezes, aquece o código e usa técnicas estatísticas para fornecer resultados confiáveis.

Para usar o criterion, você adiciona uma seção [[bench]] no seu Cargo.toml e cria um arquivo de benchmark em benches/. O crate também se integra ao cargo bench, facilitando a execução e comparação de resultados. Além disso, ele gera relatórios em HTML com gráficos, o que ajuda a visualizar a evolução do desempenho.

Aqui está um exemplo simples de benchmark usando criterion:

// benches/meu_benchmark.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};

fn fibonacci(n: u64) -> u64 {
    match n {
        0 => 1,
        1 => 1,
        _ => fibonacci(n-1) + fibonacci(n-2),
    }
}

fn criterion_benchmark(c: &mut Criterion) {
    c.bench_function("fib 20", |b| b.iter(|| fibonacci(black_box(20))));
}

criterion_group!(benches, criterion_benchmark);
criterion_main!(benches);

No exemplo, usamos black_box para evitar que o compilador otimize a chamada para um valor constante. O criterion executa a closure várias vezes e coleta estatísticas. Para rodar, basta executar cargo bench.

Medindo

Medir o desempenho corretamente é crucial. Antes de otimizar, você precisa saber onde estão os gargalos. O criterion fornece medições precisas, mas há outras técnicas: usar std::time::Instant para medir trechos de código, ou usar ferramentas de profiling para ver a distribuição de tempo entre funções.

Para medições simples, você pode usar std::time::Instant e medir o tempo de execução de uma função. No entanto, isso pode ser influenciado por variações do sistema, então é recomendado usar criterion para benchmarks formais. Exemplo:

use std::time::Instant;

fn main() {
    let start = Instant::now();
    // código a ser medido
    let _ = fibonacci(20);
    let duration = start.elapsed();
    println!("Tempo: {:?}", duration);
}

Além de medir o tempo, você pode medir o uso de memória ou o número de alocações. Para isso, ferramentas como heaptrack ou a feature alloc podem ajudar. Mas em Rust, muitas vezes a preocupação é com a eficiência de CPU, e o profiling é mais útil.

Uma boa prática é medir antes e depois de cada otimização, mantendo um histórico de resultados. O criterion permite salvar resultados em arquivos e comparar com versões anteriores, o que é excelente para detectar regressões.

Evitando otimizações prematuras

Donald Knuth disse: "A otimização prematura é a raiz de todo o mal". Em Rust, isso é especialmente relevante porque o compilador já faz muitas otimizações em nível de máquina. Antes de otimizar, você deve ter um benchmark que mostre que o código é lento e que o gargalo está onde você pensa.

Comece escrevendo código claro e idiomático, sem micro-otimizações. Use estruturas de dados adequadas e algoritmos eficientes. Depois, meça. Se o desempenho for insatisfatório, use profiling para identificar os pontos quentes. Só então otimize, e sempre valide que a otimização realmente melhorou o desempenho.

Um erro comum é otimizar partes do código que não são executadas com frequência. Por exemplo, um loop que roda 100 vezes não precisa ser otimizado se o programa gasta 90% do tempo em uma função de parsing. O profiling ajuda a evitar isso, mostrando onde o tempo é realmente gasto.

Outra prática é usar o compilador a seu favor. O Rust já otimiza em modo release (cargo build --release). Certifique-se de sempre medir com otimizações ativadas. Além disso, você pode ajustar o perfil de release no Cargo.toml para habilitar mais otimizações, como LTO (Link-Time Optimization) e código de máquina nativo.

Ferramentas

Além do criterion, existem várias ferramentas para profiling e análise de desempenho. No Linux, o perf é uma ferramenta poderosa que pode amostrar o programa e mostrar onde o tempo é gasto. Você pode gerar um flamegraph (gráfico de chamas) com a ferramenta flamegraph, que visualiza as pilhas de chamadas.

Para usar perf com Rust, compile em release e execute:

perf record ./target/release/meu_programa
perf report

Para gerar um flamegraph, você pode usar a ferramenta flamegraph (instalável via cargo install flamegraph). Basta executar:

cargo flamegraph

Isso gera um SVG interativo que mostra as chamadas de função e o tempo gasto em cada uma. Outra ferramenta é o valgrind com a ferramenta callgrind para análise de cache e contagem de instruções, embora seja mais lento.

No Windows, você pode usar o Visual Studio Profiler ou o wpr (Windows Performance Recorder). No macOS, o Instruments.app é uma ótima opção. Além disso, o crate pprof pode gerar perfis em formato pprof, que podem ser visualizados com ferramentas como pprof do Google ou o go tool pprof.

É importante lembrar que profiling em modo debug não é representativo. Sempre use releases otimizadas. Você também pode usar o #[inline] e outros atributos para ajudar o compilador, mas somente após identificar gargalos.

Boas práticas e observações finais

Ao trabalhar com desempenho, siga estas boas práticas:

  • Meça antes de otimizar: use benchmarks e profiling para guiar suas decisões.
  • Use criterion para benchmarks formais e compare resultados ao longo do tempo.
  • Perfile em modo release para ver o comportamento real do programa.
  • Evite micro-otimizações: foque em algoritmos e estruturas de dados.
  • Documente suas otimizações e os resultados obtidos.

Lembre-se de que desempenho é uma feature, mas não deve comprometer a clareza do código. Com as ferramentas certas, você pode manter seu código limpo e rápido.

Referências

Exercícios

  1. Crie um benchmark com criterion para a função soma_vetor que soma todos os elementos de um vetor de u64.

    ✓ Resposta:
    // benches/soma_benchmark.rs
    use criterion::{black_box, criterion_group, criterion_main, Criterion};
    
    fn soma_vetor(v: &[u64]) -> u64 {
        v.iter().sum()
    }
    
    fn criterion_benchmark(c: &mut Criterion) {
        let v: Vec<u64> = (0..1000).collect();
        c.bench_function("soma_vetor 1000", |b| b.iter(|| soma_vetor(black_box(&v))));
    }
    
    criterion_group!(benches, criterion_benchmark);
    criterion_main!(benches);
    
  2. Explique por que usar black_box é importante em benchmarks e dê um exemplo de problema que ocorreria sem ele.

    ✓ Resposta: Sem black_box, o compilador pode otimizar a chamada para uma constante se o argumento for conhecido em tempo de compilação. Por exemplo, fibonacci(20) poderia ser calculado durante a compilação e o benchmark mediria apenas a leitura de uma constante, não a execução real da função. black_box impede isso, forçando o compilador a tratar o valor como opaco e executar o código de fato.
  3. Escreva um código que use std::time::Instant para medir o tempo de execução de uma função que ordena um vetor de 10.000 inteiros aleatórios.

    ✓ Resposta:
    use std::time::Instant;
    
    fn main() {
        let mut v: Vec<i32> = (0..10000).collect();
        // Embaralhar (usando um gerador simples)
        for i in 0..v.len() {
            let j = (i * 7919) % v.len(); // pseudo-aleatório
            v.swap(i, j);
        }
        let start = Instant::now();
        v.sort();
        let duration = start.elapsed();
        println!("Tempo de ordenação: {:?}", duration);
    }
    
  4. Qual é a diferença entre benchmark e profiling? Quando você usaria cada um?

    ✓ Resposta: Benchmark mede o tempo de execução de uma função ou trecho de código de forma isolada, geralmente para comparar implementações ou detectar regressões. Profiling analisa o programa em execução para identificar onde o tempo é gasto (quais funções chamam quais e por quanto tempo). Use benchmark para testar desempenho de uma função específica; use profiling para descobrir gargalos em um programa completo.
  5. Como você usaria a ferramenta cargo flamegraph para analisar um programa Rust? Descreva os passos e o que você procura no gráfico gerado.

    ✓ Resposta: Primeiro, instale a ferramenta com cargo install flamegraph. Depois, certifique-se de que o programa está compilado em release (cargo build --release). Execute cargo flamegraph na raiz do projeto. Isso executará o programa (ou o binário padrão) e gerará um arquivo flamegraph.svg. Abra o SVG em um navegador: as barras horizontais representam funções, e a largura indica o tempo de amostragem. Procure por barras largas no topo (funções que estão ativas por muito tempo) e por padrões de chamadas profundas que podem indicar recursão excessiva. Use isso para identificar funções quentes que merecem otimização.