El perfil por pass: un perfilador que puedes dejar encendido
Convertir dos timestamps en una herramienta reutilizable: el mapa de nombres a ranuras, el anillo de búferes que evita bloquear la CPU, la media móvil y por qué la suma de los passes no cuadra con el fotograma.
Medir un pass una vez es un experimento. Medir veinte passes cada fotograma, durante horas, sin que la instrumentación altere lo que mide, es una herramienta, y la distancia entre las dos cosas es más grande de lo que parece. El obstáculo no es la API de consultas —esa ya la tienes— sino un instinto: poner un await justo después de submit. Ese await es el que convierte un perfilador en una máquina de mentir, porque sincroniza la CPU con la GPU y destruye exactamente el solapamiento que sostiene tu rendimiento en producción.
- Diseñar una clase perfiladora que reparta un único query set entre todos los passes de un fotograma.
- Leer los resultados con un anillo de búferes sin bloquear nunca la CPU.
- Estabilizar la señal con una media móvil exponencial y justificar por qué un fotograma aislado no es un dato.
- Interpretar el desglose por pass, incluido el tiempo que no pertenece a ningún pass.
Un mapa de nombres a ranuras
Un perfilador es, esencialmente, contabilidad. El query set es un array plano de ranuras; cada pass necesita dos, consecutivas; y lo único que hace falta para que todo encaje es una tabla que traduzca "sombras" a las ranuras 4 y 5. Todo lo demás es plomería.
La decisión de diseño que importa es declarar los passes por adelantado. Podrías repartir ranuras bajo demanda la primera vez que ves un nombre, pero entonces el tamaño del query set cambia en caliente y ya no puedes crearlo una sola vez. Como el techo son 4096 consultas, declarar hasta 2048 passes de golpe no es una restricción real, y a cambio obtienes un objeto inmutable y un orden de presentación estable.
type MarcasDeTiempo = {
querySet: GPUQuerySet;
beginningOfPassWriteIndex: number;
endOfPassWriteIndex: number;
};
export class PerfiladorGPU {
readonly disponible: boolean;
activo = true; // el interruptor de la instrumentacion
private querySet: GPUQuerySet | null = null;
private resolucion: GPUBuffer | null = null;
private anillo: GPUBuffer[] = [];
private fotograma = 0;
private readonly base = new Map<string, number>();
private readonly orden: string[] = [];
private readonly bytes: number;
private readonly consultas: number;
private readonly medias = new Map<string, number>();
private readonly alfa = 0.1;
constructor(
private readonly device: GPUDevice,
nombres: string[],
private readonly profundidad = 3,
) {
this.disponible = device.features.has("timestamp-query");
this.consultas = nombres.length * 2;
this.bytes = this.consultas * 8;
if (!this.disponible) return;
if (this.consultas > 4096) throw new Error("Mas de 2048 passes: imposible");
nombres.forEach((nombre, i) => {
this.base.set(nombre, i * 2);
this.orden.push(nombre);
});
this.querySet = device.createQuerySet({
label: "perfilador",
type: "timestamp",
count: this.consultas,
});
this.resolucion = device.createBuffer({
label: "perfilador: resolucion",
size: this.bytes,
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
});
for (let i = 0; i < profundidad; i++) {
this.anillo.push(device.createBuffer({
label: `perfilador: lectura ${i}`,
size: this.bytes,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
}));
}
}
/** Se pasa tal cual al descriptor del pass. undefined = sin instrumentar. */
marcas(nombre: string): MarcasDeTiempo | undefined {
if (!this.activo || !this.querySet) return undefined;
const b = this.base.get(nombre);
if (b === undefined) return undefined;
return {
querySet: this.querySet,
beginningOfPassWriteIndex: b,
endOfPassWriteIndex: b + 1,
};
}
}
El método marcas devuelve undefined cuando la instrumentación está apagada, y eso es deliberado: en WebIDL, pasar undefined a un miembro opcional de un diccionario equivale a no pasarlo, así que el sitio de llamada no necesita ninguna condición.
const pass = encoder.beginRenderPass({
colorAttachments: [attachmentPrincipal],
depthStencilAttachment: attachmentProfundidad,
timestampWrites: perfilador.marcas("geometria"),
});
const passCompute = encoder.beginComputePass({
timestampWrites: perfilador.marcas("culling"),
});
Una línea por pass, la misma en render y en compute, y apagar el perfilador no toca ni una llamada.
El anillo de búferes: no esperar nunca
Aquí está el corazón de la lección. La versión ingenua de la lectura es esta, y está mal:
device.queue.submit([encoder.finish()]);
await bufferLectura.mapAsync(GPUMapMode.READ); // <- el desastre
Ese await no espera “un poco”. Espera a que la GPU haya terminado todo el trabajo encolado hasta ese punto, que es del orden de uno o dos fotogramas enteros de trabajo. Durante ese tiempo tu hilo principal está parado, no está grabando los comandos del fotograma siguiente, y cuando la GPU acaba se queda sin nada que hacer mientras la CPU arranca de nuevo. Has convertido dos procesadores que trabajaban en paralelo en dos que se turnan. El coste típico en una escena normal es entre un 30 y un 60 por ciento de fotogramas por segundo, y lo peor es que los números que te devuelve el perfilador siguen siendo correctos: cada pass tarda lo que dice. Simplemente ya no es la aplicación que ibas a enviar a producción.
La solución es no esperar jamás. mapAsync devuelve una promesa; no la esperes, engánchale un then y sigue con tu vida. El problema que eso crea es que el búfer queda ocupado —en estado "pending"— durante uno o dos fotogramas, y en el siguiente ya no puedes copiar sobre él. De ahí el anillo: tres o cuatro búferes idénticos que se turnan, de modo que cuando vuelves a la ranura de la que partiste han pasado tres fotogramas y su mapeo se resolvió hace mucho.
/** Grabar fuera de cualquier pass, justo antes de finish(). */
resolver(encoder: GPUCommandEncoder): void {
if (!this.activo || !this.querySet || !this.resolucion) return;
encoder.resolveQuerySet(this.querySet, 0, this.consultas, this.resolucion, 0);
const destino = this.anillo[this.fotograma % this.profundidad];
if (destino.mapState !== "unmapped") return; // GPU muy retrasada: saltamos
encoder.copyBufferToBuffer(this.resolucion, 0, destino, 0, this.bytes);
this.pendienteDeLeer = destino;
}
private pendienteDeLeer: GPUBuffer | null = null;
/** Llamar justo despues de queue.submit(). No devuelve promesa a proposito. */
recoger(): void {
this.fotograma++;
const destino = this.pendienteDeLeer;
this.pendienteDeLeer = null;
if (!destino) return;
destino.mapAsync(GPUMapMode.READ).then(() => {
const crudos = new BigInt64Array(destino.getMappedRange());
for (let i = 0; i < this.orden.length; i++) {
const inicio = crudos[i * 2];
const fin = crudos[i * 2 + 1];
if (fin <= inicio) continue; // ranura no escrita este fotograma
const ms = Number(fin - inicio) / 1e6;
const nombre = this.orden[i];
const previa = this.medias.get(nombre);
this.medias.set(
nombre,
previa === undefined ? ms : previa + this.alfa * (ms - previa),
);
}
destino.unmap();
}).catch(() => { /* dispositivo perdido a mitad: ignorar */ });
}
El bucle de fotograma queda así, sin un solo await:
function fotograma() {
const encoder = device.createCommandEncoder();
dibujarSombras(encoder, perfilador);
dibujarGeometria(encoder, perfilador);
dibujarPostproceso(encoder, perfilador);
perfilador.resolver(encoder);
device.queue.submit([encoder.finish()]);
perfilador.recoger();
requestAnimationFrame(fotograma);
}
La comprobación de mapState no es paranoia defensiva, es la válvula de seguridad del sistema. Los tres estados son "unmapped", "pending" y "mapped", y mapAsync pone el búfer en "pending" en el mismo instante de la llamada, antes de que la GPU haya hecho nada. Usar un búfer "pending" o "mapped" como destino de una copia es un error de validación que mata el encoder entero. Con profundidad tres y una CPU que va un fotograma y medio por delante, el return de esa comprobación no salta casi nunca; cuando salta —una pausa del recolector de basura, un cambio de pestaña, una GPU saturada— simplemente pierdes la medición de un fotograma, que es exactamente lo que quieres que pase.
El precio de esta arquitectura es que el número que enseñas en pantalla es el del fotograma N-3, unos 50 milisegundos de retraso a 60 Hz. Para una barra de perfil eso es literalmente imperceptible, y a cambio no pagas ni un microsegundo de sincronización.
Un fotograma no significa nada
Si imprimes el valor crudo de un pass fotograma a fotograma verás algo desmoralizador: el mismo pass, dibujando exactamente lo mismo, mide 0,82 ms, luego 1,31 ms, luego 0,79 ms. No es un fallo de la medición. Es que la GPU no es una máquina determinista desde fuera.
Las causas son concretas y todas están fuera de tu control. El escalado dinámico de frecuencia mueve el reloj del núcleo entre, típicamente, 300 MHz en reposo y 2,5 GHz a plena carga, y tarda decenas de milisegundos en subir: los primeros fotogramas de cualquier ráfaga están medidos con un reloj lento. El compositor del sistema y cualquier otra pestaña comparten la misma GPU. Las cachés se llenan y se vacían. Y en portátiles, el límite térmico recorta la frecuencia sin avisar después de unos minutos de carga sostenida.
La respuesta es una media móvil exponencial, que es lo que hace el código de arriba en una línea:
media = media + alfa * (muestra - media);
Con alfa = 0.1, el peso de una muestra decae a 1/e en unos 9,5 fotogramas, es decir unos 160 milisegundos a 60 Hz. Es la constante de tiempo correcta para un panel que un humano mira: lo bastante rápida para que reaccione cuando entras en una zona pesada de la escena, lo bastante lenta para que los números no bailen. Si bajas alfa a 0,02 obtienes una lectura casi inmóvil que tarda casi un segundo en reaccionar; útil para comparar dos versiones del código, inútil para explorar una escena.
La media móvil exponencial tiene además una propiedad que la hace preferible a una ventana deslizante clásica: cuesta un Map de un número por pass en lugar de un array circular de sesenta, y no tiene el artefacto de que un pico entre y salga de la ventana produciendo dos escalones. Lo que sí pierde es el percentil: una media móvil no te dice nada de los picos, y los picos son lo que el usuario percibe como tirones.
Cuando sumes los milisegundos de tus veinte passes y compares con el tiempo de fotograma, no van a cuadrar. Nunca. Y el reflejo de casi todo el mundo es pensar que la medición está mal, cuando lo que ocurre es que hay trabajo de GPU que no pertenece a ningún pass y las timestamp queries, por construcción, no lo pueden ver. En las fronteras entre passes vive todo esto: las barreras de sincronización que el driver inserta para que un render target recién escrito pueda leerse como textura, las descompresiones de esos render targets cuando cambian de uso, los vaciados de caché, y en las GPU de móvil, los volcados y recargas de la memoria de tile, que en un pass a pantalla completa mueven varios megabytes cada vez. A eso hay que sumar lo que va fuera del encoder: las subidas de writeBuffer, las copias de textura, la propia resolución de tus consultas, y el bloque de trabajo que el compositor hace para presentar el swapchain. Hay un truco para cuantificarlo sin herramientas externas y cuesta dos ranuras: reserva un par extra, escribe la marca de inicio en el primer pass del fotograma y la de fin en el último, y ya tienes el tiempo de pared de la GPU para ese envío completo. Resta la suma de los passes y la diferencia es tu tiempo de frontera. Cuando esa diferencia es el 5 por ciento, no le des más vueltas. Cuando es el 35 por ciento —y en un tiler móvil con seis passes a pantalla completa lo es con frecuencia— acabas de descubrir que tu problema no es ningún shader, sino que tienes demasiados passes, y la optimización correcta es fusionarlos, no afinarlos.
Leer el desglose y apagar la luz
La presentación útil tiene tres columnas: nombre, milisegundos y porcentaje sobre el total medido. El porcentaje es el que dirige el trabajo, porque un pass de 0,4 ms es despreciable en un fotograma de 16 ms e inaceptable en uno de 2 ms.
informe(): Array<{ nombre: string; ms: number; pct: number }> {
let total = 0;
for (const ms of this.medias.values()) total += ms;
return this.orden
.filter((n) => this.medias.has(n))
.map((nombre) => {
const ms = this.medias.get(nombre)!;
return { nombre, ms, pct: total > 0 ? (ms / total) * 100 : 0 };
})
.sort((a, b) => b.ms - a.ms);
}
Un informe real de una escena con sombras en cascada tiene esta pinta, y se lee de arriba abajo:
| Pass | ms | % del total |
|---|---|---|
iluminacion |
3,84 | 44,1 |
sombras |
2,11 | 24,2 |
gbuffer |
1,52 | 17,5 |
ssao |
0,71 | 8,2 |
culling |
0,32 | 3,7 |
bloom |
0,20 | 2,3 |
Ocho coma siete milisegundos sumados. Si el tiempo de fotograma real son 11,5 ms, hay 2,8 ms de frontera y de presentación que no aparecen en la tabla. Y si el tiempo de fotograma son 16,7 ms clavados, no estás limitado por la GPU en absoluto: estás esperando al vsync, y optimizar iluminacion no va a cambiar un solo número visible.
Queda la pregunta incómoda: cuánto cuesta el perfilador. Lo cuantificable es poco. La resolución de 40 consultas y la copia de 320 bytes son del orden de unos pocos microsegundos de GPU, y el anillo ocupa 3 · 40 · 8 = 960 bytes. Lo que no se puede cuantificar de antemano es el efecto en el driver: escribir un timestamp en la frontera de un pass la convierte en una frontera real, y hay drivers —sobre todo en GPU de móvil basadas en tiles— que en ausencia de esa marca podrían fusionar dos render passes consecutivos con attachments compatibles en un solo recorrido de tiles. Instrumentar puede, en esos casos, cambiar la estructura del fotograma que estás midiendo.
Por eso el campo activo es público y por eso marcas() devuelve undefined cuando está apagado. En desarrollo, encendido. Para una medición final antes de decidir, mide dos veces: con el perfilador encendido para saber dónde está el tiempo, y con el perfilador apagado midiendo solo el tiempo de fotograma, para confirmar que la conclusión sigue en pie. Si los dos números no cuentan la misma historia, la instrumentación está interfiriendo y el perfil es sospechoso.
// Un atajo honesto para produccion: que el codigo ni siquiera exista.
const perfilador = import.meta.env.DEV
? new PerfiladorGPU(device, ["culling", "sombras", "gbuffer", "ssao", "iluminacion", "bloom"])
: null;
Todo esto mide el reloj de la GPU. Lo que no te dice es cuánto tarda tu fotograma desde el punto de vista del usuario, ni por qué envolver submit con performance.now() da cero: eso es el reloj de CPU y el de GPU. Y una vez sabes qué pass domina, la siguiente pregunta es qué lo limita, que se responde con los experimentos de cómputo o memoria.
- Implementa la clase completa y aplícala a un fotograma con al menos tres passes.
- Añade un
awaitenrecoger()y mide la caída de fotogramas por segundo. Anótala. - Reserva dos ranuras extra para el fotograma completo y calcula tu porcentaje de tiempo de frontera.
- Baja
alfade 0,1 a 0,02 y mide cuántos fotogramas tarda el panel en reaccionar a un cambio brusco de escena. - Reduce la profundidad del anillo a uno y cuenta cuántos fotogramas por segundo pierdes por saltarte mediciones.