Medir de verdad
perf, contadores de hardware y microbenchmarks que no mienten: eliminación de código muerto, ruido del sistema, escalado de frecuencia, y cómo pasar de un número a una decisión de ingeniería en vez de optimizar a ciegas.
Todo lo anterior de este nivel es inútil sin esta lección. Las cachés, el predictor de saltos y las unidades vectoriales forman un sistema con demasiadas interacciones para razonar sobre él de cabeza: la intuición de rendimiento del programador experto acierta, en estudios repetidos, alrededor de la mitad de las veces sobre dónde está el cuello de botella. La disciplina que convierte la optimización en ingeniería no es conocer trucos, es tener un aparato de medida y no fiarte de nada que no haya pasado por él.
- Leer los contadores de hardware que importan y saber qué hipótesis confirma o refuta cada uno.
- Construir microbenchmarks que resistan la eliminación de código muerto, el ruido y el escalado de frecuencia.
- Distinguir perfilado por muestreo de instrumentación, y saber cuándo miente cada uno.
- Convertir una medición en una decisión: dónde intervenir, cuánto se puede ganar y cuándo parar.
Los contadores dicen qué, no cuánto
El reloj de pared responde a una sola pregunta: si el cambio fue mejor. Los contadores de hardware responden a la que de verdad importa: por qué. Cada núcleo lleva unidades de monitorización que cuentan eventos microarquitectónicos, y perf los expone:
# Panoramica: repite cinco veces y da media con desviacion
perf stat -r 5 ./prog
# Hipotesis concretas, un grupo de eventos por hipotesis
perf stat -e cycles,instructions,branches,branch-misses ./prog
perf stat -e cache-references,cache-misses,LLC-load-misses ./prog
perf stat -e dTLB-load-misses,iTLB-load-misses,page-faults ./prog
# En Intel: reparto del cuello de botella en cuatro categorias
perf stat --topdown ./prog
La lectura no es un ritual, es un árbol de decisión. El IPC —instrucciones por ciclo, que perf stat calcula solo— sitúa el diagnóstico de entrada: por encima de tres estás cerca del techo de emisión y el problema es que haces demasiado trabajo, así que hay que atacar el algoritmo; alrededor de uno hay margen amplio; por debajo de cero coma cinco la máquina está parada esperando algo y toca averiguar qué.
Fallos de última caché altos
El límite es la memoria. Actúa sobre la disposición de datos y el patrón de acceso, no sobre la aritmética.
Fallos de predicción altos
El límite es el control de flujo. Ordena los datos, elimina la rama o replica el despachador.
IPC alto y tiempo alto
La máquina va llena. Solo queda hacer menos trabajo: mejor algoritmo, o vectorizar lo que ya haces.
Dos advertencias sobre el instrumento. La primera es que los contadores se multiplexan: si pides más eventos de los que hay registros físicos, el núcleo los rota y perf extrapola, y verás un porcentaje de multiplexación en la salida. Mide pocos eventos por pasada y agrúpalos por hipótesis. La segunda es que dentro de una máquina virtual los contadores suelen estar deshabilitados o falseados; el perfilado serio se hace sobre metal, y si eso no es posible hay que decirlo al presentar los resultados.
Un microbenchmark honesto
Casi todos los microbenchmarks publicados en internet están rotos, y casi siempre por el mismo motivo: el compilador borró el trabajo que se pretendía medir. Si el resultado de una función no se usa y la función no tiene efectos observables, eliminarla es una transformación legal, y con -O2 se aplica sin piedad.
#include <stddef.h>
/* Impide que el compilador elimine el valor: se lo entrega a un asm
vacio que finge consumirlo. No genera ninguna instruccion. */
static inline void consumir(void *p) {
__asm__ volatile("" : : "r"(p) : "memory");
}
/* Impide que mueva codigo a traves de este punto o cachee memoria
en registros: barrera para el compilador, no para la CPU. */
static inline void barrera(void) {
__asm__ volatile("" : : : "memory");
}
Con eso resuelto queda la parte estadística, que es donde se pierden las conclusiones. Un microbenchmark honesto cumple estas condiciones:
- Calentamiento. Las primeras iteraciones pagan fallos de caché fríos,
TLBfría, predictor sin historia y páginas sin asignar. Descarta un lote inicial antes de contar. - Repetición y dispersión. Un número sin desviación no es una medida. Usa
perf stat -ro repite en tu arnés, y reporta mediana y cuartiles, no media: la distribución de tiempos tiene cola derecha larga por interrupciones, y la media la sigue mientras la mediana la ignora. - Frecuencia estable. El escalado dinámico y el modo turbo cambian el reloj según la temperatura y la carga, de modo que una medida a los tres segundos y otra a los sesenta no son comparables. Fija el gobernador a rendimiento, o mide en ciclos en lugar de en segundos, que es inmune al problema.
- Aislamiento. Fija el hilo a un núcleo, evita el hermano lógico del mismo núcleo físico, y desactiva la aleatorización del espacio de direcciones si comparas versiones: el mero cambio de alineación entre dos compilaciones puede mover el tiempo un cinco por ciento sin que ninguna línea de código haya cambiado.
- Datos realistas. Un búfer de mil elementos vive en L1 y responde a preguntas que no son las tuyas. Un array ordenado le regala la predicción de saltos. Mide con el tamaño y la distribución que tendrás en producción.
taskset -c 2 chrt -f 50 setarch --addr-no-randomize \
perf stat -r 20 -e cycles,instructions,branch-misses,cache-misses ./bench
Para el cronómetro dentro del propio programa hay una sola elección defendible en Linux, y varias trampas alrededor:
#include <time.h>
#include <stdint.h>
static uint64_t ahora_ns(void) {
struct timespec t;
clock_gettime(CLOCK_MONOTONIC, &t); /* nunca CLOCK_REALTIME */
return (uint64_t)t.tv_sec * 1000000000u + (uint64_t)t.tv_nsec;
}
CLOCK_MONOTONIC no retrocede cuando el sistema ajusta la hora, cosa que CLOCK_REALTIME sí hace y que produce medidas negativas memorables. Se resuelve en espacio de usuario por el mecanismo de página compartida, así que cuesta unas decenas de nanosegundos y no una llamada al sistema completa; aun así, si lo que mides dura menos que eso, no estás midiendo tu código sino el reloj, y la única salida es medir un lote de miles de iteraciones y dividir. La tentación de usar rdtsc directamente para bajar el coste tiene su propia lista de advertencias: no serializa respecto a la ejecución fuera de orden salvo que uses la variante con lfence, y su frecuencia es la nominal invariante, no la real, de modo que traduce mal a ciclos efectivos cuando el turbo está activo.
De los números a la decisión
Con el aparato calibrado, el flujo de trabajo es un ciclo cerrado y su valor está en no saltarse ningún paso.
flowchart LR A[Medir el programa completo] --> B[Localizar el punto caliente por muestreo] B --> C[Formular una hipotesis con contadores] C --> D[Estimar la ganancia maxima por Amdahl] D --> E[Intervenir en un solo sitio] E --> F[Volver a medir y comparar distribuciones] F -->|Mejora confirmada| G[Fijar la regresion en integracion continua] F -->|Sin mejora| H[Revertir y refutar la hipotesis] H --> C style G fill:#a6e3a1,color:#11111b style H fill:#f38ba8,color:#11111b
El paso de localización se hace por muestreo, no por instrumentación: perf record interrumpe el programa miles de veces por segundo y anota dónde estaba, con un sobrecoste de un uno o dos por ciento y sin deformar lo que mide.
perf record -g --call-graph dwarf ./prog # perfil con pila de llamadas
perf report --sort=overhead,symbol # donde se va el tiempo
perf annotate funcion_caliente # instruccion a instruccion
perf record -e cache-misses -c 10000 ./prog # muestrear por eventos, no por tiempo
Ese último modo es el más infravalorado: en vez de preguntar dónde se gasta el tiempo, pregunta qué líneas provocan los fallos de caché, y suele señalar directamente la estructura de datos culpable. Compilar con -g y -fno-omit-frame-pointer no cuesta rendimiento medible y es lo que hace legibles las pilas.
El paso de comparación merece más rigor del que suele recibir. Dos versiones no se comparan por su mejor tiempo ni por su media: se comparan por sus distribuciones. Si la mediana mejora un dos por ciento y los rangos intercuartílicos de ambas versiones se solapan ampliamente, no has medido una mejora, has medido ruido con una narrativa encima. La regla operativa mínima es exigir que la diferencia supere claramente la dispersión de las repeticiones, y desconfiar por sistema de cualquier ganancia menor del cinco por ciento que no se reproduzca al reiniciar la máquina, recompilar y reordenar el orden en que se ejecutan las dos versiones. Ese último control atrapa más falsos positivos de lo que parece razonable.
El paso que casi todo el mundo omite es el de la estimación previa. La ley de Amdahl dice que si una función consume el veinte por ciento del tiempo, hacerla infinitamente rápida te da como máximo un veinticinco por ciento de mejora global. Calcula ese techo antes de invertir dos días: la mitad de las optimizaciones que se emprenden no podían, ni en el mejor caso imaginable, justificar su coste.
La frase de Knuth sobre la optimización prematura como raíz de todos los males es probablemente la más citada y peor entendida de la disciplina, porque casi nadie cita la línea siguiente, en la que insiste en que en ese tres por ciento crítico hay que ser implacable. Su tesis nunca fue que optimizar sea malo; fue que optimizar sin saber dónde es malo, y ese matiz es exactamente el contenido de esta lección. Lo que hay debajo es una cuestión epistemológica que trasciende el rendimiento. Un sistema con cinco niveles de caché, ejecución especulativa, planificación fuera de orden, un sistema operativo que preempta y un compilador que reescribe tu código no es analizable por deducción desde el fuente; es un objeto empírico, y la única forma válida de conocerlo es la experimental. Eso impone las obligaciones de cualquier ciencia experimental: hipótesis antes que intervención, un solo cambio por experimento, control del ruido, y disposición a que el dato refute lo que creías. La consecuencia más incómoda es que debes revertir las optimizaciones que no dieron mejora medible, aunque estés seguro de que teóricamente ayudan, porque un cambio que complica el código sin efecto medible es deuda pura. La consecuencia más liberadora es la contraria: con un aparato de medida decente dejas de necesitar tener razón. Puedes probar la idea rara, el algoritmo que todos dicen que es lento, la disposición de datos que parece absurda. Los números arbitran. La mayoría de las mejoras de rendimiento importantes de la historia del software vinieron de alguien que midió algo que nadie había pensado en medir, no de alguien que sabía más.
- Escribe un microbenchmark de una función aritmética pura sin
consumirnibarrera, compílalo con-O2y comprueba conobjdump -dque el bucle desapareció. Añade las barreras y verifica que vuelve. - Mide la misma función veinte veces con
perf stat -r 20y calcula media, mediana y desviación. Repite bajo carga —compila algo pesado en paralelo— y observa qué estadístico se degrada más. - Compara el tiempo en segundos y el recuento de ciclos de una ejecución corta y una larga con el gobernador en modo bajo consumo. Explica la discrepancia.
- Perfila un programa real tuyo con
perf record -g, identifica el punto caliente y calcula por Amdahl la mejora máxima alcanzable antes de tocar nada. - Repite el perfilado con
perf record -e cache-missesy compara la lista de funciones culpables con la del perfil por tiempo. Si difieren, explica por qué. - Escoge una de tus optimizaciones anteriores, mídela con rigor y revierte la que no supere el ruido. Anota qué hipótesis quedó refutada.