wandres.dev
WGSL IV · Funciones y control de flujo

Funciones: firma, paso por valor y la recursión que no existe

Cómo se declara una función en WGSL, qué tipos admiten sus parámetros y su retorno, por qué el grafo de llamadas tiene que ser acíclico, y qué cuesta de verdad pasar una struct grande.

⏱ 16 min

Las funciones de WGSL parecen las de cualquier lenguaje hasta que intentas algo que el hardware no puede hacer, y entonces el compilador te para en seco. No hay recursión, no hay sobrecarga, no hay punteros a función y no hay valores por defecto. Cada una de esas ausencias tiene una explicación en la arquitectura de una GPU, y conocerla te dice cómo reescribir lo que querías hacer.

🎯 Al terminar esta lección sabrás
  • Declarar funciones con parámetros y retorno de los tipos permitidos.
  • Explicar por qué el grafo de llamadas tiene que ser acíclico y reescribir un algoritmo recursivo.
  • Razonar sobre el coste real del paso por valor en una GPU.
  • Usar @must_use y saber por qué una función de usuario no vale en una expresión constante.

La forma de una función

fn atenuacion(distancia : f32, radio : f32) -> f32 {
  let x = clamp(1.0 - distancia / radio, 0.0, 1.0);
  return x * x;
}

fn escribirResultado(indice : u32, valor : vec4f) {
  salida[indice] = valor;      // sin flecha: no devuelve nada
}

Los parámetros llevan su tipo detrás de dos puntos, el retorno detrás de la flecha, y una función sin flecha no devuelve nada. Si la función declara retorno, todos los caminos de ejecución tienen que devolver un valor; el compilador lo comprueba y no acepta un camino que se caiga por el final.

Los tipos válidos para un parámetro son los constructibles —escalares, vectores, matrices, arrays de tamaño fijo, structs de todo lo anterior— y los punteros. No caben texturas, samplers, atómicos ni arrays sin longitud.

Los tipos válidos para el retorno son solo los constructibles: una función no puede devolver un puntero. Es una restricción tajante y elimina de raíz la posibilidad de devolver la dirección de una variable local, que en C es una de las formas clásicas de corromper memoria.

Lo que el lenguaje no te deja

No hay recursión. El grafo de llamadas tiene que ser acíclico, y el compilador lo comprueba. Ni directa ni mutua ni a través de diez niveles.

No hay sobrecarga de funciones de usuario. Dos fn con el mismo nombre son un error aunque tengan firmas distintas. Las funciones integradas sí están sobrecargadas —max acepta i32, u32, f32 y sus vectores— pero ese privilegio no se extiende a tu código.

No hay parámetros por defecto ni número variable de argumentos. Cada llamada pasa exactamente los parámetros declarados.

No hay punteros a función ni funciones anónimas. Todas las llamadas son a un nombre conocido en compilación, lo que permite al compilador inlinear cuando quiera y le permite conocer el grafo completo.

La razón de fondo de casi todas es la misma: una GPU no tiene pila por invocación en el sentido de una CPU. Los registros se reparten estáticamente entre las invocaciones en vuelo, y esa repartición se decide al compilar. Una llamada recursiva o indirecta impediría conocer la profundidad máxima, y con ella el consumo de registros, y con ello el número de invocaciones que caben.

La reescritura de un algoritmo recursivo es siempre la misma: una pila explícita en un array local y un bucle.

const PILA_MAX : u32 = 32u;

fn recorrerArbol(raiz : u32, objetivo : vec3f) -> u32 {
  var pila : array<u32, PILA_MAX>;
  var cima : u32 = 0u;
  pila[0] = raiz;
  cima = 1u;

  var mejor : u32 = 0xffffffffu;

  while (cima > 0u) {
    cima = cima - 1u;
    let nodo = pila[cima];

    if (!intersecta(nodo, objetivo)) { continue; }

    if (esHoja(nodo)) {
      mejor = min(mejor, nodo);
    } else if (cima + 2u <= PILA_MAX) {
      pila[cima] = hijoIzquierdo(nodo);      cima = cima + 1u;
      pila[cima] = hijoDerecho(nodo);        cima = cima + 1u;
    }
  }
  return mejor;
}

El detalle que se olvida y que produce corrupciones difíciles de encontrar es la comprobación de desbordamiento antes de apilar. Con una pila de tamaño fijo y un árbol más profundo de lo previsto, sin esa comprobación escribes fuera del array; con ella, pierdes un resultado, que es mucho más fácil de diagnosticar.

Paso por valor, y lo que cuesta

Todos los parámetros de WGSL se pasan por valor: la función recibe una copia y modificarla no afecta al que llamó. Devolver structs también copia.

En cualquier otro lenguaje eso sería motivo de preocupación con structs grandes. En WGSL no lo es tanto, por dos motivos. El primero es que el compilador inlinea agresivamente: sin recursión y con el grafo de llamadas completo, casi todas las llamadas acaban desapareciendo y los parámetros se convierten en registros. El segundo es que las copias que sobreviven son copias entre registros, no tráfico de memoria.

Donde el paso por valor sí duele es cuando el parámetro es un array grande, porque un array grande no cabe en registros y la copia se hace en memoria local. Ahí es donde entran los punteros:

// Copia un array de 256 flotantes en cada llamada.
fn sumaCopia(v : array<f32, 256>) -> f32 { /* ... */ }

// No copia nada: pasa una direccion.
fn sumaPuntero(p : ptr<function, array<f32, 256>>) -> f32 { /* ... */ }

@must_use y las expresiones constantes

@must_use marca una función cuyo resultado no se puede tirar. Es útil en funciones puras cuyo único efecto es el valor devuelto, porque llamarlas y descartar el resultado siempre es un error del programador:

@must_use
fn saturado(x : f32) -> f32 { return clamp(x, 0.0, 1.0); }

Y hay una limitación que sorprende: una función de usuario no se puede llamar en una expresión de tiempo de compilación. Esto no compila:

fn doble(x : u32) -> u32 { return x * 2u; }
// const TAM = doble(32u);           // ERROR
// var<workgroup> t : array<f32, doble(32u)>;   // ERROR

Solo las funciones integradas se pueden evaluar en compilación. Para calcular constantes derivadas hay que escribir la expresión directamente:

const BASE : u32 = 32u;
const TAM  : u32 = BASE * 2u;              // legal
var<workgroup> t : array<f32, TAM>;        // legal
La pila explícita no es una molestia: es la única versión que se puede hacer rápida

Cuando alguien descubre que no hay recursión, la reacción es tratarlo como una limitación que hay que sortear. La segunda lectura, después de escribir unos cuantos recorridos de árbol en GPU, es que la pila explícita es la versión buena y que la recursión ocultaba decisiones que aquí tienes que tomar.

La primera decisión es el tamaño máximo de la pila, y tomarla te obliga a saber la profundidad de tu estructura. Una BVH construida sin control de balanceo puede tener ramas de sesenta niveles; una construida bien tiene veinticinco. Con recursión eso no se ve. Con una pila de tamaño fijo, o dimensionas para el caso peor —y pagas registros que reducen la ocupación de todas las invocaciones— o construyes la estructura de forma que el caso peor sea acotado. La segunda opción es la correcta y la recursión te la ocultaba.

La segunda decisión es dónde vive la pila. Un array<u32, 32> en el espacio function son 128 bytes por invocación que compiten por registros. Con dos mil invocaciones en vuelo eso es un cuarto de megabyte de banco de registros dedicado a una pila. Reducirla a 24 entradas, o a 16, puede duplicar la ocupación y con ella el rendimiento, aunque el shader haga exactamente lo mismo.

Y la tercera, que es la que separa un recorrido lento de uno rápido, es que muchos recorridos se pueden escribir sin pila ninguna. Una BVH con enlaces de salto —cada nodo guarda a dónde ir si el test falla— se recorre con un solo índice y cero memoria auxiliar. Un octree con codificación de Morton se recorre por aritmética de bits. Un árbol de esqueleto se recorre en orden de padres si lo ordenas al construirlo.

Ninguna de esas tres reformulaciones se le ocurre a nadie mientras tenga disponible la palabra return dentro de sí misma. Que WGSL te la quite te fuerza a mirar la estructura de datos, que es donde estaba el rendimiento desde el principio.

Con las funciones declaradas, toca lo que va dentro: let, var, const y override.