Erfan Ahmadi.

Advanced HLSL to SPIR-V · Part 1

Inline SPIR-V

SPIR-V can express more than HLSL lets you write. Inline SPIR-V lets you call those instructions straight from HLSL with DXC.

Why inline SPIR-V?

When you compile HLSL for Vulkan, DXC turns your shader into SPIR-V. SPIR-V can express more than HLSL lets you write. Some instructions have no HLSL function, some options can’t be passed, and some types have no HLSL name.

With inline SPIR-V, you write a small declaration in HLSL that tells DXC “a call to this function is exactly this SPIR-V instruction”. Then you call it like any other HLSL function.

There are three good reasons to learn it.

  1. Use SPIR-V features that HLSL doesn’t expose (yet). Explicit barrier and memory control, subgroup operations HLSL doesn’t have, and new SPIR-V extensions not exposed in HLSL. Another good example is atomics: HLSL’s Interlocked* functions don’t let you choose memory ordering; DXC always emits them as relaxed.
  2. Atomics on memory you reach through an address. Atomic functions like InterlockedAdd work on groupshared variables and buffer elements. Inline SPIR-V lets you run atomics with BDA (buffer device address) too. (More recently, vk::BufferPointer was added to DXC, which allows it, but only with relaxed ordering.)
  3. More modular code. Once an instruction is wrapped in a small HLSL function, you can reuse it everywhere and build your own library on top, like we do in Nabla. If you’re curious how we wrote a single-source C++/HLSL library, take a look at our Vulkanised 2024 talk, Beyond SPIR-V: Single Source C++ and Shader Programming.

Here’s a taste. HLSL has no way to write an atomic store with release ordering. One declaration adds it:

HLSL
1template<typename T>
2[[vk::ext_instruction(/*OpAtomicStore*/ 228)]]
3void atomicStore([[vk::ext_reference]] T pointer,
uint32_t scope,
uint32_t semantics,
T value);
4
5groupshared uint32_t ready;
⋮…
11 atomicStore(ready, 2, 0x104, 1u);
⋮…
SPIR-V
; the declaration emits nothing
⋮…
5%ready = OpVariable %_ptr_Workgroup_uint Workgroup
⋮…
11OpAtomicStore %ready %uint_2 %uint_260 %uint_1
⋮…

Try it out yourself

You can see that the templated function we wrote is just another way to get access to SPIR-V functions. If you're wondering where those magic numbers come from, they come from the SPIR-V spec.

OpAtomicStore %ready %uint_2 %uint_260 %uint_1
no result (void) instruction (opcode 228) pointer: where to store scope: Workgroup memory ordering: Release value to store

You don’t need to follow every detail yet. The next sections explain each piece.

I assume you know HLSL and compute shaders, and have already seen a bit of SPIR-V. The recap below covers some of the important building blocks this post relies on.

SPIR-V recap

OpVariable is a pointer

HLSL
1struct MyStruct
2{
3 float f;
4};
5
6static MyStruct privateVar;
SPIR-Vraw output
%MyStruct = OpTypeStruct %float
%_ptr_Private_MyStruct = OpTypePointer Private %MyStruct
%privateVar = OpVariable %_ptr_Private_MyStruct Private

Try it out yourself

The first two lines are only type definitions; nothing exists in memory yet. The third line, OpVariable, is where real memory comes in. And notice its type: OpVariable isn’t really a “variable” holding a struct. It’s a pointer to a memory location that holds the struct.

Storage classes

The word after OpTypePointer (Private above) is the storage class: where the memory lives. (LLVM calls this an address space.) Each kind of HLSL variable gets its own:

HLSLStorage class
ConstantBuffer<MyStruct> uniformBuffer;Uniform
RWStructuredBuffer<float> output;StorageBuffer
groupshared float sharedMemory[64];Workgroup
static MyStruct privateVar;Private
MyStruct localVar; (inside a function)Function

An important rule for OpVariable: its type must be an OpTypePointer, and both must name the same storage class.

✕
%uint = OpTypeInt 32 0
%var  = OpVariable %uint Private

error: OpVariable Result Type <id> '4[%uint]' is not a pointer type.

✕
%uint_ptr = OpTypePointer Function %uint
%var      = OpVariable %uint_ptr Private

error: Storage class must match result type storage class

✓
%uint_ptr = OpTypePointer Private %uint
%var      = OpVariable %uint_ptr Private

OpLoad and OpStore

Since a variable is a pointer, reading and writing it always go through that pointer. OpLoad takes a pointer and gives you the value stored there. OpStore takes a pointer and a value, and writes the value there.

HLSL
1groupshared uint32_t counter;
2
3[numthreads(64, 1, 1)]
4void main()
5{
6 uint32_t value = counter;
7 counter = value + 1u;
8}
SPIR-Vraw output
%counter = OpVariable %_ptr_Workgroup_uint Workgroup
groupshared uint32_t counter;
%value = OpVariable %_ptr_Function_uint Function
uint32_t value: a local is a pointer too.
%14 = OpLoad %uint %counter
Read counter…
OpStore %value %14
…and write it into value.
%15 = OpLoad %uint %value
Read value.
%16 = OpIAdd %uint %15 %uint_1
value + 1u
OpStore %counter %16
Write it into counter.

Try it out yourself

This recap is a short extract from Francisco Letterio’s talk at the Shading Languages Symposium 2026, The Vulkan SPIR-V and its Fundamental Limits. Watch it for the full picture.

Calling SPIR-V directly from HLSL

These attributes do all the work: vk::ext_instruction, vk::ext_literal, vk::ext_reference, and the pair vk::ext_capability / vk::ext_extension. Each one changes how DXC turns a function declaration, or one of its parameters, into SPIR-V.

1. vk::ext_instruction

Put [[vk::ext_instruction(opcode)]] on a function declaration with no body. Every call to it becomes exactly that one instruction, right where the call is. The function’s return type becomes the instruction’s result type, and each argument becomes an operand. With a second argument naming an extended instruction set, such as "GLSL.std.450", the call becomes OpExtInst instead.

HLSL
1[[vk::binding(0,0)]] RWStructuredBuffer<uint32_t> buf;
2
3[[vk::ext_instruction(/*OpBitReverse*/ 204)]]
4uint32_t myBitReverse(uint32_t v);
5
6[[vk::ext_instruction(/*FindILsb*/ 73, "GLSL.std.450")]]
7uint32_t myFindLSB(uint32_t v);
8
9[numthreads(64, 1, 1)]
10void main(uint32_t3 id : SV_DispatchThreadID)
11{
12 buf[id.x * 3 + 0] = myBitReverse(id.x);
13 buf[id.x * 3 + 1] = reversebits(id.x);
14 buf[id.x * 3 + 2] = myFindLSB(id.x);
15}
SPIR-Vexcerpt
%1 = OpExtInstImport "GLSL.std.450"
The extended set, imported once.
%21 = OpCompositeExtract %uint %20 0
id.x
%22 = OpBitReverse %uint %21
myBitReverse(id.x)
OpStore %24 %22
buf[… + 0]
OpStore %26 %22
buf[… + 1]: reversebits(id.x) is the same instruction, so the optimizer reused %22.
%27 = OpExtInst %uint %1 FindILsb %21
myFindLSB(id.x)
OpStore %29 %27
buf[… + 2]

Try it out yourself

Each argument is passed by value: DXC reads it first and uses the result as the operand. The next two attributes change that.

You might be wondering what happens if you change the argument and return types. DXC still compiles that for you, but in some cases the SPIR-V validator will stop you. For example, if you change uint32_t to uint64_t on myBitReverse above, you get:

✕
fatal error: generated SPIR-V is invalid: [VUID-RuntimeSpirv-None-10824] Expected 32-bit int type for Base operand: BitReverse
This is allowed if you enable the maintenance9 feature (or use the --allow-vulkan-32-bit-bitwise command line flag)
%24 = OpBitReverse %ulong %23

2. vk::ext_literal

Some operands aren’t values at all. They’re plain numbers written inside the instruction, such as an index or an option; the SPIR-V spec calls them literals. [[vk::ext_literal]] on a parameter makes DXC write the argument as a number. It has to be known at compile time.

HLSL
1[[vk::binding(0,0)]] RWStructuredBuffer<uint32_t> buf;
2
3[[vk::ext_instruction(/*OpCompositeExtract*/ 81)]]
4uint32_t extract(uint32_t3 v,
[[vk::ext_literal]] uint32_t index);
5
6[numthreads(64, 1, 1)]
7void main(uint32_t3 id : SV_DispatchThreadID)
8{
9 buf[0] = extract(id, 1); // same as id.y
10}
SPIR-Vexcerpt
%17 = OpLoad %v3uint %gl_GlobalInvocationID
id
%18 = OpCompositeExtract %uint %17 1
extract(id, 1): the 1 is written inside the instruction, not as an id.
OpStore %19 %18
buf[0] = …

Try it out yourself

Forget [[vk::ext_literal]] and DXC writes the id of the constant 1 into that slot instead. Every tool reading the instruction then sees whatever number that id happens to have:

✕
uint32_t extract(uint32_t3 v, uint32_t index);

Vector access is out of bounds, vector size is 3, but access index is 5

The 5 is an id number, so it changes between compilers and even between unrelated edits. If it happens to be in range, nothing complains and the wrong component is read.

3. vk::ext_reference

Some instructions need a pointer: OpLoad, OpStore and every atomic. From the recap, a variable already is a pointer. [[vk::ext_reference]] on a parameter passes the argument’s pointer instead of reading its value. So the argument must be something that has a pointer: a variable, not a temporary.

HLSL
1groupshared uint32_t counter;
2[[vk::binding(0,0)]] RWStructuredBuffer<uint32_t> buf;
3
4[[vk::ext_instruction(/*OpAtomicIAdd*/ 234)]]
5uint32_t atomicAdd([[vk::ext_reference]] uint32_t pointer,
uint32_t scope, uint32_t semantics,
uint32_t value);
6
7[numthreads(64, 1, 1)]
8void main(uint32_t gi : SV_GroupIndex)
9{
10 buf[gi] = atomicAdd(counter, 2, 0, 1u);
11}
SPIR-Vexcerpt
%counter = OpVariable %_ptr_Workgroup_uint Workgroup
groupshared uint32_t counter: a pointer, as in the recap.
%21 = OpAtomicIAdd %uint %counter %uint_2 %uint_0 %uint_1
The first operand is %counter itself: the pointer, not a value read from it. Then scope 2 (Workgroup), semantics 0 (relaxed) and the value 1.
OpStore %22 %21
buf[gi] = the value before the add

Try it out yourself

A temporary has no pointer, so DXC refuses it:

✕
buf[gi] = atomicAdd(counter + 1, 2, 0, 1u);

error: argument for a parameter with vk::ext_reference attribute must be a reference

A normal HLSL function with an inout parameter passes that pointer through as well, so you can wrap these calls in your own helper functions.

4. vk::ext_capability and vk::ext_extension

An instruction from a SPIR-V extension only works if the module declares it: OpExtension for the extension and OpCapability for the feature. For HLSL’s built-in functions DXC adds these lines itself. For your own declarations you add them with these two attributes. They’re only emitted if the function is actually called.

HLSL
1[[vk::binding(0,0)]] RWStructuredBuffer<uint32_t> buf;
2
3[[vk::ext_extension("SPV_KHR_shader_clock")]]
4[[vk::ext_capability(/*ShaderClockKHR*/ 5055)]]
5[[vk::ext_instruction(/*OpReadClockKHR*/ 5056)]]
6uint32_t2 readClock(uint32_t scope);
7
8[numthreads(64, 1, 1)]
9void main(uint32_t3 id : SV_DispatchThreadID)
10{
11 buf[id.x] = readClock(/*Subgroup*/ 3).x;
12}
SPIR-Vexcerpt
OpCapability ShaderClockKHR
From ext_capability(5055).
OpExtension "SPV_KHR_shader_clock"
From ext_extension.
%19 = OpReadClockKHR %v2uint %uint_3
readClock(3): scope 3 is Subgroup.
%20 = OpCompositeExtract %uint %19 0
.x

Try it out yourself

Without the two attributes, DXC still emits the instruction, but the validator rejects the module:

✕
[[vk::ext_instruction(/*OpReadClockKHR*/ 5056)]]
uint32_t2 readClock(uint32_t scope);

Opcode ReadClockKHR requires one of these capabilities: ShaderClockKHR

These lines only declare what the shader needs. The device still has to support the feature, here VK_KHR_shader_clock, when you create the pipeline.

In the next part, we will name SPIR-V types ourselves, and learn how to use C++ templates in HLSL 2021 to extract information about those SPIR-V types (such as their storage class) and specialize our functions with it.

Where things stand

Written around October 2026, against DXC’s main branch. Clang’s HLSL support hasn’t come out yet.

Inline SPIR-V started as DXC’s version of GLSL’s GL_EXT_spirv_intrinsics and is documented on the DXC wiki. It’s being standardized in HLSL proposal 0011, still under review, but it’s already available in DXC. It only applies when compiling to SPIR-V.

References