microsoft / microsoft/DirectXShaderCompiler
[SPIR-V] RWByteAddressBuffer.Store<uint16_t> lowers to a non-atomic 32-bit read-modify-write, racing adjacent 16-bit stores (DXIL emits a native 16-bit store)
@pow2clk is already working on this.
Since Jul 24, 2026.
- Dominant language
- C++
- Stars
- 3.7k
- Forks
- 900
- Avg merge
- 2d 11h
- Merged PRs (30d)
- 44
Description
Description
A templated 16-bit store into a RWByteAddressBuffer — buf.Store<uint16_t>(off, v) — is lowered by the SPIR-V backend to a non-atomic 32-bit read-modify-write of the enclosing uint word (OpLoad → OpBitwiseAnd/OpBitwiseOr → OpStore). Because a ByteAddressBuffer is represented in SPIR-V as a uint runtime array (ArrayStride 4), two 16-bit slots at off and off+2 land in the same 32-bit word. When different invocations store to those two slots concurrently, their word-sized read-modify-writes race and clobber each other — even though the stores target disjoint bytes and are independent in the source. The identical HLSL compiled to DXIL emits a single native dx.op.rawBufferStore.i16 (a real 2-byte store) that does not touch the neighbouring half, so concurrent writes to adjacent slots stay independent on D3D12. The result is a silent DXIL↔SPIR-V divergence: the same source silently loses concurrent updates on Vulkan, with no diagnostic. DXC clearly can emit a native narrow store — it does for a typed RWStructuredBuffer<uint16_t> (StorageBuffer16BitAccess + a direct OpStore); the read-modify-write fallback is specific to ByteAddressBuffer.
Steps to Reproduce
bab16_repro.hlsl:
RWByteAddressBuffer buf : register(u0);
[numthreads(64, 1, 1)]
void main(uint3 tid : SV_DispatchThreadID)
{
// slots 2*tid and 2*tid+1 share one 32-bit word
buf.Store<uint16_t>(tid.x * 2u, (uint16_t)tid.x);
}
SPIR-V (shows the bug) — reproduces on DXC trunk: https://godbolt.org/z/q6eW7ov35
dxc -T cs_6_2 -E main -spirv -fspv-target-env=vulkan1.3 -enable-16bit-types bab16_repro.hlsl -Fo bab16.spv
DXIL (for comparison — correct) — DXC trunk: https://godbolt.org/z/zsvW7r357
dxc -T cs_6_2 -E main -enable-16bit-types bab16_repro.hlsl -Fo bab16.dxil
Contrast case — a typed 16-bit buffer, which compiles to the expected native store:
// sb16_repro.hlsl
RWStructuredBuffer<uint16_t> buf : register(u0);
[numthreads(64, 1, 1)]
void main(uint3 tid : SV_DispatchThreadID) { buf[tid.x] = (uint16_t)tid.x; }
dxc -T cs_6_2 -E main -spirv -fspv-target-env=vulkan1.3 -enable-16bit-types sb16_repro.hlsl -Fo sb16.spv
DXC trunk: https://godbolt.org/z/9qWa593fz
Actual Behavior
The RWByteAddressBuffer.Store<uint16_t> SPIR-V has only OpCapability Int16 (no StorageBuffer16BitAccess), the buffer is a uint array (ArrayStride 4), and the store is a plain non-atomic OpLoad / mask / OpStore on the shared 32-bit word:
OpDecorate %_runtimearr_uint ArrayStride 4 ; ByteAddressBuffer == uint[] (32-bit)
...
%25 = OpShiftRightLogical %uint %23 %uint_2 ; word = byteOff >> 2
%26 = OpUMod %uint %23 %uint_4 ; byteInWord = byteOff % 4
%27 = OpShiftLeftLogical %uint %26 %uint_3 ; shift = byteInWord * 8 (0 or 16)
%28 = OpAccessChain %_ptr_StorageBuffer_uint %buf %uint_0 %25 ; -> the shared 32-bit word
%29 = OpUConvert %uint %24 ; zext(u16 value)
%30 = OpShiftLeftLogical %uint %29 %27 ; value << shift
%31 = OpISub %uint %uint_16 %27
%32 = OpShiftLeftLogical %uint %uint_65535 %31 ; keep-mask for the *other* half
%33 = OpLoad %uint %28 ; <-- READ whole 32-bit word
%34 = OpBitwiseAnd %uint %33 %32 ; <-- MODIFY (clear our 16 bits)
%35 = OpBitwiseOr %uint %34 %30 ; insert value
OpStore %28 %35 ; <-- WRITE whole word (NON-ATOMIC)
Invocations A (off) and B (off+2) both execute OpLoad %28 → modify → OpStore %28 on the same pointer %28. With no atomicity or synchronization, whichever OpStore lands second overwrites the other half → lost update. The masking arithmetic is functionally correct for a single thread; the defect is that a 2-byte store was widened to a non-atomic 4-byte read-modify-write.
The same source compiled to DXIL is a single native 16-bit store — no read of the neighbouring half, so off and off+2 are independent:
call void @dx.op.rawBufferStore.i16(i32 140, %dx.types.Handle %1, i32 %4, i32 undef,
i16 %3, i16 undef, i16 undef, i16 undef, i8 1, i32 2)
; RawBufferStore(uav, index, elementOffset, value0.., mask=1, alignment=2)
Environment
- DXC version:
dxcompiler.dll: 1.10(5347-fe261573)(1.9.0.5347) - 1.9.0.5347 (fe2615732)(Vulkan SDK 1.4.350.0) - Host Operating System: Windows 11, Version 10.0.26200.8655
Expected Behavior
RWByteAddressBuffer.Store<uint16_t> should emit a native sub-word store so that writes to disjoint 2-byte ranges stay independent — matching the DXIL rawBufferStore.i16 semantics. DXC already does exactly this for a typed RWStructuredBuffer<uint16_t>:
OpCapability StorageBuffer16BitAccess
OpDecorate %_runtimearr_ushort ArrayStride 2
%ushort = OpTypeInt 16 0
%20 = OpAccessChain %_ptr_StorageBuffer_ushort %buf %int_0 %18
OpStore %20 %19 ; native 16-bit store, 2 bytes, no read-modify-write
i.e. an aliased 16-bit (ushort) view of the buffer under StorageBuffer16BitAccess and a direct OpStore. If a native narrow store into ByteAddressBuffer is genuinely not representable on the target, DXC should at minimum diagnose the widened non-atomic read-modify-write rather than emit it silently, since it silently drops concurrent writes in a way neither the source nor the DXIL output does.
Why this is incorrect
- HLSL/DXIL semantics: stores to non-overlapping addresses do not interfere, and the DXIL lowering preserves that — a native 2-byte store touches only its own two bytes.
- The SPIR-V lowering instead reads the whole enclosing 32-bit word, edits its 16 bits, and writes the whole word back (
OpLoad %28→OpBitwiseAnd/OpBitwiseOr→OpStore %28), all non-atomic. Two invocations writing the two halves of one word both dereference the same pointer%28; with no atomicity or ordering between them, whicheverOpStoreruns second overwrites the other invocation's half — a plain lost update, visible directly in the generated code. - The net effect: two source-level stores to distinct, non-overlapping addresses become two stores to the same 32-bit word that silently drop one another's data depending on execution order. The DXIL output has no such interaction, so this is a backend miscompile — the source program is correct.
References:
- HLSL templated
ByteAddressBufferLoad<T>/Store<T>(the feature used here): https://github.com/microsoft/DirectXShaderCompiler/wiki/ByteAddressBuffer-Load-Store-Additions - HLSL 16-bit scalar types (
uint16_t,-enable-16bit-types): https://github.com/microsoft/DirectXShaderCompiler/wiki/16-Bit-Scalar-Types - DXC SPIR-V CodeGen (ByteAddressBuffer represented as a
uintruntime array): https://github.com/microsoft/DirectXShaderCompiler/blob/main/docs/SPIR-V.rst - DXIL
RawBufferStoreop: https://github.com/microsoft/DirectXShaderCompiler/blob/main/docs/DXIL.rst - SPIR-V specification —
StorageBuffer16BitAccesscapability / 16-bit storage: https://registry.khronos.org/SPIR-V/specs/unified1/SPIRV.html VK_KHR_16bit_storage/storageBuffer16BitAccess: https://registry.khronos.org/vulkan/specs/1.3-extensions/man/html/VK_KHR_16bit_storage.html
Impact
Any shader that packs two or more sub-32-bit fields into a RWByteAddressBuffer and writes them from different invocations is exposed: on SPIR-V, a write to one field silently drops a concurrent write to whichever neighbouring field shares its 32-bit word, while the identical source is correct on DXIL. The corruption is data- and scheduling-dependent — it only manifests when two writers hit the same word in flight — so it is nondeterministic and is diagnosed by neither the compiler nor the validation layers.
Workaround
Keep the packed two-slots-per-word layout but replace the widened non-atomic read-modify-write with disjoint-half atomics on the enclosing word (SPIR-V only; keep the native Store<uint16_t> on DXIL):
void storeU16(uint slot, uint value) // value in [0, 0xFFFF]
{
#if defined(TARGET_VULKAN)
uint byteOff = slot * 2u;
uint wordOff = byteOff & ~3u;
uint shift = (byteOff & 3u) * 8u; // 0 or 16
uint old;
buf.InterlockedAnd(wordOff, ~(0xFFFFu << shift), old); // atomically clear our 16 bits
buf.InterlockedOr (wordOff, (value & 0xFFFFu) << shift, old); // atomically set them
#else
buf.Store<uint16_t>(slot * 2u, uint16_t(value)); // native 16-bit store on DXIL
#endif
}
InterlockedAnd/InterlockedOr are atomic read-modify-writes on the 32-bit word; because each invocation only touches its own 16 disjoint bits, any interleaving of two neighbours' AND/OR yields the correct value for both halves — no lost updates. (A reader observing its own slot between the AND and OR sees a transient zero; fine for a write-then-later-read pattern.)
Alternatives:
- Don't pack — one 32-bit
uintper slot. Trivially race-free at 2× the memory. - Typed view — declare the data as
RWStructuredBuffer<uint16_t>; DXC then emits the native 16-bit store shown above. Not always possible if the buffer must also be addressed at other widths.
Note: there is no DXC flag that fixes this for ByteAddressBuffer. -enable-16bit-types with a vulkan1.3 target env still produces the 32-bit read-modify-write (the Int16 capability is emitted, but StorageBuffer16BitAccess and a 16-bit array view are not, by design for a byte-addressed buffer).
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Assessment
This issue has not been assessed yet.