HLSL Working Group

[0048] - Local Resources Behavior

StatusAccepted
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:

  1. Establish expected behavior for each local-resource pattern, grounded in the HLSL specification rather than in any one compiler’s current behavior.
  2. 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.
  3. 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 .hlsl extension) 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, or TBD / unspecified when 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 to TBD / unspecified if the spec working group disagrees.
  • Ought Runtime — what the shader ought to do at runtime if it compiles, or N/A when 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:

TestBehaviorOught CompileClangDXC
consolidated_assignmentsChained 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 globalClean✅✅

Offload tests (executed on hardware):

TestBehaviorOught CompileOught RuntimeClangDXC
local_resource_alias_globalInitialize a local from a single global, then Store through the localCleanStore reaches the global’s binding✅✅

Const-Qualified Locals

TestBehaviorOught CompileClangDXC
const_local_storeconst 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:

TestBehaviorOught CompileClangDXC
arrayTwo 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 valuesClean✅✅
array_partial_init_dynamic4-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 elementsClean (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):

TestBehaviorOught CompileOught RuntimeClangDXC
local_resource_array_dynamic_indexDynamic (runtime) index into a local resource arrayCleanStore reaches the dynamically-indexed global❌ Clean (sema); codegen error: “Load of … is not a global resource handle”✅
local_resource_struct_methodStruct with a member function that operates on a resource memberCleanStore reaches the global via the member function✅✅

Loop Patterns

TestBehaviorOught CompileOught RuntimeClangDXC
local_resource_loop_array_indexPick resource from a global array inside a loop using loop indexCleanPer-iteration Store reaches the indexed global✅✅

Binding Ambiguity Diagnostics

Clang tests (llvm-project) — not executed, so no runtime claim:

TestBehaviorOught CompileClangDXC
branched_reassign_ambiguousLocal 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 siteError † (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_unambiguousTernary 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_errsGrouped warning cases: ternary initializer, reassignment from a resource-array element, ternary assignment to an uninitialized local, and ternary assignment to a static globalWarning † (each shape leaves the binding ambiguous)✅ Warning (-Whlsl-explicit-binding) for all four shapes❌ Clean (no diagnostic for any shape)
bindingsGrouped 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 arrayClean (binding stays unique in every shape)✅ Clean✅ Clean

Offload tests (executed on hardware):

TestBehaviorOught CompileOught RuntimeClangDXC
local_resource_reassign_different_globalPlain reassignment of a local to a different globalWarning † (binding-ambiguous reassignment)Store reaches the most-recently-assigned global✅ Warning (-Whlsl-explicit-binding)❌ Clean

Global Resource Array Indexing

TestBehaviorOught CompileClangDXC
init_from_global_array_dynamic_indexInitialize a local from a dynamically-indexed element of a global resource arrayClean✅✅

Function Forwarding and Multiple Uses

TestBehaviorOught CompileOught RuntimeClangDXC
local_resource_with_wave_intrinsicLocal 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 offsetCleanWave intrinsic returns per-lane rank and each lane’s store reaches its offset in the global✅✅

Static Storage

TestBehaviorOught CompileClangDXC
static_localstatic RWByteAddressBuffer buf = g; inside a functionClean❌ Clean at -O0; asserts at -O1+ in GlobalOpt (#205169)❌ ICE: llvm::cast<X>() argument of incompatible type
static_conststatic const local resource with Load method callError † (Load not const-qualified)❌ Clean (no diagnostic)❌ ICE

Type Mixing and Alternative Resource Types

TestBehaviorOught CompileOught RuntimeClangDXC
local_resource_read_onlyLocal ByteAddressBuffer (read-only SRV) initialized from a global, Loaded fromCleanLoad returns the bound global’s contents✅✅

Invalid Type Operations

TestBehaviorOught CompileClangDXC
assign_wrong_typeAssign RWStructuredBuffer to RWByteAddressBufferError (incompatible resource types)✅ Error: “no viable overloaded ‘=’”✅ Error: type mismatch

Invalid Declarations

TestBehaviorOught CompileClangDXC
explicit_registerExplicit register() attribute on a local resource variableError (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.

TestBehaviorOught CompileOught RuntimeClangDXC
local_resource_if_else_array_elementsif(cond) buf = arr[NonUniformResourceIndex(0)]; else buf = arr[NonUniformResourceIndex(1)]; where cond is derived from SV_DispatchThreadID so both branches execute across lanesCleanBoth 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, -verify fails 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 -verify can 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