El coste de CPU: por qué el cuello de botella casi nunca es la GPU
Cómo se reparte el trabajo de un fotograma entre CPU y GPU, cómo saber cuál de las dos manda, y qué parte de ese reparto cambia WebGPU.
Cuando una escena web va a treinta fotogramas por segundo, la conclusión intuitiva es que la GPU no da más de sí. En la web esa conclusión es falsa la mayoría de las veces. El presupuesto se agota en la CPU, preparando y validando comandos, mientras la GPU pasa buena parte del fotograma esperando trabajo. Entender ese reparto es lo que permite decidir si tu problema lo arregla WebGPU, y en qué medida.
- Descomponer un fotograma web en sus fases de CPU y de GPU.
- Determinar experimentalmente cuál de las dos limita el rendimiento.
- Cuantificar qué parte del coste de CPU elimina WebGPU y cuál no toca.
- Aplicar el orden correcto de optimización según dónde esté el cuello.
Anatomía de un fotograma
Un fotograma con gráficos acelerados tiene cuatro fases, dos en cada lado.
Lógica de aplicación, en CPU. Actualizar el estado del mundo, física, animación, entrada del usuario, cálculo de matrices de transformación. Es tu código y es medible con las herramientas de siempre.
Preparación de comandos, en CPU. Recorrer la escena, decidir qué es visible, ordenar por material, y emitir las llamadas a la API gráfica. Aquí es donde vive el coste que WebGL infla: cada llamada cruza la frontera al proceso de GPU y paga validación.
Ejecución de comandos, en GPU. El trabajo real: transformar vértices, rasterizar, sombrear fragmentos, escribir a memoria. Depende del número de triángulos, del número de píxeles cubiertos, de la complejidad de los shaders y del ancho de banda de memoria.
Composición y presentación. El navegador combina el resultado con el resto de la página y lo entrega al compositor del sistema.
Las dos fases de CPU y las dos de GPU se solapan entre fotogramas. Mientras la GPU ejecuta el fotograma N, la CPU está preparando el N+1. Esa canalización es la que hace que el tiempo de fotograma sea el máximo de los dos lados, no la suma. Y es también la que hace que la métrica «fotogramas por segundo» sea inútil para diagnosticar: te dice cuál es el máximo, no cuál de los dos lo produce.
Cómo saber cuál manda
Hay tres experimentos que dan la respuesta en cinco minutos y no requieren ninguna herramienta especial.
Reduce la resolución a la mitad. Si el tiempo de fotograma baja de forma apreciable, estás limitado por la GPU en la fase de fragmentos: menos píxeles, menos trabajo. Si no cambia nada, la GPU no era el problema.
Sustituye el fragment shader por uno que devuelva un color constante. Si no cambia nada, el coste de sombreado no era el cuello. Combinado con el experimento anterior, esto separa «limitado por píxeles» de «limitado por complejidad de sombreado».
Dibuja la mitad de los objetos. Si el tiempo de fotograma baja aproximadamente a la mitad y los dos experimentos anteriores no hicieron nada, estás limitado por el número de llamadas de dibujo, es decir, por la CPU. Este es el caso más frecuente en la web.
Hay una cuarta señal, más directa: si el perfilador del navegador muestra que el hilo principal está ocupado el fotograma entero y una parte grande de ese tiempo está dentro de funciones de la API gráfica, no hace falta seguir buscando.
El tiempo de GPU no aparece en el perfilador de JavaScript, porque no ocurre en el hilo de JavaScript. WebGPU expone timestamp-query como feature opcional para medirlo desde dentro del propio flujo de comandos; hay que pedirla en requestDevice y no todos los dispositivos la ofrecen. Sin ella, los tres experimentos de arriba siguen siendo la mejor herramienta.
Qué elimina WebGPU y qué no
Es importante ser preciso aquí, porque la mejora se exagera con frecuencia.
Elimina la validación por dibujo. El grueso del trabajo de comprobación se hace al crear pipelines y bind groups. Es la ganancia grande y es real.
Elimina el viaje de ida y vuelta por llamada. Los comandos se graban en un GPUCommandEncoder del lado de la página y se envían en lote con queue.submit(). En lugar de mil cruces de frontera hay uno.
Reduce el trabajo de rastreo de estado. Como el estado va en objetos ya construidos, la implementación no tiene que reconstruir la configuración a partir de un montón de variables globales en cada dibujo.
Permite grabar fuera del hilo principal. Un worker puede tener su propio GPUDevice obtenido desde WorkerNavigator.gpu, grabar sus propios command buffers y enviarlos. El coste de preparación deja de competir con el layout y los eventos de la página. Conviene ser preciso sobre el alcance: los objetos de WebGPU no se transfieren entre hilos, así que lo que se reparte es el trabajo de grabación de un dispositivo por hilo, no un dispositivo compartido por varios.
No elimina el coste de recorrer tu escena. Si tu motor tarda cuatro milisegundos en decidir qué dibujar antes de emitir el primer comando, esos cuatro milisegundos siguen ahí. WebGPU abarata la emisión, no la decisión.
No hace la GPU más rápida. Si estás limitado por relleno de píxeles, por ancho de banda de memoria o por un shader caro, WebGPU no cambia nada en absoluto. La misma GPU hace el mismo trabajo.
El orden de magnitud de la mejora, para una escena limitada por llamadas de dibujo, va de tres a diez veces más llamadas por fotograma con el mismo presupuesto de CPU. Eso es mucho, y es exactamente cero si tu problema estaba en otro sitio.
Las palancas que siguen valiendo
Que WebGPU abarate los dibujos no jubila las técnicas de reducción. Solo cambia el punto en el que dejan de ser urgentes.
La instanciación sigue siendo la palanca más eficaz: un solo draw con instanceCount alto dibuja miles de copias con datos por instancia leídos de un búfer. Cuesta lo mismo que un dibujo.
Los render bundles de WebGPU permiten pregrabar una secuencia de comandos y reproducirla en fotogramas sucesivos sin volver a emitirla, lo que ataca directamente el coste de grabación cuando la escena es estable.
El dibujado indirecto —drawIndirect y drawIndexedIndirect— toma los parámetros del dibujo de un búfer de la GPU en lugar de argumentos de JavaScript, lo que permite que un compute shader decida qué y cuánto dibujar sin que la CPU se entere. Es la base de las arquitecturas dirigidas por GPU.
Y sigue valiendo lo más básico: no dibujar lo que no se ve. El descarte por frustum y por oclusión ahorra las dos cosas a la vez, coste de CPU y coste de GPU, y ninguna API lo hace por ti.
El error de método más extendido en optimización gráfica es mirar una media. Un contador que dice «16,4 ms» esconde exactamente el problema que arruina la percepción de fluidez: la mayoría de los fotogramas van a 12 ms y uno de cada cuarenta va a 90 ms. La media no se mueve, la experiencia es horrible, y el fotograma culpable no aparece si abres el perfilador diez segundos porque los parones son raros por definición.
Lo que hay que registrar es la distribución: el percentil 95 y el 99 del tiempo de fotograma, y el peor fotograma de la sesión. Se hace con veinte líneas —guardar los deltas de requestAnimationFrame en un array circular de unos pocos miles de entradas y ordenarlo cuando lo pidas— y cambia por completo qué se optimiza.
En WebGPU la razón es todavía más fuerte que en WebGL, porque las causas de los parones son discretas y localizables: la primera compilación de un pipeline, la primera vez que se usa una textura recién subida, un mapAsync que espera a que la GPU termine, un writeBuffer con megabytes en medio del fotograma. Ninguna de esas cosas afecta a la media y todas se ven inmediatamente en el percentil 99. Optimiza la cola de la distribución primero; la media casi siempre se arregla sola por el camino.
El coste de CPU es la razón económica del rediseño. La razón funcional es distinta y más grande: WebGL no tiene compute shaders.