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 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 / resolucionvarying 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 normalvarying 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.