wandres.dev
BUFFERS · Memoria en la GPU

El modelo de memoria: qué es visible desde dónde

Los tres dominios de memoria de WebGPU, qué operaciones cruzan cada frontera y a qué coste, la asimetría entre escribir y leer, y cómo cambia todo en un dispositivo con memoria unificada.

⏱ 18 min

WebGPU no expone la memoria, pero sí expone todas sus consecuencias: qué combinaciones de uso son legales, qué operaciones son síncronas, qué promesas hay que esperar y por qué leer cuesta mucho más que escribir. Reconstruir el modelo subyacente convierte esas reglas dispersas en un solo principio del que se deducen todas.

🎯 Al terminar esta lección sabrás
  • Distinguir los tres dominios de memoria y qué vive en cada uno.
  • Enumerar las operaciones que cruzan cada frontera y su coste relativo.
  • Explicar la asimetría entre la escritura y la lectura.
  • Ajustar las expectativas de coste en dispositivos con memoria unificada.

Tres dominios

El montón de JavaScript. Float32Array, ArrayBuffer, tus objetos. Vive en la memoria del proceso de la página, la gestiona el recolector de basura, y la GPU no puede verlo jamás. Ni una sola operación de la GPU lee de aquí directamente.

La memoria visible por la CPU. Una región a la que tanto la CPU como la GPU pueden acceder, aunque para la GPU sea lenta. Es donde vive un búfer con MAP_READ o MAP_WRITE mientras está mapeado, y donde viven los búferes de staging internos de la implementación. En una tarjeta discreta es memoria del sistema o una ventana del bus; en un chip integrado es la misma memoria del sistema.

La memoria del dispositivo. VRAM propiamente dicha. Donde viven los búferes de vértices, las texturas, los búferes de almacenamiento. Es la única memoria que la GPU lee rápido, y no es directamente accesible desde JavaScript por ninguna vía.

La frontera relevante es la de en medio: el montón de JavaScript nunca toca la memoria del dispositivo. Todo movimiento entre los dos pasa por el dominio intermedio, y toda operación de la API que mueve datos lo que hace es orquestar ese trayecto.

flowchart LR
js[Monton de JavaScript] -->|writeBuffer copia sincrona| stg[Memoria visible por la CPU]
stg -->|copia en la cola| vram[Memoria del dispositivo]
vram -->|copyBufferToBuffer| stg2[Buffer de staging MAP_READ]
stg2 -->|mapAsync y getMappedRange| js2[Monton de JavaScript]
style js fill:#89b4fa,color:#11111b
style js2 fill:#89b4fa,color:#11111b
style stg fill:#f9e2af,color:#11111b
style stg2 fill:#f9e2af,color:#11111b
style vram fill:#fab387,color:#11111b

Las dos mitades del diagrama tienen la misma forma y coste muy distinto, y esa asimetría es lo importante.

Escribir es fácil, leer no

Escribir. queue.writeBuffer copia tus datos al staging de forma síncrona y encola la copia al destino. Cuando la función retorna, tu array ya se puede reutilizar. No hay ninguna espera, ninguna promesa, ninguna sincronización con la GPU. El motivo es que la CPU va por delante: la escritura se encola detrás de trabajo que aún no ha empezado, así que no hay que esperar a nada.

Leer. El resultado que quieres leer todavía no existe: lo va a producir un trabajo que está encolado. Para leerlo hay que grabar una copia a un búfer de staging, enviar, esperar a que la GPU llegue a ese punto, y solo entonces mapear. Esa espera es real y dura lo que dure la cola: típicamente uno o dos fotogramas.

Es la asimetría fundamental de cualquier arquitectura con un acelerador, y explica por qué el patrón de lectura tiene cinco pasos y el de escritura uno. Toda la lección 8 del nivel 8 va de hacerlo sin destruir el rendimiento.

Las operaciones y su coste

Operación Qué cruza Coste
mappedAtCreation más getMappedRange JS a staging Copia síncrona, sin espera
queue.writeBuffer JS a staging, luego a VRAM Copia síncrona más copia en cola
queue.writeTexture Igual, con conversión de disposición Similar, algo mayor
copyExternalImageToTexture Imagen a VRAM por camino rápido El más eficiente para imágenes
copyBufferToBuffer VRAM a VRAM Muy rápido, sin CPU
copyBufferToTexture y su inverso VRAM a VRAM Rápido, con reglas de alineación de filas
mapAsync más getMappedRange Staging a JS Espera a la GPU: uno o más fotogramas
Búfer STORAGE leído como VERTEX Nada Gratis: es el mismo búfer

La última fila es la que da valor a WebGPU y merece subrayarse. Un búfer que un compute shader acaba de escribir se puede usar como búfer de vértices sin copiar nada. Declaras el búfer con STORAGE | VERTEX, lo escribes en un compute pass, lo lees en un render pass del mismo command buffer, y los datos nunca salen de la memoria del dispositivo. Ese es el patrón que hace posible una simulación de un millón de partículas a sesenta fotogramas por segundo, y es literalmente imposible expresarlo en WebGL.

Coherencia sin barreras

WebGPU no expone barreras de memoria y aun así garantiza que ves lo que esperas ver. Las reglas son tres y conviene tenerlas explícitas.

Entre pases del mismo command buffer, el orden se respeta. Si el pase A escribe un búfer y el pase B lo lee, B ve lo que A escribió. La implementación deduce la dependencia e inserta lo que haga falta.

Dentro de un mismo pase, no hay garantía de orden entre invocaciones. Dos invocaciones del mismo dispatch que escriben la misma posición producen un resultado no determinista. Para eso están las operaciones atómicas y las barreras de WGSL.

Un recurso no puede estar en lectura y en escritura a la vez en el mismo pase. La validación lo rechaza: no puedes enlazar el mismo búfer como read y como read_write en el mismo bind group, ni leer una textura que estás usando como adjunto de color. De ahí el patrón de doble búfer: dos búferes, uno de lectura y uno de escritura, que se intercambian cada fotograma.

let [leer, escribir] = [bufferA, bufferB];

function paso(encoder) {
  const pase = encoder.beginComputePass();
  pase.setPipeline(pipelineSimulacion);
  pase.setBindGroup(0, grupos.get(leer));   // grupos precreados por combinacion
  pase.dispatchWorkgroups(Math.ceil(N / 64));
  pase.end();
  [leer, escribir] = [escribir, leer];      // intercambio para el siguiente paso
}

Los bind groups conviene crearlos una vez, los dos, y alternar entre ellos: crear uno por fotograma es coste innecesario.

Memoria unificada

En un móvil, en un portátil con gráficos integrados o en un chip de la familia Apple Silicon, la CPU y la GPU comparten físicamente la misma memoria. Es tentador concluir que las copias desaparecen. No es así, y conviene ser preciso sobre qué cambia y qué no.

Lo que cambia: la copia entre el dominio visible por la CPU y el del dispositivo puede ser gratuita, porque es la misma memoria. Las implementaciones lo aprovechan cuando pueden, y en esas plataformas writeBuffer y mapAsync son bastante más baratos.

Lo que no cambia: la API es la misma en todas partes y la validación es la misma. No hay ninguna forma de escribir código «para memoria unificada» ni de saltarse el patrón de staging. Tu código sigue siendo el mismo; lo que cambia es lo que hace la implementación por debajo.

Lo que empeora: el ancho de banda es compartido. En una tarjeta discreta la GPU tiene su bus para ella sola, con cientos de gigabytes por segundo. En un móvil, ese ancho de banda es una fracción y lo compite con la CPU, el compositor y el resto del sistema. El coste relativo de la memoria frente al cálculo es todavía mayor en un dispositivo integrado, y todo lo que se dijo sobre intensidad aritmética se aplica con más fuerza.

El modelo mental que hay que instalar es que los datos tienen domicilio, y mudarse cuesta

Casi todo lo que la gente hace mal con la memoria en WebGPU viene de una suposición implícita que nadie enuncia: que los datos están «en algún sitio» y que la API los lleva donde hagan falta. En una CPU eso es aproximadamente cierto, porque las cachés lo hacen invisible. En una GPU no lo es en absoluto.

El modelo correcto es que cada dato vive en un sitio concreto, y el trabajo de la aplicación es que viva donde se usa. La pregunta que hay que hacerse ante cada estructura no es qué formato tiene, sino: quién la escribe, quién la lee, y cuántas veces cruza la frontera en un fotograma.

De esa pregunta salen las tres reglas de diseño que ordenan cualquier arquitectura de datos en GPU. Uno: si un dato lo produce la GPU y lo consume la GPU, no debe pasar nunca por la CPU. Suena obvio y se viola constantemente, sobre todo cuando alguien lee un resultado solo para volver a escribirlo, o para tomar una decisión que podría tomarse en la GPU con dibujado indirecto. Dos: si un dato lo produce la CPU y no cambia, se sube una vez y se queda. El patrón de resubir la geometría cada fotograma porque «es más fácil» es el que hunde más aplicaciones. Tres: si un dato tiene que volver a la CPU, que sea lo más pequeño posible y lo menos frecuente posible. Un contador de cuatro bytes en lugar de un array de un millón; una vez cada treinta fotogramas en vez de cada uno.

El corolario que se aplica al diseñar y no al optimizar: un flujo de datos con forma de ida y vuelta por fotograma está mal diseñado, no mal implementado. Ninguna optimización lo arregla, porque el coste es la latencia de la cola, y esa no se puede reducir. Hay que rediseñarlo para que el bucle se cierre entero dentro de la GPU. Reconocer esa forma en un diagrama, antes de escribir código, es probablemente la habilidad más rentable de todo este track.

Con los datos en su sitio, ya se puede dibujar algo. El camino completo empieza en el módulo de shader.