wandres.dev
RENDIMIENTO · zero-cost, SIMD

Perfilar: samply, flamegraphs y benchmarks estadísticos con criterion

Medir, no adivinar. Los perfiladores de muestreo —samply y perf— responden dónde se va el tiempo y lo dibujan en un flamegraph; criterion responde cuánto y con qué certeza, corriendo cada caso cientos de veces y dando intervalos de confianza que distinguen una mejora real del ruido. La metodología que ata ambos: perfila para hallar el punto caliente, mide ese punto con estadística, y vuelve a perfilar el conjunto.

⏱ 20 min

La primera regla del rendimiento es también la más incumplida: medir, no adivinar. La intuición sobre dónde se va el tiempo de un programa es notoriamente mala —hasta los mejores ingenieros aciertan la mitad de las veces—, y optimizar por corazonada suele endurecer código que nadie ejecuta mientras el verdadero cuello de botella sigue intacto. Perfilar responde dos preguntas distintas con dos instrumentos distintos. ¿Dónde se quema el tiempo? lo contesta un perfilador de muestreo —samply, perf— que fotografía la pila cientos de veces por segundo y dibuja el resultado en un flamegraph. ¿Cuánto tarda esto, y mi cambio lo mejoró de verdad o fue ruido? lo contesta criterion, que trata cada medición como lo que es —una muestra estadística— y te da intervalos de confianza en vez de un número engañoso. Esta lección te enseña a manejar ambos y, sobre todo, a encadenarlos.

🎯 Al terminar esta lección sabrás
  • Usar un perfilador de muestreo (samply, perf) y leer un flamegraph correctamente.
  • Configurar el binario para que las pilas sean legibles: release con símbolos de depuración.
  • Escribir benchmarks con criterion, blindarlos con black_box e interpretar sus intervalos.
  • Aplicar la metodología perfilar, medir, reperfilar sin caer en la microoptimización prematura.

Dónde se va el tiempo: muestreo y flamegraphs

Un perfilador de muestreo no instrumenta tu código: lo interrumpe a intervalos regulares —pongamos mil veces por segundo— y anota la pila de llamadas en ese instante. Al acumular decenas de miles de muestras, las funciones donde el programa pasa más tiempo aparecen en más pilas, y esa frecuencia es el perfil. samply es la herramienta moderna y multiplataforma: perfila un binario y abre la interfaz del Firefox Profiler en el navegador.

cargo install samply
cargo build --release
samply record ./target/release/mi_programa

En Linux, perf es el estándar del sistema (perf record -g --call-graph dwarf ./bin y luego perf report), y cargo flamegraph envuelve perf o dtrace para escupir directamente un SVG. El flamegraph es la visualización canónica, y leerlo bien exige desaprender una intuición: el eje horizontal no es el tiempo, es la proporción de muestras. Cada caja es un marco de pila; su anchura es la fracción del tiempo total que se pasó ahí (sumando todas sus apariciones), y el apilamiento vertical es la profundidad de llamada. Se busca la barra ancha: la función gorda en la cima de una pila es donde arde la CPU.

⚠️
Sin símbolos de depuración, el flamegraph es ilegible

Un error clásico arruina el perfil antes de empezar: compilar en release sin símbolos. El código optimizado corre a velocidad real, pero sin la tabla de símbolos el flamegraph muestra direcciones hexadecimales en vez de nombres de función, y no distingues nada. La solución es pedir ambas cosas a la vez —optimización y símbolos— reactivando debug en el perfil de release, tal como viste en el nivel 47:

[profile.release]
debug = true            # o "line-tables-only": simbolos sin inflar tanto el binario

Nunca perfiles en dev: medirías código sin optimizar, un perfil que no se parece en nada al que ejecutan tus usuarios.

Cuánto y con qué certeza: criterion

El flamegraph te señala dónde mirar; para saber cuánto tarda algo y si un cambio lo mejora hace falta otra cosa, porque una sola medición es ruido puro: la frecuencia de la CPU fluctúa, la caché está fría o caliente, el planificador del sistema se entromete. criterion resuelve esto tratando el tiempo como una distribución estadística. Ejecuta un calentamiento, toma cientos de muestras, aplica bootstrap para estimar la incertidumbre, y te devuelve no un número sino un intervalo de confianza. Además guarda el resultado anterior y te dice, al reejecutar, si el cambio es una mejora significativa, una regresión o está dentro del ruido.

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

[[bench]]
name = "mi_bench"
harness = false          # criterion trae su propio arnes, no el de libtest
// benches/mi_bench.rs
use criterion::{criterion_group, criterion_main, Criterion};
use std::hint::black_box;

fn fib(n: u64) -> u64 {
    if n < 2 { n } else { fib(n - 1) + fib(n - 2) }
}

fn bench_fib(c: &mut Criterion) {
    c.bench_function("fib 20", |b| b.iter(|| fib(black_box(20))));
}

criterion_group!(benches, bench_fib);
criterion_main!(benches);

Se lanza con cargo bench. Dos detalles son decisivos. El primero es black_box: envuelve la entrada para que el optimizador no pueda plegar el cálculo a una constante en tiempo de compilación —sin él, criterion mediría la nada, porque el compilador precomputaría fib(20)—. El segundo es que criterion genera un informe HTML con gráficas de la distribución en target/criterion/, donde ves la forma de la medición, no solo su media. Existe una alternativa más ligera y reciente, divan, con menos ceremonia; pero el principio es idéntico: nunca confíes en una sola cifra.

flowchart TD
A[Sospechas que algo es lento] --> B[samply o perf muestrean la pila mil veces por segundo]
B --> C[Flamegraph revela la barra ancha el punto caliente]
C --> D[criterion mide ese punto con cientos de muestras]
D --> E{El intervalo mejora de verdad}
E -->|si| F[Reperfilas el conjunto y buscas el siguiente punto]
E -->|no| G[Descartas era ruido y pruebas otra idea]
style A fill:#f38ba8,color:#11111b
style C fill:#fab387,color:#11111b
style D fill:#89b4fa,color:#11111b
style F fill:#a6e3a1,color:#11111b

La metodología: perfilar, medir, reperfilar

Los dos instrumentos no compiten: se encadenan. El flujo disciplinado empieza siempre por el perfilador de muestreo sobre una carga realista, para hallar el punto caliente real —no el que imaginas—. Solo entonces aíslas esa función en un benchmark de criterion, iteras sobre ella midiendo cada cambio contra el intervalo anterior, y cuando la has mejorado vuelves a perfilar el conjunto para confirmar que el cuello de botella se movió a otra parte. Perfilar sin medir te deja sin saber si mejoraste; medir sin perfilar te lleva a optimizar código que no importa.

Ese último es el pecado capital, el que Knuth bautizó y que la lección 5 del nivel desarrolla: la microoptimización prematura. Un microbenchmark que hace diez veces más rápida una función que consume el 1 % del tiempo total es esfuerzo tirado, porque por la ley de Amdahl la ganancia máxima del programa es ese 1 %. El benchmark aislado también miente por omisión: mide con la caché caliente y sin la contención real, así que una función que vuela en criterion puede arrastrarse en producción por fallos de caché que el microbenchmark nunca reprodujo. El perfil sobre carga real es el que manda; criterion es para afinar lo que el perfil ya señaló.

💡
Async se perfila al revés: la métrica no es la CPU

Todo lo anterior vale para código síncrono, donde el cuello de botella es cómputo. En async la pregunta cambia de raíz —la métrica reina no es el porcentaje de CPU sino el poll time, cuánto retiene una tarea el hilo antes de ceder— y las herramientas también: tokio-console para el reparto de hilos, tracing para la latencia lógica. Un perfilador de CPU aplicado a un servicio de E/S muestra un sistema casi ocioso justo cuando la latencia es terrible. Ese mundo tiene su propio tratamiento en el nivel 36; aquí nos quedamos en el rendimiento de cómputo.

El perfilador existe porque tu intuición sobre el rendimiento es, estadísticamente, mala

Hay una humildad incómoda en el corazón de la ingeniería de rendimiento, y es que la persona menos fiable para decir dónde es lento un programa es la que lo escribió. No por falta de talento, sino por la naturaleza del problema: el tiempo de ejecución se concentra, casi siempre, en un puñado diminuto del código —el famoso pequeño porcentaje que de verdad importa— y ese punto rara vez coincide con el que uno cree, porque nuestra atención se fija en lo que nos costó escribir, no en lo que le cuesta a la máquina ejecutar. El bucle que te dio dolores de cabeza puede correrse una vez por invocación, mientras el inocente format! dentro de una función auxiliar se ejecuta un millón de veces y devora la mitad del tiempo. El perfilador de muestreo es el antídoto contra esta ceguera sistemática: no razona, no supone, no tiene ego; fotografía la realidad cientos de veces por segundo y deja que la frecuencia hable. Y su compañero estadístico, criterion, cura la segunda enfermedad de la intuición, la de creer que un número es una medición: en un procesador moderno, con frecuencia dinámica y cachés y un planificador entrometido, medir una vez es casi no medir, y solo la distribución —el intervalo, la varianza, la comparación con la corrida anterior— distingue una mejora real de una fluctuación afortunada. Interiorizar esto reordena tu forma de trabajar. Dejas de discutir sobre rendimiento con opiniones y empiezas a zanjarlo con datos; dejas de optimizar por estética o por miedo y empiezas a hacerlo por evidencia. El ingeniero novato optimiza lo que le parece feo; el maduro perfila primero y descubre, una y otra vez, que lo feo era irrelevante y que el ladrón del tiempo se escondía donde nunca habría mirado. La herramienta no sustituye al juicio: lo alimenta con la única materia prima que el rendimiento respeta, que es la medición.

📝
Lo esencial de perfilar

Dos preguntas, dos herramientas. Dónde se va el tiempo lo dicen los perfiladores de muestreo —samply, perf, cargo flamegraph—, que fotografían la pila y la dibujan en un flamegraph cuya anchura es proporción de muestras, no tiempo lineal; exige compilar en release con debug = true o no verás nombres. Cuánto y con qué certeza lo dice criterion, que toma cientos de muestras, da intervalos de confianza, detecta regresiones y necesita black_box para que el optimizador no pliegue el cálculo. La metodología: perfila carga real para hallar el punto caliente, mide ese punto con criterion, reperfila el conjunto, y nunca microoptimices lo que el perfil no señaló. En async cambia todo: la métrica es el poll time (nivel 36).

⚔️ Deja que la medición decida
  1. Perfila con samply un programa con un bucle intensivo y localiza en el flamegraph la barra más ancha; explica por qué su anchura representa proporción de muestras y no orden temporal.
  2. Compila el mismo binario en release sin debug y luego con debug = true; compara la legibilidad de los dos flamegraphs y explica la diferencia.
  3. Escribe un benchmark de criterion para dos implementaciones de la misma función; interpreta los intervalos de confianza y decide si la diferencia es significativa o ruido.
  4. Quita el black_box de un benchmark cuya entrada es constante y observa cómo el tiempo se desploma a casi cero; explica qué precomputó el optimizador.
  5. Argumenta, apoyándote en la ley de Amdahl, por qué acelerar diez veces una función que ocupa el 1 % del perfil es esfuerzo malgastado, y qué habrías debido perfilar primero.