El reloj de CPU y el de GPU: dos dominios que no se restan
Por qué performance.now alrededor de submit mide cero, por qué onSubmittedWorkDone rompe lo que mide, la cuantización de 100 microsegundos de Chrome, y cómo obtener un tiempo de fotograma honesto.
Casi todas las mediciones de WebGPU que circulan por internet son inválidas, y no por descuido sino porque el instinto correcto en cualquier otro contexto —envolver la operación con dos marcas de tiempo— aquí devuelve un número que no tiene nada que ver con lo que se pretendía medir. La causa es que hay dos procesadores trabajando en paralelo, cada uno con su propio reloj, su propia cola y su propio retraso respecto al otro, y ninguna de las dos marcas que tomas desde JavaScript observa a la GPU. Entender esa asimetría es lo que separa un número que sirve de un número que consuela.
- Explicar qué mide exactamente cada una de las tres formas ingenuas de cronometrar un fotograma.
- Modelar el solapamiento CPU-GPU como dos tuberías y predecir el tiempo de fotograma resultante.
- Distinguir latencia de rendimiento y decidir cuál optimizar en un caso concreto.
- Obtener un tiempo de fotograma fiable con
requestAnimationFramey leerlo por percentiles.
Tres formas de medir mal
La primera: envolver submit. Es lo que hace todo el mundo la primera vez.
const t0 = performance.now();
device.queue.submit([encoder.finish()]);
const t1 = performance.now();
console.log(`fotograma: ${(t1 - t0).toFixed(2)} ms`); // 0,08 ms
Ese número es 0,08 ms tanto si el fotograma dibuja un triángulo como si dibuja dos millones. queue.submit encola: entrega el command buffer al driver y vuelve. No dibuja nada, no espera a nada, y en cuanto vuelve la GPU puede que ni haya empezado. Lo que has cronometrado es el coste de entregar un puntero.
Hay una variante de esto que sí mide algo real y conviene no tirarla. Si mueves t0 al principio de la grabación, antes del createCommandEncoder, lo que mides es tu coste de CPU: el tiempo que tarda tu código en recorrer la escena, ordenar, actualizar uniformes y grabar los comandos. Es una cifra legítima y a menudo la que importa, porque en aplicaciones con muchos objetos el cuello está ahí. Simplemente no es tiempo de GPU y no hay que confundirla.
La segunda: esperar a que termine. device.queue.onSubmittedWorkDone() devuelve una promesa que se resuelve cuando la GPU ha terminado todo lo encolado hasta ese momento.
device.queue.submit([encoder.finish()]);
const t0 = performance.now();
await device.queue.onSubmittedWorkDone();
const t1 = performance.now(); // esto si es tiempo de GPU... de otra aplicacion
Ahora sí esperas, y el número tiene sentido dimensional. El problema es doble. Primero, no mide tu envío: mide el drenaje de la cola entera, incluido lo que quedara pendiente de fotogramas anteriores y lo que otras pestañas hayan metido en la misma GPU. Segundo, y más grave, el acto de esperar cambia lo que se mide. Al vaciar la cola cada fotograma eliminas el solapamiento: la GPU acaba y se queda parada mientras la CPU graba el siguiente, las cachés se enfrían entre fotogramas, y el escalado dinámico de frecuencia ve una carga intermitente y baja el reloj. El resultado es un microbenchmark de un fotograma aislado corriendo solo en la máquina. Sirve para comparar dos versiones de un shader; no describe tu aplicación.
Y hay ruido añadido que nadie cuenta: la promesa no se resuelve en el instante en que la GPU acaba, sino en el siguiente turno del bucle de eventos que el navegador dedica a ello. Eso son entre decenas y cientos de microsegundos de incertidumbre encima del valor.
La tercera: restar un timestamp de GPU de uno de CPU. Suena razonable —tengo el nanosegundo en que la GPU empezó el pass y tengo performance.now()— y no significa absolutamente nada. Son dos dominios de reloj distintos: orígenes distintos, contadores distintos, y la propia especificación dice que cómo se determina el valor de un timestamp es dependiente de la implementación. WebGPU no expone ninguna forma de calibrar un reloj contra el otro. La única operación con sentido sobre dos timestamps de GPU es restarlos entre sí, y solo si vienen del mismo query set y del mismo envío.
El escalado dinámico de frecuencia añade la segunda mitad del problema, y es sutil porque no invalida el nanosegundo sino su interpretación. El contador que produce los timestamps suele ser de frecuencia fija, así que un nanosegundo sigue siendo un nanosegundo. Lo que cambia es cuánto trabajo cabe dentro: un núcleo de GPU que en reposo va a 300 MHz y bajo carga sostenida a 2,5 GHz hace ocho veces más en el mismo nanosegundo. Por eso los primeros fotogramas de cualquier ráfaga miden mal, y por eso ninguna medición vale nada antes de uno o dos segundos de carga continua.
Dos tuberías que se solapan, no una suma
La imagen mental correcta cabe en una tabla. Mientras la CPU graba el fotograma N, la GPU está ejecutando el N-1. No se turnan: se solapan.
| Instante | CPU | GPU | Pantalla |
|---|---|---|---|
| t0 | graba N | ejecuta N-1 | muestra N-2 |
| t1 | graba N+1 | ejecuta N | muestra N-1 |
| t2 | graba N+2 | ejecuta N+1 | muestra N |
De ahí sale la regla que casi nadie aplica: el tiempo de fotograma es el máximo de los dos, no la suma. Con una CPU que tarda 6 ms grabando y una GPU que tarda 10 ms ejecutando, el fotograma sale cada 10 ms, no cada 16. Y la consecuencia práctica duele: si optimizas el lado que no domina, no cambia nada. Bajar la CPU de 6 ms a 3 ms en ese escenario mejora exactamente cero fotogramas por segundo. El trabajo tirado a la basura no es el 50 por ciento, es el 100.
Por eso la primera pregunta de cualquier sesión de optimización no es “qué optimizo” sino “quién manda”. Y se responde con dos números, no con uno: tiempo de CPU por fotograma —cronometrando tu grabación, que para eso sí vale performance.now()— y tiempo de GPU por fotograma, sumando los passes con timestamp queries. El mayor de los dos es tu presupuesto real.
Hay un tercer candidato que arruina el diagnóstico si no lo tienes en cuenta: el vsync. Si tu tiempo de fotograma medido es 16,7 ms clavados y estable, no estás limitado ni por CPU ni por GPU: estás esperando a la pantalla. En ese régimen los dos números anteriores son irrelevantes y la única información útil es cuánto margen te sobra. Un fotograma con 6 ms de CPU y 8 ms de GPU dentro de un presupuesto de 16,7 ms tiene la mitad libre, y eso es lo que quieres saber antes de añadir un efecto.
Aquí está la trampa que hace perder tardes enteras. Cuando estás limitado por GPU, tu perfilador de JavaScript te enseña un fotograma precioso: 4 ms de trabajo, el resto inactivo. La conclusión obvia —“la CPU está de sobra, el problema no es mío”— es correcta y completamente inútil, porque no te dice dónde está el tiempo que falta. Y no lo dice porque el freno no está en ninguna llamada de tu código. La CPU no puede correr indefinidamente por delante: el navegador impone contrapresión limitando cuántos fotogramas pueden estar en vuelo, y esa contrapresión no se aplica bloqueando submit ni getCurrentTexture, sino retrasando la siguiente llamada a requestAnimationFrame. El resultado es que la espera cae en el hueco entre dos ejecuciones de tu callback, que es precisamente el sitio donde un perfilador de CPU no tiene nada que enseñar. Se ve como una franja vacía y se interpreta como ociosidad, cuando en realidad es tu GPU trabajando a tope. De aquí salen dos cosas prácticas. La primera: el intervalo entre marcas de requestAnimationFrame es la única señal que captura ese tiempo invisible, y por eso es el número más importante que puedes medir, muy por encima de cualquier desglose por pass. La segunda: cuando veas ese hueco, la prueba diagnóstica cuesta treinta segundos. Baja la resolución del lienzo a la mitad. Si el hueco se encoge, la GPU mandaba. Si sigue igual, el freno estaba en otra parte y acabas de descartar la hipótesis equivocada sin escribir una línea de instrumentación.
Latencia contra rendimiento
Son dos cosas distintas y optimizar una puede empeorar la otra. Latencia es cuánto tarda un fotograma concreto desde que se muestrea la entrada del usuario hasta que sus fotones salen del panel. Rendimiento es cuántos fotogramas salen por segundo. Un sistema puede tener un rendimiento excelente y una latencia horrible, y de hecho la forma más fácil de subir el rendimiento es empeorar la latencia.
La cadena de latencia completa, en un caso normal a 60 Hz:
| Etapa | Coste típico |
|---|---|
| Muestreo de la entrada hasta el inicio del fotograma | 0-16,7 ms |
| Grabación en CPU | 3-8 ms |
| Espera en cola antes de que la GPU empiece | 0-33 ms |
| Ejecución en GPU | 5-15 ms |
| Composición y presentación | 8-16,7 ms |
| Barrido del panel | 0-16,7 ms |
Total realista: entre 40 y 80 milisegundos. Es un orden de magnitud más que el tiempo de fotograma, y explica por qué una aplicación a 60 fps estables puede seguir sintiéndose pastosa.
El mecanismo que enfrenta las dos métricas es el encolado. Cuantos más fotogramas permitas en vuelo, más margen tiene el sistema para absorber un pico de CPU sin que la GPU se quede sin trabajo, así que el rendimiento sube y se estabiliza. Y cada fotograma en vuelo añade un fotograma entero de latencia. Con tres fotogramas encolados a 60 Hz tienes 50 ms de retraso extra antes de que la GPU empiece siquiera. Limitarlo a uno recorta esos 50 ms a 16, y a cambio cualquier hipo de la CPU deja a la GPU parada y pierdes fotogramas por segundo.
Cuál priorizar depende del producto y no hay respuesta general. En realidad virtual la latencia de movimiento a fotón por encima de unos 20 ms produce malestar físico, así que se sacrifica lo que haga falta. En un juego competitivo la latencia es rendimiento del jugador. En un visor de producto, un configurador o una visualización de datos, nadie nota 60 ms de latencia y sí nota un tirón, así que se encola todo lo que se pueda. El error es no elegir: optimizar a ciegas el número que resulte más fácil de medir y descubrir tarde que el que importaba era el otro.
Cuantización, percentiles y el número que sí sirve
Antes de fiarte de un solo microsegundo hay que saber esto: en Chrome, los valores de las timestamp queries están cuantizados a 100 microsegundos por defecto. Es una mitigación deliberada contra ataques de temporización, de la misma familia que el redondeo que ya sufre performance.now(), que también está limitado a 100 microsegundos salvo que la página esté aislada entre orígenes con COOP y COEP, donde baja a 5.
La consecuencia práctica es dura y hay que interiorizarla: un pass que dura 30 microsegundos se mide como 0 o como 100. No como 30, ni como algo cercano a 30. Cualquier medida por debajo de medio milisegundo es, con la cuantización activa, ruido con formato de número. Un desglose de doce passes ligeros puede enseñarte seis ceros y seis valores de 0,1 ms, y no significa que seis passes sean gratis.
Se puede desactivar para desarrollar. En chrome://flags/#enable-webgpu-developer-features, activando la opción WebGPU Developer Features, los timestamps dejan de redondearse y pasan a tener resolución completa. Es un flag de desarrollo: úsalo tú, no lo esperes en la máquina de ningún usuario, y no construyas lógica que dependa de él.
Con la cuantización activa hay un truco que la esquiva sin flags: mide N repeticiones dentro del mismo par de consultas y divide. Si el pass dura 30 microsegundos, ejecútalo cincuenta veces entre beginningOfPassWriteIndex y endOfPassWriteIndex, mide 1,5 ms con dos cifras buenas y divide entre cincuenta. Es exactamente la técnica de siempre para medir por debajo de la resolución del reloj, y funciona igual aquí.
Y para el número que de verdad manda, el tiempo de fotograma percibido, la fuente correcta es requestAnimationFrame:
const muestras: number[] = [];
let anterior = 0;
function fotograma(ahora: DOMHighResTimeStamp) {
if (anterior !== 0) muestras.push(ahora - anterior);
anterior = ahora;
dibujar();
requestAnimationFrame(fotograma);
}
requestAnimationFrame(fotograma);
Usa el argumento del callback, no un performance.now() dentro de él. El argumento es la marca de inicio de fotograma que asigna el navegador y es idéntica para todos los callbacks del mismo fotograma, así que excluye el jitter de planificación de tu propia función. Con performance.now() dentro estarías midiendo, además del fotograma, cuánto tardó el navegador en llamarte.
Y ahora la parte que casi nadie hace bien: no mires la media. Ordena las muestras y mira el percentil 95.
function percentil(xs: number[], p: number): number {
const s = [...xs].sort((a, b) => a - b);
return s[Math.min(s.length - 1, Math.floor(p * s.length))];
}
const utiles = muestras.slice(120); // tira los dos primeros segundos
console.log("mediana", percentil(utiles, 0.5).toFixed(1));
console.log("p95 ", percentil(utiles, 0.95).toFixed(1));
console.log("p99 ", percentil(utiles, 0.99).toFixed(1));
Hay dos razones para desconfiar de la media y las dos son concluyentes. La primera es que el usuario percibe los picos, no el promedio. Noventa y cinco fotogramas de 8 ms y cinco de 60 ms dan una media de 10,6 ms, que parece un resultado excelente, y describen una aplicación que da cinco tirones visibles por segundo. El p95 en ese caso es 60 y te lo dice a la cara.
La segunda es más traicionera: con vsync activo, los intervalos entre fotogramas no son continuos sino múltiplos de 16,67 ms. La distribución es bimodal, con un montón en 16,7 y otro en 33,3, y la media cae en 21,4, que es un valor que ningún fotograma tuvo jamás. Promediar una distribución bimodal produce un número que describe un fotograma inexistente. La mediana te dice cuál de los dos modos domina, y el p95 te dice cuánto pesa el malo.
Descartar los primeros fotogramas no es cosmética. Ahí dentro está la compilación de shaders, la creación de pipelines, el primer llenado de cachés y la rampa de frecuencia de la GPU: dos segundos a 60 Hz son 120 muestras que solo describen el arranque.
Con estos dos números —tiempo de fotograma por percentiles y desglose de GPU por pass— ya sabes cuánto tardas y dónde. Lo que falta es por qué tarda eso un shader concreto, y esa pregunta se responde con los tres experimentos de cómputo o memoria. El desglose por pass, si aún no lo tienes montado, está en el perfil por pass.
- Mide tu tiempo de CPU por fotograma cronometrando desde
createCommandEncoderhastasubmit. - Mide tu tiempo de GPU sumando los passes y compara: quién manda en tu escena.
- Calcula mediana, p95 y p99 de los intervalos de
requestAnimationFrametirando los primeros 120. - Activa el flag de WebGPU Developer Features y comprueba cuántos de tus passes medían 0 por cuantización.
- Mide un pass ligero cincuenta veces dentro del mismo par de consultas y compara con la medida suelta.