Bucles y su coste
for, while y do-while como una única máquina; qué hace el optimizador con tu bucle (rotación, LICM, reducción de fuerza, desenrollado, vectorización) y por qué el aliasing es el verdadero límite del rendimiento.
Las tres formas de bucle de C son azúcar sintáctico sobre el mismo grafo de flujo: una condición, un cuerpo y un salto atrás. Lo que decide el rendimiento no es cuál eliges, sino cuánta libertad le dejas al optimizador para reescribirlo. Un bucle que escribes en cuatro líneas puede convertirse en algo irreconocible —o quedarse literalmente como lo escribiste— y la diferencia suele estar en una promesa que olvidaste hacer.
- Ver
for,whileydo-whilecomo la misma estructura de control. - Identificar las transformaciones clásicas: rotación, LICM, reducción de fuerza y desenrollado.
- Entender por qué el aliasing y las llamadas opacas bloquean la optimización.
- Escribir bucles que el compilador pueda vectorizar, y verificarlo.
Las tres formas son la misma máquina
Un bucle es un grafo con tres piezas: preheader (lo que se ejecuta una vez antes), cuerpo y latch (el salto atrás condicionado). for y while comprueban antes de entrar; do-while comprueba después. Nada más.
/* estas tres construcciones generan el mismo grafo de flujo */
for (size_t i = 0; i < n; i++) trabajo(i);
size_t i = 0;
while (i < n) { trabajo(i); i++; }
size_t j = 0;
if (n > 0) do { trabajo(j); j++; } while (j < n);
El detalle sabroso es que el compilador prefiere internamente la tercera forma. La transformación se llama rotación de bucle o loop inversion: mueve la comprobación al final y protege la entrada con un test previo. Motivo: en el cuerpo rotado hay un solo salto por iteración en vez de dos, lo que sienta mejor al predictor de saltos y deja el cuerpo como un bloque básico limpio para vectorizar.
Elige la forma por claridad semántica, no por velocidad: for cuando el patrón de iteración es conocido, while cuando iteras hasta que ocurre algo, do-while cuando el cuerpo debe correr al menos una vez. Y declara siempre el índice dentro del for: acota el alcance y le confirma al compilador que nadie lo observa fuera.
Lo que el optimizador hace con tu bucle
Entre tu código y el ensamblador hay una tubería de pases que reescriben el bucle. Estos son los que más importan conocer:
LICM
Movimiento de código invariante. Todo cálculo cuyo resultado no cambia entre iteraciones sube al preheader y se ejecuta una sola vez.
Reducción de fuerza
Sustituye operaciones caras por baratas sobre variables de inducción: una multiplicación por índice se convierte en una suma incremental de puntero.
Desenrollado
Replica el cuerpo k veces y divide el conteo entre k. Amortiza el coste del control y abre ventana para reordenar instrucciones independientes.
Unswitching
Saca del cuerpo un if cuya condición es invariante, duplicando el bucle en dos versiones especializadas sin ramas dentro.
flowchart LR A[Tu bucle fuente] --> B[Rotacion: comprobar al final] B --> C[LICM: subir invariantes al preheader] C --> D[Reduccion de fuerza sobre inducciones] D --> E[Desenrollado parcial] E --> F[Vectorizacion SIMD si no hay dependencias] F --> G[Ensamblador final irreconocible] style F fill:#a6e3a1,color:#11111b style G fill:#89b4fa,color:#11111b
El ejemplo canónico de invariante que el compilador no puede mover:
/* O de n al cuadrado: strlen se llama en cada iteración */
for (size_t i = 0; i < strlen(s); i++)
procesar(s[i]);
/* O de n: la longitud se calcula una vez */
size_t len = strlen(s);
for (size_t i = 0; i < len; i++)
procesar(s[i]);
Con GCC y Clang modernos, la primera versión sí se optimiza si procesar es visible y demostrablemente no toca s. Si procesar vive en otra unidad de traducción, el compilador debe asumir que podría modificar la cadena y vuelve a llamar a strlen cada vuelta. La optimización no es una propiedad del código: es una propiedad de lo que el compilador puede demostrar.
El enemigo real: aliasing y llamadas opacas
Casi todo bucle que no se optimiza tropieza con la misma pregunta: ¿pueden dos punteros apuntar al mismo sitio? Si el compilador no puede descartarlo, debe recargar de memoria tras cada escritura y la vectorización se cae.
/* El compilador debe asumir que dst y src podrían solaparse:
emite código escalar con recarga en cada iteración. */
void escalar(float *dst, const float *src, size_t n, float k)
{
for (size_t i = 0; i < n; i++)
dst[i] = src[i] * k;
}
/* restrict promete que no se solapan: vía libre a SIMD */
void vectorial(float *restrict dst, const float *restrict src,
size_t n, float k)
{
for (size_t i = 0; i < n; i++)
dst[i] = src[i] * k;
}
Sin restrict, muchos compiladores generan dos versiones del bucle más una comprobación de solape en tiempo de ejecución: código más grande y una rama extra. Con restrict la promesa es tuya, y romperla es comportamiento indefinido silencioso.
Los otros tres bloqueos habituales:
- Llamadas opacas en el cuerpo. Una función externa puede tocar cualquier memoria global; obliga a recargar todo. Compilar con LTO devuelve la visibilidad perdida.
- Estado
volatileo compartido. Cada accesovolatilees una barrera para el optimizador: es exactamente su propósito. - Aritmética de coma flotante. Reasociar sumas cambia el resultado, así que sin
-ffast-mathel compilador no reordena una reducción defloat. Es correcto que no lo haga.
Desenrollado y vectorización
El desenrollado ya no es la victoria fácil de los años noventa: los procesadores modernos ejecutan fuera de orden y predicen el salto del latch casi siempre bien. Su valor hoy es secundario: expone instrucciones independientes que el planificador puede solapar y prepara el terreno para SIMD.
/* Sugerencia, no orden: el compilador puede ignorarla */
#pragma GCC unroll 4
for (size_t i = 0; i < n; i++)
acum += datos[i];
Lo que de verdad mueve la aguja es la vectorización: procesar 4, 8 o 16 elementos por instrucción. Requiere que el cuerpo no tenga dependencias entre iteraciones, que el conteo sea calculable antes de entrar y que no haya saltos impredecibles dentro. Verifícalo, no lo supongas:
# GCC: qué bucles vectorizó y cuáles no, con el motivo
gcc -O3 -march=native -fopt-info-vec-missed prog.c -o prog
# Clang: informe equivalente
clang -O3 -march=native -Rpass-missed=loop-vectorize prog.c -o prog
Y por encima de todo esto manda la memoria. Un bucle perfectamente vectorizado que recorre un array con salto de 4096 bytes irá más lento que uno escalar secuencial: fallará en cada línea de caché. Antes de tocar el desenrollado, arregla el patrón de acceso.
El código fuente de un bucle no describe lo que hará la máquina, sino el resultado observable que exiges de ella. Entre ambos, la regla as-if del estándar le da al compilador permiso total para reescribirlo: rotarlo, partirlo, fusionarlo, invertir el orden, convertirlo en una llamada a memset, o eliminarlo entero si nadie observa su efecto. Interiorizar esto cambia cómo optimizas: dejas de escribir trucos —desenrollar a mano, cachear en variables locales, sustituir índices por punteros— porque el optimizador ya lo hace mejor, y pasas a retirar obstáculos. Los obstáculos son siempre incertidumbre: un puntero que podría solaparse, una llamada cuyo cuerpo no se ve, un volatile innecesario, una reducción de coma flotante que no puedes reasociar. Cada restrict, cada const, cada static en una función local, cada LTO activado, es información que convierte un “podría pasar” en un “no puede pasar”. El rendimiento en C es, en el fondo, un ejercicio de eliminar dudas del compilador.
- Compila el bucle con
strlenen la condición con y sin la funciónprocesaren otra unidad de traducción; compara el ensamblador. - Añade
restrictal bucle escalar y comprueba con-fopt-info-vecque pasa a vectorizarse. - Escribe una reducción de
floaty observa que-O3no la vectoriza hasta que permites reasociar. - Compara un recorrido de matriz por filas frente a por columnas y mide los fallos de caché con
perf stat. - Fuerza
#pragma GCC unroll 8en un bucle ya vectorizado y verifica si mejora o empeora.