El control de flujo: loop, continuing y el switch sin fallthrough
Las cinco construcciones de control de WGSL, el bloque continuing del que salen for y while, por qué no hay fallthrough ni salto etiquetado, y cómo salir de un bucle anidado.
El control de flujo de WGSL tiene una pieza que no existe en ningún lenguaje de propósito general: el bloque continuing, del que salen por azúcar sintáctico el for y el while. Y le faltan dos que sí existen en todos: el fallthrough del switch y el salto etiquetado. Las tres ausencias y la presencia responden al mismo requisito, que es que todo el control de flujo tiene que ser estructurado para poder traducirse a lo que entiende una GPU.
- Escribir las cinco construcciones de control con su sintaxis exacta.
- Desazucarar un
foren suloopconcontinuingequivalente. - Escribir un
switchcorrecto con sus cláusulas múltiples y sudefaultobligatorio. - Salir de un bucle anidado sin salto etiquetado.
Condicionales
if acepta la condición con paréntesis o sin ellos, y encadena con else if. La condición tiene que ser un bool escalar: una comparación de vectores hay que reducirla con all o any.
if intensidad > 1.0 {
color = color / intensidad;
} else if intensidad > 0.0 {
color = color * intensidad;
} else {
color = vec3f(0.0);
}
Las llaves son obligatorias siempre, incluso para una sola sentencia. Se acabaron los if de una línea sin llaves y la clase de bugs que producían.
loop y el bloque continuing
loop es la construcción primitiva, y es un bucle infinito del que se sale con break:
var i = 0u;
loop {
if i >= n { break; }
suma = suma + datos[i];
i = i + 1u;
}
Lo interesante viene con el bloque opcional continuing, que se ejecuta al final de cada iteración, tanto si la iteración terminó normalmente como si terminó con un continue:
var i = 0u;
loop {
if datos[i] < 0.0 { continue; } // salta al continuing, no al principio
suma = suma + datos[i];
continuing {
i = i + 1u; // se ejecuta siempre
break if i >= n; // condicion de salida al final: un do-while
}
}
Dos reglas sobre el continuing: no puede contener un continue que se refiera a ese mismo bucle, y el único break que admite es un break if como última sentencia. Esa sentencia es la que convierte el bucle en un do-while, porque la condición se evalúa después del cuerpo.
Con eso en la mano, for y while se explican solos. Este bucle:
for (var i = 0u; i < n; i = i + 1u) {
if datos[i] < 0.0 { continue; }
suma = suma + datos[i];
}
es exactamente esto:
{
var i = 0u;
loop {
if !(i < n) { break; }
if datos[i] < 0.0 { continue; }
suma = suma + datos[i];
continuing { i = i + 1u; }
}
}
Y de ahí sale la respuesta a la pregunta que todo el mundo se hace la primera vez: sí, un continue dentro de un for ejecuta el incremento. No es un salto al principio de la comprobación: es un salto al continuing, que contiene el incremento. Es el mismo comportamiento que en C, pero aquí se ve por qué.
Las tres declaraciones del for son opcionales: for (;;) { } es un bucle infinito legal, y for (; i < n;) { } es un while con otro nombre.
switch sin fallthrough
El selector tiene que ser de tipo entero, i32 o u32. Cada cláusula case puede tener varios valores separados por comas, y su cuerpo es un bloque con llaves. No hay fallthrough: cada cláusula termina donde termina su bloque, sin break explícito.
var color : vec3f;
switch (tipoMaterial) {
case 0u: {
color = vec3f(1.0, 0.0, 0.0);
}
case 1u, 2u: { // dos valores, un cuerpo
color = vec3f(0.0, 1.0, 0.0);
}
default: {
color = vec3f(0.5);
}
}
La cláusula default es obligatoria y solo puede haber una. No es una recomendación de estilo: sin ella el módulo no compila. La razón es que el compilador necesita que el switch sea total —que haya una rama para cualquier valor posible del selector— para poder generar control de flujo estructurado sin un camino sin definir.
Los valores de los case tienen que ser expresiones de tiempo de compilación y no se pueden repetir entre cláusulas.
Salir: return, discard y el break que no existe
return sale de la función, con valor si la función lo declara. En un punto de entrada, sale del shader.
discard solo existe en fragment shaders y anula la contribución de ese fragmento. No es un return: es una sentencia que puede aparecer en cualquier punto, y el lenguaje está definido de forma que la ejecución posterior no invalida las derivadas de las invocaciones vecinas.
Y lo que no existe: no hay break con etiqueta y no hay goto. Salir de dos bucles anidados de golpe exige un centinela:
var encontrado = false;
var resultado = 0u;
for (var y = 0u; y < alto; y = y + 1u) {
for (var x = 0u; x < ancho; x = x + 1u) {
if rejilla[y * ancho + x] == objetivo {
resultado = y * ancho + x;
encontrado = true;
break; // sale solo del bucle interior
}
}
if encontrado { break; } // y este del exterior
}
La alternativa más limpia cuando el cuerpo lo permite es extraer los dos bucles a una función y usar return, que sí sale de todo de una vez.
La intuición que traes de la CPU es que un if evita ejecutar lo que hay dentro. En una GPU eso es cierto solo si todas las invocaciones del grupo SIMD toman el mismo camino; si unas van por un lado y otras por otro, el hardware ejecuta los dos lados con las invocaciones inactivas enmascaradas, y el coste es la suma.
Pero hay un escalón anterior que casi nadie tiene en cuenta: el compilador ni siquiera genera la rama si el cuerpo es corto. Un if que decide entre dos expresiones aritméticas se convierte en una operación de selección sin salto, porque un salto en una GPU cuesta más que ejecutar cuatro instrucciones de más. Estas dos versiones producen el mismo código máquina:
if x > 0.0 { y = a * 2.0; } else { y = a * 3.0; }
y = select(a * 3.0, a * 2.0, x > 0.0);
De ahí sale el criterio para saber cuándo una rama merece la pena, que no es el que la gente usa. No importa cuántas instrucciones ahorra: importa si evita un acceso a memoria o una llamada a textura. Una rama que salta doscientas instrucciones aritméticas ahorra poco cuando el otro lado del grupo la ejecuta igualmente. Una rama que evita muestrear cuatro texturas ahorra latencia real incluso si solo una invocación del grupo la evita, porque las texturas que no se piden no se piden.
Y de ahí sale también la respuesta a por qué las optimizaciones de «salida temprana» a veces no hacen nada. Un if distancia > radio { return; } al principio de un shader de iluminación solo acelera si grupos enteros de invocaciones están fuera del radio, y eso pasa si las invocaciones vecinas en pantalla están vecinas en el mundo, que es exactamente lo que hacen las técnicas de agrupación de luces por teselas. El if no es la optimización: la optimización es la coherencia espacial que hace que el if sea uniforme dentro del grupo. Sin ella, el if es decoración.
Falta la restricción que convierte esto en un problema de verdad: el control de flujo uniforme.