Bind Resources and Descriptor Spaces#
Slang only.
Goal. Place resources into specific descriptor sets, and state a constant buffer’s layout yourself rather than leaving it to reflection.
Reflection here means the binding layout SPG reads back out of a compiled Slang shader: which space and slot each resource sits in, and where each value sits in the constant buffer.
Before you start. Types and Values, and a Slang node that runs.
The Shape#
Every resource needs two things settled: which descriptor set it belongs to, the space, and
which slot it occupies within that set. A flat bind = { ... } states neither and relies
on reflection for both. Grouping states them from the script:
return slang.compute({
slang.bind({
slang.ParameterBlock(
slang.float(inputs["strength"]) -- -> slot 0, the constant buffer
),
slang.Texture2D(inputs["Image"]), -- -> slot 1
slang.RWTexture2D(outputs["Result"]), -- -> slot 2
}, 1),
slang.bind({
slang.StructuredBuffer(weights), -- -> space 2, slot 0
}, 2),
numthreads = { 8, 8, 1 },
grid = { math.ceil(width / 8), math.ceil(height / 8), 1 },
})
Each entry’s slot is its position in the group, counting from zero.
How It Works#
Form |
What it states |
|---|---|
|
Neither space nor slot. Both come from reflection, matched shader-global per descriptor kind. |
|
Slots, but not the space. The space comes from reflection; if the shader uses more than one, the load fails naming the spaces it found. |
|
Both. Space |
Two or more groups must each state a space, and may be listed in any order. Grouping also scopes the matching: within a group, resources are matched against the reflected resources of that space alone.
Lua rejects two ParameterBlock s in one group, a duplicate space across groups, and a space
that is negative or not an integer.
Loose uniform values sit where the compiler puts them. A shader can declare its values as
uniform globals instead of a ParameterBlock struct. The compiler gathers them into one
buffer and gives it a binding and a descriptor space of its own, around the resources the shader
places, such as binding 1 of space 0 beside a resource at [[vk::binding(0, 0)]]. Reflection
does not report that place, so SPG reads it from the compiled shader and binds the values there. A
launch script that states it, as the ParameterBlock’s position in a group, must state the same
place. When SPG cannot read the place from the compiled shader, the launch script has to state it,
and without it the node’s graph stops with a message saying so.
The With-Reflection Case#
Every Slang example takes the first form. The shader states the space and the slot for each
resource, and the launch script states neither: its bind list is purely positional and is
matched against what reflection found.
InvertKernel.slang, from the runnable pipeline example#// Second pass of the pipeline: invert an image. Slang counterpart of
// InvertKernel.cu. The input is whatever upstream shader is connected to
// inputs:Image; here, the grayscale output.
//
// On Slang, value-inputs are packed into
// a constant buffer described by a struct, which the shader reads through a
// ParameterBlock. The struct field order must match the order the launch
// script lists the values in slang.ParameterBlock(...).
struct Params
{
float strength;
};
[[vk::binding(0, 1)]] ParameterBlock<Params> g_Params;
[[vk::binding(1, 1)]] Texture2D<float4> g_InImage;
[[vk::binding(2, 1)]] RWTexture2D<float4> g_OutInverted;
[shader("compute")]
[numthreads(32, 32, 1)]
void invert(uint3 tid : SV_DispatchThreadID)
{
uint width = 0, height = 0;
g_InImage.GetDimensions(width, height);
if (tid.x >= width || tid.y >= height)
return;
float4 pixel = g_InImage.Load(int3(tid.xy, 0));
// lerp(original, inverted, strength) per RGB channel. The CUDA kernel does
// the same blend on 0..255 integers; here the channels are normalized
// floats, so full inversion is 1.0 - value.
float3 inverted = lerp(pixel.rgb, 1.0 - pixel.rgb, g_Params.strength);
g_OutInverted[tid.xy] = float4(inverted, pixel.a);
}
InvertKernel.slang.lua, from the runnable pipeline example#-- Launch script for InvertKernel.slang. The function name must equal the
-- subIdentifier ("invert").
function invert(inputs, outputs)
assert(inputs["Image"].rank == 2, "Input must be a 2D image")
outputs["Inverted"] = slang.image(inputs["Image"].shape, inputs["Image"].dtype)
return slang.compute({
-- The bind list is positional. The parameter block comes first and
-- carries the value-inputs in the order the shader's Params struct
-- declares them; the resources follow in declaration order.
bind = {
slang.ParameterBlock(
slang.float(inputs["strength"]) -- -> float strength
),
slang.Texture2D(inputs["Image"]), -- -> Texture2D<float4> g_InImage
slang.RWTexture2D(outputs["Inverted"]), -- -> RWTexture2D<float4> g_OutInverted
},
-- The shader's [numthreads(32, 32, 1)] places the dispatch; the grid
-- follows from the output's shape.
})
end
The highlighted blocks are one contract seen from both ends. The Params struct fixes the
field order the ParameterBlock call has to follow, and the vk::binding declarations fix
the order of everything after it. A value that states its offset goes to the field that starts
there instead, and the values after it continue from that field.
Stating the Layout#
Each dtype constructor and slang.array take an optional table saying where the value sits
in the block: offset, the byte it starts at, and strides, one number per level of the
value, outermost first, each the bytes from one index of that level to the next. The levels
are the value’s array dimensions and, for a matrix, its rows and then its columns. The lanes of
a vector sit one after another and take no stride. A stride of 0 leaves that level’s stride
to the shader. A bare number in place of the table is the offset of a single number:
slang.ParameterBlock(
slang.uint(inputs["key"], 0),
slang.float4x4(inputs["xform"], { offset = 16, strides = { 4, 16 } }),
slang.array(inputs["weights"], slang.float, { offset = 80, strides = { 16 } })
)
The strides of some common fields, as a padded layout lays them out, such as slangc’s SPIR-V build of a constant buffer:
Field |
|
Reads as |
|---|---|---|
|
|
each number 16 bytes after the one before |
|
|
the next row 4 bytes on, the next column 16 |
|
|
the next row 16 bytes on, the next column 4 |
|
|
the next matrix 32 bytes on, then as one matrix |
An array of structs goes in field by field. Each field of the structs is a value of its own,
an array starting at the offset of that field in the first struct. A value that states an offset
goes to the field, or the field of the structs, that starts there, and the values after it take
the fields after that one. With reflection the offset is enough, and the shader gives the strides;
without it, the stride of each struct is stated too. For struct Item { float a; int b; }
items[3] followed by float gain, where each Item takes 16 bytes:
-- With reflection
slang.array(inputs["a"], slang.float, { offset = 0 }),
slang.array(inputs["b"], slang.int, { offset = 4 }),
slang.float(inputs["gain"])
-- Without reflection
slang.array(inputs["a"], slang.float, { offset = 0, strides = { 16 } }),
slang.array(inputs["b"], slang.int, { offset = 4, strides = { 16 } }),
slang.float(inputs["gain"], 48)
A value that goes to an array of structs without stating an offset stops the node’s graph with an error that names the field.
A dtype constructor holds one value: given an input with more elements, it stops the node’s
graph with an error that names the input. An array goes in with slang.array.
A matrix layout stated as { offset, order, stride } is deprecated and logs a warning; it will
be removed in a later release. order = slang.column_major, stride = 16 on a float4x4 is
strides = { 4, 16 }, and order = slang.row_major, stride = 16 is strides = { 16, 4 }.
An order alone or a stride alone leaves the rest of the layout to the shader, so it works
only with reflection; without it, the node’s graph stops with an error that names what is
missing.
Without reflection, offsets are derived when every value in the block is a single four-byte number. They become required as soon as any vector or matrix appears, since alignment then decides the packing.
A value fills the block from its offset with all of its elements: an array given at an offset is packed whole, each element where its strides place it, and for a shader without reflection SPG sizes the block to hold every element. A value with more elements than the field the shader declares for it holds stops the node’s graph, with an error that names the node, the value, its element count and the field. A field holds the elements the shader declares, whatever padding its layout puts between or after them: each element of an array, and each row or column of a matrix, goes where the layout starts it.
What Reflection Fills In#
.slang source and a pre-compiled .slang-module both carry reflection. A .spv, which is
SPIR-V byte code, does not. Refer to Compile It Yourself.
Reflection checks the launch script: what
the script states, reflection verifies; what it leaves out, reflection supplies; what reflection
does not report, the script supplies. Where the two disagree about where a value sits, its
parameter-block offset or the strides of an array or matrix, SPG packs the value where the shader
declares it and logs a warning once per node and value that names the value, what the script states and
what the shader declares. Where they disagree about a descriptor space, a binding slot or a
texture’s dimension, the node’s graph stops with an error that names the resource and both sides.
A value that neither the shader nor the script places, such as one more value than the shader’s
fields, stops the node’s graph with an error that names the value.
SPG reads the fields of a ConstantBuffer<T> from the compiled shader, so they are filled in and
checked as the fields of a ParameterBlock<T> are.
With reflection |
Without reflection ( |
|
|---|---|---|
Descriptor space |
From the shader, or stated |
Must be stated, with |
Binding slot |
From the shader, or by position in a group |
By position in a group, counting from zero |
Parameter-block offsets |
From the shader; a stated one that differs logs a warning |
Must be stated, unless every value is a single four-byte number |
Strides of an array or matrix |
From the shader; stated ones that differ log a warning |
Must be stated, with |
Thread group size |
The shader’s |
Must be stated with |
Entry point |
|
Read from the binary |
|
Derived from the output’s shape and the thread group size |
The same, once a thread group size exists |
A flat bind = { ... } therefore cannot work without reflection: nothing states a space, so
the node reports that the shader exposes no descriptor space and its graph stops.
Verify It Worked#
Bind a value with an exact, reversible effect and check it byte for byte. An XOR key is the usual choice: every output byte must equal the input byte XOR the key, with no exceptions. This matters because a node whose parameter block never bound reads zeros, and XOR with zero is the identity, so a broken binding renders an untouched copy rather than an error.
When It Goes Wrong#
“Shader exposes no descriptor space”: a flat
bindwith a.spv. Group withslang.bind.“… but the shader places that buffer at binding …” or “… but the shader declares … there”: a group lists its entries in another order than the shader’s bindings. Order the group as the shader’s
vk::bindingdeclarations.A warning “… in the launch script but the shader declares it …”: a stated offset or strides differ from the shader’s, and the shader’s were used. Correct the launch script, or leave the layout to reflection.
A value lands in the wrong field: the order does not match the struct. A wrong count is an error, a wrong order only a warning.