Los errores que ya no deberías cometer
El catálogo de los fallos que se repiten en todos los proyectos WebGPU, agrupados por familia, cada uno con su síntoma, su causa real y su arreglo.
Los errores de WebGPU no son infinitos: son unos treinta, se repiten en todos los proyectos y casi todos vienen de tres o cuatro malentendidos de fondo. Esta lección los pone juntos por familias, con el síntoma por el que se manifiestan, la causa real (que casi nunca es la que parece) y el arreglo. Léela una vez ahora y otra el día que algo no cuadre.
- Reconocer un fallo por su síntoma antes de haber leído una sola línea de código.
- Separar la causa aparente de la causa real en los errores más frecuentes.
- Aplicar la corrección estructural en vez del parche que hace que el síntoma desaparezca.
- Auditar un proyecto propio contra el catálogo completo.
Los de modelo mental
Son los peores porque no producen errores: producen números y comportamientos que parecen razonables.
Medir con el reloj de la CPU alrededor de submit. El síntoma es que tu render “tarda 0,2 ms” y aun así el fotograma va a treinta por segundo. La causa es que submit solo encola: grabar y ejecutar están separadas por uno o dos fotogramas. El arreglo es medir con timestamp queries dentro de los passes, y medir el fotograma con los intervalos de requestAnimationFrame.
Esperar dentro del bucle de fotograma. Un await device.queue.onSubmittedWorkDone() o un await buffer.mapAsync(...) por fotograma sincroniza la CPU con la GPU y hunde el rendimiento entre un treinta y un setenta por ciento, dependiendo de cuánto encauzamiento estabas aprovechando. El arreglo es no esperar nunca: anillo de buffers, lee el del fotograma anterior y sigue.
Creer que los errores se lanzan. Casi nada en WebGPU tira una excepción. Los objetos se devuelven inválidos y el error viaja por otro canal. Si estás envolviendo llamadas en try para capturar errores de validación, no vas a capturar ninguno. Se usan error scopes y el evento uncapturederror.
Creer que hay sincronización entre workgroups. workgroupBarrier() sincroniza las invocaciones de un workgroup, no de todos. Dos workgroups del mismo despacho pueden ejecutarse en cualquier orden, incluso completamente en serie, y no hay ninguna primitiva que los sincronice. Si tu algoritmo necesita que todos terminen una etapa antes de empezar la siguiente, necesita dos despachos, no una barrera. El síntoma clásico: funciona con pocos elementos y falla con muchos, porque con pocos cabía todo en un workgroup.
Meter barreras manuales entre passes. El error contrario y también frecuente. Las dependencias de memoria entre passes distintos las resuelve la implementación: si un compute pass escribe un buffer y el siguiente render pass lo lee, WebGPU garantiza el orden. Las barreras del lenguaje son solo para dentro de un mismo despacho.
Crear objetos en el bucle de dibujado. Un createBindGroup por objeto y por fotograma con mil objetos son mil creaciones y mil destrucciones por fotograma. El coste no es de GPU, es de CPU y de recolector de basura, y se ve como un tiempo de fotograma irregular con picos. Un bind group con offset dinámico resuelve el caso general.
Los de recursos y estado
Falta un flag de usage. Es el error número uno en volumen absoluto. El mensaje de validación es claro cuando has etiquetado los objetos y críptico cuando no. Recuerda las combinaciones prohibidas: MAP_READ solo se combina con COPY_DST, MAP_WRITE solo con COPY_SRC, y QUERY_RESOLVE no se combina con ninguno de mapeo, así que resolver consultas y leerlas necesita dos buffers y una copia.
El padding de WGSL no coincide con el de tu Float32Array. El síntoma es que un valor llega bien y el siguiente llega desplazado o con basura, y encima cambia cuando reordenas los campos. La causa: un vec3<f32> se alinea a 16 bytes aunque ocupe 12, y las estructuras se alinean al mayor de sus miembros. El arreglo estructural no es contar bytes a mano, que es donde nace el bug: es declarar explícitamente el padding en la estructura de WGSL y generar los offsets desde una única definición compartida.
Se olvida unmap(). El buffer se queda mapeado, el siguiente mapAsync sobre él falla, y el mensaje habla de un estado inválido sin decir por qué. Comprueba buffer.mapState antes de pedir el mapeo.
No se destruye al redimensionar. Cada cambio de tamaño de la ventana crea texturas nuevas de profundidad, de G-buffer y de post-proceso; si no llamas a destroy() sobre las viejas, arrastrar el borde de la ventana durante cinco segundos puede reservar cientos de megabytes. Y el redimensionado tiene una segunda trampa: hay que volver a llamar a configure() en el contexto, y hay que acotar el tamaño resultante a maxTextureDimension2D, porque un monitor de mucha resolución multiplicado por devicePixelRatio se pasa de 8192 con más facilidad de la que parece.
Se guarda la textura del canvas entre fotogramas. context.getCurrentTexture() devuelve una textura válida para el fotograma actual. Guardarla, o crear su vista una vez y reutilizarla, produce fallos intermitentes y difíciles de reproducir. Pídela cada fotograma y lo más tarde posible.
Se consulta adapter.limits en vez de device.limits. Ya lo has visto en el nivel anterior y aparece aquí porque es el error de portabilidad más caro: no falla en tu máquina, falla en la del usuario.
Se crea un sampler por material. Los samplers son estado, no datos: casi todos tus materiales quieren el mismo. Con maxSamplersPerShaderStage en 16, crear uno por material es además un camino directo a chocar contra el límite. Cachéalos por descriptor.
Los de rendimiento
Optimizar antes de medir. El más caro de todos, medido en semanas. La pregunta de si el cuello está en cómputo o en memoria se responde en media hora con tres experimentos, y sin esa respuesta cualquier optimización es una apuesta con un cincuenta por ciento de acierto.
Optimizar la aritmética cuando el cuello es de memoria. El síntoma es desconcertante: quitas la mitad de las operaciones del shader y el tiempo no baja ni un uno por ciento. No es que no funcione la optimización: es que la ALU estaba esperando datos y sigue esperándolos. Reduce bytes y aumenta reutilización.
Elegir 256 como tamaño de workgroup por defecto. Más grande no es mejor. Un workgroup de 256 invocaciones consume registros y memoria compartida que limitan cuántos grupos residen a la vez, y muchas veces 64 va más rápido. Hazlo un override y prueba tres valores; cuesta cinco minutos.
Entrelazar datos que el kernel no usa juntos. Si tu partícula tiene posición, velocidad, color y vida en la misma estructura de 48 bytes, un kernel que solo integra posiciones trae 48 bytes por partícula para usar 12. Separar los arrays sube la eficiencia del tráfico a casi el cien por cien y no cuesta nada más que reorganizar los buffers.
Guardar en vez de recalcular. Con una lectura de memoria global costando cientos de ciclos, escribir un valor intermedio a un buffer para leerlo en el siguiente pass suele ser más caro que volver a calcularlo. La intuición que traes de la CPU, donde memorizar casi siempre gana, está invertida.
Usar rgba32float por costumbre. Cuadruplica el tráfico frente a rgba8unorm y lo duplica frente a rgba16float. Hay sitios donde hace falta, como un buffer de acumulación de path tracing; en un target de post-proceso intermedio casi nunca.
Añadir un depth prepass sin overdraw, o no añadirlo con overdraw. No es una técnica que se aplique siempre: es un intercambio. Duplica el coste de vértices y elimina el sombreado repetido, así que gana cuando el mismo píxel se sombrea varias veces y pierde cuando no. Mídelo en tu escena, con la escena llena, no con tres cubos.
Los de producción
Ningún objeto tiene label. Cuesta cero y transforma todos los mensajes de error del proyecto. No hay ningún argumento en contra.
No se maneja device.lost. El síntoma es que la aplicación se congela sin errores después de suspender el portátil, de actualizar el driver o de cambiar de GPU. Un device.lost que rehace la inicialización salvo cuando la razón es "destroyed" convierte un fallo total en un parpadeo.
Se piden features sin filtrar. Un requiredFeatures escrito a mano rechaza con TypeError en cualquier dispositivo que no las tenga, y como falla en requestDevice el síntoma es una pantalla negra sin más información. Filtra siempre por adapter.features.
No se distingue “no hay WebGPU” de “hay WebGPU y lo pedí mal”. Los dos acaban en el mismo catch y en el mismo mensaje, y con eso tu telemetría deja de servir para nada. Son cuatro casos y merecen cuatro mensajes: sin API, sin adaptador, adaptador de reserva, y fallo en la creación del dispositivo.
La calidad se decide leyendo capacidades. Un dispositivo con todas las features puede ir a quince fotogramas por segundo y uno sin ninguna puede ir a ciento veinte. La única fuente fiable sobre el rendimiento es el rendimiento: arranca conservador, mide uno o dos segundos, sube.
El perfilador se queda en producción con su espera. Si tu perfilador hace un await para leer los tiempos, en desarrollo te miente y en producción te cuesta rendimiento a cambio de nada. Anillo de buffers, lectura diferida, y un interruptor para apagarlo entero.
La ruta de reserva no se ejecuta nunca. Escrita el mes tres, rota el mes seis, descubierta por un usuario el mes nueve. Si no hay una manera de ejecutarla a diario, no cuenta como ruta de reserva.
Si pones el catálogo entero encima de la mesa y buscas el patrón, aparece uno solo y es incómodo de aceptar: casi todos son intuiciones correctas de la programación de CPU aplicadas a un sitio donde no valen. En la CPU, medir con un reloj alrededor de una llamada funciona, porque llamar es ejecutar; en la GPU no, porque grabar no es ejecutar. En la CPU, memorizar un resultado casi siempre gana; en la GPU, con la memoria costando cientos de ciclos y la aritmética uno, recalcular suele ganar. En la CPU, más hilos y más grandes suele ser mejor; en la GPU, un workgroup mayor puede reducir la ocupación y empeorarlo todo. En la CPU, esperar un resultado es lo normal; en la GPU, esperar es la operación más cara que existe. En la CPU, agrupar los datos de una entidad en una estructura es buena localidad; en la GPU, si el kernel solo toca un campo, es tirar tres cuartas partes del ancho de banda. La conclusión práctica no es “desconfía de tu intuición”, que es un consejo inútil, sino algo bastante más accionable: cada vez que una decisión te parezca obvia, comprueba si la estás tomando por una propiedad de la GPU o por costumbre de la CPU, porque la costumbre acierta en la mitad de los casos y falla justo en los que importan. Y por eso el nivel 2, el de la arquitectura de la GPU por dentro, resulta ser el que más veces hay que releer de todo el track: no porque haga falta la información, sino porque hace falta el reflejo.
- Busca en tu código todos los
awaitque ocurren dentro del bucle de fotograma. Cada uno es un candidato. - Busca todas las llamadas a
create...que estén dentro de un bucle de dibujado. - Comprueba que redimensionar la ventana treinta veces seguidas no aumenta la memoria de tu pestaña.
- Provoca a mano una pérdida de dispositivo con
device.destroy()y comprueba que tu aplicación vuelve sola. - Cuenta cuántos de tus objetos de WebGPU tienen
label. Si no son todos, arréglalo hoy.