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

onSubmittedWorkDone: qué garantiza y para qué sirve de verdad

Qué significa exactamente que la promesa resuelva, sus tres usos legítimos, por qué no sirve para sincronizar recursos y cómo medir tiempo de GPU sin timestamp-query.

⏱ 15 min

queue.onSubmittedWorkDone() es la única forma que tiene tu código de enterarse de que la GPU ha terminado algo. Es una función sin argumentos que devuelve una promesa, y su simplicidad esconde una semántica precisa que conviene tener clara, porque se usa mal con frecuencia: para sincronizar cosas que ya estaban sincronizadas, o dentro del bucle de fotograma, donde destruye el rendimiento.

🎯 Al terminar esta lección sabrás
  • Enunciar con precisión qué garantiza la promesa y qué no.
  • Aplicar sus tres usos legítimos: medir, reciclar y limitar la profundidad de la cola.
  • Explicar por qué no hace falta para sincronizar recursos entre pases.
  • Medir tiempo de GPU sin la feature timestamp-query.

Qué garantiza

await device.queue.onSubmittedWorkDone();

No recibe argumentos y resuelve con undefined. La garantía es esta: todo el trabajo encolado antes de la llamada ha terminado de ejecutarse en la GPU.

«Encolado antes de la llamada» incluye los submit, los writeBuffer, los writeTexture y los copyExternalImageToTexture anteriores. No incluye nada encolado después.

La especificación añade una garantía complementaria: su resolución implica la resolución de los mapAsync pendientes sobre búferes usados exclusivamente en esa cola. Es decir, si la promesa de onSubmittedWorkDone ya resolvió, la de mapAsync sobre ese búfer también.

Y lo que no garantiza: no dice nada sobre cuándo empezó el trabajo, ni cuánto tardó cada parte, ni si el resultado se ha presentado en pantalla. Solo dice que terminó.

Los tres usos legítimos

Uno: medir. Envolver un trabajo entre dos marcas de tiempo de CPU con una espera en medio da el tiempo total de GPU de ese trabajo. No es tan preciso como timestamp-query y funciona en cualquier dispositivo.

Dos: reciclar recursos. Un búfer de staging, una textura temporal o cualquier objeto que quieras reutilizar no se puede tocar mientras la GPU lo está usando. La promesa te dice cuándo es seguro.

device.queue.submit([encoder.finish()]);
device.queue.onSubmittedWorkDone().then(() => {
  disponibles.push(staging);      // ya nadie lo esta usando
});

Fíjate en que no hay await: se lanza la promesa y se sigue. El reciclaje ocurre cuando ocurra.

Tres: limitar la profundidad de la cola. Si la GPU es más lenta que la CPU, el trabajo se acumula y aparece latencia de entrada. Contar cuántos fotogramas hay en vuelo y no encolar más de dos o tres controla ese problema:

let enVuelo = 0;
const MAX_EN_VUELO = 2;

function frame() {
  if (enVuelo < MAX_EN_VUELO) {
    const encoder = device.createCommandEncoder();
    // ... grabar el fotograma
    device.queue.submit([encoder.finish()]);
    enVuelo++;
    device.queue.onSubmittedWorkDone().then(() => { enVuelo--; });
  }
  requestAnimationFrame(frame);
}

Otra vez sin await. El contador se decrementa cuando la promesa resuelve, y el fotograma siguiente comprueba si hay hueco. Si no lo hay, se salta el trabajo de ese fotograma en lugar de esperar.

⚠️
Uno en vuelo es peor que dos

Bajar MAX_EN_VUELO a 1 da la latencia mínima y un rendimiento pésimo, porque elimina el solapamiento entre CPU y GPU por completo. Dos es el equilibrio habitual: la CPU prepara el siguiente mientras la GPU ejecuta el actual, y la latencia se mantiene acotada.

Para qué NO sirve

El malentendido más extendido es usarlo para asegurarse de que un recurso está listo antes de leerlo en otro pase. No hace falta y es contraproducente.

WebGPU garantiza el orden de la cola y deduce las dependencias entre pases del mismo command buffer. Si el pase A escribe una textura y el pase B la lee, grabarlos en ese orden es suficiente:

// CORRECTO: no hace falta ninguna espera.
const encoder = device.createCommandEncoder();
const a = encoder.beginComputePass();  /* escribe en resultado */  a.end();
const b = encoder.beginRenderPass(d);  /* lee resultado */         b.end();
device.queue.submit([encoder.finish()]);
// INNECESARIO Y DAÑINO: serializa CPU y GPU sin ganar nada.
device.queue.submit([cbComputo]);
await device.queue.onSubmittedWorkDone();
device.queue.submit([cbRender]);

La segunda versión produce el mismo resultado visual y aproximadamente la mitad de rendimiento, porque la CPU se para a esperar y la GPU se queda sin trabajo siguiente. Es el antipatrón que aparece cuando alguien traduce mentalmente el modelo de sincronización explícita de Vulkan a una API que no lo necesita.

La regla: si los dos trabajos están en la misma cola, ya están ordenados. Esperar solo tiene sentido cuando el consumidor es tu código de JavaScript, no otro pase.

Medir tiempo de GPU sin timestamp-query

timestamp-query es una feature opcional y no está en todas partes. Sin ella, onSubmittedWorkDone da una aproximación suficiente para comparar alternativas, que es para lo que se mide en el 90% de los casos.

async function medirGPU(device, grabar, repeticiones = 50) {
  // Calentamiento: la primera pasada incluye compilacion y reservas.
  for (let i = 0; i < 5; i++) {
    const e = device.createCommandEncoder();
    grabar(e);
    device.queue.submit([e.finish()]);
  }
  await device.queue.onSubmittedWorkDone();

  const t0 = performance.now();
  for (let i = 0; i < repeticiones; i++) {
    const e = device.createCommandEncoder();
    grabar(e);
    device.queue.submit([e.finish()]);
  }
  await device.queue.onSubmittedWorkDone();
  const t1 = performance.now();

  return (t1 - t0) / repeticiones;
}

const ms = await medirGPU(device, (e) => {
  const p = e.beginComputePass();
  p.setPipeline(pipeline);
  p.setBindGroup(0, grupo);
  p.dispatchWorkgroups(1024);
  p.end();
});
console.log(`${ms.toFixed(3)} ms por lanzamiento`);

Tres decisiones de ese código importan. El calentamiento descarta la primera pasada, que incluye la compilación real del pipeline y las reservas iniciales y puede ser un orden de magnitud más lenta. Las repeticiones amortizan el coste fijo del submit y la latencia de la promesa, que en una sola medición dominarían el resultado. Y la medida incluye el coste de grabar, así que sirve para comparar dos implementaciones del mismo trabajo, no para obtener el tiempo absoluto de GPU.

Para eso último hace falta timestamp-query, que escribe marcas dentro del propio flujo de comandos con el campo timestampWrites del descriptor del pase.

El calentamiento no es una formalidad de benchmark: es la razón de que las medidas mientan

La medición de rendimiento en GPU tiene una trampa que invalida más comparaciones de las que la gente cree, y no es el ruido: es que las primeras ejecuciones de cualquier cosa son sistemáticamente distintas de las siguientes, y por causas que se acumulan.

Qué ocurre solo la primera vez. El pipeline se compila de verdad al código máquina de esa GPU, lo que puede costar decenas de milisegundos. Las reservas de memoria se hacen y el asignador de la implementación coloca los recursos. Los recursos se inicializan a cero, porque WebGPU garantiza que no ves memoria ajena, y eso es una escritura completa de cada búfer y cada textura. Las cachés están frías. Y si la GPU estaba ociosa, su gestión de energía la tiene a frecuencia baja y tarda decenas de milisegundos en subir.

Ese último punto es el más traicionero, y produce el resultado paradójico que desconcierta a todo el mundo: la versión que mides segunda parece más rápida que la primera, sea cual sea. Si comparas A y B en ese orden, gana B; si las comparas al revés, gana A. Con una diferencia real del 10% entre las dos, el orden de medición decide el ganador.

De ahí tres reglas que hay que aplicar siempre. Calienta al menos cinco iteraciones y descártalas. Mide en bucles de decenas de repeticiones, no una vez. Y la que casi nadie aplica: alterna el orden —A, B, A, B— en lugar de medir todas las A y luego todas las B, que es lo que anula el efecto de la frecuencia y de las cachés.

Y un criterio de honestidad que ahorra decisiones equivocadas: si la diferencia entre dos alternativas está por debajo del 10%, no hay diferencia. La variabilidad entre ejecuciones, entre estados térmicos y entre lo que el sistema esté haciendo en ese momento es de ese orden. Elige por legibilidad y sigue con lo siguiente.

Falta juntar todo en el patrón que se usa en producción: leer sin provocar un parón.