Perfilado de rendimiento: gprof, perf y callgrind

Cuando un programa es lento, no adivines dónde está el problema. La intuición suele fallar en sistemas complejos debido a la jerarquía de cachés, la predicción de saltos o la optimización agresiva del compilador. Para encontrar la causa raíz, necesitamos profiling (perfilado), que es el proceso de medir el uso de recursos (CPU, memoria, instrucciones) durante la ejecución.

Existen tres aproximaciones principales. Primero, la instrumentación con gprof, donde el compilador inserta código extra en cada función para contar cuántas veces se llama y cuánto tiempo permanece en ella. Esto genera un archivo gmon.out que se analiza con gprof. Es útil para ver la jerarquía de llamadas (call graph), pero el código resultante tiene un overhead (sobrecoste) significativo y sufre cuando el compilador hace inlining (fusionar una función dentro de su llamada), haciendo que las funciones desaparezcan del reporte.

Segundo, el muestreo de hardware con perf. A diferencia de gprof, perf no modifica tu ejecutable. Utiliza la PMU (Performance Monitoring Unit) del procesador para interrumpir la ejecución a intervalos regulares y capturar el estado de los contadores de rendimiento. Esto es extremadamente eficiente y permite ver fallos de caché (cache-misses), ciclos de CPU y errores de predicción de saltos sin alterar el comportamiento natural del programa.

Tercero, la simulación detallada con valgrind --tool=callgrind. No usa instrumentación de compilación ni contadores de hardware, sino que simula la CPU a nivel de instrucciones. Es extremadamente lento, pero su precisión es absoluta: cuenta cada instrucción y cada acceso a caché de forma determinística, lo que lo hace ideal para un análisis exhaustivo en herramientas como kcachegrind.

Debes usar gprof para entender la estructura macro de llamadas en programas sencillos. Usa perf cuando necesites optimizar el rendimiento real en producción o entender por qué el procesador está esperando datos de la memoria. Usa callgrind cuando el tiempo de ejecución no sea un problema y necesites una precisión quirúrgica para encontrar un error de lógica en el uso de la caché. Si ignoras esto y aplicas optimizaciones basadas en suposiciones, podrías estar optimizando código que ni siquiera es el cuello de botella real.

#include <stdio.h>
#include <stdlib.h>

// Función recursiva ineficiente para demostrar el call graph de gprof
long long fibonacci(int n) {
    if (n < 2) return n;
    return fibonacci(n - 1) + fibonacci(n - 2);
}

// Función con mal uso de localidad de datos para disparar cache misses
void procesar_matriz(int size) {
    // Asignación de filas dispersas para fragmentar la memoria
    int **matrix = malloc(size * sizeof(int *));
    if (!matrix) return;

    for (int i = 0; i < size; i++) {
        matrix[i] = malloc(size * sizeof(int));
        if (!matrix[i]) return;
        for (int j = 0; j < size; j++) {
            matrix[i][j] = i + j;
        }
    }

    long long suma = 0;
    // Acceso por columna en una matriz de filas (Stride alto)
    // Esto es letal para la caché de la CPU
    for (int j = 0; j < size; j++) {
        for (int i = 0; i < size; i++) {
            suma += matrix[i][j];
        }
    }

    printf("Suma final: %lld\n", suma);

    for (int i = 0; i < size; i++) free(matrix[i]);
    free(matrix);
}

int main(int argc, char *argv[]) {
    int n = 40;
    if (argc > 1) n = atoi(argv[1]);

    printf("Iniciando cálculos...\n");
    procesar_matriz(2000);
    printf("Resultado Fibonacci(%d): %lld\n", n, fibonacci(n));

    return 0;
}

Análisis del flujo de profiling

Si compilas este programa con gcc -pg -O0 -o perfilado perfilado.c, al ejecutarlo se generará un archivo gmon.out. Al ejecutar gprof ./perfilado, observarás que fibonacci domina el tiempo total debido a su complejidad exponencial $O(2^n)$, y el call graph te mostrará claramente la recursión. Sin embargo, si compilas con -O3, es muy probable que gprof no muestre la función fibonacci porque el compilador la habrá integrado directamente en main para evitar el coste de la llamada, eliminando la capacidad de la herramienta para medirla individualmente.

Para analizar la eficiencia de la memoria en procesar_matriz, utiliza perf stat -e cache-references,cache-misses ./perfilado. Verás una tasa de fallos de caché desastrosa. Esto ocurre porque el bucle anidado accede a la matriz saltando de fila en fila (matrix[i][j] con j externo e i interno), lo que significa que cada iteración interna salta grandes bloques de memoria, invalidando las líneas de caché.

Si utilizas perf record -g ./perfilado y luego perf report, verás un mapa de calor de dónde se está consumiendo la CPU. Si usas perf annotate sobre procesar_matriz, la herramienta te mostrará el código ensamblador directamente. Verás cómo las instrucciones de carga (mov, vmovdqu) son las que más ciclos consumen, confirmando que el cuello de botella es el acceso a memoria y no el cálculo matemático.

El error frecuente

Un error crítico es intentar usar gprof con código optimizado (-O2 o -O3) para localizar una función específica que sospechas es lenta. Debido al inlining, el compilador fusiona el cuerpo de la función con la función llamadora para eliminar el overhead del salto. Para gprof, esa función “deja de existir” como entidad con su propio stack frame, lo que resulta en un reporte incompleto o engañoso donde el tiempo parece estar concentrado en la función padre, ocultando la verdadera culpabilidad. Si necesitas precisión en código optimizado, salta directamente a perf (muestreo de hardware) o callgrind (simulación).

122

Dejar un comentario

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *

Scroll al inicio