Medir el rendimiento correctamente es tan importante como optimizar. Sin mediciones fiables cualquier "optimización" puede ser contraproducente o invisible. El flujo correcto en Rust es: primero benchmark estadístico con criterion para cuantificar el problema, luego flamegraph para localizar el cuello de botella, y solo entonces aplicar técnicas de optimización verificando que la mejora es real.
Benchmarks estadísticos con criterion
criterion ejecuta cada función de benchmark miles de veces, estima la varianza, descarta outliers y calcula intervalos de confianza. Detecta automáticamente regresiones entre ejecuciones y genera informes HTML comparativos.
// Cargo.toml
// [dev-dependencies]
// criterion = { version = "0.5", features = ["html_reports"] }
//
// [[bench]]
// name = "procesamiento"
// harness = false
// benches/procesamiento.rs
use criterion::{black_box, criterion_group, criterion_main, BenchmarkId, Criterion};
fn buscar_lineal(datos: &[u32], objetivo: u32) -> bool {
datos.iter().any(|&x| x == objetivo)
}
fn buscar_binaria(datos: &[u32], objetivo: u32) -> bool {
datos.binary_search(&objetivo).is_ok()
}
fn benchmark_busqueda(c: &mut Criterion) {
let mut grupo = c.benchmark_group("busqueda");
for tamanio in [100usize, 1_000, 10_000, 100_000] {
let datos: Vec<u32> = (0..tamanio as u32).collect();
let objetivo = tamanio as u32 / 2; // elemento en el centro
grupo.bench_with_input(BenchmarkId::new("lineal", tamanio), &tamanio, |b, _| {
b.iter(|| buscar_lineal(black_box(&datos), black_box(objetivo)))
});
grupo.bench_with_input(BenchmarkId::new("binaria", tamanio), &tamanio, |b, _| {
b.iter(|| buscar_binaria(black_box(&datos), black_box(objetivo)))
});
}
grupo.finish();
}
criterion_group!(benches, benchmark_busqueda);
criterion_main!(benches);
// Ejecutar: cargo bench
// Ver informe HTML: target/criterion/report/index.html
cargo flamegraph: localizar cuellos de botella
El flamegraph muestra visualmente qué funciones consumen más tiempo de CPU. Las barras más anchas en la parte superior son los puntos más calientes. Hacer clic amplía una función para ver su pila de llamadas.
// Instalación:
// cargo install flamegraph
// (Linux: necesita perf; macOS: necesita DTrace/sudo)
// Configurar para símbolos de debug con optimizaciones en Cargo.toml:
// [profile.release]
// debug = true
// Generar flamegraph del binario principal:
// cargo flamegraph --bin mi-app
// De un benchmark criterion específico:
// cargo flamegraph --bench procesamiento -- --bench busqueda/lineal
// Interpretar el flamegraph:
// - Eje X: tiempo de CPU acumulado (más ancho = más costoso)
// - Eje Y: profundidad de la pila de llamadas
// - Las funciones en la cima son las que ejecutan instrucciones reales
// - Busca la función más ancha que no es código tuyo (stdlib, serde, tokio...)
Técnicas de optimización: Cow, slices e inlining
Una vez localizado el cuello de botella, las técnicas de optimización más efectivas en Rust son evitar alocaciones innecesarias con Cow, preferir slices a Vec en las firmas de funciones, y guiar al compilador con #[inline] para funciones pequeñas en rutas críticas.
use std::borrow::Cow;
// ANTES: siempre aloca un String nuevo
fn normalizar_v1(s: &str) -> String {
if s.chars().all(|c| c.is_lowercase()) {
s.to_string() // alocación innecesaria si ya está en minúsculas
} else {
s.to_lowercase()
}
}
// DESPUÉS: solo aloca cuando es necesario
fn normalizar_v2(s: &str) -> Cow<str> {
if s.chars().all(|c| c.is_lowercase()) {
Cow::Borrowed(s) // cero alocaciones
} else {
Cow::Owned(s.to_lowercase())
}
}
// Preferir slice a Vec en firmas de función
// ANTES: obliga al llamador a tener un Vec
fn suma_v1(datos: &Vec<u32>) -> u32 { datos.iter().sum() }
// DESPUÉS: acepta Vec, arrays, slices y cualquier cosa que dereferencia a [u32]
fn suma_v2(datos: &[u32]) -> u32 { datos.iter().sum() }
// Inlining para funciones pequeñas en rutas críticas
#[inline(always)]
fn cuadrado(x: f64) -> f64 { x * x }
fn norma(v: &[f64]) -> f64 {
v.iter().map(|&x| cuadrado(x)).sum::<f64>().sqrt()
}
fn main() {
let texto = "rust es genial";
let n1 = normalizar_v1(texto);
let n2 = normalizar_v2(texto);
println!("{n1} | {n2}"); // texto ya está en minúsculas: Cow::Borrowed en v2
let datos = vec![3.0, 4.0];
println!("norma: {}", norma(&datos)); // 5.0
}
El flujo correcto para no optimizar a ciegas
La regla de oro de la optimización es medir antes y después para confirmar que el cambio tiene efecto. Criterion facilita esto con comparaciones automáticas entre ejecuciones.
// Antipatrón: optimizar sin medir
// fn procesar(datos: &[u32]) -> u32 {
// // "esto debería ser más rápido"...
// datos.iter().copied().fold(0, |a, b| a + b) // ¿más rápido que .sum()?
// }
// Patrón correcto:
// 1. cargo bench ? establece baseline
// 2. cargo flamegraph ? identifica hot path
// 3. Aplica cambio
// 4. cargo bench ? compara con baseline
// criterion detecta regresiones automáticamente:
// "Performance has regressed by 15.3% (±2.1%)"
// "Performance has improved by 8.7% (±1.4%)"
// Solo optimizar lo que el flamegraph muestra como cuello de botella.
// Si una función ocupa el 2% del tiempo, optimizarla al máximo
// solo mejora el total en un 2%. Busca el 40-60% del flamegraph.
El 80 % de las ganancias de rendimiento en Rust provienen de evitar alocaciones de heap innecesarias (usar Cow, &str en lugar de String, &[T] en lugar de Vec<T>) y de estructurar los datos para favorecer el acceso secuencial en memoria (cache-friendly). Las optimizaciones de nivel más bajo (SIMD, inlining manual) solo valen la pena cuando las mediciones muestran que el código pasa la mayor parte del tiempo en esa función concreta.
