Recursive AI

Specialization constants on Vulkan, Metal and WebGPU

All oneshader recipes

Declare HLSL specialization constants once with [[vk::constant_id]] and set them when the pipeline is created: VkSpecializationInfo on Vulkan, function constants on Metal and pipeline-overridable constants on WebGPU, from one compiled shader.

A specialization constant is a value the shader reads like a constant but the pipeline chooses, so one compiled shader serves many variants without recompiling. OneShader 1.2.0 compiles [[vk::constant_id(N)]] const T Name = default; (bool, int, uint or float) to each target’s mechanism, and generates the C# to fill them in.

Shaders/Blur.hlsl, a separable Gaussian blur whose radius, direction and sigma are pipeline-time constants:

HLSL
// Pipeline-time constants: one compiled shader, many pipelines.
[[vk::constant_id(0)]] const int Radius = 4;
[[vk::constant_id(1)]] const bool Horizontal = true;
[[vk::constant_id(2)]] const float Sigma = 2.0;
 
Texture2D<float4> Source : register(t0);
[[vk::image_format("rgba8")]] RWTexture2D<float4> Target : register(u1);
 
[shader("compute")]
[numthreads(8, 8, 1)]
void CSMain(uint3 id : SV_DispatchThreadID)
{
    int2 step = Horizontal ? int2(1, 0) : int2(0, 1);
    float4 sum = 0;
    float total = 0;
    for (int i = -Radius; i <= Radius; i++)
    {
        float w = exp(-(i * i) / (2 * Sigma * Sigma));
        sum += Source[int2(id.xy) + step * i] * w;
        total += w;
    }
    Target[id.xy] = sum / total;
}

The project references RecursiveAI.OneShader.Build 1.2.0 with OneShaderTargets set to SpirV;Msl;Wgsl. Program.cs:

C#
using OneShader.Layouts;
using OneShader.Layouts.Metal;
using OneShader.Layouts.Vulkan;
using Spec.Shaders;
 
// The constants, their ids and defaults, generated from the shader.
Console.WriteLine($"Radius: id {Blur.SpecializationConstants.Radius.Id}, default {Blur.SpecializationConstants.Radius.Default}");
Console.WriteLine($"CSMain reads ids [{string.Join(", ", Blur.CSMain.SpecConstantIds.ToArray())}]");
 
// A vertical blur with radius 8: set fields on the generated struct.
Blur.Specialization s = Blur.Specialization.Defaults;
s.Radius = 8;
s.Horizontal = 0;   // a bool is a 4-byte 0 or 1
 
// Vulkan: the contents of VkSpecializationInfo.
SpecializationInfo vk = s.ToVulkan(Blur.CSMain.SpecConstantIds);
foreach (SpecializationMapEntry e in vk.MapEntries)
    Console.WriteLine($"Vulkan: constantID {e.ConstantId}, offset {e.Offset}, size {e.Size}");
Console.WriteLine($"Vulkan: data {Convert.ToHexString(vk.Data)}");
 
// Metal: MTLFunctionConstantValues entries, by index and MTLDataType.
foreach (FunctionConstantValue v in s.ToMetal(Blur.CSMain.SpecConstantIds))
    Console.WriteLine($"Metal: index {v.Index}, MTLDataType {v.Type.DataType}, bits 0x{v.Bits:x8}");
 
// WebGPU: the compute stage's constants record, keyed by decimal id.
Console.WriteLine($"WebGPU: {Record(s.ToWebGpu(Blur.CSMain.SpecConstantIds))}");
 
// The same by name, from the entry layout (this also works for shaders compiled at run time).
SpecializationValues byName = SpecializationValues.For(Blur.CSMain.Layout).Set("Radius", 8).Set("Horizontal", false);
Console.WriteLine($"By name: {Record(byName.ToWebGpu())}");
 
// What each target compiled the constant to.
Console.WriteLine(Line(Blur.CSMain.Wgsl, "override Radius"));
Console.WriteLine(Line(Blur.CSMain.Msl, "function_constant(0)"));
 
static string Record(IReadOnlyDictionary<string, double> c) =>
    "{ " + string.Join(", ", c.Select(kv => $"\"{kv.Key}\": {kv.Value}")) + " }";
 
static string Line(ReadOnlySpan<byte> utf8, string text) =>
    System.Text.Encoding.UTF8.GetString(utf8).Split('\n').First(l => l.Contains(text)).Trim();
Text
Radius: id 0, default 4
CSMain reads ids [0, 1, 2]
Vulkan: constantID 0, offset 0, size 4
Vulkan: constantID 1, offset 4, size 4
Vulkan: constantID 2, offset 8, size 4
Vulkan: data 080000000000000000000040
Metal: index 0, MTLDataType 29, bits 0x00000008
Metal: index 1, MTLDataType 53, bits 0x00000000
Metal: index 2, MTLDataType 3, bits 0x40000000
WebGPU: { "0": 8, "1": 0, "2": 2 }
By name: { "0": 8, "1": 0, "2": 2 }
@id(0) override Radius: i32 = 4i;
constant int Radius [[function_constant(0)]];

The SPIR-V passes spirv-val and the WGSL passes naga. On SPIR-V the constants are OpSpecConstant (and OpSpecConstantTrue for the bool) with SpecId 0, 1 and 2, the ids DXC writes for the same source.

Passing them to each API

  • Vulkan: MapEntries and Data are the contents of VkSpecializationInfo (pMapEntries, pData). Each constant is 4 bytes, in declaration order: 08000000 is the radius 8, 00000000 the false, 00000040 the float 2.0.
  • Metal: set each value on an MTLFunctionConstantValues at its index, with the MTLDataType number (29 is int, 53 bool, 3 float). A function that declares function constants has to be created with makeFunction(name:constantValues:), even to get the defaults: pass an empty MTLFunctionConstantValues for those. The MSL reads each constant through is_function_constant_defined, with the default as the fallback.
  • WebGPU: the record goes in the stage’s constants when creating the pipeline. Pass the entry point’s SpecConstantIds, as above, because WebGPU rejects constants the entry point doesn’t declare. Vulkan and Metal ignore extra ones.
  • By name: SpecializationValues.For(layout) takes an EntryLayout from the generated code or from run-time reflection (EntryLayout.TryCreate), with typed Set overloads. A value of the wrong type or an unknown name throws.

Fixing constants at compile time

To bake some values in instead, compile with CompileOptions.SpecializationOverrides (RecursiveAI.OneShader 1.2.0):

C#
using OneShader;
 
// Fix Radius at compile time: it becomes a plain constant that the optimizer folds.
// The others stay pipeline-time constants.
CompileResult result = ShaderCompiler.CompileFile("Shaders/Blur.hlsl", new CompileOptions
{
    SpecializationOverrides = [new("Radius", 2), new("Blurr", 1)],
});
foreach (var d in result.Diagnostics)
    Console.WriteLine(d);
Console.WriteLine($"left as constants: {string.Join(", ", result.Reflection!.SpecConstants.Select(c => $"{c.Name} (id {c.Id})"))}");
 
CompileResult bad = ShaderCompiler.CompileFile("Shaders/Blur.hlsl", new CompileOptions
{
    SpecializationOverrides = [new("Radius", 2.5)],
});
foreach (var d in bad.Diagnostics)
    Console.WriteLine(d);
Text
Shaders/Blur.hlsl(1,1): warning OSH0335: The shader declares no specialization constant named 'Blurr', so the override is ignored. Check the spelling against the [[vk::constant_id]] declarations.
left as constants: Horizontal (id 1), Sigma (id 2)
Shaders/Blur.hlsl(1,1): error OSH0335: The value 2.5 does not fit the int specialization constant 'Radius': use a whole number in its range.

Limits

  • Types: bool, int, uint and float scalars. A vector or struct, static, a missing or non-constant default, a negative id or a duplicate id is error OSH0335.

  • Not where the size must be known at compile time: an array size, a [numthreads] argument or a case label that uses one is error OSH0310, as DXC refuses the same uses:

    Text
    /tmp/nt.hlsl(3,33): error OSH0310: Argument 1 of 'numthreads' must be an integer constant. It uses a specialization constant, whose value is only known when the pipeline is created: use a literal or a 'static const' there, or choose the value with a #define.
  • Hot reload: InterfaceCompatibility reports a removed constant or a changed id or type. New constants and changed defaults stay compatible.

For variants that change resources or code structure, compile them separately with Axes. The generated members are under Specialization constants in Generated bindings.