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)

Open
#8,646 2 comments 0 reactions 1 assignee View on GitHub

@pow2clk is already working on this.

Since Jul 24, 2026.

bug needs-triage spirv
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 RWByteAddressBufferbuf.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 (OpLoadOpBitwiseAnd/OpBitwiseOrOpStore). 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 %28OpBitwiseAnd/OpBitwiseOrOpStore %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, whichever OpStore runs 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:

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 uint per 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

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.