Labeling GPU Objects for Readable Spatial Pipeline Errors
“Binding size is smaller than the minimum binding size for [Buffer]” is a message that begins a search. “…for [tiles/z14/vertex]” is a message that ends one. The difference is a label field that every WebGPU descriptor accepts, costs a string, and appears in every validation message, every uncaptured error and every frame capture. It is the highest-value-per-effort practice in this whole section and it is skipped constantly, because the moment labels are needed is the moment adding them is most disruptive. This page is a naming convention that scales past a handful of objects, what labels buy in a capture as opposed to a message, and the one place a label is actively load-bearing. It is one stage of debugging and validation for spatial GPU pipelines.
Runnable reference implementation
Labels are most useful when they are structural rather than descriptive, which means generating them from the same values that determine the object’s identity.
/** A label scheme: subsystem / instance / role. Stable, sortable, greppable. */
function label(subsystem: string, instance: string, role: string): string {
return `${subsystem}/${instance}/${role}`;
}
function createTileBuffers(device: GPUDevice, z: number, x: number, y: number) {
const key = `z${z}-${x}-${y}`;
return {
vertices: device.createBuffer({
label: label("tiles", key, "vertex"),
size: VERTEX_BYTES,
usage: GPUBufferUsage.VERTEX | GPUBufferUsage.COPY_DST,
}),
indices: device.createBuffer({
label: label("tiles", key, "index"),
size: INDEX_BYTES,
usage: GPUBufferUsage.INDEX | GPUBufferUsage.COPY_DST,
}),
};
}
Passes and encoders take labels too, and those are what make a capture readable rather than a wall of anonymous draw calls.
const encoder = device.createCommandEncoder({ label: "frame/1284" });
const cull = encoder.beginComputePass({ label: "frame/1284/cull" });
// ...
const draw = encoder.beginRenderPass({ label: "frame/1284/basemap", colorAttachments: [/* ... */] });
Parameter reference
| Value | Setting here | Guidance |
|---|---|---|
| Scheme | subsystem/instance/role |
Sortable and greppable; a flat descriptive string stops scaling at about twenty objects. |
| Instance component | the natural key | A tile key, a layer id, a frame number. Something that identifies which one. |
| Where applied | at creation | Adding labels while debugging means editing the code you are trying to understand. |
| Frame labels | include the frame number | Makes a capture’s passes self-ordering and lets a log line be matched to a capture. |
| Production | keep them | The cost is a string; the benefit is a usable bug report from a machine you do not have. |
What labels buy in a capture
Error messages are the obvious payoff and the smaller one. The larger is a frame capture, where an unlabelled application presents a list of RenderPass, RenderPass, ComputePass with nothing to distinguish them, and a labelled one presents frame/1284/basemap, frame/1284/labels, frame/1284/cull.
That difference changes what a capture is for. With labels, three questions are answerable at a glance: are the passes the ones expected, in the order expected; is anything recorded twice; and does each pass bind the buffers its name implies. Duplicate passes in particular are a common integration bug — a lifecycle hook firing more often than expected — and they are visible immediately in a labelled capture and essentially invisible in an unlabelled one.
Buffer labels do the same for bindings. A bind group whose entries read tiles/z14-8192-5461/vertex and camera/frame is self-documenting; one whose entries read Buffer, Buffer requires cross-referencing sizes against the code.
Where a label is load-bearing
Labels are usually diagnostic, and there is one case where a missing one is a real defect: an error report from a user’s machine.
A validation error that reaches production telemetry carries its message and nothing else. With labels, that message identifies the subsystem, the instance and the role — enough to reproduce, often enough to fix without reproducing. Without them, the same report says a buffer somewhere was the wrong size, which is not actionable.
That is the argument for keeping labels in production builds rather than stripping them as debug artefacts. The cost is a few kilobytes of strings across an application; the benefit is that the one report you get from the machine that fails is worth reading.
Generating labels rather than writing them
Hand-written labels drift. A buffer created in two places with two spellings of the same name is worse than no label, because it looks authoritative and is misleading. Generating them removes the possibility.
The mechanism is a single allocator. Every buffer, texture and pipeline in the application is created through one function that takes the subsystem, the instance and the role as arguments and assembles the label itself. That function is also the natural place to count allocations, to apply an optional error scope, and to register the resource for cleanup — so the labelling discipline arrives free with infrastructure that is worth having anyway.
The instance component is the part that needs thought. It should be the natural key of whatever the object belongs to: a tile key for tile resources, a layer id for layer resources, a frame number for per-frame ones. What it should not be is a counter, because a counter tells you that this was the forty-third buffer created and nothing about which forty-third.
For per-frame objects there is a small subtlety: a frame number that increments forever produces labels that are unique and unmatchable across a capture boundary. Using the frame number modulo a few hundred keeps them short and still distinguishes adjacent frames, which is all a capture needs.
What to do with a capture once it is readable
Labels make a capture navigable; knowing what to look at makes it useful, and three passes over it answer most questions.
The first pass reads the list of passes and compares it against what the frame should contain. Missing passes, extra passes, and passes in the wrong order are all visible immediately and all common in framework integrations where a lifecycle hook fires unexpectedly.
The second pass checks the bindings of the draw or dispatch that is producing wrong output. Each bind group entry names a labelled resource, and the question is whether the resource bound at each slot is the one the shader expects — a mismatch here is the most common cause of a pass that runs and produces nonsense.
The third pass reads the draw parameters: vertex count, instance count, and for an indirect draw the contents of the indirect buffer. A count of zero renders nothing and reports nothing, and it is the single most common reason a labelled, correctly-bound, correctly-ordered frame still shows an empty screen.
Failure modes
- Labels exist but are all the same. A constant string rather than a generated one. The instance component is what makes them useful.
- Labels were added during debugging and then removed. They cost nothing to keep and everything to re-add next time.
- A capture shows passes with names that do not match the code. Usually correct information about an integration doing something unexpected — a host library recording its own passes with your encoder, for instance.
- The label is on the wrong object. Bind groups and bind group layouts both accept labels and both appear in messages; labelling only one leaves half the errors anonymous.
- Labels leak sensitive data. Rare but real: a label built from a user-supplied layer name reaches telemetry. Build them from ids rather than from names.
Backend / Python interop note
Where a payload identifies itself, the client can label from the payload rather than from its own bookkeeping, and the two then agree by construction.
A tile response carrying its (z, x, y) in the schema metadata lets the client build tiles/z14-8192-5461/vertex from what the server said rather than from what the client requested. Those are usually the same and occasionally are not — a server that redirects a request to a coarser tile, or a cache that returns a neighbour, produces a mismatch that a label built from the response makes visible and one built from the request hides.
There is a related habit worth adopting for anything derived: carry the provenance in the label. A buffer holding the survivors of a cull pass labelled cull/z14-8192-5461/out says both what produced it and what it came from, which in a pipeline with several derivation stages is the difference between reading a capture and reconstructing one.
The same applies to schema versions. Including the version in the label of any buffer built from a payload — arrow/v3/points — turns a class of decode mismatch into something a capture shows directly, and costs a string concatenation on a path that already has the version in hand.
Related
- Debugging and validation for spatial GPU pipelines — the topic this page belongs to.
- Using error scopes to localize GPU validation failures — what produces the messages labels make readable.
- Diagnosing NaN coordinates in WGSL shaders — the failures that produce no message at all.
- Frame profiling with timestamp queries — where pass labels pay off a second time.
- WebGPU compute vs render pipeline fundamentals — the objects worth labelling.