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.
- 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. - Atomics on memory you reach through an address.
Atomic functions like
InterlockedAddwork ongroupsharedvariables and buffer elements. Inline SPIR-V lets you run atomics with BDA (buffer device address) too. (More recently,vk::BufferPointerwas added to DXC, which allows it, but only with relaxed ordering.) - 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:
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
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:
| HLSL | Storage 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.
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.
reversebits(id.x) is the same instruction, so the optimizer reused %22.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.
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.
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.
ext_capability(5055).ext_extension.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
- SPIR-V specification: Khronos, every opcode, operand and storage class
- GLSL.std.450 extended instruction set: Khronos
- Inline SPIR-V: DXC wiki
- Proposal 0011: Inline SPIR-V: HLSL specs
- GL_EXT_spirv_intrinsics: the GLSL extension inline SPIR-V comes from
- HLSL to SPIR-V feature mapping: DXC docs
- std::memory_order: cppreference, what relaxed, acquire and release mean
- Nabla’s HLSL library: a real library built on these attributes
- The Vulkan SPIR-V and its Fundamental Limits: Shading Languages Symposium 2026, Francisco Letterio
- Beyond SPIR-V: Single Source C++ and Shader Programming: Vulkanised 2024, Mateusz Kielan