Timestamp queries: la única medida real de la GPU
La API completa de las timestamp queries, del requiredFeatures al BigInt64Array, y por qué hacen falta dos búferes para leer un número de ocho bytes.
La GPU no tiene console.time. Ejecuta lo que le mandas horas de reloj después de que tu código lo haya grabado, en un orden que no controlas, y lo único que devuelve son píxeles. Las timestamp queries son la puerta que la especificación abrió para preguntarle cuánto ha tardado de verdad: dos marcas de tiempo en nanosegundos escritas por el propio hardware al entrar y al salir de un pass. La API es pequeña, pero tiene tres reglas de alineación y una incompatibilidad de flags que hace tropezar a todo el mundo la primera vez.
- Detectar y solicitar la feature
timestamp-querysin querequestDevicefalle en los dispositivos que no la tienen. - Instrumentar un render pass y un compute pass con
timestampWritesy sus dos índices. - Resolver el conjunto de consultas respetando las reglas de
QUERY_RESOLVE, el múltiplo de 256 y los 8 bytes por consulta. - Leer los enteros de 64 bits y convertirlos a milisegundos sin perder precisión.
Pedir la capacidad sin romper el arranque
timestamp-query es una feature opcional. En agosto de 2026 la tienen prácticamente todos los escritorios con GPU dedicada y la mayoría de los móviles recientes, pero no es universal, y lo que no puedes hacer es pedirla a ciegas: si la incluyes en requiredFeatures y el adaptador no la soporta, requestDevice rechaza la promesa y te quedas sin dispositivo. No degrada, no avisa: falla. El orden correcto es preguntar primero al adaptador y construir la lista después.
const adaptador = await navigator.gpu?.requestAdapter();
if (!adaptador) throw new Error("Sin adaptador WebGPU");
const puedeMedir = adaptador.features.has("timestamp-query");
const device = await adaptador.requestDevice({
label: "dispositivo principal",
requiredFeatures: puedeMedir ? ["timestamp-query"] : [],
});
// A partir de aquí, la fuente de verdad es el dispositivo, no el adaptador:
// device.features.has("timestamp-query") es lo que de verdad tienes activo.
El matiz de la última línea no es cosmético. adapter.features es lo que el hardware ofrece; device.features es lo que tú pediste y te concedieron. Si construyes la lista condicionalmente y luego alguien la filtra por política del navegador, solo el dispositivo lo sabe. Toda la instrumentación posterior debe colgar de una bandera derivada de device.features.has("timestamp-query"), porque pasar timestampWrites a un pass en un dispositivo sin la feature es un error de validación que revienta el encoder entero, no solo la medición.
El conjunto de consultas es un objeto opaco que vive en la GPU y funciona como un array de ranuras donde el hardware escribe.
const CONSULTAS = 2; // una al entrar, otra al salir
const querySet = device.createQuerySet({
label: "reloj del pass principal",
type: "timestamp",
count: CONSULTAS,
});
Solo hay dos valores válidos para type: "occlusion" y "timestamp". Y count tiene un techo duro que la especificación fija en 4096: createQuerySet valida que no lo superes. Suena a mucho hasta que decides medir cada pass por separado en un motor con cuarenta pasadas y anillo de tres fotogramas, y entonces son 240 ranuras, todavía cómodo pero ya no infinito. Cuando termines, querySet.destroy() libera la memoria; un conjunto de consultas por fotograma es una fuga garantizada.
Dónde se escriben los tiempos
No hay una función “marca el tiempo aquí”. WebGPU no te deja poner un cronómetro en mitad de un pass porque la GPU no ejecuta comandos en el orden en que los lees: dentro de un pass hay solapamiento, reordenación y ejecución simultánea de cientos de draws. Lo único que el hardware sabe garantizar son las fronteras. Por eso la unidad de medida es el pass completo, y por eso la instrumentación va en el descriptor.
timestampWrites es un objeto, no un array, y tiene exactamente tres campos:
| Campo | Tipo | Obligatorio |
|---|---|---|
querySet |
GPUQuerySet de tipo "timestamp" |
sí |
beginningOfPassWriteIndex |
índice dentro del conjunto | no |
endOfPassWriteIndex |
índice dentro del conjunto | no |
Los dos índices son opcionales por separado. Puedes marcar solo la entrada, solo la salida, o las dos; si das las dos tienen que ser índices distintos, y cualquiera de ellos debe ser menor que el count del conjunto. Va igual en beginRenderPass y en beginComputePass, con la misma forma exacta:
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: contexto.getCurrentTexture().createView(),
clearValue: { r: 0, g: 0, b: 0, a: 1 },
loadOp: "clear",
storeOp: "store",
}],
timestampWrites: {
querySet,
beginningOfPassWriteIndex: 0,
endOfPassWriteIndex: 1,
},
});
const passCompute = encoder.beginComputePass({
timestampWrites: {
querySet,
beginningOfPassWriteIndex: 2,
endOfPassWriteIndex: 3,
},
});
Nada te obliga a usar un conjunto distinto por pass: un solo querySet de 64 entradas puede dar servicio a 32 passes del mismo fotograma, mezclando render y compute, siempre que cada uno use su par de índices. Eso es exactamente lo que hace un perfilador, y es la razón de que el límite de 4096 esté donde está.
Una ranura que nunca se escribe se resuelve como cero. Es un detalle práctico importante: si tu pass no llegó a ejecutarse porque una rama del código lo saltó, no obtienes basura ni un error, obtienes un cero que se convierte en una diferencia negativa o absurda. Vale la pena filtrarlo explícitamente al leer.
Resolver, copiar, leer: el baile de los dos búferes
Los valores viven dentro del conjunto de consultas, que es opaco. Para verlos hay que volcarlos a un búfer con resolveQuerySet, un método del command encoder que se graba fuera de cualquier pass:
encoder.resolveQuerySet(querySet, 0, CONSULTAS, bufferResolucion, 0);
La firma es resolveQuerySet(querySet, firstQuery, queryCount, destination, destinationOffset) y arrastra tres reglas que conviene memorizar juntas porque las tres producen errores de validación distintos:
- El búfer destino necesita el flag
GPUBufferUsage.QUERY_RESOLVE. Sin él, error. destinationOffsettiene que ser múltiplo de 256. No de 8, no de 4: de 256. Es la alineación que exigen los backends nativos para el destino de un resolve.- Cada consulta ocupa 8 bytes, así que el destino debe tener al menos
destinationOffset + 8 * queryCountbytes de tamaño.
Y aquí llega el muro contra el que choca todo el mundo. La reacción natural es crear un solo búfer con QUERY_RESOLVE | MAP_READ y leerlo directamente. No se puede. La especificación restringe MAP_READ a combinarse únicamente con COPY_DST, y MAP_WRITE únicamente con COPY_SRC. Cualquier otra combinación con un flag de mapeo es un error en createBuffer. Así que hacen falta dos búferes y un copyBufferToBuffer entre ellos:
const BYTES = CONSULTAS * 8;
// Vive en memoria de la GPU. La GPU escribe aquí, la CPU no lo ve.
const bufferResolucion = device.createBuffer({
label: "resolucion de timestamps",
size: BYTES,
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
});
// Vive en memoria visible desde el host. Solo puede recibir copias.
const bufferLectura = device.createBuffer({
label: "lectura de timestamps",
size: BYTES,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
});
Parece una restricción arbitraria de la especificación y no lo es en absoluto. Un búfer con MAP_READ tiene que estar en memoria visible desde el host: en una tarjeta dedicada eso significa memoria del sistema accesible por PCIe, o un rincón de la VRAM expuesto por la ventana BAR, y en los dos casos es memoria no cacheada y de escritura lenta desde el punto de vista de la GPU. El destino de un resolveQuerySet, en cambio, lo escribe la propia GPU con una operación interna que el driver quiere resolver a máxima velocidad en memoria local. Si la especificación permitiera QUERY_RESOLVE | MAP_READ, el driver estaría obligado a colocar el destino del resolve en el lado lento, y cada resolución pagaría un viaje por el bus. Al prohibirlo, WebGPU te fuerza a escribir explícitamente el copyBufferToBuffer que de todas formas iba a ocurrir, pero ahora lo ves en tu código y sabes lo que cuesta. Es el mismo principio que gobierna toda la API: ninguna transferencia entre dominios de memoria ocurre a tus espaldas. Cuando entiendes esto, la regla deja de ser una cosa que memorizas y pasa a ser algo que puedes deducir: MAP_WRITE solo va con COPY_SRC por la razón simétrica, porque un búfer donde tú escribes desde la CPU solo tiene sentido como origen de una subida. Y de propina te explica por qué existe mappedAtCreation: es el único atajo que la API concede, y solo funciona una vez, en el momento en que el búfer todavía no ha entrado en el juego.
La lectura es asíncrona porque tiene que esperar a que la GPU llegue a ese punto de la cola. mapAsync devuelve una promesa que se resuelve cuando el contenido está disponible en la CPU; a partir de ahí getMappedRange() te da un ArrayBuffer y unmap() lo devuelve a la GPU invalidando ese ArrayBuffer, así que todo lo que quieras conservar hay que copiarlo antes.
await bufferLectura.mapAsync(GPUMapMode.READ);
const crudos = new BigInt64Array(bufferLectura.getMappedRange());
const inicio = crudos[0];
const fin = crudos[1];
bufferLectura.unmap(); // crudos deja de ser válido aquí
const ms = Number(fin - inicio) / 1e6;
BigInt64Array o BigUint64Array, porque los timestamps son enteros de 64 bits en nanosegundos y no caben en un Number. Y el orden de las operaciones no es negociable: resta primero en BigInt, convierte después. Un timestamp crudo puede ser el contador del hardware desde el arranque, y 2^53 nanosegundos son unos 104 días; en una máquina que lleva encendida más que eso, convertir cada valor a Number antes de restar redondea los dos y la diferencia sale mal, o sale cero. La resta en BigInt es exacta siempre, y el resultado, unos pocos millones de nanosegundos, cabe holgadamente en un Number.
Entre las dos vistas, BigInt64Array con signo es la más práctica pese a que los valores son sin signo. Si por lo que sea el final resulta menor que el principio —una ranura sin escribir, un pass que no llegó a ejecutarse, una cuantización agresiva del navegador—, con la vista con signo obtienes un número negativo que detectas con una comparación. Con BigUint64Array obtienes el resultado módulo 2^64, o sea unos 18 trillones de nanosegundos, que en milisegundos son 18 mil millones y ensucian cualquier media que estés calculando.
La medición completa
Todo junto, una función que mide un pass de principio a fin y devuelve milisegundos:
const CONSULTAS = 2;
const BYTES = CONSULTAS * 8;
const activo = device.features.has("timestamp-query");
const querySet = activo
? device.createQuerySet({ type: "timestamp", count: CONSULTAS })
: null;
const bufferResolucion = activo
? device.createBuffer({
size: BYTES,
usage: GPUBufferUsage.QUERY_RESOLVE | GPUBufferUsage.COPY_SRC,
})
: null;
const bufferLectura = activo
? device.createBuffer({
size: BYTES,
usage: GPUBufferUsage.MAP_READ | GPUBufferUsage.COPY_DST,
})
: null;
async function medirUnPass(): Promise<number | null> {
const encoder = device.createCommandEncoder({ label: "fotograma medido" });
const pass = encoder.beginRenderPass({
colorAttachments: [{
view: contexto.getCurrentTexture().createView(),
clearValue: { r: 0.02, g: 0.02, b: 0.05, a: 1 },
loadOp: "clear",
storeOp: "store",
}],
...(activo && querySet
? {
timestampWrites: {
querySet,
beginningOfPassWriteIndex: 0,
endOfPassWriteIndex: 1,
},
}
: {}),
});
pass.setPipeline(pipeline);
pass.setBindGroup(0, bindGroup);
pass.draw(3);
pass.end();
// Fuera del pass, y solo si el buffer de lectura esta libre.
if (activo && querySet && bufferResolucion && bufferLectura) {
encoder.resolveQuerySet(querySet, 0, CONSULTAS, bufferResolucion, 0);
if (bufferLectura.mapState === "unmapped") {
encoder.copyBufferToBuffer(bufferResolucion, 0, bufferLectura, 0, BYTES);
}
}
device.queue.submit([encoder.finish()]);
if (!activo || !bufferLectura) return null;
if (bufferLectura.mapState !== "unmapped") return null;
await bufferLectura.mapAsync(GPUMapMode.READ);
const crudos = new BigInt64Array(bufferLectura.getMappedRange());
const inicio = crudos[0];
const fin = crudos[1];
bufferLectura.unmap();
if (fin <= inicio) return null; // ranura sin escribir o cuantizada a 0
return Number(fin - inicio) / 1e6;
}
Fíjate en la comprobación de mapState antes de grabar la copia. Un búfer mapeado, o con un mapeo pendiente, no puede ser destino de copyBufferToBuffer: es error de validación. Los tres estados posibles son "unmapped", "pending" y "mapped", y llamar a mapAsync pone el búfer en "pending" de inmediato, antes incluso de que la GPU haya hecho nada. Con un solo búfer y un await por fotograma la secuencia se cierra sola, pero en cuanto sueltas el await para no bloquear, el estado deja de ser trivial.
Y ese await es exactamente el problema. Tal como está escrita, esta función espera a que la GPU termine el pass antes de devolver, lo que significa que la CPU se para en seco al final de cada fotograma. Como código de diagnóstico puntual está perfecto: ejecútalo cien veces, quédate con la mediana y tienes un número honesto del coste de ese pass aislado. Como instrumentación permanente destruye justo aquello que intentabas medir, porque elimina el solapamiento entre CPU y GPU que sostiene el rendimiento en producción. Convertir esto en una herramienta que puedas dejar encendida es el trabajo de el perfil por pass, y la razón profunda de por qué los dos relojes no son comparables está en el reloj de CPU y el de GPU.
- Comprueba
adapter.features.has("timestamp-query")en tu máquina y en un móvil, y anota la diferencia. - Mide un pass que dibuje un triángulo a pantalla completa y otro que dibuje diez mil instancias. Compara.
- Sustituye
BigInt64ArrayporBigUint64Arrayy provoca deliberadamente una ranura sin escribir para ver el valor de 18 trillones. - Pon
destinationOffseta 128 enresolveQuerySety lee el mensaje de validación completo. - Mide cien veces seguidas el mismo pass sin cambiar nada y calcula la diferencia entre el mínimo y el máximo.