Desempenho em Rust
O Rust tem fama de ser rápido, e essa fama é merecida. Mas “o Rust é rápido” não quer dizer que todo programa em Rust é rápido. Um algoritmo lento é lento em qualquer linguagem, e alguns hábitos podem custar caro sem você perceber.
A boa notícia: a maioria dos grandes ganhos é simples. Compile em modo release. Meça antes de mudar qualquer coisa. Não copie dados que você só precisa ler. Escolha a coleção certa. Esta página mostra cada um desses pontos, com medições reais onde elas ajudam. Pense nisso como regular uma bicicleta: primeiro veja se os pneus estão cheios (modo release), depois se preocupe com peças mais leves.
Passo zero: medir com Instant
Seção intitulada “Passo zero: medir com Instant”Antes de deixar algo mais rápido, você precisa saber o quanto ele é lento. std::time::Instant é um cronômetro que já vem na biblioteca padrão. Chame Instant::now() antes do trabalho e .elapsed() depois:
use std::time::Instant;
fn main() { let start = Instant::now();
// Gera 5 milhões de números pseudoaleatórios com um xorshift simples. let mut x: u64 = 12345; let mut numbers = Vec::with_capacity(5_000_000); for _ in 0..5_000_000 { x ^= x << 13; x ^= x >> 7; x ^= x << 17; numbers.push(x % 1_000_000); }
numbers.sort(); let sum: u64 = numbers.iter().take(1000).sum();
println!("os 1000 menores somam {sum}"); println!("levou {:?}", start.elapsed());}O formato {:?} mostra uma Duration de um jeito fácil de ler, como 250.271745ms. Não tem botão “Experimente você mesmo” aqui de propósito: os tempos mudam a cada execução e em cada máquina, então não dá para comparar com uma saída fixa. Rode no seu computador com cargo run.
Build de debug vs. build de release
Seção intitulada “Build de debug vs. build de release”Esta é a dica de desempenho mais importante do Rust, e é só uma flag.
cargo run # build de debug: compila rápido, roda devagarcargo run --release # build de release: compila mais devagar, roda muito mais rápidoUm build de debug pula a maior parte das otimizações, para compilar rápido e ser fácil de depurar. Ele também verifica overflow de inteiros. Um build de release liga o otimizador por completo.
Aqui vai uma medição de exemplo, rodando o programa acima três vezes em cada modo, na máquina que usamos para escrever esta página (um servidor na nuvem com CPU Intel Xeon). Os seus números vão ser diferentes; o que importa é a diferença entre eles:
debug build: took 5.02s, 4.27s, 4.83srelease build: took 250ms, 297ms, 265msIsso é cerca de 15 a 20 vezes mais rápido, com uma única flag. A diferença depende muito do código: laços e iteradores são os que mais ganham. Mas ela nunca é pequena o bastante para ser ignorada.
Então a regra é simples: nunca julgue a velocidade por um build de debug. Se alguém diz “o Rust é lento” depois de medir o tempo com cargo run, quase sempre é por isso.
Pegue emprestado em vez de clonar
Seção intitulada “Pegue emprestado em vez de clonar”.clone() faz uma cópia completa. Para uma String ou um Vec, isso significa uma nova alocação no heap e copiar cada byte. Às vezes você precisa de uma cópia, mas muitas vezes você só precisa ler os dados, e um borrowing (empréstimo) não custa nada.
O caso mais comum são os parâmetros de funções. Se uma função só lê uma string, receba &str, e não String:
fn count_long_words(text: &str, min_len: usize) -> usize { text.split_whitespace().filter(|w| w.len() >= min_len).count()}
fn main() { let owned = String::from("the quick brown fox jumps over the lazy dog");
// &String vira &str automaticamente, sem precisar copiar. println!("{}", count_long_words(&owned, 5));
// Uma string literal já é um &str. println!("{}", count_long_words("borrowing is cheap and cloning is not", 6));
// Nunca entregamos `owned`, então ela continua utilizável. println!("{owned}");}Saída:
32the quick brown fox jumps over the lazy dogAs frases ficaram em inglês porque o resultado depende do tamanho das palavras em bytes (w.len()), e letras acentuadas ocupam 2 bytes em UTF-8.
Um parâmetro &str aceita tanto uma String (emprestada com &) quanto uma string literal. Um parâmetro String obrigaria quem chama a abrir mão da sua string ou a cloná-la. A mesma ideia vale para listas: receba &[T] em vez de Vec<T>. Veja Borrowing e Slices para os detalhes.
Fique de olho também em clones dentro de laços. for name in names.clone() copia a lista inteira só para olhar para ela. for name in &names pega emprestado de graça.
Aloque uma vez, não várias
Seção intitulada “Aloque uma vez, não várias”Quando um Vec fica sem espaço, ele aloca um bloco maior e move tudo para lá. Se você sabe o tamanho de antemão, Vec::with_capacity reserva tudo de uma vez. Este programa conta quantas vezes cada Vec precisou crescer ao receber 1000 números:
fn count_growths(mut v: Vec<u32>, n: u32) -> u32 { let mut growths = 0; let mut cap = v.capacity(); for i in 0..n { v.push(i); if v.capacity() != cap { growths += 1; cap = v.capacity(); } } growths}
fn main() { println!("Vec::new(): cresceu {} vezes", count_growths(Vec::new(), 1000)); println!("Vec::with_capacity(): cresceu {} vezes", count_growths(Vec::with_capacity(1000), 1000));}Saída:
Vec::new(): cresceu 9 vezesVec::with_capacity(): cresceu 0 vezesCrescer nove vezes não é um desastre: o Vec dobra de tamanho a cada vez, então continua barato. Mas, em um laço muito executado que cria milhões de vetores, isso se acumula. (O padrão exato de crescimento é um detalhe interno da biblioteca padrão e pode mudar.)
Mais fácil ainda: deixe collect() e join() fazerem o trabalho. Muitas vezes eles já sabem o tamanho final e alocam uma vez só:
fn main() { // collect() monta o Vec de uma vez. let squares: Vec<u64> = (1..=5).map(|n| n * n).collect(); println!("{squares:?}");
// join() calcula o tamanho total primeiro e depois copia uma vez só. let colors = ["vermelho", "verde", "azul"]; println!("{}", colors.join(", "));}Saída:
[1, 4, 9, 16, 25]vermelho, verde, azulIteradores têm custo zero
Seção intitulada “Iteradores têm custo zero”Você pode achar que .iter().filter().map().sum() é mais lento que um laço simples, porque parece um monte de chamadas de função. Não é. Em um build de release, o compilador coloca todas essas pequenas closures (funções anônimas) direto no código (inlining), e você ganha o mesmo código de máquina enxuto de um laço escrito à mão. O Rust chama isso de abstração de custo zero (zero-cost abstraction): você não paga nada pelo estilo mais bonito.
fn sum_even_squares_loop(nums: &[u64]) -> u64 { let mut total = 0; for i in 0..nums.len() { if nums[i] % 2 == 0 { total += nums[i] * nums[i]; } } total}
fn sum_even_squares_iter(nums: &[u64]) -> u64 { nums.iter().filter(|&&n| n % 2 == 0).map(|&n| n * n).sum()}
fn main() { let nums: Vec<u64> = (1..=10).collect(); println!("laço: {}", sum_even_squares_loop(&nums)); println!("iter: {}", sum_even_squares_iter(&nums));}Saída:
laço: 220iter: 220A versão com iterador pode até ser um pouco mais rápida. Com nums[i], o Rust verifica a cada acesso se i está dentro da slice. O iterador já sabe que não pode sair dos limites, então essas verificações somem. Use a forma que for mais fácil de ler, que geralmente é o iterador.
Escolha a coleção certa
Seção intitulada “Escolha a coleção certa”Escolher a estrutura de dados certa muitas vezes vale mais do que qualquer ajuste fino. Um exemplo clássico: perguntar muitas vezes “este item está na minha lista?”.
Vec::containsolha cada elemento, um por um. As buscas ficam mais lentas conforme a lista cresce.HashSet::contains(eHashMap::get) vai quase direto para a resposta. As buscas continuam rápidas, não importa o tamanho.
use std::collections::HashSet;
fn main() { let banned: Vec<u32> = (0..20_000).map(|n| n * 3).collect(); let queries: Vec<u32> = (0..20_000).collect();
// Lento para listas grandes: cada contains() percorre o Vec inteiro. let hits_vec = queries.iter().filter(|q| banned.contains(q)).count();
// Rápido: monta um HashSet uma vez, e cada busca é ligeira. let banned_set: HashSet<u32> = banned.iter().copied().collect(); let hits_set = queries.iter().filter(|q| banned_set.contains(q)).count();
println!("Vec encontrou {hits_vec}, HashSet encontrou {hits_set}");}Saída:
Vec encontrou 6667, HashSet encontrou 6667A mesma resposta, com um custo bem diferente. Quando colocamos cronômetros Instant em volta de cada parte e rodamos um build de release, esta foi uma medição de exemplo na nossa máquina:
Vec: 6667 hits in 27.97msHashSet: 6667 hits in 551.60µsCerca de 50 vezes mais rápido, e isso incluindo o tempo para montar o conjunto. Já para uma lista de 10 itens, um Vec está ótimo e pode até ganhar. Saiba mais nas páginas de HashSet e HashMap.
Faça profiling antes de otimizar
Seção intitulada “Faça profiling antes de otimizar”Programadores são famosos por errar o palpite de onde o tempo é gasto. Então não chute: faça profiling. Um profiler observa o seu programa rodando e mostra quais funções levam mais tempo. Normalmente são um ou dois pontos críticos, e o resto do código não faz diferença nenhuma.
Dois profilers populares e gratuitos que funcionam com Rust:
# flamegraph: desenha uma imagem de onde o tempo é gasto (Linux, macOS)cargo install flamegraphcargo flamegraph
# samply: abre os resultados no Firefox Profiler, no seu navegadorcargo install samplycargo build --releasesamply record ./target/release/my_appOs profilers precisam de símbolos de depuração para mostrar os nomes das funções. Adicione isto ao Cargo.toml para que os builds de release os mantenham (o código não fica mais lento, só o arquivo fica maior):
[profile.release]debug = trueBenchmarks com criterion
Seção intitulada “Benchmarks com criterion”Medir o tempo de algo uma vez só com Instant dá resultados instáveis. Um benchmark roda o código muitas vezes e usa estatística para chegar a um número confiável. O #[bench] embutido no Rust só funciona na versão nightly, então a maioria das pessoas usa o crate (pacote) criterion (em inglês). Configure assim:
[dev-dependencies]criterion = "0.8"
[[bench]]name = "fib_bench"harness = falseColoque o código que você quer testar na sua biblioteca (src/lib.rs) e depois escreva o benchmark em benches/fib_bench.rs:
use criterion::{criterion_group, criterion_main, Criterion};use my_crate::fibonacci;use std::hint::black_box;
fn bench_fib(c: &mut Criterion) { c.bench_function("fibonacci 20", |b| b.iter(|| fibonacci(black_box(20))));}
criterion_group!(benches, bench_fib);criterion_main!(benches);O black_box impede o compilador de ser esperto demais e calcular a resposta antes da hora, o que deixaria o benchmark sem sentido. Rode com:
cargo benchUm resultado de exemplo da nossa máquina (o criterion mostra uma estimativa baixa, a melhor estimativa e uma estimativa alta):
fibonacci 20 time: [24.417 µs 25.079 µs 25.737 µs]Rode de novo depois de uma mudança, e o criterion diz se o código ficou mais rápido ou mais lento, e se a diferença é real ou só ruído.
Configurações do perfil de release
Seção intitulada “Configurações do perfil de release”As configurações padrão de release são boas. Quando você precisa de um pouco mais, estes são os ajustes mais comuns no Cargo.toml:
[profile.release]opt-level = 3 # o padrão do release; "s" ou "z" otimizam para tamanholto = true # otimização em tempo de link: otimiza entre todos os crates (build mais lento)codegen-units = 1 # uma unidade = mais chances de otimizar, mas compila mais devagarpanic = "abort" # pula o código de unwinding; binário menor, mas não dá para capturar panicsNormalmente esses ajustes dão alguns por cento de ganho, não milagres, e deixam os builds mais lentos. Teste um de cada vez e meça cada um. lto = "thin" é um meio-termo popular, que compila mais rápido que lto = true.
Erros comuns
Seção intitulada “Erros comuns”Medir o tempo de um build de debug. Já falamos disso, mas é o erro número um: sempre meça com --release.
Clonar só para agradar o compilador. Digamos que uma função recebe uma String, e você a chama com uma referência:
fn shout(text: String) -> String { text.to_uppercase()}
fn main() { let name = String::from("ferris"); let loud = shout(&name); println!("{loud} {name}");}O compilador diz:
error[E0308]: mismatched types --> src/main.rs:7:22 |7 | let loud = shout(&name); | ----- ^^^^^ expected `String`, found `&String` | | | arguments to this function are incorrectA mensagem quer dizer “tipos incompatíveis”: a função esperava uma String, mas recebeu uma &String. Dá vontade de escrever shout(name.clone()). Funciona, mas copia a string a cada chamada. A solução melhor é mudar o parâmetro para text: &str. Aí shout(&name) compila e nada é copiado.
println! em um laço muito executado. Cada println! trava o stdout e escreve a linha na hora. Para algumas linhas, tudo bem. Para um milhão, fica lento. Envolva o stdout em um BufWriter para que ele escreva em blocos grandes:
use std::io::{self, BufWriter, Write};
fn main() { let mut out = BufWriter::new(io::stdout().lock()); for i in 1..=5 { writeln!(out, "linha {i}").unwrap(); }}Saída:
linha 1linha 2linha 3linha 4linha 5Na nossa medição de exemplo, imprimir um milhão de linhas em um arquivo levou cerca de 570–670ms com println! e cerca de 40–60ms com BufWriter (build de release).
- Sempre meça a velocidade com
cargo run --release: builds de debug podem ser muitas vezes mais lentos. - Use
Instant::now()e.elapsed()para medições rápidas, e o criterion para benchmarks confiáveis. - Pegue emprestado (
&str,&[T]) em vez de clonar quando você só precisa ler os dados. - Aloque uma vez:
Vec::with_capacity,collect()ejoin()evitam crescimentos repetidos. - Iteradores têm custo zero; escolha um
HashSet/HashMappara muitas buscas. - Faça profiling primeiro e depois otimize os pontos críticos que o profiler mostrar.
Teste o que aprendeu
Seção intitulada “Teste o que aprendeu”Teste o que aprendeu
1. Seu programa parece lento quando você roda com cargo run. O que você deve tentar primeiro?
2. Uma função só lê uma string. Qual tipo de parâmetro evita cópias desnecessárias?
3. Você precisa verificar se 50.000 IDs estão em uma lista de 50.000 IDs bloqueados. Qual é a melhor escolha para a lista de bloqueados?
4. O que significa "iteradores têm custo zero"?
5. Por que um benchmark com criterion usa black_box?
Pontuação: 0 / 5
Para saber mais
Seção intitulada “Para saber mais”- The Rust Performance Book (em inglês): um guia curto e prático sobre configurações de build, profiling e melhorias de velocidade comuns.
- Comparing Performance: Loops vs. Iterators — The Rust Book (em inglês): por que os iteradores viram código rápido.
- Profiles — The Cargo Book (em inglês): todas as configurações de
[profile], com o valor padrão de cada uma. std::time::Instant(em inglês): o cronômetro da biblioteca padrão.- Documentação do criterion (em inglês): o crate de benchmarks usado acima.