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:
- Escribe el código correcto primero.
- Ejecuta los benchmarks para establecer una línea base.
- Genera un flamegraph para identificar el cuello de botella real.
- Optimiza solo la función que aparece más ancha en el flamegraph.
- Ejecuta los benchmarks de nuevo para verificar que la mejora es real.
- 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.
