[0048] - Local Resources Behavior
| Status | Accepted |
|---|---|
| Author |
Introduction
Local resource variables — resource handles declared within function scope rather than at global scope — are a common pattern in HLSL shaders. Despite their widespread use, the semantics around initialization, assignment, aliasing, and control flow have historically been under-documented and inconsistently handled across compilers. This proposal establishes, for each local-resource pattern, what the HLSL specification ought to require (the “ought” claim) and compares Clang’s and DXC’s current behavior against that claim. Patterns whose specified behavior is not yet settled are flagged as TBD so the relevant HLSL spec issues can be filed.
Motivation
DXC has never had structured test coverage for local resource patterns. Many valid and invalid usage patterns were left untested. Without a clear specification, compiler implementers cannot distinguish correct behavior from bugs, and users cannot predict which patterns are safe to rely on.
This proposal aims to:
- Establish expected behavior for each local-resource pattern, grounded in the HLSL specification rather than in any one compiler’s current behavior.
- Compare Clang’s and DXC’s current behavior against the expected behavior so that compiler-side bugs (in either compiler) and spec gaps can be told apart and tracked separately.
- Enable regression testing so that future compiler changes can be validated against the expected behavior, and so that every row whose current behavior is wrong points back to a tracking compiler bug or spec issue.
Proposed solution
Each local-resource pattern is documented below in a per-category “ought” table with the following columns:
- Test — the test file name (without the
.hlslextension) that exercises the pattern. - Behavior — a concise description of the pattern, unique among all rows.
- Ought Compile — what the HLSL specification ought to require at
compile time:
Clean,Warning,Error, orTBD / unspecifiedwhen the spec has not yet decided. A†after the value indicates the claim is reasoned from analogous language rules but not yet pinned to a written HLSL spec proposal (i.e. maybe should be TBD); it is a candidate to be relaxed toTBD / unspecifiedif the spec working group disagrees. - Ought Runtime — what the shader ought to do at runtime if it
compiles, or
N/Awhen a compile error is expected. A†here has the same meaning as on Ought Compile. This column appears only for tests in the offload test suite, which are actually executed. Tests living in the clang tree are compile-only, so they make no runtime claim and omit the column. - Clang / DXC — ✅ if the compiler’s current behavior matches the ought claim; ❌ followed by the actual behavior otherwise. A ❌ marks either a compiler bug or, when the ought claim is itself uncertain, a spec issue that needs to be resolved.
The Clang and DXC columns reflect end-to-end compilation —
sema plus codegen plus the backend’s DXIL emission — not front-end-only
or -emit-llvm results. Each test name links to a Compiler Explorer
view that runs dxc_trunk and hlsl_clang_trunk side-by-side with
-T <profile> -E <entry> (no -emit-llvm, no -verify), so a reader
clicking the link sees exactly the invocation that produced the result
recorded in the table. This matters for binding-ambiguous patterns: a
-verify or -emit-llvm probe stops before DXIL emission and would
miss the codegen-stage error that both compilers raise from
DxilCondenseResources / equivalent.
Assignment and Initialization Forms
✅ = currently matches the “ought” claim. ❌ = does not match (actual behavior in parentheses).
Clang tests (llvm-project) — not executed, so no runtime claim:
| Test | Behavior | Ought Compile | Clang | DXC |
|---|---|---|---|---|
consolidated_assignments | Chained aliasing/initialization forms — ternary initializer (constant and same-arm runtime conditions), struct aggregate init from a function return, direct init from a function return, a method called directly on a function return, alias chain, comma initializer, self-assign, dead-branch reassignment, and forwarding through a call chain — all resolving to one global | Clean | ✅ | ✅ |
Offload tests (executed on hardware):
| Test | Behavior | Ought Compile | Ought Runtime | Clang | DXC |
|---|---|---|---|---|---|
local_resource_alias_global | Initialize a local from a single global, then Store through the local | Clean | Store reaches the global’s binding | ✅ | ✅ |
Const-Qualified Locals
| Test | Behavior | Ought Compile | Clang | DXC |
|---|---|---|---|---|
const_local_store | const RWByteAddressBuffer local = gBuf; then local.Store(...) (also passes local to a callee doing Load on a const RWByteAddressBuffer parameter, which both compilers accept) | Clean (per int *const semantics: const qualifies the handle binding, not the buffer’s mutability — Store should remain callable) | ❌ Error: “no matching member function for call to ‘Store’” (clang treats const on the handle as forbidding non-const member calls, like a C++ const T) | ✅ Clean |
Struct and Array Patterns
Clang tests (llvm-project) — not executed, so no runtime claim:
| Test | Behavior | Ought Compile | Clang | DXC |
|---|---|---|---|---|
array | Two plain local resource arrays of different sizes (Arr[2] and Arr2[1]), each initialized from a distinct global, then Stored through at distinct offsets and values | Clean | ✅ | ✅ |
array_partial_init_dynamic | 4-element local resource array with only arr[0] and arr[1] assigned, both from the same global; dynamic store arr[tid.x & 1] provably stays within the assigned elements | Clean (every reachable element resolves to the same unique global) | ❌ Sema clean, but codegen fails: “Load of … is not a global resource handle”, then asserts in DXILLegalizePass (#192538) | ✅ Clean |
Offload tests (executed on hardware):
| Test | Behavior | Ought Compile | Ought Runtime | Clang | DXC |
|---|---|---|---|---|---|
local_resource_array_dynamic_index | Dynamic (runtime) index into a local resource array | Clean | Store reaches the dynamically-indexed global | ❌ Clean (sema); codegen error: “Load of … is not a global resource handle” | ✅ |
local_resource_struct_method | Struct with a member function that operates on a resource member | Clean | Store reaches the global via the member function | ✅ | ✅ |
Loop Patterns
| Test | Behavior | Ought Compile | Ought Runtime | Clang | DXC |
|---|---|---|---|---|---|
local_resource_loop_array_index | Pick resource from a global array inside a loop using loop index | Clean | Per-iteration Store reaches the indexed global | ✅ | ✅ |
Binding Ambiguity Diagnostics
Clang tests (llvm-project) — not executed, so no runtime claim:
| Test | Behavior | Ought Compile | Clang | DXC |
|---|---|---|---|---|
branched_reassign_ambiguous | Local initialized from one global, then reassigned to a different global inside an if (buf = gBuf1; if (cond) { buf = gBuf2; }) — buf resolves to either global at the access site | Error † (binding-ambiguous reassignment that cannot be folded away) | ❌ Warning at sema, then codegen error “Resource access is not guaranteed to map to a unique global resource” | ✅ Codegen error: “local resource not guaranteed to map to unique global resource” |
ternary_lvalue_unambiguous | Ternary lvalue with a statically constant condition ((true ? a : b) = g) | Warning † at sema (front end is syntactic and cannot prove unambiguity); Clean codegen (backend folds the constant condition) | ❌ Clean at sema (warning does not fire); ✅ Clean codegen | ✅ Clean |
bindings_errs | Grouped warning cases: ternary initializer, reassignment from a resource-array element, ternary assignment to an uninitialized local, and ternary assignment to a static global | Warning † (each shape leaves the binding ambiguous) | ✅ Warning (-Whlsl-explicit-binding) for all four shapes | ❌ Clean (no diagnostic for any shape) |
bindings | Grouped must-not-warn cases pinning the diagnostic’s lower boundary: single-path conditional assign, self-assign of an uninitialized local, conditional reassign to the same global, and ternary/if-else selecting between elements of one resource array | Clean (binding stays unique in every shape) | ✅ Clean | ✅ Clean |
Offload tests (executed on hardware):
| Test | Behavior | Ought Compile | Ought Runtime | Clang | DXC |
|---|---|---|---|---|---|
local_resource_reassign_different_global | Plain reassignment of a local to a different global | Warning † (binding-ambiguous reassignment) | Store reaches the most-recently-assigned global | ✅ Warning (-Whlsl-explicit-binding) | ❌ Clean |
Global Resource Array Indexing
| Test | Behavior | Ought Compile | Clang | DXC |
|---|---|---|---|---|
init_from_global_array_dynamic_index | Initialize a local from a dynamically-indexed element of a global resource array | Clean | ✅ | ✅ |
Function Forwarding and Multiple Uses
| Test | Behavior | Ought Compile | Ought Runtime | Clang | DXC |
|---|---|---|---|---|---|
local_resource_with_wave_intrinsic | Local resource handle used together with a wave intrinsic (WavePrefixCountBits); each lane stores a per-lane, wave-derived value through the local handle at a per-lane offset | Clean | Wave intrinsic returns per-lane rank and each lane’s store reaches its offset in the global | ✅ | ✅ |
Static Storage
| Test | Behavior | Ought Compile | Clang | DXC |
|---|---|---|---|---|
static_local | static RWByteAddressBuffer buf = g; inside a function | Clean | ❌ Clean at -O0; asserts at -O1+ in GlobalOpt (#205169) | ❌ ICE: llvm::cast<X>() argument of incompatible type |
static_const | static const local resource with Load method call | Error † (Load not const-qualified) | ❌ Clean (no diagnostic) | ❌ ICE |
Type Mixing and Alternative Resource Types
| Test | Behavior | Ought Compile | Ought Runtime | Clang | DXC |
|---|---|---|---|---|---|
local_resource_read_only | Local ByteAddressBuffer (read-only SRV) initialized from a global, Loaded from | Clean | Load returns the bound global’s contents | ✅ | ✅ |
Invalid Type Operations
| Test | Behavior | Ought Compile | Clang | DXC |
|---|---|---|---|---|
assign_wrong_type | Assign RWStructuredBuffer to RWByteAddressBuffer | Error (incompatible resource types) | ✅ Error: “no viable overloaded ‘=’” | ✅ Error: type mismatch |
Invalid Declarations
| Test | Behavior | Ought Compile | Clang | DXC |
|---|---|---|---|---|
explicit_register | Explicit register() attribute on a local resource variable | Error (register only applies to globals) | ✅ Error: "‘register’ attribute only applies to cbuffer/tbuffer and external global variables" | ❌ Clean (silently ignores the local register()) |
Ternary Conditional Resource Assignment (CodeGen)
These patterns construct a local resource whose binding depends on runtime control flow. The “ought” claim throughout this section is that the assignment is well-formed but binding-ambiguous, so a warning should be emitted and the runtime behavior is to access whichever global the control flow selected. Patterns where both ternary branches resolve to the same binding are unambiguous and should compile clean.
| Test | Behavior | Ought Compile | Ought Runtime | Clang | DXC |
|---|---|---|---|---|---|
local_resource_if_else_array_elements | if(cond) buf = arr[NonUniformResourceIndex(0)]; else buf = arr[NonUniformResourceIndex(1)]; where cond is derived from SV_DispatchThreadID so both branches execute across lanes | Clean | Both branches execute; each thread’s store reaches the distinct selected array element with the per-thread value | ✅ | ✅ |
Key Behavioral Themes
Ambiguous Binding: Sema Warning vs. CodeGen Error
A recurring pattern across the tables above is a local resource whose
binding cannot be resolved to a single unique global at compile time
(reassignment across control flow, ternary merges, etc.). The expected
behavior is a compile-time diagnostic that surfaces the ambiguity to the
user. Clang emits
-Whlsl-explicit-binding at sema for these patterns. DXC has no
equivalent sema diagnostic — it either silently accepts the pattern
(producing implementation-defined runtime behavior) or rejects it as a
hard error during its DxilCondenseResources codegen pass. Neither
behavior is the spec-defined baseline; the ought-tables flag both
deviations.
Both Compilers Crash on Static Resources
DXC asserts internally on static local resources and static const
resources. Clang accepts both at -O0, but a plain static local
resource also asserts in Clang once optimizations run
(GlobalOpt/SRAGlobal at -O1 and above,
#205169). These
are compiler bugs in both, not intentional behavior.
Texture2D vs RWByteAddressBuffer
Some behaviors differ between resource types. DXC ICEs with both
static Texture2D and static RWByteAddressBuffer. The test suite
uses RWByteAddressBuffer because Clang does not yet support Texture2D.
Test Placement
Test placement follows from each row’s “Ought Compile” column above, not from which compiler currently passes or fails. Within the set of shaders that ought to compile, placement further depends on whether the test exercises behavior that benefits from on-hardware validation.
Motivation. Local resource assignment is, fundamentally, a static
question: which global resource does each use of a local handle bind to?
That question is answered entirely by tracking how a pointer to a global
flows through assignments, returns, parameters, struct members, and
control flow — all of which are visible in the LLVM IR. For the bulk of
the surface area covered by this proposal, a focused IR test in the
clang tree (CodeGenHLSL/ for codegen, SemaHLSL/ for diagnostics)
gives a sharper, faster, and more vendor-agnostic check than running a
compute shader on real hardware and comparing buffer contents. Running
every pattern on real GPUs would mostly be re-verifying the IR through a
much noisier and slower channel.
The offload test suite is reserved for cases where on-hardware behavior adds information that IR inspection cannot — that is, where the execution of the shader, not just its IR, is the interesting thing.
Shaders that ought to compile without error (Clean or Warning) belong in the offload test suite under
test/Feature/LocalResources/only if the test exercises one of:- a common, idiomatic HLSL usage pattern that a smoke test should catch end-to-end (initialization from a global, reassignment, function forwarding, struct member access, simple control flow); or
- a pattern whose runtime behavior may differ across vendors, drivers, or targets (e.g. wave-conditional reassignment, bindless / unbounded resource arrays, group-shared interactions), where comparing actual GPU output is the only way to catch a divergence.
Everything else that ought to compile — pattern variants whose binding resolution is fully decidable by reading the IR (e.g. static-condition ternaries that collapse to a single global, plain shadowing, no-op self-assignment, simple aliasing) — belongs as an IR-level test in the clang tree (
CodeGenHLSL/) rather than as an offload test. Where a compiler’s current behavior disagrees with the Ought column, prefer filing a tracking issue that carries the repro over landing a test that pins the wrong behavior. XFAIL is only useful when the expected output is already known exactly; for a diagnostic that does not exist yet,-verifyfails on unexpected diagnostics too, so an XFAILed test whose guessed wording does not match stays silently XFAILed instead of signaling that it should be re-enabled.Shaders that ought to fail compilation with an error belong in the clang tree (typically
SemaHLSL/), where-verifycan pin the expected diagnostic. They are not added to the offload test suite regardless of any current compiler’s behavior.Shaders whose Ought claim is TBD / unspecified are blocked on spec work; this document is the place to surface those gaps so the HLSL spec issues can be filed.
The net effect is that the offload LocalResources/ directory holds a
small, curated set of shaders that together cover the everyday
patterns and the vendor-divergent ones; the long tail of variant
patterns lives as IR-level tests in clang, where they are easier to
maintain and faster to run.
Alternatives considered
Document only observed behavior, without ought claims. An earlier
draft of this proposal listed Clang and DXC behavior side-by-side
without expressing what either compiler ought to do. That approach was
rejected because it leaves no way to tell a compiler bug apart from
correct behavior, makes XFAIL decisions ad-hoc, and forces the
ought-discussion to happen scattered across individual test PRs rather
than as a single comprehensive survey. The current per-row ought claim
makes spec gaps explicit (via TBD / unspecified) and gives every ❌
a clear next step: either fix the compiler or file a spec issue.
Organize tests by which compiler currently passes. An earlier
draft used subdirectories named ClangPass-DXCCodegenError/,
DXCPass-ClangSemaError/, etc. That structure bakes current compiler
behavior into the file layout, which would require moving tests every
time a compiler changes. The Ought-driven placement described in
Test Placement is stable against compiler fixes.
Acknowledgments
Helena Kotas, Justin Bogner, Finn Plummer