Introdução
Rust é famosa por oferecer performance próxima a C e C++, mas escrever código Rust não garante automaticamente código rápido. A linguagem fornece as ferramentas e abstrações para alcançar alta performance, mas cabe ao desenvolvedor usá-las corretamente.
O princípio fundamental da otimização é: meça antes de otimizar. Otimizações prematuras baseadas em intuição frequentemente pioram a legibilidade do código sem ganho real de performance. Neste artigo, vamos explorar como identificar gargalos, aplicar otimizações comprovadas e usar as ferramentas do ecossistema Rust para extrair o máximo de desempenho do seu código.
O Problema: Otimização às Cegas
Muitos desenvolvedores cometem erros de performance sem perceber, ou otimizam as partes erradas do código.
Não Faça Isso: Alocações Desnecessárias
// ERRADO: Aloca uma nova String a cada iteração
fn processar_nomes(nomes: &[String]) -> Vec<String> {
let mut resultado = Vec::new();
for nome in nomes {
// .to_uppercase() cria uma nova String a cada chamada
let formatado = format!("Sr(a). {}", nome.to_uppercase());
resultado.push(formatado);
}
resultado
}
// ERRADO: Concatenação com + aloca repetidamente
fn construir_relatorio(itens: &[String]) -> String {
let mut relatorio = String::new();
for item in itens {
relatorio = relatorio + item + "\n"; // Nova alocação a cada +
}
relatorio
}
Não Faça Isso: Clonar sem Necessidade
// ERRADO: Clona o HashMap inteiro para uma simples busca
fn buscar_usuario(
banco: &std::collections::HashMap<u64, String>,
id: u64,
) -> Option<String> {
let copia = banco.clone(); // Clona TUDO para buscar UM item
copia.get(&id).cloned()
}
// ERRADO: Clona a struct inteira para acessar um campo
fn obter_nome(usuario: &Usuario) -> String {
let copia = usuario.clone();
copia.nome // Poderia simplesmente retornar uma referência
}
#[derive(Clone)]
struct Usuario {
nome: String,
email: String,
}
A Solução: Profile-Driven Optimization
Passo 1: Benchmarks com Criterion
Antes de otimizar, estabeleça uma baseline mensurável:
# Cargo.toml
[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }
[[bench]]
name = "meus_benchmarks"
harness = false
// benches/meus_benchmarks.rs
use criterion::{black_box, criterion_group, criterion_main, Criterion};
fn soma_iterador(dados: &[i64]) -> i64 {
dados.iter().sum()
}
fn soma_loop(dados: &[i64]) -> i64 {
let mut total = 0i64;
for &valor in dados {
total += valor;
}
total
}
fn soma_fold(dados: &[i64]) -> i64 {
dados.iter().fold(0i64, |acc, &x| acc + x)
}
fn benchmark_somas(c: &mut Criterion) {
let dados: Vec<i64> = (0..10_000).collect();
let mut group = c.benchmark_group("somas");
group.bench_function("iterador", |b| {
b.iter(|| soma_iterador(black_box(&dados)))
});
group.bench_function("loop", |b| {
b.iter(|| soma_loop(black_box(&dados)))
});
group.bench_function("fold", |b| {
b.iter(|| soma_fold(black_box(&dados)))
});
group.finish();
}
criterion_group!(benches, benchmark_somas);
criterion_main!(benches);
Execute com cargo bench para obter medições estatisticamente precisas.
Passo 2: Profiling com Flamegraph
Instale e use flamegraph para identificar onde o tempo é gasto:
# Instalar
cargo install flamegraph
# Gerar flamegraph (Linux)
cargo flamegraph --bin meu_programa
# Para benchmarks específicos
cargo flamegraph --bench meus_benchmarks -- --bench
No Cargo.toml, habilite símbolos de debug mesmo em release:
[profile.release]
debug = true # Permite profiling com nomes de funções
Faça Isso: Otimizações Comprovadas
String vs &str — Evite Alocações
// ERRADO: Aceita String, forçando o chamador a alocar
fn saudacao(nome: String) -> String {
format!("Olá, {nome}!")
}
// CORRETO: Aceita &str, funciona com String e &str sem alocação
fn saudacao(nome: &str) -> String {
format!("Olá, {nome}!")
}
fn main() {
// Ambos funcionam sem custo extra:
println!("{}", saudacao("Mundo")); // &str direto
println!("{}", saudacao(&String::from("Rust"))); // &String → &str
}
Para maior flexibilidade, use impl AsRef<str>:
fn saudacao(nome: impl AsRef<str>) -> String {
format!("Olá, {}!", nome.as_ref())
}
Vec::with_capacity — Pré-aloque Quando Souber o Tamanho
// ERRADO: Vec cresce dinamicamente, realocando múltiplas vezes
fn gerar_quadrados(n: usize) -> Vec<u64> {
let mut resultado = Vec::new(); // Capacidade inicial = 0
for i in 0..n {
resultado.push((i as u64) * (i as u64));
}
resultado
}
// CORRETO: Uma única alocação
fn gerar_quadrados(n: usize) -> Vec<u64> {
let mut resultado = Vec::with_capacity(n);
for i in 0..n {
resultado.push((i as u64) * (i as u64));
}
resultado
}
// MELHOR: Use iteradores — o compilador otimiza automaticamente
fn gerar_quadrados(n: usize) -> Vec<u64> {
(0..n as u64).map(|i| i * i).collect()
}
Iteradores vs Loops — Deixe o Compilador Otimizar
Iteradores em Rust são abstrações de custo zero (zero-cost abstractions). O compilador os transforma em código equivalente a loops manuais otimizados:
// Funcional com iteradores — idiomático e rápido
fn processar_vendas(vendas: &[f64]) -> f64 {
vendas
.iter()
.filter(|&&v| v > 100.0) // Filtra vendas acima de R$100
.map(|&v| v * 0.1) // Calcula 10% de comissão
.sum() // Soma tudo
}
// Equivalente com loop — mesma performance, menos idiomático
fn processar_vendas_loop(vendas: &[f64]) -> f64 {
let mut total = 0.0;
for &v in vendas {
if v > 100.0 {
total += v * 0.1;
}
}
total
}
Ambas as versões geram código de máquina praticamente idêntico, mas a versão com iteradores é mais composível e menos propensa a erros.
Evite Alocações em Loops
use std::fmt::Write;
// ERRADO: Aloca uma nova String a cada iteração com format!
fn gerar_csv_ruim(dados: &[(String, f64)]) -> String {
let mut csv = String::new();
for (nome, valor) in dados {
csv += &format!("{nome},{valor}\n"); // Aloca e descarta a cada iteração
}
csv
}
// CORRETO: Usa write! diretamente no buffer existente
fn gerar_csv_bom(dados: &[(String, f64)]) -> String {
let mut csv = String::with_capacity(dados.len() * 30); // Estimativa
for (nome, valor) in dados {
writeln!(csv, "{nome},{valor}").unwrap();
}
csv
}
Cow — Clone on Write para Flexibilidade
use std::borrow::Cow;
// Cow evita alocação quando não é necessária
fn normalizar_nome(nome: &str) -> Cow<'_, str> {
if nome.contains(char::is_uppercase) {
// Somente aloca quando precisa transformar
Cow::Owned(nome.to_lowercase())
} else {
// Sem alocação — retorna referência ao original
Cow::Borrowed(nome)
}
}
fn main() {
let n1 = normalizar_nome("maria"); // Cow::Borrowed — zero alocação
let n2 = normalizar_nome("MARIA"); // Cow::Owned — aloca apenas aqui
println!("{n1}, {n2}");
}
Flags do Compilador para Performance
Cargo.toml — Profile de Release Otimizado
[profile.release]
opt-level = 3 # Otimização máxima
lto = "fat" # Link-Time Optimization agressiva
codegen-units = 1 # Compilação single-threaded para melhor otimização
panic = "abort" # Menor binário (sem unwind tables)
strip = true # Remove símbolos de debug
[profile.release-with-debug]
inherits = "release"
debug = true # Para profiling
strip = false
Impacto típico:
| Flag | Impacto em Performance | Impacto na Compilação |
|---|---|---|
lto = "fat" | +10-20% mais rápido | 2-3x mais lento |
codegen-units = 1 | +5-10% mais rápido | 2x mais lento |
opt-level = 3 | Máxima otimização | Mais lento |
panic = "abort" | Binário menor | Neutro |
Target-Specific Optimizations
# Compilar para a CPU específica da máquina
RUSTFLAGS="-C target-cpu=native" cargo build --release
# Verificar quais features da CPU estão disponíveis
rustc --print target-features
Armadilhas Comuns
1. Medir em Modo Debug
# ERRADO: cargo run compila em modo debug (sem otimizações)
cargo run
# CORRETO: Sempre use --release para benchmarks
cargo run --release
A diferença pode ser de 10-100x entre debug e release.
2. Usar collect() Intermediários
// ERRADO: Coleta intermediária desnecessária
let resultado: i64 = dados
.iter()
.filter(|&&x| x > 0)
.collect::<Vec<_>>() // Alocação desnecessária!
.iter()
.map(|&&x| x * 2)
.sum();
// CORRETO: Chain sem intermediários
let resultado: i64 = dados
.iter()
.filter(|&&x| x > 0)
.map(|&x| x * 2)
.sum();
3. HashMap quando Vec Basta
use std::collections::HashMap;
// ERRADO: HashMap para poucas entradas indexadas por inteiro
let mut mapa: HashMap<usize, String> = HashMap::new();
for i in 0..100 {
mapa.insert(i, format!("item_{i}"));
}
// CORRETO: Vec é muito mais rápido para acesso por índice
let vetor: Vec<String> = (0..100).map(|i| format!("item_{i}")).collect();
// vetor[42] é O(1) sem hashing
4. Serialização/Desserialização em Hot Path
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize)]
struct Evento {
tipo: String,
timestamp: u64,
dados: Vec<u8>,
}
// ERRADO: Serializar/desserializar JSON em um loop quente
fn processar_eventos_ruim(eventos_json: &[String]) -> usize {
let mut total = 0;
for json_str in eventos_json {
let evento: Evento = serde_json::from_str(json_str).unwrap();
total += evento.dados.len();
}
total
}
// MELHOR: Desserializar uma vez, processar em memória
fn processar_eventos_bom(eventos: &[Evento]) -> usize {
eventos.iter().map(|e| e.dados.len()).sum()
}
Exemplos do Mundo Real
Processamento de Dados em Lote
use std::io::{self, BufRead, BufWriter, Write};
use std::fs::File;
fn processar_arquivo_grande(entrada: &str, saida: &str) -> io::Result<()> {
let arquivo_entrada = File::open(entrada)?;
let leitor = io::BufReader::with_capacity(64 * 1024, arquivo_entrada); // Buffer de 64KB
let arquivo_saida = File::create(saida)?;
let mut escritor = BufWriter::with_capacity(64 * 1024, arquivo_saida);
for linha in leitor.lines() {
let linha = linha?;
if let Some((chave, valor)) = linha.split_once(',') {
if let Ok(num) = valor.trim().parse::<f64>() {
if num > 1000.0 {
writeln!(escritor, "{chave},{num}")?;
}
}
}
}
escritor.flush()?;
Ok(())
}
As técnicas aplicadas aqui incluem:
- BufReader/BufWriter com buffer grande para reduzir syscalls
- split_once em vez de
split().collect()para evitar alocação - Processamento streaming em vez de carregar tudo na memória
Paralelismo com Rayon
# Cargo.toml
[dependencies]
rayon = "1"
use rayon::prelude::*;
fn calcular_media_paralela(dados: &[f64]) -> f64 {
let soma: f64 = dados.par_iter().sum(); // Paraleliza automaticamente
soma / dados.len() as f64
}
fn processar_imagens(caminhos: &[String]) -> Vec<u64> {
caminhos
.par_iter()
.map(|caminho| {
// Cada imagem é processada em uma thread diferente
let bytes = std::fs::read(caminho).unwrap_or_default();
bytes.len() as u64
})
.collect()
}
Basta trocar .iter() por .par_iter() para paralelizar operações CPU-bound com o Rayon.
Checklist de Otimização
- Meça primeiro — Use
criterionouflamegraphantes de qualquer mudança - Compile em release — Nunca meça performance em modo debug
- Pré-aloque —
Vec::with_capacity,String::with_capacity - Prefira referências —
&strsobreString,&[T]sobreVec<T> - Use iteradores — Composíveis, legíveis e zero-cost
- Minimize alocações em loops — Reutilize buffers
- Ative LTO —
lto = "fat"para builds de release - Considere
Cow— Quando a alocação é condicional - Use Rayon — Para paralelizar operações CPU-bound
- Perfil target-cpu —
RUSTFLAGS="-C target-cpu=native"para a máquina alvo
Veja Também
- Receita: Medir Tempo de Execução — Como medir a performance do seu código
- Boas Práticas de Error Handling — Trate erros sem sacrificar performance
- CI/CD para Projetos Rust — Automatize benchmarks no pipeline
- Logging e Observabilidade — Monitore a performance em produção
- Rust vs Go: Qual Escolher — Comparação de performance entre Rust e Go