wandres.dev
STORAGE BUFFERS · Lectura y escritura arbitraria

Storage frente a uniform: límites, layout y modos de acceso

Las tres diferencias que separan un storage buffer de un uniform —tamaño, escritura y reglas de layout— con los números exactos de cada límite y lo que implican en la práctica.

⏱ 17 min

Un storage buffer es el mismo objeto GPUBuffer con otro flag de uso y otro espacio de direcciones en el shader. La diferencia no está en la memoria sino en el contrato: dos mil veces más tamaño, permiso de escritura, y reglas de layout más laxas. Las tres cosas vienen juntas porque las tres derivan de la misma decisión de hardware, y saber cuál es esa decisión te dice cuándo elegir uno u otro sin tener que memorizar una lista.

🎯 Al terminar esta lección sabrás
  • Enumerar los tres límites que separan un storage buffer de un uniform, con sus valores.
  • Calcular el layout de una misma struct en los dos espacios y explicar la diferencia.
  • Declarar un storage buffer en WGSL con su modo de acceso y su flag de uso correcto.
  • Elegir entre uniform y storage a partir del patrón de acceso, no del tamaño.

Los tres límites

La primera diferencia es de tamaño, y es de dos órdenes de magnitud:

Límite Uniform Storage
Tamaño máximo del binding 65 536 B (16 384 en compat) 134 217 728 B (128 MiB)
Bindings por etapa 12 8
Bindings dinámicos por pipeline layout 8 4
Alineación mínima del offset 256 B 256 B

maxStorageBufferBindingSize vale 128 MiB garantizados, y está acotado además por maxBufferSize, cuyo mínimo garantizado es 256 MiB. Con esos números, un storage buffer puede contener la escena entera: un millón de partículas con 32 bytes cada una son 32 MiB, cómodamente dentro del presupuesto.

El precio es el número de bindings: 8 por etapa frente a los 12 de uniform, y solo 4 dinámicos por pipeline layout frente a 8. Hay además límites específicos por etapa que en algunos dispositivos son más estrictos: maxStorageBuffersInVertexStage y maxStorageBuffersInFragmentStage, que en el modo de compatibilidad valen 0 y 4 respectivamente. Un shader de vértices que lea storage buffers puede no funcionar en el modo de compatibilidad, y conviene saberlo antes de construir el renderer entero sobre esa suposición.

La escritura y el modo de acceso

En WGSL, la declaración lleva espacio de direcciones y modo de acceso:

// Solo lectura. Se puede leer desde cualquier etapa, incluida vertex.
@group(0) @binding(0) var<storage, read> luces : array<Luz>;

// Lectura y escritura. Solo desde fragment y compute, nunca desde vertex.
@group(0) @binding(1) var<storage, read_write> acumulador : array<atomic<u32>>;

Del lado de la API, el type del layout distingue los dos casos: 'read-only-storage' para read y 'storage' para read_write. Ambos exigen GPUBufferUsage.STORAGE en la creación del buffer.

La restricción de visibilidad es dura y viene de la portabilidad: una entrada de tipo 'storage' no puede ser visible desde GPUShaderStage.VERTEX. Escribir desde la etapa de vértices no está disponible en todo el hardware que WebGPU cubre, así que la especificación lo prohíbe en vez de dejarlo como comportamiento opcional. 'read-only-storage' sí es visible desde vertex, y es la forma correcta de leer datos por instancia desde el vertex shader.

Si omites el modo de acceso en WGSL, el valor por defecto del espacio storage es read. Escribirlo explícitamente cuesta seis caracteres y evita el error de creer que un buffer es escribible cuando no lo es.

El layout, que es donde está la sorpresa buena

El espacio storage no impone las restricciones extra que impone uniform. Ni el stride de 16 en los arrays, ni la alineación a 16 de las structs y arrays anidados. Se aplican solo las cuatro reglas generales.

La consecuencia es que la misma struct puede ocupar tamaños muy distintos según el espacio:

struct Pesos { valores : array<f32, 64> };

En uniform, el stride del array se redondea a 16 y la struct ocupa 1024 bytes. En storage, el stride es el natural, 4, y la struct ocupa 256 bytes. Cuatro veces menos, y con localidad perfecta: los 64 floats están contiguos.

Lo mismo pasa con las structs anidadas. Este caso, que en uniform obliga a un padding de 12 bytes, en storage no lo tiene:

struct Cabecera { n : u32 };
struct Bloque {
  cab   : Cabecera,     // uniform: ocupa 16 por el redondeo. storage: ocupa 4.
  datos : array<f32>,   // uniform: no vale, los runtime arrays son solo storage
};

Ese detalle final es importante y lo desarrollo en la lección de arrays en tiempo de ejecución: un array sin tamaño solo existe en el espacio storage.

ℹ️
Menos restricciones no significa que la alineación desaparezca

Las cuatro reglas generales siguen vigentes en storage. Un vec3<f32> sigue midiendo 12 y alineándose a 16, y un array<vec3<f32>> sigue teniendo stride 16. Lo que desaparece es el redondeo adicional a 16 que impone el espacio uniform. Si tu bug era el vec3, cambiar de espacio no lo arregla.

Elegir entre los dos

El criterio no es el tamaño. Es el patrón de acceso, y se reduce a una pregunta: dentro de un grupo de invocaciones que se ejecutan a la vez, ¿todas leen la misma dirección?

Si la respuesta es sí —las matrices de cámara, los parámetros del frame, las constantes del material— el uniform buffer permite al hardware usar su camino de constantes con difusión, y es la elección correcta aunque el dato sea diminuto.

Si la respuesta es no —cada instancia lee su entrada, cada píxel su tile, cada partícula su estado— el storage buffer es la elección correcta aunque el dato sea pequeño. Meterlo en un uniform no lo hace más rápido: lo hace más lento, porque el camino de constantes no está diseñado para accesos divergentes.

Y hay tres casos donde no hay elección posible: si el shader escribe, tiene que ser storage; si el tamaño no se conoce al compilar, tiene que ser storage; si el tamaño supera los 64 KiB —16 KiB en compat—, tiene que ser storage.

El coste de un storage buffer está en el acceso, no en el tipo

La leyenda dice que los storage buffers son lentos, y viene de una época en que algunos drivers los traducían peor. Hoy la diferencia de latencia entre leer un uniform y leer un storage uniformemente indexado es prácticamente nula en hardware moderno: ambos acaban en la caché. Lo que sí es lento es un patrón de acceso malo, y los storage buffers lo permiten con una facilidad que los uniform no tienen. Un array<Particula> con structs de 36 bytes hace que dos invocaciones consecutivas lean direcciones separadas 36 bytes, lo que rompe la coalescencia: la GPU tiene que emitir transacciones de memoria solapadas y desperdicia ancho de banda. El mismo array con structs de 32 bytes —redondeando con un campo de relleno— alinea cada elemento con la mitad de una línea de caché y la coalescencia funciona. La diferencia medida entre esas dos versiones puede ser del 30 % en un shader limitado por memoria, y no tiene nada que ver con si el buffer es storage o uniform: tiene que ver con que la libertad de layout que da el espacio storage también te deja hacerlo mal. Redondea siempre el tamaño de los elementos de un array grande a una potencia de dos.