wandres.dev
WGSL II · Tipos y espacios de dirección

Los seis espacios de dirección y qué se puede hacer en cada uno

El mapa completo de function, private, workgroup, uniform, storage y handle: quién comparte cada memoria, qué modos de acceso admite, qué tipos acepta y dónde se declara.

⏱ 22 min

En GLSL todas las variables vivían en un mismo saco y el calificativo delante del nombre —uniform, buffer, shared— decidía casi por accidente cómo se comportaban. WGSL convierte eso en un concepto de primera clase: cada variable pertenece a un espacio de dirección que determina quién la ve, cuánto vive, si se puede escribir y qué tipos admite. Es el concepto que más cuesta a quien viene de WebGL, y es el que ordena todo lo demás.

🎯 Al terminar esta lección sabrás
  • Elegir el espacio de dirección correcto para cada dato de un shader.
  • Enunciar los modos de acceso de cada espacio y cuál se puede escribir explícitamente.
  • Declarar variables en los seis espacios con la sintaxis exacta que cada uno exige.
  • Predecir el coste en registros o en memoria compartida de una declaración antes de escribirla.

Qué decide un espacio de dirección

Un espacio de dirección responde a cuatro preguntas de golpe: quién comparte esa memoria, cuánto tiempo vive, qué se puede hacer con ella y qué tipos acepta. Las cuatro respuestas van juntas y no se pueden mezclar: no hay forma de tener memoria compartida entre invocaciones que además sea escribible desde la CPU, ni memoria privada que sobreviva a la invocación.

flowchart TB
A[Alcance por invocacion] --> F[function]
A --> P[private]
B[Alcance por grupo de trabajo] --> W[workgroup]
C[Alcance por recurso enlazado] --> U[uniform]
C --> S[storage]
C --> H[handle]
F --> F2[var dentro de una funcion - read_write]
P --> P2[var private a nivel de modulo - read_write]
W --> W2[var workgroup solo en compute - read_write]
U --> U2[var uniform con group y binding - solo read]
S --> S2[var storage con group y binding - read o read_write]
H --> H2[texturas y samplers sin espacio escrito - solo read]
style A fill:#cba6f7,color:#11111b
style B fill:#fab387,color:#11111b
style C fill:#94e2d5,color:#11111b
style F fill:#89b4fa,color:#11111b
style P fill:#89b4fa,color:#11111b
style W fill:#89b4fa,color:#11111b
style U fill:#89b4fa,color:#11111b
style S fill:#89b4fa,color:#11111b
style H fill:#89b4fa,color:#11111b
style F2 fill:#a6e3a1,color:#11111b
style P2 fill:#a6e3a1,color:#11111b
style W2 fill:#a6e3a1,color:#11111b
style U2 fill:#a6e3a1,color:#11111b
style S2 fill:#a6e3a1,color:#11111b
style H2 fill:#a6e3a1,color:#11111b

Los tres bloques de arriba son las tres escalas de compartición que existen en una GPU, y el mapa completo cabe en esa idea: memoria que es tuya, memoria que es de tu grupo, y memoria que viene de fuera y la ve todo el mundo.

La tabla completa

Espacio Lo comparte Modos Cómo se declara Qué admite
function una invocación read_write var dentro de una función cualquier tipo constructible
private una invocación read_write var<private> a nivel de módulo cualquier tipo constructible
workgroup todas las invocaciones de un grupo read_write var<workgroup> a nivel de módulo tamaño fijo; admite atómicos
uniform todo el pipeline read var<uniform> con @group y @binding host-shareable, sin arrays sin longitud
storage todo el pipeline read o read_write var<storage> con @group y @binding host-shareable; admite array sin longitud al final y atómicos
handle todo el pipeline read var sin espacio, con @group y @binding solo texturas y samplers

Y la sintaxis de los seis, uno detrás de otro:

var<private> semilla : u32;                                  // private
var<workgroup> parciales : array<f32, 64>;                   // workgroup

@group(0) @binding(0) var<uniform> camara : Camara;          // uniform
@group(0) @binding(1) var<storage, read> luces : array<Luz>; // storage de lectura
@group(0) @binding(2) var<storage, read_write> salida : array<vec4f>;
@group(0) @binding(3) var muestreo : sampler;                // handle
@group(0) @binding(4) var albedo : texture_2d<f32>;          // handle

fn ejemplo() {
  var acumulado : vec3f;      // function: el espacio se omite siempre
  acumulado.x = 1.0;
}

Cuatro reglas que se comprueban en compilación y que explican la mayoría de los errores de este nivel:

workgroup solo existe en compute. Si un punto de entrada @vertex o @fragment alcanza, aunque sea a través de tres funciones auxiliares, una variable var<workgroup>, el módulo no compila. Es la restricción que hace que las funciones de reducción no se puedan reutilizar entre etapas sin cuidado.

Los atómicos solo viven en workgroup y en storage. Un atomic<u32> en private no tiene sentido —no hay con quién competir— y en uniform tampoco, porque es de solo lectura.

Los arrays sin longitud solo viven en storage. Un uniform buffer tiene tamaño conocido en el momento de crear el pipeline, y eso es parte de por qué es más rápido.

uniform, storage y handle exigen @group y @binding. Son los tres espacios que corresponden a recursos externos, y sin las dos coordenadas no hay forma de conectarlos con un bind group.

Los modos de acceso

Hay tres modos en el lenguaje: read, write y read_write. La regla que sorprende es que solo se puede escribir explícitamente el modo de acceso de una variable en el espacio storage. En los demás no se escribe, porque no hay elección: function, private y workgroup son siempre read_write, y uniform y handle son siempre read.

@group(0) @binding(0) var<storage> a : array<f32>;              // read por defecto
@group(0) @binding(1) var<storage, read> b : array<f32>;        // identico al anterior
@group(0) @binding(2) var<storage, read_write> c : array<f32>;  // lectura y escritura

El modo tiene que casar con el type de la entrada del bind group layout: read se corresponde con 'read-only-storage' y read_write con 'storage'. Y hay una restricción de portabilidad que muerde: una entrada de storage escribible no puede ser visible desde la etapa de vértices. Si necesitas leer un array grande en el vertex shader, tiene que ser read.

El modo write a secas no se usa en buffers. Aparece en el tipo de las texturas de almacenamiento, que llevan su acceso dentro del propio tipo:

@group(0) @binding(3) var destino : texture_storage_2d<rgba8unorm, write>;

Punteros, alcance y coste

Un puntero lleva el espacio de dirección dentro de su tipo, y por eso no hay forma de pasar un puntero a memoria de grupo donde se espera uno a memoria privada:

fn suma(p : ptr<function, array<f32, 4>>) -> f32 {
  return (*p)[0] + (*p)[1] + (*p)[2] + (*p)[3];
}

El modo de acceso también forma parte del tipo cuando el espacio es storage: ptr<storage, Datos, read_write>. En los demás espacios se omite igual que en la declaración de la variable.

Y ahora la parte que decide el rendimiento. Declarar una variable en un espacio u otro no es solo una cuestión de visibilidad: es una decisión sobre qué recurso físico consume.

Una variable en function o en private vive, si cabe, en registros, y el banco de registros de una GPU se reparte entre todas las invocaciones en vuelo. Cuantos más registros consuma tu shader, menos invocaciones caben a la vez y menos capacidad tiene la GPU de tapar la latencia de memoria. Un var<private> tabla : array<f32, 256> no cabe en registros y acaba en memoria local, que es memoria del dispositivo con caché: cientos de veces más lenta que un registro, y por invocación.

Una variable en workgroup consume el pool de memoria compartida del multiprocesador, que está acotado por maxComputeWorkgroupStorageSize, con un mínimo garantizado de 16384 bytes. Ese pool también limita cuántos grupos caben a la vez: si un grupo pide 16 KiB y el hardware tiene 64 KiB, caben cuatro grupos como mucho, independientemente de los registros.

⚠️
El array grande por invocación es el antipatrón más caro de WGSL

var<private> historial : array<vec4f, 64>; son 1024 bytes por invocación. Con unas pocas miles de invocaciones en vuelo eso son megabytes de memoria local y un shader que va a velocidad de memoria por mucho que la aritmética sea trivial. Si el algoritmo necesita un array de trabajo grande, casi siempre hay una reformulación: recorrer en varias pasadas, usar memoria de grupo compartida entre las invocaciones, o guardar el estado en un storage buffer indexado por identificador global.

El espacio de dirección es el modelo de coherencia, no solo el de visibilidad

La lectura fácil de esta tabla es «quién ve qué». La lectura que hace falta para escribir compute shaders correctos es otra: el espacio de dirección determina qué garantías tienes sobre cuándo tus escrituras se hacen visibles para otro.

En function y private la pregunta no existe: nadie más puede leer esa memoria, así que el compilador puede reordenar, mantener valores en registros indefinidamente y no escribir nunca a memoria si no hace falta. Es la razón de que estos dos espacios sean gratis y de que el compilador optimice ahí con total libertad.

En workgroup sí existe y tiene respuesta: workgroupBarrier(). Sin barrera, que una invocación escriba y otra lea no garantiza absolutamente nada, ni siquiera dentro del mismo grupo, porque las invocaciones avanzan a ritmos distintos y el compilador puede tener el valor en un registro. La barrera es a la vez sincronización de ejecución y publicación de la memoria.

En storage la pregunta se vuelve global y la respuesta se vuelve incómoda: entre invocaciones de grupos distintos, dentro del mismo dispatch, no hay ninguna forma de sincronizar. storageBarrier() ordena los accesos, pero no espera a nadie fuera del grupo. Si el algoritmo necesita que todos los grupos hayan terminado la fase uno antes de empezar la fase dos, la única herramienta correcta es terminar el dispatch y lanzar otro. Ese es el motivo de que los algoritmos de reducción y de scan se escriban en varias pasadas y no en un bucle con barreras.

La conclusión práctica que se saca de aquí, y que a mí me habría ahorrado mucho tiempo: cuando un compute shader da resultados distintos entre ejecuciones, el fallo casi nunca es de aritmética; es que hay una lectura y una escritura en dos espacios distintos separadas por una barrera que no existe o que no era la que hacía falta. Antes de depurar el cálculo, dibuja qué invocación escribe cada byte y qué invocación lo lee. Si esas dos invocaciones no están en el mismo grupo, el diseño ya está mal y ninguna barrera lo va a salvar.