Por qué leer de la GPU es caro
La asimetría entre escribir y leer, de dónde sale la latencia real de una lectura, qué le hace a la canalización de fotogramas y qué alternativas hay a leer.
Escribir a la GPU es una operación que se encola y se olvida. Leer de la GPU es esperar a que ocurra algo que aún no ha empezado. Esa asimetría no es un defecto de WebGPU ni de la web: es una propiedad de cualquier arquitectura con un acelerador que trabaja por detrás de quien le da órdenes. Entender de dónde sale exactamente el coste es lo que distingue una lectura que cuesta unos microsegundos de CPU de una que divide el rendimiento por dos.
- Descomponer la latencia de una lectura en sus componentes reales.
- Explicar qué le hace a la canalización de fotogramas una lectura síncrona.
- Estimar el coste de una lectura en fotogramas, no en milisegundos.
- Enumerar las alternativas a leer y cuándo cada una aplica.
De dónde sale la latencia
Cuando pides el resultado de un cálculo que acabas de encolar, el tiempo que esperas se descompone en cuatro sumandos, y solo uno de ellos es el que la gente imagina.
Uno: el trabajo que hay por delante en la cola. La GPU está ocupada con el fotograma anterior, o con los dos anteriores. Tu copia está detrás de todo eso y no empieza hasta que aquello termina. En una aplicación que va a 60 fotogramas por segundo con dos fotogramas en vuelo, son unos 33 milisegundos antes de que empiece nada.
Dos: el cálculo que produce el dato. El compute pass o el render pass que escribe el resultado. Puede ser desde microsegundos hasta varios milisegundos.
Tres: la copia a memoria visible por la CPU. En una tarjeta discreta implica atravesar el bus. Para unos pocos kilobytes es despreciable; para el contenido de un framebuffer son megabytes por un bus que es un orden de magnitud más lento que la memoria interna de la GPU.
Cuatro: la notificación de vuelta. La implementación tiene que enterarse de que la GPU terminó y resolver la promesa, lo que ocurre en un punto del bucle de eventos del navegador.
Sumado, la latencia típica de una lectura va de uno a tres fotogramas. No es un coste que se pueda optimizar reduciendo bytes: es latencia estructural, y el primer sumando —el trabajo que hay por delante— la domina.
De ahí la conclusión que ordena todo el nivel: la unidad correcta para medir una lectura no son los milisegundos, son los fotogramas. Y por eso la solución no consiste en hacer la lectura más rápida, sino en no esperarla.
Qué le hace a la canalización
El diseño de un motor gráfico moderno se apoya en que la CPU va por delante. Mientras la GPU ejecuta el fotograma N, la CPU prepara el N+1. Los dos lados trabajan a la vez y el tiempo de fotograma es el máximo de los dos, no la suma.
Una lectura síncrona rompe eso de la forma más directa posible:
// LO QUE NO HAY QUE HACER NUNCA
function frame() {
const encoder = device.createCommandEncoder();
// ... trabajo
encoder.copyBufferToBuffer(resultado, staging, TAM);
device.queue.submit([encoder.finish()]);
await staging.mapAsync(GPUMapMode.READ); // <- la CPU se para aqui
const datos = new Float32Array(staging.getMappedRange());
usar(datos);
staging.unmap();
requestAnimationFrame(frame);
}
Ese await obliga a la CPU a esperar a que la GPU vacíe la cola entera. Durante esa espera la CPU no prepara nada, así que cuando la GPU termina, no tiene trabajo siguiente y se queda ociosa mientras la CPU vuelve a arrancar. Los dos lados alternan en lugar de solaparse.
El síntoma es característico y desconcierta a quien no lo conoce: el perfilador de CPU muestra el hilo mayormente ocioso, las medidas de GPU muestran la GPU mayormente ociosa, y el tiempo de fotograma es aproximadamente el doble de la suma de las dos partes. Los dos lados parecen infrautilizados porque cada uno está esperando al otro.
Si hay un solo hábito que llevarse de este nivel es este: ningún await dentro de la función que se llama desde requestAnimationFrame, con la única excepción de esperas que ya estaban resueltas. Ni mapAsync, ni onSubmittedWorkDone, ni createRenderPipelineAsync. Todo lo que necesite esperar se lanza sin esperar y se recoge en un fotograma posterior.
Antes de leer, pregúntate si hace falta
La lectura correcta es la que no ocurre. Cuatro alternativas cubren la mayoría de los casos donde la gente lee sin necesitarlo.
Dibujado indirecto. El caso más común es leer un contador para saber cuántos objetos dibujar. No hace falta: un compute shader puede escribir el número de instancias directamente en un búfer con usage INDIRECT, y drawIndexedIndirect lo consume sin que la CPU se entere. Es la técnica que hace posible el culling en GPU.
Reducción en GPU. Si lo que quieres es un máximo, una media o una suma sobre un array grande, hazla en la GPU y lee cuatro bytes en vez de cuatro megabytes. Una reducción en árbol sobre un millón de elementos son un par de dispatches.
Consumir el resultado en la GPU. Si el dato que quieres leer alimenta otra etapa de tu propio render, no hace falta que pase por la CPU. Un histograma para exposición automática, un cálculo de luminancia media, una máscara de visibilidad: todo eso puede quedarse en un búfer que el siguiente pase lee.
Aceptar datos viejos. Si necesitas el valor en la CPU pero no necesitas que sea de este fotograma, léelo con retraso. Casi ningún uso real —una interfaz que muestra estadísticas, una decisión de nivel de detalle, una adaptación de exposición— nota tres fotogramas de antigüedad.
Cuando ninguna de las cuatro aplica, entonces sí hay que leer, y hay dos casos legítimos y frecuentes: selección por identificador de objeto al hacer clic, y captura de imagen para exportar. Los dos son eventos puntuales, no operaciones de cada fotograma, y ahí el coste de una lectura es perfectamente asumible.
El coste en números
Para una escena que va a 60 fotogramas por segundo con dos fotogramas en vuelo, leer cuatro bytes cuesta aproximadamente lo mismo que leer cuatro megabytes: entre 30 y 50 milisegundos de latencia, porque el sumando dominante es la espera en la cola.
Eso tiene una consecuencia práctica poco intuitiva: si vas a leer, lee de golpe todo lo que necesites. Cinco lecturas de cuatro bytes en fotogramas distintos cuestan cinco veces la latencia; una lectura de veinte bytes cuesta una. Agrupar los resultados en un solo búfer de staging es casi siempre la decisión correcta.
Y otra: el tamaño sí importa para el ancho de banda, no para la latencia. Leer el framebuffer entero de una pantalla 4K son 33 MB por el bus, lo que sí es un coste real de transferencia además de la latencia. Para eso, reducir en la GPU antes de leer es lo que separa una captura que tarda 50 ms de una que tarda 300.
El error de fondo con las lecturas casi nunca es de código: es haber diseñado un flujo de datos con forma de ida y vuelta por fotograma. La CPU manda trabajo, espera el resultado, decide algo, manda más trabajo. Cada uno de esos ciclos cuesta la latencia entera de la cola, y ninguna optimización lo arregla, porque la latencia no viene de lo que haces sino de dónde está el dato.
Reconocer esa forma en un diagrama, antes de escribir código, es la habilidad que más tiempo ahorra. Dibuja el flujo de datos de un fotograma con dos columnas, CPU y GPU. Cada flecha que cruza de la columna derecha a la izquierda y vuelve a cruzar a la derecha en el mismo fotograma es un problema de arquitectura. Una flecha que cruza a la izquierda y muere ahí —una estadística que se muestra en pantalla— no lo es. Una que cruza a la izquierda y vuelve tres fotogramas después tampoco.
Las tres formas de romper un ciclo, en orden de preferencia. Mover la decisión a la GPU: si el resultado solo sirve para decidir cuánto trabajo hacer, el dibujado indirecto elimina el ciclo entero. Retrasar el consumo: si la CPU necesita el valor, que use el de hace N fotogramas y que N sea lo bastante grande para que la lectura ya haya llegado sin espera. Reducir antes de cruzar: si el ciclo es inevitable, que lo que cruce sean bytes, no megabytes.
Y una advertencia sobre el orden en que se descubre esto. Un ciclo por fotograma funciona perfectamente en una demo pequeña, donde la cola está casi vacía y la latencia es de un fotograma escaso. Se manifiesta cuando la escena crece y la cola se llena, que es justo cuando ya hay mucho código apoyado en él. Por eso conviene mirar la forma del flujo de datos al principio, cuando el diagrama cabe en una servilleta y cambiarlo no cuesta nada.
Con el porqué claro, toca la mecánica: el búfer de staging.