wandres.dev
SINCRONIZACIÓN · Asincronía y el mapeo

El búfer de staging: por qué hacen falta dos búferes y una copia

Por qué el búfer que escribe la GPU no se puede mapear, cómo se dimensiona el búfer intermedio, y las reglas de alineación al leer texturas.

⏱ 16 min

El primer intento de todo el mundo es el mismo: crear el búfer de resultados con STORAGE | MAP_READ, lanzar el compute shader y mapearlo. La validación lo rechaza, y sin entender por qué, la solución correcta parece un rodeo burocrático. No lo es: es la consecuencia directa de que la memoria rápida de una GPU y la memoria visible desde la CPU no son la misma memoria.

🎯 Al terminar esta lección sabrás
  • Explicar por qué las reglas de usage prohíben combinar almacenamiento y mapeo.
  • Crear el par de búferes correcto para una lectura.
  • Dimensionar el búfer de staging respetando las alineaciones.
  • Aplicar el relleno de filas al leer una textura.

Por qué no se puede con uno solo

Las reglas de exclusión de usage son terminantes: si MAP_READ está presente, el único otro flag permitido es COPY_DST. Cualquier otra combinación produce un búfer inválido.

// INVALIDO. Y este es exactamente el primer intento de casi todo el mundo.
const resultado = device.createBuffer({
  size: 1024,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.MAP_READ,
});

La razón es de hardware. Un búfer que la CPU puede mapear tiene que vivir en memoria que la CPU pueda direccionar: memoria del sistema, o una ventana del bus hacia la VRAM. Esa memoria es lenta para la GPU. Un búfer de almacenamiento que un compute shader escribe miles de veces por lanzamiento tiene que vivir en la memoria rápida del dispositivo, que la CPU no ve.

WebGPU podría haber aceptado la combinación y colocado el búfer en memoria lenta, con una pérdida de rendimiento silenciosa en el shader. Prefirió rechazarla y obligar a que la transferencia sea visible en tu código. Es el mismo criterio que en getPreferredCanvasFormat: una operación cara nunca ocurre sin aparecer en el código.

El par correcto

Dos búferes con usos complementarios y una copia entre ellos.

const N = 1024;
const TAM = N * 4;                       // 1024 floats

// El que escribe el shader: memoria rapida del dispositivo.
const trabajo = device.createBuffer({
  label: 'resultado del computo',
  size: TAM,
  usage: GPUBufferUsage.STORAGE | GPUBufferUsage.COPY_SRC,
});

// El que lee la CPU: memoria visible, solo estos dos flags.
const staging = device.createBuffer({
  label: 'staging de lectura',
  size: TAM,
  usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});

Y la copia, grabada fuera de cualquier pase, después del pase que produce el dato:

const encoder = device.createCommandEncoder({ label: 'computo y lectura' });

const pase = encoder.beginComputePass();
pase.setPipeline(pipeline);
pase.setBindGroup(0, grupo);
pase.dispatchWorkgroups(Math.ceil(N / 64));
pase.end();

encoder.copyBufferToBuffer(trabajo, 0, staging, 0, TAM);
device.queue.submit([encoder.finish()]);

El orden dentro del encoder es lo único que hace falta para que la copia vea el resultado del cómputo: la implementación deduce la dependencia y coloca la barrera. No hay que sincronizar nada a mano.

💡
El búfer de trabajo puede tener más usos

trabajo solo tiene la restricción de incluir COPY_SRC. Puede tener además VERTEX si la geometría que produce se dibuja, o INDIRECT si contiene parámetros de dibujado, o COPY_DST si la CPU lo inicializa. Las reglas de exclusión solo afectan al búfer mapeable.

Dimensionar el staging

Tres reglas, y la tercera solo aplica a texturas.

El tamaño tiene que ser múltiplo de 4. Lo exige la copia, y también el rango que se va a mapear.

El desplazamiento del mapeo tiene que ser múltiplo de 8 y el tamaño del rango mapeado múltiplo de 4. Si mapeas el búfer entero desde cero, no hay problema.

Al copiar desde una textura, bytesPerRow tiene que ser múltiplo de 256. Esta es la que sorprende y la que produce imágenes sesgadas en diagonal cuando se ignora.

function tamanoStagingParaTextura(ancho, alto, bytesPorPixel) {
  const bytesPorFila = Math.ceil(ancho * bytesPorPixel / 256) * 256;
  return { bytesPorFila, tamano: bytesPorFila * alto };
}

const { bytesPorFila, tamano } = tamanoStagingParaTextura(640, 480, 4);
// ancho 640 * 4 = 2560 bytes, que ya es multiplo de 256: sin relleno.
// Con ancho 500: 2000 bytes -> 2048 por fila, 48 de relleno.

Y la copia de textura a búfer, con esos números:

encoder.copyTextureToBuffer(
  { texture, mipLevel: 0, origin: { x: 0, y: 0, z: 0 } },
  { buffer: staging, offset: 0, bytesPerRow: bytesPorFila, rowsPerImage: alto },
  { width: ancho, height: alto, depthOrArrayLayers: 1 }
);

Al leer los datos de vuelta hay que deshacer el relleno fila a fila, no tratar el búfer como un array contiguo de píxeles:

function desempaquetar(bytes, ancho, alto, bytesPorFila, bytesPorPixel) {
  const salida = new Uint8Array(ancho * alto * bytesPorPixel);
  const anchoUtil = ancho * bytesPorPixel;
  for (let y = 0; y < alto; y++) {
    salida.set(
      bytes.subarray(y * bytesPorFila, y * bytesPorFila + anchoUtil),
      y * anchoUtil
    );
  }
  return salida;
}

Si el ancho de la textura ya produce un múltiplo de 256 —256, 512, 1024 píxeles en rgba8unorm— el relleno es cero y el código sin desempaquetar funciona por casualidad. Es la razón de que este error aparezca tarde, la primera vez que alguien captura una textura de tamaño arbitrario.

Reducir antes de copiar

Como la latencia domina el coste, el tamaño de la copia importa menos de lo que parece. Pero cuando el volumen es grande, el ancho de banda sí se nota, y hay una decisión de diseño que casi siempre gana: reducir en la GPU y leer el resultado pequeño.

El caso típico: quieres el valor máximo de un búfer de un millón de floats. Leer los 4 MB y recorrerlos en JavaScript son 4 MB por el bus y un bucle de un millón de iteraciones en el hilo principal. La alternativa es un par de dispatches que reducen a un solo valor y una lectura de 4 bytes:

// Pase 1: cada workgroup reduce 256 elementos a 1.
// Pase 2: reduce los parciales a un unico valor.
// Se leen 4 bytes en lugar de 4 MB.
encoder.copyBufferToBuffer(parcialFinal, 0, staging, 0, 4);

La misma idea vale para el histograma de una imagen, para una comprobación de convergencia, para un contador de objetos visibles. Si lo que necesitas es un resumen, no traigas los datos: trae el resumen.

El búfer de staging es un recurso escaso, y crearlo dentro del bucle es el error que sigue al error

Una vez que alguien entiende que hacen falta dos búferes, el siguiente paso natural es crear el staging cada vez que hace falta leer. Es la segunda trampa y es más sutil que la primera, porque funciona perfectamente en las pruebas.

Los tres problemas que produce, en orden de aparición. Uno: reservar memoria de vídeo tiene coste, y hacerlo cada fotograma genera presión sobre el asignador de la implementación. Dos: un búfer no se puede destruir mientras está pendiente de mapeo, así que si creas uno por fotograma y lees con dos fotogramas de retraso, tienes tres vivos a la vez sin haberlo decidido. Tres, y el que muerde de verdad: un búfer mapeado no se puede volver a usar hasta que se desmapea, y un búfer con un mapAsync pendiente no admite otro. Si el ciclo de lectura tarda más que el de creación, se acumulan búferes en estado pendiente hasta que la memoria se agota.

La forma correcta es un anillo de búferes de staging preasignados, típicamente tres o cuatro, con un estado explícito por cada uno. Se coge uno libre, se usa, y vuelve al anillo cuando su lectura ha terminado. Si no hay ninguno libre, se salta la lectura de ese fotograma en lugar de esperar. Esa última decisión es la clave y es la que casi nadie toma: saltarse una lectura es gratis y esperar cuesta la canalización entera.

Y una observación sobre el dimensionado que ahorra depurar: el número de búferes del anillo tiene que ser mayor que el número de fotogramas en vuelo. Con dos fotogramas en vuelo y dos búferes, siempre habrá fotogramas en los que no haya ninguno libre. Con cuatro, prácticamente nunca. Cuatro búferes de unos pocos kilobytes son irrelevantes en memoria y eliminan una clase entera de comportamiento errático.

Con los búferes en su sitio, toca la mecánica del mapeo: mapAsync y getMappedRange.