Paralelismo masivo: miles de invocaciones que no se hablan
Qué se puede y qué no se puede programar cuando cada invocación está aislada, y cómo se reformulan los algoritmos que necesitan vecinos o acumuladores.
La GPU no es una CPU rápida: es una máquina con un modelo de cómputo distinto, diseñada para ejecutar la misma función pura sobre millones de entradas independientes. Todo lo que puedas expresar en esa forma va a ir absurdamente rápido, y todo lo que no, no va a ir. Aprender a reconocer en cuál de los dos grupos cae un problema —y a mover problemas del segundo al primero— es lo que separa escribir shaders de pelearse con ellos.
- Enunciar la forma canónica a la que hay que reducir cualquier algoritmo de shader.
- Reformular tres algoritmos clásicos que en CPU usan vecinos o acumuladores.
- Explicar qué es la ocupación y por qué un shader con muchas variables vivas va más lento.
- Reconocer las dos únicas grietas del aislamiento y sus condiciones.
La forma canónica
Todo shader es, sin excepción, una función de esta forma:
salida = f( coordenada, uniforms, texturas )
La coordenada es lo único que distingue una invocación de otra: el índice del vértice y sus atributos en la etapa de vértices, la posición en pantalla y las varyings interpoladas en la de fragmentos. Los uniforms son idénticos para todas las invocaciones del mismo dibujado. Las texturas son memoria de solo lectura, accesible en cualquier posición.
Y f tiene que ser pura: sin efectos secundarios, sin estado, y con el mismo resultado para la misma entrada. No es una recomendación de estilo, es la única forma que el modelo admite. Si tu algoritmo no cabe en esa firma, no cabe.
Las tres cosas que la firma prohíbe explícitamente son las que más cuesta soltar. No puedes leer la salida de otra invocación: no existe “qué color le tocó al píxel de al lado”. No puedes acumular en una variable compartida: no existe “suma uno a este contador global”. Y no puedes decidir el orden: no existe “primero los de arriba y luego los de abajo”.
Tres algoritmos reformulados
Desenfoque. En CPU se escribe recorriendo píxeles y promediando vecinos ya calculados. En GPU no hay vecinos calculados, pero sí hay una textura de solo lectura con el resultado de la pasada anterior. La reformulación es: cada fragmento muestrea la textura N veces alrededor de su propia coordenada y promedia.
uniform sampler2D uEntrada;
uniform vec2 uTexel; // 1.0 / resolucion
varying vec2 vUv;
void main() {
vec4 suma = vec4( 0.0 );
suma += texture2D( uEntrada, vUv + vec2( -1.0, -1.0 ) * uTexel ) * 0.0625;
suma += texture2D( uEntrada, vUv + vec2( 0.0, -1.0 ) * uTexel ) * 0.125;
suma += texture2D( uEntrada, vUv + vec2( 1.0, -1.0 ) * uTexel ) * 0.0625;
suma += texture2D( uEntrada, vUv + vec2( -1.0, 0.0 ) * uTexel ) * 0.125;
suma += texture2D( uEntrada, vUv ) * 0.25;
suma += texture2D( uEntrada, vUv + vec2( 1.0, 0.0 ) * uTexel ) * 0.125;
suma += texture2D( uEntrada, vUv + vec2( -1.0, 1.0 ) * uTexel ) * 0.0625;
suma += texture2D( uEntrada, vUv + vec2( 0.0, 1.0 ) * uTexel ) * 0.125;
suma += texture2D( uEntrada, vUv + vec2( 1.0, 1.0 ) * uTexel ) * 0.0625;
gl_FragColor = suma;
}
Los pesos suman uno. Y observa la diferencia conceptual: no lee resultados, lee entradas. Nadie escribe donde otro lee.
Simulación de partículas. En CPU se actualiza un array en el sitio. En GPU eso es imposible: no puedes leer y escribir la misma textura en la misma pasada, porque el orden entre invocaciones no está definido. La reformulación es el doble búfer: lees del estado A, escribes el estado B, e intercambias.
Suma total. En CPU es un bucle con acumulador. En GPU no hay acumulador, así que se hace por reducción: una pasada que produce una textura de la mitad de lado sumando bloques de dos por dos, repetida hasta llegar a un téxel. Diez pasadas para una textura de 1024. Cada pasada es una función pura y el resultado es la suma.
Ocupación: por qué un shader gordo va lento sin hacer más trabajo
La GPU no tiene cachés grandes ni predicción de saltos. Su estrategia para esconder la latencia de la memoria es otra: mantener muchísimos grupos de trabajo en vuelo a la vez, de modo que cuando uno se queda esperando una lectura de textura, el planificador cambia a otro instantáneamente.
El número de grupos que puede tener en vuelo depende de los registros. Cada unidad de ejecución tiene un banco de registros de tamaño fijo que se reparte entre los grupos residentes. Si tu shader necesita muchos valores vivos a la vez, cada grupo se lleva más registros y caben menos grupos. Esa proporción es la ocupación.
La consecuencia es contraintuitiva: un shader puede ejecutar exactamente las mismas operaciones que otro y ser más lento porque mantiene más variables vivas simultáneamente y por tanto esconde peor la latencia. Y también explica por qué desenrollar un bucle a mano —que reduce instrucciones de control— puede empeorar el rendimiento si multiplica las variables vivas.
No hay forma de medir la ocupación desde WebGL. Lo que sí puedes hacer es escribir con esta idea en la cabeza: reutiliza variables en vez de declarar una nueva por paso intermedio, agrupa las lecturas de textura en vez de repartirlas, y desconfía de las funciones auxiliares con muchos parámetros y muchos temporales.
Las dos grietas del aislamiento
Hay exactamente dos formas de que una invocación sepa algo de otra, y las dos tienen condiciones estrictas.
La primera son las derivadas. dFdx( x ) devuelve la diferencia del valor de x entre este fragmento y su vecino horizontal dentro del bloque de dos por dos, y dFdy lo mismo en vertical. Funciona porque el hardware ejecuta los cuatro fragmentos del bloque a la vez, no porque haya comunicación. Es una lectura de vecino, sí, pero solo dentro del bloque, solo entre fragmentos y solo si todos han seguido el mismo camino de ejecución. En WebGL 2 estas funciones son parte del núcleo del lenguaje y no hacen falta extensiones.
// Normal geometrica del triangulo, sin usar el atributo normal
varying vec3 vMundo;
void main() {
vec3 n = normalize( cross( dFdx( vMundo ), dFdy( vMundo ) ) );
gl_FragColor = vec4( n * 0.5 + 0.5, 1.0 );
}
La segunda es la mezcla, el blending. No es programable, pero permite que varias invocaciones contribuyan al mismo píxel de forma acumulativa, porque la operación se hace en hardware de función fija y con garantías de orden dentro de un mismo dibujado. Sumar con THREE.AdditiveBlending es la forma canónica de acumular en GPU sin atómicos, y es la base de los sistemas de partículas luminosas y de los histogramas en GPU.
El salto mental que desbloquea la programación de GPU es dejar de iterar sobre las entradas y empezar a iterar sobre las salidas. Un algoritmo de CPU que dice “para cada partícula, deposita su brillo en los píxeles cercanos” es imposible en un fragment shader, porque exige escribir en posiciones arbitrarias. El mismo algoritmo reformulado como “para cada píxel, suma el brillo de las partículas cercanas” sí es posible, y es exactamente el mismo resultado. Ese giro tiene nombre en la literatura: se pasa de un algoritmo de dispersión a uno de recolección. La recolección siempre se puede paralelizar sin sincronización porque cada salida tiene un único escritor; la dispersión no, porque varios escritores compiten por la misma salida. Y cuando la recolección resulta demasiado cara —para cada píxel, mirar diez mil partículas es inviable— la salida no es volver a la dispersión, es cambiar la estructura: rejillas espaciales, texturas jerárquicas, o directamente dibujar cada partícula como un cuadrado pequeño y dejar que el blending aditivo haga la dispersión por ti en hardware. Casi todos los trucos famosos de gráficos en tiempo real, del shadow mapping al bloom, son alguna variante de esta misma conversión.