Otimização de Performance em Rust: Guia | Rust Brasil

Guia de otimização de performance em Rust: benchmarks, profiling, SIMD, cache-friendly code e dicas práticas.

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:

FlagImpacto em PerformanceImpacto na Compilação
lto = "fat"+10-20% mais rápido2-3x mais lento
codegen-units = 1+5-10% mais rápido2x mais lento
opt-level = 3Máxima otimizaçãoMais lento
panic = "abort"Binário menorNeutro

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

  1. Meça primeiro — Use criterion ou flamegraph antes de qualquer mudança
  2. Compile em release — Nunca meça performance em modo debug
  3. Pré-aloqueVec::with_capacity, String::with_capacity
  4. Prefira referências&str sobre String, &[T] sobre Vec<T>
  5. Use iteradores — Composíveis, legíveis e zero-cost
  6. Minimize alocações em loops — Reutilize buffers
  7. Ative LTOlto = "fat" para builds de release
  8. Considere Cow — Quando a alocação é condicional
  9. Use Rayon — Para paralelizar operações CPU-bound
  10. Perfil target-cpuRUSTFLAGS="-C target-cpu=native" para a máquina alvo

Veja Também