wandres.dev
DEPURAR WEBGPU · Errores, scopes y validación

label: la propiedad que convierte un mensaje inútil en un diagnóstico

Qué hace exactamente label, por qué etiquetar un solo objeto no basta, cómo nombrar por función, y los grupos y marcadores de depuración en el flujo de comandos.

⏱ 16 min

Hay una línea de código que cambia más tu experiencia depurando WebGPU que cualquier herramienta externa, y es poner una cadena en el descriptor. Sin ella, la validación te dice que «el bind group en el índice cero no es compatible con el layout»; con ella, te dice que tu bind group de material del suelo no es compatible con el layout del pipeline de sombras. Es el mismo error, la misma implementación y el mismo instante: lo único que cambia es que el mensaje ahora habla de tu programa en lugar de hablar de la especificación.

🎯 Al terminar esta lección sabrás
  • Etiquetar cualquier objeto de WebGPU en su creación y modificar la etiqueta después.
  • Leer un mensaje de validación que encadena las etiquetas de varios objetos a la vez.
  • Aplicar un criterio de nombrado por función que sirva tanto en errores como en capturas.
  • Agrupar comandos con pushDebugGroup, popDebugGroup e insertDebugMarker.

Qué es exactamente label

Todos los descriptores de creación de WebGPU aceptan un miembro label de tipo cadena, y no hay excepciones: buffers, texturas, vistas de textura, samplers, bind group layouts, bind groups, pipeline layouts, módulos de shader, pipelines de render y de cómputo, command encoders, command buffers, los tres tipos de pass encoder, render bundles, query sets y el propio dispositivo. La propiedad viene de una interfaz común que heredan todos, y por eso está en todas partes con el mismo nombre.

Además de escribirse en la creación, label es legible y escribible en cualquier momento sobre el objeto ya creado. Eso abre dos usos que no son obvios. El primero es enriquecer una etiqueta con información que solo conoces después: un buffer creado por un cargador genérico puede recibir el nombre del fichero de origen cuando este termina de resolverse. El segundo es leerla para tus propios mensajes, de modo que tus console.error hablen el mismo idioma que los de la validación.

const uniformes = device.createBuffer({
  label: "camara/uniformes",
  size: 256,
  usage: GPUBufferUsage.UNIFORM | GPUBufferUsage.COPY_DST,
});

// Se puede reescribir más tarde.
uniformes.label = `camara/uniformes (${escena.nombre})`;

// Y leerse, para que tus errores hablen como los del navegador.
if (datos.byteLength > uniformes.size) {
  throw new Error(`"${uniformes.label}" es demasiado pequeño: ${datos.byteLength} bytes`);
}

Lo que la etiqueta no hace: no participa en ninguna validación, no afecta a la compatibilidad de layouts, y dos objetos con la misma etiqueta no tienen ninguna relación entre sí. Es puramente diagnóstica, y esa es exactamente la razón por la que puedes usarla con total libertad.

El mismo error, con etiquetas y sin ellas

Vale la pena ver la transformación con un caso concreto. Supón un bind group cuyo buffer de uniformes es más pequeño de lo que el layout declara. Sin etiquetar nada, Chrome —con Dawn debajo— produce un mensaje con esta forma:

Binding size (64) of [Buffer] is less than the minimum binding size (256).

 - While validating entries[0] as a Buffer.
 - While validating [BindGroupDescriptor] against [BindGroupLayout]
 - While calling [Device].CreateBindGroup([BindGroupDescriptor]).

Ahí tienes tres piezas de información inútil: hay un buffer, hay un bind group y hay un layout. Cuál de los cuarenta buffers de tu escena, cuál de los ocho layouts y para qué pipeline es una investigación entera. Con las etiquetas puestas, el mismo error se lee así:

Binding size (64) of [Buffer "material/suelo/uniformes"] is less than the
minimum binding size (256).

 - While validating entries[0] as a Buffer.
 - While validating [BindGroupDescriptor "material/suelo/bg"] against
   [BindGroupLayout "layout/material-pbr"]
 - While calling [Device].CreateBindGroup([BindGroupDescriptor "material/suelo/bg"]).

El diagnóstico ha pasado de una hora a diez segundos: el buffer de uniformes del material del suelo se creó con 64 bytes y el layout de materiales PBR pide 256. Probablemente añadiste un campo a la struct de WGSL y no actualizaste el tamaño.

Fíjate en el detalle que hace que esto funcione de verdad: el mensaje incluye las etiquetas de la cadena entera, no solo la del objeto que falló. Buffer, descriptor de bind group y layout aparecen los tres en el mismo texto. Y ahí está la consecuencia práctica que se le escapa a casi todo el mundo: etiquetar un solo objeto no sirve de casi nada. Si etiquetas los buffers pero no los layouts, el mensaje te dice qué buffer es pero no contra qué está fallando. Si etiquetas los pipelines pero no los bind groups, sabes en qué pipeline pasa pero no qué recurso. Las etiquetas rinden de forma superlineal: el valor no está en cada una, está en que la cadena esté completa.

En el camino de comandos la cadena es aún más larga, porque el error se emite en finish() y el mensaje reconstruye el recorrido desde el pipeline hasta el encoder. Un mensaje completo puede citar el pipeline, su pipeline layout, el bind group layout en el índice que falla, el bind group que intentaste enlazar, el buffer que hay dentro, el pass encoder y el command encoder. Siete etiquetas, siete oportunidades de haber sido perezoso.

Nombrar por función, no por tipo

La disciplina cabe en tres reglas y todas se aprenden por las malas.

Etiqueta en el momento de crear. No como una pasada posterior «cuando haya tiempo», porque ese momento no llega y porque la información que hace falta para nombrar bien —qué es esto y para qué— solo la tienes delante mientras escribes la creación.

El nombre dice la función, no el tipo. "textura2" no aporta nada: que es una textura ya lo dice el mensaje de error, y que es la segunda no lo sabe nadie. "gbuffer/normales" te sitúa en un instante. El tipo es redundante y el número de orden es ruido; lo que necesitas saber es qué papel juega ese objeto en tu render. Un separador jerárquico —una barra, dos puntos, lo que prefieras— convierte el conjunto de etiquetas en un árbol navegable y hace que buscar por prefijo funcione.

Numera las instancias cuando hay varias. Si tienes cuatro cascadas de sombra, "sombras/cascada-0" a "sombras/cascada-3". Si tienes un buffer por objeto, mete el identificador del objeto. Una etiqueta compartida por cien recursos es tan poco útil como no tener etiqueta, con el agravante de que crees que estás cubierto.

La forma de que esto no dependa de la fuerza de voluntad es que el sistema de tipos lo exija. En TypeScript, la etiqueta es opcional en todos los descriptores, pero una intersección la vuelve obligatoria, y una fábrica con prefijo hace que las jerarquías salgan gratis:

type ConEtiqueta<D> = D & { label: string };

class Fabrica {
  constructor(
    private readonly device: GPUDevice,
    private readonly prefijo = "",
  ) {}

  /** Una fábrica hija que antepone su propio tramo a todo lo que cree. */
  rama(nombre: string): Fabrica {
    return new Fabrica(this.device, `${this.prefijo}${nombre}/`);
  }

  buffer(d: ConEtiqueta<GPUBufferDescriptor>): GPUBuffer {
    return this.device.createBuffer({ ...d, label: this.prefijo + d.label });
  }

  textura(d: ConEtiqueta<GPUTextureDescriptor>): GPUTexture {
    return this.device.createTexture({ ...d, label: this.prefijo + d.label });
  }

  bindGroup(d: ConEtiqueta<GPUBindGroupDescriptor>): GPUBindGroup {
    return this.device.createBindGroup({ ...d, label: this.prefijo + d.label });
  }

  pipelineRender(d: ConEtiqueta<GPURenderPipelineDescriptor>): GPURenderPipeline {
    return this.device.createRenderPipeline({ ...d, label: this.prefijo + d.label });
  }
}

Olvidarse de la etiqueta pasa a ser un error de compilación, no un descuido que descubres seis meses después mirando un mensaje anónimo. Y el uso queda limpio:

const gbuffer = new Fabrica(device, "gbuffer/");

const normales = gbuffer.textura({
  label: "normales",              // etiqueta real: "gbuffer/normales"
  size: [ancho, alto],
  format: "rgba16float",
  usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.TEXTURE_BINDING,
});

El esfuerzo, además, se cobra dos veces. Las mismas etiquetas aparecen en los capturadores de fotogramas, donde la lista de recursos de una captura pasa de ser doscientas entradas llamadas «Texture 147» a un árbol legible. Nombrar bien no es solo higiene para los errores: es la diferencia entre poder leer una captura y no poder.

Grupos y marcadores en el flujo de comandos

Las etiquetas nombran objetos. Para nombrar tramos de trabajo existen tres métodos que agrupan comandos en una estructura de árbol:

  • pushDebugGroup(nombre) abre un grupo.
  • popDebugGroup() lo cierra.
  • insertDebugMarker(nombre) deja una marca puntual sin abrir nada.

Están en GPUCommandEncoder, GPURenderPassEncoder, GPUComputePassEncoder y GPURenderBundleEncoder. No están en GPUQueue: el nivel de la cola no tiene comandos que agrupar.

const encoder = device.createCommandEncoder({ label: "fotograma" });

encoder.pushDebugGroup("sombras");
for (let i = 0; i < 4; i++) {
  const pass = encoder.beginRenderPass(descriptorCascada(i));
  pass.pushDebugGroup(`cascada-${i}`);
  dibujarOcluyentes(pass, i);
  pass.popDebugGroup();
  pass.end();
}
encoder.popDebugGroup();

encoder.pushDebugGroup("gbuffer");
const pass = encoder.beginRenderPass(descriptorGBuffer);
pass.insertDebugMarker("opacos");
dibujarOpacos(pass);
pass.insertDebugMarker("decals");
dibujarDecals(pass);
pass.end();
encoder.popDebugGroup();

device.queue.submit([encoder.finish()]);

Dos reglas de uso. Los grupos tienen que estar equilibrados dentro del mismo encoder: un popDebugGroup sin su push es un error de validación, y terminar un encoder o un pass con grupos abiertos también lo es. Y no se pueden cruzar niveles: un grupo abierto en el command encoder no envuelve a los que abras dentro de un pass, cada encoder mantiene su propia pila.

Lo que ganas es que la vista de comandos de un capturador deja de ser una lista plana de mil llamadas y pasa a ser un árbol con tus nombres, y que algunos mensajes de validación citan el grupo activo cuando el error se produjo. Es la única forma que tienes de decirle a una herramienta externa cómo se llama cada parte de tu render.

El coste en producción es prácticamente cero: sin un capturador conectado ni una capa de depuración activa, la implementación descarta el nombre sin llegar a construir nada en el backend. Aun así, deja la posibilidad de apagarlos. Cada llamada crea una cadena en JavaScript, y si tus nombres son plantillas interpoladas por objeto en una escena de diez mil objetos, la basura que generas por fotograma sí se nota. Un envoltorio trivial resuelve el asunto:

const DEPURAR = import.meta.env.DEV;

function grupo(e: GPURenderPassEncoder, nombre: () => string, cuerpo: () => void) {
  if (DEPURAR) e.pushDebugGroup(nombre());
  cuerpo();
  if (DEPURAR) e.popDebugGroup();
}

Pasar el nombre como función y no como cadena es lo que evita que la interpolación se ejecute cuando la depuración está apagada.

La misma cadena atraviesa tres capas, y por eso etiquetar es la inversión más rentable

La razón por la que las etiquetas rinden tanto no es que el navegador las guarde en un mapa para imprimirlas: es que viajan hacia abajo hasta el objeto nativo. Tanto Dawn como wgpu propagan label al nombre de depuración del objeto real del backend cuando este lo admite, y los tres backends lo admiten: D3D12 tiene un método para asignar nombre a cualquier recurso, Vulkan tiene la extensión de utilidades de depuración con nombres de objeto, y Metal tiene su propia propiedad de etiqueta en buffers, texturas y pipelines. Eso significa que la cadena que escribes en tu descriptor de JavaScript es literalmente la misma cadena que aparece en el árbol de recursos de RenderDoc, en la lista de PIX o en el depurador de Metal de Xcode. Un único texto cruza el intérprete de JavaScript, el proceso de GPU y la API nativa. La consecuencia es que la decisión de nombrar bien no se paga en el sitio donde la tomas: se paga meses después, cuando tienes un problema de rendimiento que solo se reproduce en un portátil concreto y la única vía es una captura nativa. Si etiquetaste, abres la captura y encuentras tu pass de sombras en cinco segundos. Si no, tienes doscientas texturas numeradas y una tarde por delante correlacionando tamaños y formatos a mano para adivinar cuál es cuál. El corolario cínico, y es el que hace que merezca la pena: el momento en el que necesitas las etiquetas es siempre un momento en el que ya no puedes añadirlas, porque el bug está en producción, en la máquina de otro, y recompilar con etiquetas y volver a reproducirlo cuesta más que el bug.

⚔️ Instrumenta tu proyecto en una tarde
  1. Añade label a los diez objetos más usados de tu escena, con jerarquía por barras.
  2. Provoca un error de compatibilidad de bind group y cuenta cuántas de tus etiquetas aparecen en el mensaje.
  3. Sustituye tus llamadas de creación por una fábrica que exija etiqueta en el tipo.
  4. Envuelve cada fase de tu render en un pushDebugGroup con el nombre de la fase.
  5. Comprueba qué pasa si terminas un pass sin cerrar un grupo abierto.