Skip to content

Fix #12711: skip void fields in byte-address struct load/store - #12724

Draft
nv-slang-bot[bot] wants to merge 1 commit into
masterfrom
fix/issue-12711
Draft

Fix #12711: skip void fields in byte-address struct load/store#12724
nv-slang-bot[bot] wants to merge 1 commit into
masterfrom
fix/issue-12711

Conversation

@nv-slang-bot

@nv-slang-bot nv-slang-bot Bot commented Aug 25, 2026

Copy link
Copy Markdown
Contributor

Motivation

Loading a struct through ByteAddressBuffer.Load<T>(offset) fails to compile when T
(recursively) contains an empty / zero-sized field. Consider this shader (based on the reporter's minimal
case in #12711):

struct Empty {}
struct Item { float a; Empty b; }
ByteAddressBuffer g_buffer;

float4 main() : SV_Target
{
    Item item = g_buffer.Load<Item>(0);
    return float4(item.a);
}
  • Targeting SPIR-V it fails with error[E30002]: divide by zero.
  • Targeting HLSL/DXIL it emits (g_buffer_0).Load<void >(4U); for the empty field, which DXC
    rejects with "External function used in non-library profile" (there is no Load<void>
    intrinsic).

The store direction (RWByteAddressBuffer.Store<Item>) is broken the same way. Replacing Empty b
with an ordinary field (float c) compiles cleanly, confirming the empty field is the trigger.

Proposed solution

Skip the empty field when the byte-address-buffer legalization pass decomposes a struct load/store
field-by-field. This is the principled fix because it sits exactly where the offending
per-field access is generated, and it matches an already-established in-tree pattern for the same
void-field shape (see the process report).

Change summary

File Change
source/slang/slang-ir-byte-address-legalize.cpp In emitLegalLoad and emitLegalStore, skip a void-typed struct field before recursing, so no per-field load/store is emitted for it and it is omitted from the reconstructed makeStruct.
tests/bugs/byte-address-load-empty-field.slang New codegen-only regression test exercising both the load and store paths for -target spirv, -target hlsl, and -target glsl (GLSL shares SPIR-V's divide-by-stride path). It covers the empty field in first position (ItemFirst { Empty a; float b; }) and middle position (ItemMid { float a; Empty b; float c; }); the middle case's Load<float >(20U) assertion proves the following real field keeps its natural offset across the skip. The forbidden Load<void> is checked by a dedicated HLSLNOVOID prefix whose only directive is a CHECK-NOT (so it scans the whole output and a first-position regression cannot escape). SPIR-V/GLSL assert codegen reaches entry-point emission (both abort with E30002 on master).

Concepts and vocabulary

  • Byte-address-buffer legalization (slang-ir-byte-address-legalize.cpp): a pre-emit IR pass
    that turns a single ByteAddressBuffer.Load<T> / .Store<T> into per-field accesses at computed
    byte offsets. For GLSL/SPIR-V it further rewrites those into structured-buffer accesses, turning
    a byte offset into an element index via index = offset / stride.
  • void-typed field: the representation an empty/zero-sized field is given before this pass
    runs (see below). It is intentional, not malformed.
  • cleanUpVoidType: a later IR pass (slang-ir-cleanup-void.cpp) that removes void fields
    from struct types and the matching void operands from makeStruct/call.

Process report

Root cause / producer→consumer trace. An empty field does not reach byte-address legalization
as an empty struct — it reaches it as a void-typed field. legalizeResourceTypes
(slang-emit.cpp invokes it before byte-address legalization) rebuilds the struct and, in
slang-legalize-types.cpp, deliberately assigns getVoidType() to a field that legalized to
nothing "to keep field count aligned". So when emitLegalLoad
(slang-ir-byte-address-legalize.cpp, struct branch) walks Item's fields, field b has type
void. The field loop had no skip for it, so it recursed and bottomed out at emitSimpleLoad,
which emits the access with the field's own (void) type:

  • On HLSL, emitSimpleLoad produces a ByteAddressBufferLoad whose result type is void, and the
    C-like emitter prints emitType(...).Load<void >(4U).
  • On SPIR-V/GLSL (translateToStructuredBufferOps), it computes
    index = offset / getNaturalSizeAndAlignment(void).getStride(). The natural stride of void is
    0, so this is a divide-by-zero; SCCP constant-folds the Div and raises E30002.

The fix and why it is at the right layer. Before recursing into a field in emitLegalLoad and
emitLegalStore, we now continue past any field whose type as<IRVoidType> is non-null. A void
field carries no data, so emitting no load/store for it — and omitting it from the reconstructed
makeStruct — is correct.

Input-shape check (per the methodology): the void field is valid, intentional input, not a
malformed shape, so the consumer-side skip is principled rather than a band-aid:

  • It is deliberately synthesized by the producer (legalizeResourceTypes
    slang-legalize-types.cpp) to preserve field-count alignment for layout, and that alignment is
    relied on elsewhere — so eliminating the field at the producer would be a much larger,
    layout-affecting change (see the second alternative under Alternatives considered below, deferred).
  • Sibling buffer-lowering code already treats a void field as "skip me" with the identical
    pattern: slang-ir-lower-buffer-element-type.cpp skips as<IRVoidType> fields when building its
    pack/unpack functions, and slang-ir-cleanup-void.cpp removes void fields from struct types.
    This PR simply applies the same, established treatment at the one byte-address site that was
    missing it.

Why as<IRVoidType> specifically (and not "natural size == 0"). The producer's output is a
void-typed field, and the later cleanUpVoidType pass reconciles the makeStruct operand count
against the struct's field count by dropping precisely the void operands and void
fields. Skipping by void type keeps our emitted makeStruct consistent with what that pass will
do. A retained empty-struct field (a different shape) is already handled correctly by the
existing recursion — it enters the struct branch, its zero-field loop emits nothing, and
makeStruct with zero args yields an empty value — so it needs no special case here and must not
be dropped (doing so would leave a permanent operand/field-count mismatch that cleanUpVoidType
would not fix).

Alternatives considered.

  • Guard the divide-by-zero at the stride computation only. This would fix SPIR-V but leave the
    HLSL/DXIL Load<void> (a different symptom of the same missing skip) broken, so it is
    insufficient as the fix. It could be added as cheap defense-in-depth if desired.
  • Eliminate empty/void fields before byte-address legalization (run empty-type elimination
    earlier). Most representation-pure, but the void field exists specifically to keep field-count
    alignment for layout, so moving its elimination earlier is a broad, layout-affecting
    pass-ordering change with a much larger blast radius. Flagged to maintainers as a possible
    follow-up; not pursued here.

Testing. tests/bugs/byte-address-load-empty-field.slang loads and stores two structs —
ItemFirst { Empty a; float b; } and ItemMid { float a; Empty b; float c; } — through a
ByteAddressBuffer / RWByteAddressBuffer for -target spirv, -target hlsl, and -target glsl.
The empty field's position matters: because a CHECK-NOT bounded by positive CHECKs only scans
the region around them, a first-position regressed Load<void> would be emitted before any positive
match and could escape an interleaved guard. So the forbidden load is checked by a dedicated
HLSLNOVOID prefix whose only directive is HLSLNOVOID-NOT: Load<void, which scans the entire
output. (There is no Store<void> form to forbid — a byte-address store emits .Store(offset, value)
with no template argument — so the store path is covered by successful compilation plus the positive
Store( check.) ItemMid's Load<float >(20U) assertion proves the field after the skipped empty
field keeps its natural offset. SPIR-V and GLSL assert codegen reaches entry-point emission (both
abort with E30002: divide by zero on master, and HLSL with internal error E99999). All four
directives fail on master and pass with this change; locally
slang-test tests/bugs/byte-address-load-empty-field.slang reports 100% of tests passed (4/4),
and re-running the existing tests/bugs/*byte*, tests/spirv/*byte*, tests/bugs/empty-struct*,
and tests/hlsl-intrinsic/byte-address-buffer/ tests shows no regressions. A must-fail mutation
drill (disabling only the load-path skip, rebuilding, re-running) confirms the checks are
non-vacuous: with that one skip removed the test drops to 1/4 — the HLSLNOVOID prefix fails on
the re-emitted first-position Load<void>, and the SPIRV/GLSL prefixes fail because the
zero-stride load division aborts codegen again (only the positive HLSL prefix still passes, since
the real fields are still loaded/stored) — and it returns to 4/4 once the skip is restored.

Closes #12711.

🤖 Generated by an automated Slang coworker — may be inaccurate. A human maintainer should verify.

@nv-slang-bot nv-slang-bot Bot added the pr: non-breaking PRs without breaking changes label Aug 25, 2026
@jhelferty-nv

Copy link
Copy Markdown
Contributor

Automated notice (PR board sync) — do not reply to this comment.

Auto-assigned @jkwak-work as shepherd for this Bot PR.

FYI for maintainers: committer signal on the changed files is higher for expipiplus1 than for the auto-requested reviewer (jkwak-work). They were not auto-requested; a human may optionally add them as a reviewer.

1 similar comment
@jhelferty-nv

Copy link
Copy Markdown
Contributor

Automated notice (PR board sync) — do not reply to this comment.

Auto-assigned @jkwak-work as shepherd for this Bot PR.

FYI for maintainers: committer signal on the changed files is higher for expipiplus1 than for the auto-requested reviewer (jkwak-work). They were not auto-requested; a human may optionally add them as a reviewer.

An empty/zero-sized struct field is turned into a void-typed field upstream by
legalizeResourceTypes to keep field-count alignment. The byte-address-buffer
legalization pass had no skip for it, so it emitted a per-field access of the
void field: a Load<void> on HLSL/DXIL (invalid intrinsic) and, for SPIR-V/GLSL,
offset / stride where stride(void) == 0, i.e. a divide by zero (E30002).

Skip void-typed fields in emitLegalLoad and emitLegalStore before recursing, so
no load/store is emitted for them and they are omitted from the reconstructed
makeStruct; the later cleanUpVoidType pass drops the matching void field from the
struct type, keeping operand and field counts consistent. Mirrors the existing
void-field skip in slang-ir-lower-buffer-element-type.cpp and slang-ir-cleanup-void.cpp.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

pr: non-breaking PRs without breaking changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

ByteAddressBuffer.Load of a struct with zero-sized field fails to compile

2 participants