Profiling en Rust: cargo-flamegraph, perf y benchmarks con Criterion

Rust es rápido por diseño, pero eso no significa que todo el código Rust sea óptimo. Un algoritmo cuadrático en Rust sigue siendo cuadrático. Una allocación innecesaria dentro de un loop sigue siendo costosa. El profiling es la forma de encontrar esos cuellos de botella con datos reales en lugar de suposiciones.

La regla de oro: mide primero, optimiza después. El profiling te dice qué optimizar. Sin eso, optimizar partes del código que no son el cuello de botella es tiempo perdido.

Criterion.rs: benchmarks estadísticos

El framework de benchmarks de Rust incorporado en cargo bench es básico. Criterion.rs ofrece benchmarks estadísticos robustos: mide la varianza, detecta si el rendimiento ha cambiado entre ejecuciones y genera gráficas HTML.

[dev-dependencies]
criterion = { version = "0.5", features = ["html_reports"] }

[[bench]]
name = "mi_benchmark"
harness = false

Archivo benches/mi_benchmark.rs:

use criterion::{black_box, criterion_group, criterion_main, Criterion, BenchmarkId};

fn suma_iterativa(n: u64) -> u64 {
    (1..=n).sum()
}

fn suma_formula(n: u64) -> u64 {
    n * (n + 1) / 2
}

fn bench_suma(c: &mut Criterion) {
    let mut grupo = c.benchmark_group("suma");

    for n in [100u64, 1_000, 10_000, 100_000].iter() {
        grupo.bench_with_input(BenchmarkId::new("iterativa", n), n, |b, n| {
            b.iter(|| suma_iterativa(black_box(*n)))
        });

        grupo.bench_with_input(BenchmarkId::new("formula", n), n, |b, n| {
            b.iter(|| suma_formula(black_box(*n)))
        });
    }

    grupo.finish();
}

criterion_group!(benches, bench_suma);
criterion_main!(benches);
cargo bench

La función black_box() evita que el compilador optimice el cálculo fuera del benchmark. Sin ella, podría calcular el resultado en tiempo de compilación y el benchmark no mediría nada real.

Criterion genera reportes HTML en target/criterion/ con gráficas de distribución de tiempos y comparativas entre ejecuciones anteriores.

cargo-flamegraph: visualizar dónde va el tiempo

Un flamegraph muestra el stack de llamadas del programa y cuánto tiempo se pasa en cada función. Es la herramienta más visual para encontrar cuellos de botella:

cargo install flamegraph

En Linux (requiere perf):

# Generar flamegraph de tu binario
cargo flamegraph --bin mi_programa

# Generar flamegraph de un benchmark de Criterion
cargo flamegraph --bench mi_benchmark -- --bench

Esto genera un fichero flamegraph.svg que puedes abrir en el navegador. Las barras horizontales más anchas son las funciones donde más tiempo se pasa. Las barras apiladas muestran el call stack.

perf stat: estadísticas del sistema

Para una visión rápida del rendimiento a nivel de CPU:

cargo build --release
perf stat ./target/release/mi_programa

Muestra métricas como:

  • Tiempo de CPU en modo usuario y kernel.
  • Número de instrucciones y ciclos por instrucción (IPC).
  • Cache misses de L1 y LLC.
  • Branch mispredictions.

Un IPC alto (cerca de 4 en CPUs modernas) indica código eficiente. Un IPC bajo suele indicar muchos cache misses o branch mispredictions.

DHAT: profiling de memoria heap

DHAT (Dynamic Heap Analysis Tool) es parte de Valgrind y analiza los patrones de uso del heap: qué funciones allotan más, qué allocaciones son de corta vida y cuáles persisten.

# Compilar con información de debug (necesario para símbolos)
cargo build --profile profiling

# Perfil en Cargo.toml:
[profile.profiling]
inherits = "release"
debug = true
valgrind --tool=dhat ./target/profiling/mi_programa
# Genera dhat.out.PID

# Abrir el viewer de DHAT:
# https://nnethercote.github.io/dh_view/dh_view.html

En Rust puro, también puedes usar el crate dhat para profiling de heap sin Valgrind:

[dev-dependencies]
dhat = "0.3"
#[cfg(test)]
mod tests {
    use dhat::{Dhat, DhatAlloc};

    #[global_allocator]
    static ALLOC: DhatAlloc = DhatAlloc;

    #[test]
    fn test_uso_memoria() {
        let _dhat = Dhat::start_heap_profiling();
        // ... código a analizar ...
        // Al final del test, DHAT imprime el reporte
    }
}

cargo-asm: verificar el assembly generado

Para optimizaciones de bajo nivel, a veces quieres verificar qué assembly genera el compilador:

cargo install cargo-asm
cargo asm --release mi_crate::mi_modulo::mi_funcion

Esto es útil para verificar que el compilador vectorizó un loop con SIMD, que una función fue inlined donde esperabas, o que no hay allocaciones en el hot path.

Antipatrones habituales de rendimiento

El profiling suele revelar los mismos antipatrones:

Allocaciones en loops

// Malo: allota un Vec en cada iteración
for item in &datos {
    let procesado: Vec<String> = item.campos.iter()
        .map(|f| f.to_uppercase())
        .collect();
    procesar(&procesado);
}

// Mejor: reutilizar el buffer
let mut buffer = Vec::new();
for item in &datos {
    buffer.clear();
    buffer.extend(item.campos.iter().map(|f| f.to_uppercase()));
    procesar(&buffer);
}

Clones innecesarios

// Malo: clona el string completo
fn procesar(datos: Vec<String>) {
    for s in datos.clone() {  // clone innecesario
        println!("{}", s);
    }
}

// Mejor: tomar prestado
fn procesar(datos: &[String]) {
    for s in datos {
        println!("{}", s);
    }
}

HashMap con strings como clave cuando hay pocas variantes

// Menos eficiente: hash de string en cada lookup
let mut mapa: HashMap<String, u64> = HashMap::new();
mapa.insert("activo".into(), 1);
let val = mapa.get("activo");

// Más eficiente: enum como clave, O(1) por comparación directa
#[derive(Hash, Eq, PartialEq)]
enum Estado { Activo, Inactivo, Pendiente }
let mut mapa: HashMap<Estado, u64> = HashMap::new();
mapa.insert(Estado::Activo, 1);

El workflow de optimización

El proceso recomendado es iterativo:

  1. Escribe el código correcto primero.
  2. Ejecuta los benchmarks para establecer una línea base.
  3. Genera un flamegraph para identificar el cuello de botella real.
  4. Optimiza solo la función que aparece más ancha en el flamegraph.
  5. Ejecuta los benchmarks de nuevo para verificar que la mejora es real.
  6. Repite desde el paso 3.

El compilador de Rust ya hace muchas optimizaciones automáticamente (inlining, vectorización, eliminación de código muerto). Antes de optimizar manualmente, verifica con cargo-asm que el compilador no lo ha optimizado ya.

COMPARTE ESTE ARTÍCULO

COMPARTIR EN FACEBOOK
COMPARTIR EN TWITTER
COMPARTIR EN LINKEDIN
COMPARTIR EN WHATSAPP