wandres.dev
COMMAND ENCODING · El modelo de comandos

Grabar en vez de ejecutar: el modelo de comandos

Por qué WebGPU separa la grabación de la ejecución, qué compra esa separación en coste de CPU y en paralelismo, y qué obliga a cambiar en el diseño de una aplicación.

⏱ 17 min

Es el cambio conceptual que más cuesta a quien viene de WebGL, y también el que más rinde. En WebGL, drawArrays era una orden que se ejecutaba. En WebGPU, draw es una anotación en una lista que todavía no ha ido a ninguna parte. Toda la ganancia de rendimiento en CPU, la posibilidad de trabajar desde varios hilos y buena parte de la forma de la API se derivan de esa sola decisión.

🎯 Al terminar esta lección sabrás
  • Explicar la separación entre grabación, envío y ejecución.
  • Cuantificar qué ahorra la grabación en lote frente a la ejecución por llamada.
  • Describir cómo la grabación habilita el uso de varios hilos.
  • Reconocer qué restricciones impone el modelo sobre tu código.

Tres momentos, no uno

Una orden de dibujo en WebGPU pasa por tres fases separadas en el tiempo.

Grabación. encoder.beginRenderPass(...), pase.setPipeline(...), pase.draw(...). Todo esto escribe en una estructura de datos del lado de la página. No cruza ninguna frontera de proceso, no habla con el controlador, no toca la GPU. Es el equivalente a rellenar un formulario.

Envío. queue.submit([buffer]). El formulario relleno cruza al proceso de GPU, se traduce a la API nativa y se entrega al controlador. Una sola vez por lote, sea cual sea el número de comandos que contenga.

Ejecución. La GPU hace el trabajo cuando le toca, sin avisar y sin que tu código pueda observarlo directamente.

flowchart TB
a[createCommandEncoder] --> b[beginRenderPass o beginComputePass]
b --> c[setPipeline setBindGroup draw o dispatch]
c --> d[end cierra el ambito del pase]
d --> e{Mas pases}
e -->|si| b
e -->|no| f[finish produce un GPUCommandBuffer inmutable]
f --> g[queue submit con uno o varios buffers]
g --> h[Traduccion a la API nativa]
h --> i[La GPU ejecuta cuando le toca]
style a fill:#89b4fa,color:#11111b
style b fill:#89b4fa,color:#11111b
style c fill:#89b4fa,color:#11111b
style d fill:#89b4fa,color:#11111b
style e fill:#cba6f7,color:#11111b
style f fill:#a6e3a1,color:#11111b
style g fill:#f9e2af,color:#11111b
style h fill:#f9e2af,color:#11111b
style i fill:#fab387,color:#11111b

Las cajas azules ocurren en el hilo de tu código, son baratas y no cruzan nada. La verde es el punto de validación. Las amarillas son el único cruce de frontera. La naranja está fuera de tu control.

Lo que compra la separación

El coste por llamada se desploma. En WebGL, cada llamada a la API se serializaba y se enviaba al proceso de GPU, donde se validaba. Con mil dibujos, mil cruces. En WebGPU, mil dibujos se anotan en una estructura local y cruzan la frontera una vez. La grabación de un comando es del orden de decenas de nanosegundos: escribir unos cuantos bytes en un búfer.

La validación se hace una vez, sobre una secuencia completa. finish() comprueba la coherencia de todo el lote de golpe, en lugar de comprobar cada comando aislado sin contexto.

La traducción puede ser mejor. La implementación ve la secuencia entera antes de traducirla, así que puede fusionar transiciones de estado, deducir mejor las barreras y reordenar lo que sea seguro reordenar. Con comandos que llegan de uno en uno eso no es posible.

La CPU se desacopla de la GPU. Grabar el fotograma N+1 mientras la GPU ejecuta el N es lo natural en este modelo, y es lo que hace que el tiempo de fotograma sea el máximo de los dos lados en lugar de la suma.

Varios hilos

Aquí conviene ser preciso, porque se dice con frecuencia que «WebGPU permite grabar comandos en varios hilos» sin explicar qué significa exactamente en la web.

Lo que no se puede hacer: compartir un GPUDevice entre el hilo principal y un worker. Los objetos de WebGPU no son transferibles ni clonables por postMessage. No hay un GPUCommandEncoder que se pase de un hilo a otro.

Lo que se puede hacer, y es lo que importa: un worker puede tener su propio dispositivo, obtenido desde WorkerNavigator.gpu, y hacer todo su trabajo ahí. Con OffscreenCanvas, ese worker puede además ser el dueño del canvas y ejecutar el bucle de render completo fuera del hilo principal.

// hilo principal: cede el canvas y se olvida
const offscreen = canvas.transferControlToOffscreen();
const worker = new Worker('./render.js', { type: 'module' });
worker.postMessage({ canvas: offscreen }, [offscreen]);

El beneficio no es que la GPU trabaje más: es que el trabajo de grabación deja de competir con el layout, el estilo, los manejadores de eventos y el resto del hilo principal. En una aplicación donde la escena convive con una interfaz de verdad, esa separación es lo que distingue un desplazamiento fluido de uno que se atasca cada vez que la escena se complica.

El modelo de grabación es lo que lo hace posible: grabar no necesita ninguna coordinación con nadie, porque no toca estado compartido. En una API de estado global como WebGL, esto sería inexpresable.

ℹ️
La cola sigue siendo una

Aunque varios hilos graben, la ejecución en el hardware está serializada por la cola del dispositivo correspondiente. La paralelización es del trabajo de CPU, no de la GPU. Esperar que dos workers hagan que la GPU vaya al doble es un malentendido frecuente.

Lo que el modelo obliga a cambiar

Tres restricciones que hay que interiorizar, y son las que más errores producen al empezar.

No puedes leer nada durante la grabación. No hay ningún método que devuelva un resultado de la GPU en mitad de un pase, porque en ese momento la GPU no ha ejecutado nada. Cualquier decisión que dependa de un resultado tiene que tomarse con datos de un fotograma anterior, o delegarse en la propia GPU con dibujado indirecto.

El orden de grabación es el orden de ejecución dentro de un lote. Lo que grabas antes se ejecuta antes. Suena obvio y tiene una consecuencia útil: si el pase A escribe una textura y el pase B la lee, basta con grabarlos en ese orden dentro del mismo encoder para que la implementación deduzca la dependencia e inserte las barreras necesarias.

Los objetos efímeros son de un solo uso. Un encoder se usa una vez y muere en finish(). Un command buffer se envía una vez. Un pase se cierra con end() y no admite nada más. Guardarlos para reutilizarlos no funciona; para eso existen los render bundles.

La grabación es barata, y eso cambia qué merece la pena optimizar

Hay un reflejo heredado de WebGL que sobrevive a la migración y hace daño: minimizar el número de llamadas a la API a toda costa. En WebGL tenía sentido, porque cada llamada cruzaba una frontera de proceso y pagaba validación. En WebGPU, grabar un comando cuesta decenas de nanosegundos y no cruza nada.

Las consecuencias prácticas son bastante liberadoras. Ordenar los objetos por pipeline sigue mereciendo la pena, pero mucho menos: un setPipeline extra no es un desastre, es una anotación más en un búfer. Agrupar mallas fusionándolas en una sola deja de ser la primera optimización, porque el coste que evitaba ya casi no existe, y a cambio pierdes la posibilidad de descartar objetos individualmente. Un pase con quinientos dibujos distintos es perfectamente razonable en WebGPU y era el enemigo en WebGL.

Lo que sí sigue costando, y ahí hay que poner la atención, es otra cosa. Cuesta el trabajo de CPU antes de grabar: recorrer la escena, calcular matrices, ordenar, decidir qué es visible. Ese código no ha cambiado y ahora es la mayor parte del presupuesto. Cuesta empezar y terminar pases, sobre todo en GPUs de tiles. Y cuesta crear objetos, muy especialmente pipelines.

De ahí la regla de perfilado que hay que aplicar al migrar: antes de optimizar el número de dibujos, mide cuánto tiempo se va en grabar. En la mayoría de las aplicaciones migradas la respuesta sorprende: la grabación es una fracción pequeña, y el tiempo está en el recorrido de la escena, en la aritmética de matrices en JavaScript y en la creación de objetos que nadie ha sacado del bucle. Optimizar draw calls en WebGPU es, con frecuencia, resolver el problema de la API anterior.

Con el modelo claro, toca la mecánica concreta: el command encoder y sus operaciones.