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

Leer sin provocar un parón: el anillo de staging completo

Los cinco errores del primer intento de lectura, y una implementación completa del anillo de búferes de staging que lee resultados de la GPU sin bloquear el bucle de fotograma.

⏱ 20 min

Este es el patrón que todo el mundo escribe mal la primera vez, y la razón es que la versión ingenua funciona: da el resultado correcto y no lanza ningún error. Lo único que hace es partir el rendimiento por la mitad, de una forma que no aparece en ningún perfilador porque su síntoma es que los dos lados están ociosos. La versión correcta no es más difícil, es distinta: se basa en no esperar nunca y en aceptar datos de hace unos fotogramas.

🎯 Al terminar esta lección sabrás
  • Identificar los cinco errores del intento ingenuo de lectura.
  • Implementar un anillo de búferes de staging con reciclaje seguro.
  • Integrar la lectura en el bucle de fotograma sin ningún await.
  • Decidir qué hacer cuando no hay ningún búfer libre.

Los cinco errores del primer intento

// LA VERSION QUE TODO EL MUNDO ESCRIBE PRIMERO
async function frame() {
  const staging = device.createBuffer({                    // (1)
    size: TAM,
    usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
  });

  const encoder = device.createCommandEncoder();
  const pase = encoder.beginComputePass();
  pase.setPipeline(pipeline); pase.setBindGroup(0, grupo);
  pase.dispatchWorkgroups(grupos);
  pase.end();
  encoder.copyBufferToBuffer(trabajo, 0, staging, 0, TAM);

  await staging.mapAsync(GPUMapMode.READ);                 // (2) y (3)
  device.queue.submit([encoder.finish()]);

  const datos = new Float32Array(staging.getMappedRange());
  staging.unmap();                                          // (4)
  usar(datos);                                              // (5)

  requestAnimationFrame(frame);
}

Error uno: crear el staging dentro del bucle. Una reserva de memoria de vídeo por fotograma. Y como un búfer con mapeo pendiente no se puede destruir sin más, se acumulan.

Error dos: el await dentro del bucle de fotograma. Es el error central. La CPU se para hasta que la GPU vacía la cola, la GPU se queda sin trabajo siguiente, y el solapamiento entre fotogramas desaparece. El rendimiento cae aproximadamente a la mitad con los dos lados aparentando estar ociosos.

Error tres: mapAsync antes de submit. La copia todavía no se ha enviado cuando se pide el mapeo. En el mejor caso la promesa resuelve antes de que la copia ocurra y lees datos viejos; en el peor, el búfer está en estado pendiente cuando la copia intenta usarlo y es un error de validación. El submit va siempre antes del mapAsync.

Error cuatro: desmapear antes de copiar los datos. El ArrayBuffer que devuelve getMappedRange() se desancla al llamar a unmap(). La vista datos se queda con longitud cero sin lanzar nada.

Error cinco: usar datos después. Consecuencia del anterior: se lee un array vacío y todo el mundo se pregunta por qué el resultado es undefined.

El anillo de staging, completo

La versión correcta se apoya en tres ideas: búferes preasignados, ninguna espera y el último resultado disponible en lugar del de este fotograma.

export class LectorGPU {
  /**
   * @param {GPUDevice} device
   * @param {number} tamano  bytes a leer, multiplo de 4
   * @param {number} copias  buferes del anillo, mayor que los frames en vuelo
   */
  constructor(device, tamano, copias = 4, label = 'lector') {
    this.device = device;
    this.tamano = tamano;
    this.todos = [];
    this.libres = [];
    this.porMapear = [];
    this.ultimo = null;      // ArrayBuffer con el resultado mas reciente
    this.version = 0;        // se incrementa con cada lectura completada
    this.saltadas = 0;       // diagnostico: lecturas omitidas por falta de sitio
    this.cerrado = false;

    for (let i = 0; i < copias; i++) {
      const b = device.createBuffer({
        label: `${label} staging ${i}`,
        size: tamano,
        usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
      });
      this.todos.push(b);
      this.libres.push(b);
    }
  }

  /**
   * Paso 1: durante la grabacion del fotograma.
   * Graba la copia si hay un bufer libre. Nunca espera.
   * @returns {boolean} true si la lectura se ha encolado
   */
  grabarCopia(encoder, origen, origenOffset = 0) {
    if (this.cerrado) return false;
    const staging = this.libres.pop();
    if (!staging) { this.saltadas++; return false; }
    encoder.copyBufferToBuffer(origen, origenOffset, staging, 0, this.tamano);
    this.porMapear.push(staging);
    return true;
  }

  /**
   * Paso 2: justo despues de queue.submit().
   * Lanza los mapeos sin esperarlos.
   */
  trasEnviar() {
    while (this.porMapear.length > 0) {
      const staging = this.porMapear.pop();
      staging.mapAsync(GPUMapMode.READ).then(
        () => {
          if (this.cerrado) return;
          this.ultimo = staging.getMappedRange().slice(0);  // copia antes de unmap
          this.version++;
          staging.unmap();
          this.libres.push(staging);                        // vuelve al anillo
        },
        () => {
          // Dispositivo perdido o bufer destruido: no se recicla.
        }
      );
    }
  }

  /** Libera todo. Los mapeos pendientes se rechazan y se ignoran. */
  destruir() {
    this.cerrado = true;
    this.libres.length = 0;
    this.porMapear.length = 0;
    for (const b of this.todos) b.destroy();
    this.todos.length = 0;
  }
}

Cinco decisiones de ese código son las que lo hacen correcto.

grabarCopia devuelve false en lugar de esperar. Si no hay búfer libre, la lectura de ese fotograma se salta. El contador saltadas sirve para saber si el anillo es demasiado pequeño.

trasEnviar es una llamada separada. Existe únicamente para garantizar el orden: primero submit, después mapAsync. Es la corrección del error tres.

El .then no se espera. El reciclaje ocurre cuando ocurra, sin bloquear a nadie.

slice(0) copia antes de unmap(). El ArrayBuffer que se guarda en this.ultimo es independiente y sobrevive.

El segundo argumento de then captura el rechazo. Si el dispositivo se pierde o el búfer se destruye con un mapeo pendiente, la promesa rechaza. Sin ese manejador sería un rechazo no capturado en la consola cada vez que se cierra la vista.

Usarlo en un fotograma

const lector = new LectorGPU(device, 4, 4, 'contador de visibles');

function frame(t) {
  const encoder = device.createCommandEncoder({ label: 'frame' });

  // Reiniciar el contador que el compute shader va a incrementar.
  encoder.clearBuffer(contador, 0, 4);

  const sim = encoder.beginComputePass({ label: 'culling' });
  sim.setPipeline(pipelineCulling);
  sim.setBindGroup(0, grupoCulling);
  sim.dispatchWorkgroups(Math.ceil(numObjetos / 64));
  sim.end();

  // Encolar la lectura del contador. Si no hay sitio, se salta y no pasa nada.
  lector.grabarCopia(encoder, contador);

  const render = encoder.beginRenderPass(descRender);
  dibujar(render);
  render.end();

  device.queue.submit([encoder.finish()]);
  lector.trasEnviar();                       // SIEMPRE despues del submit

  // Consumir el ultimo valor disponible, que sera de hace 2 o 3 fotogramas.
  if (lector.ultimo) {
    const visibles = new Uint32Array(lector.ultimo)[0];
    hud.textContent = `${visibles} objetos visibles`;
  }

  requestAnimationFrame(frame);
}
requestAnimationFrame(frame);

No hay ni un solo await en la función. Esa es la propiedad que hay que verificar y es un criterio de revisión perfectamente aplicable: si aparece un await dentro de la función que llama requestAnimationFrame, algo está mal.

Y el valor que se consume es lector.ultimo, que es de hace dos o tres fotogramas. Para un contador que se muestra en pantalla, para una decisión de nivel de detalle o para una adaptación de exposición, esa antigüedad es completamente invisible.

⚠️
Cuándo el retraso sí importa

Hay casos donde tres fotogramas de antigüedad no valen: una selección al hacer clic tiene que devolver el objeto que estaba bajo el ratón en ese momento, no el de hace 50 ms. Para eventos puntuales, la lectura con await es la respuesta correcta, porque no está en el bucle: ocurre una vez, y que tarde 50 ms es perfectamente aceptable. El anillo es para lecturas repetidas; el await puntual es para lecturas esporádicas.

Dimensionar el anillo y leer el diagnóstico

El número de búferes tiene que ser mayor que el número de fotogramas en vuelo. Con dos fotogramas en vuelo y dos búferes, siempre habrá fotogramas sin ninguno libre. Con cuatro, prácticamente nunca.

El contador saltadas es el diagnóstico:

setInterval(() => {
  if (lector.saltadas > 0) {
    console.warn(`lecturas saltadas: ${lector.saltadas} - el anillo va justo`);
    lector.saltadas = 0;
  }
}, 5000);

Si sube de forma sostenida, hay dos causas posibles: el anillo es pequeño, o la GPU va tan por detrás que las lecturas tardan más de lo que dura un ciclo del anillo. En el primer caso se añaden búferes; en el segundo, el problema real es que la cola está saturada y hay que mirar el rendimiento, no la lectura.

Y una precaución de ciclo de vida: llama a destruir() al desmontar la vista. Si no, quedan búferes de vídeo vivos y promesas que resuelven sobre un objeto que ya nadie usa. Con device.lost enganchado, el manejador de pérdida también debería llamarlo.

La lectura correcta es un flujo, no una petición, y ese cambio de forma es lo que hay que interiorizar

El obstáculo real no es la API: es que se piensa en la lectura como una pregunta. «Dame el valor». Una pregunta tiene respuesta inmediata por definición, y ahí es donde entra el await que rompe la canalización.

La forma correcta es pensarla como un flujo: la GPU produce valores a su ritmo, tu código los recoge cuando llegan, y en cada fotograma usa el más reciente que tenga. Nadie espera a nadie. Es exactamente el mismo cambio de forma que va de una llamada bloqueante a un suscriptor de eventos, y produce las mismas ventajas: se puede saltar valores, se puede acumular, se puede tener varios flujos independientes sin coordinarlos.

Ese cambio de forma tiene una consecuencia de diseño que va mucho más allá de la lectura: el estado de tu aplicación deja de tener un único instante presente. Hay datos de este fotograma, datos de hace tres, y ambos conviven. Suena incómodo y en la práctica es liberador, porque es lo que ya hace cualquier sistema distribuido y lo que ya hace tu propio motor con la canalización de fotogramas. La única disciplina que exige es ser explícito sobre la antigüedad de cada dato: nombrar visiblesHaceUnosFotogramas en lugar de visibles no es pedantería, es lo que evita que alguien construya una decisión de física sobre un número que no es de ahora.

Y hay un caso límite que conviene tener en la cabeza porque no tiene solución dentro de este patrón: cuando el resultado de la lectura tiene que afectar al mismo fotograma que lo produjo. Ahí no hay anillo que valga, porque el ciclo es intrínseco. Las dos salidas son mover la decisión a la GPU —con dibujado indirecto o con un pase adicional que consuma el resultado sin salir del dispositivo— o rediseñar el algoritmo para que tolere un fotograma de retraso. Reconocer que estás en ese caso pronto, en el diagrama y no en el perfilador, es lo que evita construir mucho código sobre un cimiento que no se puede optimizar.

⚔️ Reto práctico

Instrumenta el ejemplo para medir la antigüedad real de cada lectura.

Guarda el número de fotograma en el momento de llamar a grabarCopia, asócialo al búfer de staging que se ha usado, y cuando la promesa resuelva calcula la diferencia con el fotograma actual. Muéstrala en pantalla junto con el valor leído.

Después, prueba a subir la carga de GPU hasta que el tiempo de fotograma se duplique y observa cómo cambia la antigüedad. Ese número es la profundidad efectiva de tu cola, y es la medida que te dice cuántos búferes necesita el anillo de verdad.

Con esto, las cuatro regiones del mapa del territorio están cubiertas: iniciación, datos, programas y comandos. A partir de aquí todo es profundizar en cada una.