[0046] - HLSL ConstantBuffer<T> Implementation
| Status | Under Consideration |
|---|---|
| Author | |
| Sponsor |
- PRs: #193237
Introduction
This proposal details the implementation of the ConstantBuffer<T> resource
type in Clang and LLVM for HLSL. Unlike the legacy cbuffer keyword, where each
member of the cbuffer becomes its own global variable, ConstantBuffer<T>
behaves as a standard type, supporting instantiation, arrays, function
parameters, and assignments. The ConstantBuffer<T> type acts more like other
resource types than it does a cbuffer. The unique aspect of the
ConstantBuffer<T> type is that it can be used as a drop-in replacement for
T, as if ConstantBuffer<T> inherited from T. However, it is not really
inheritance.
Motivation
HLSL developers require ConstantBuffer<T> as it is part of the language
standard, and it is used.
Proposed solution
We propose implementing ConstantBuffer<T> as a built-in template class that
provides an implicit conversion to a reference of type T in the
hlsl_constant address space. The operator will return a const reference
(const hlsl_constant T &) to enforce proper const-correctness. To ensure a
seamless developer experience, Sema will be modified to automatically inject
this conversion when accessing members of a ConstantBuffer.
Places with HLSL-specific handling for aggregates will also be updated to
convert the ConstantBuffer<T> to T when necessary.
Frontend (Clang AST/Sema)
- Built-in Template: Define
ConstantBuffer<T>inHLSLExternalSemaSource.cppas a template class containing a single__hlsl_resource_tmember (the handle). The handle’s contained type is exactlyT. - Implicit Conversion Operator: Define an implicit conversion operator
operator const hlsl_constant T &() constwithin theConstantBuffer<T>template. - Sema Member Lookup Interception: Modify
Sema::LookupMemberExpr(inSemaExprMember.cpp) to detect member accesses onConstantBuffer<T>. If detected, Sema will inject a call to the implicit conversion operator, effectively transformingcb.fieldinto((const hlsl_constant T &)cb).field. - Sema Constraints: Enforce that
Tmust be a user-defined struct or class, and reject primitive types, vectors, arrays, or matrices asT. This is implemented using a C++20 concept constraint named__is_constant_buffer_element_compatibleapplied to theConstantBuffertemplate declaration inHLSLExternalSemaSource.cpp. This concept evaluates a new built-in type trait__builtin_hlsl_is_constant_buffer_element_compatibleto verify thatTis a complete struct/class (not a union) and does not contain any intangible types.
CodeGen (Clang)
- Handle Types: Emit instances of
ConstantBuffer<T>using the appropriate target types. - Conversion Operator Implementation: The conversion operator is
implemented using a new overload of
__builtin_hlsl_resource_getpointerthat does not take an index. - Intrinsic Redirection: In CodeGen, this builtin lowers to a new target
intrinsic
llvm.[dx|spv].resource.getbasepointerintrinsic. Unlike the version used for buffers, this overload does not take an offset index, asConstantBufferalways represents a single instance ofTat the start of the buffer. - Pointer Address Space: The resulting pointer targets the appropriate
constant/uniform address space (
addrspace(2)for DXIL,addrspace(12)for SPIR-V).
Backend (LLVM)
New Intrinsic: The new intrinsic llvm.[dx|spv].resource.getbasepointer
will have to be implemented in the DirectX and SPIR-V backends. This intrinsic
will be the same as llvm.[dx|spv].resource.getpointer except it does not have
to index into the struct contained in the resource. For SPIR-V, this will simply
return the result of the OpVariable instruction that defines the resource. For
DirectX, the backend will translate this new getbasepointer intrinsic and the
subsequent getelementptr and load operations into the appropriate
dx.op.cbufferLoadLegacy or dx.op.cbufferLoad operations.
Detailed design
Built-in Class Definition
The ConstantBuffer<T> type is defined in the compiler conceptually as the
following C++ template class:
template <typename T>
class ConstantBuffer {
// Underlying handle with resource class CBuffer and contained type T
__hlsl_resource_t [[hlsl::resource_class(CBuffer)]] [[hlsl::contained_type(T)]] __handle;
public:
// Implicit conversion to const reference of type T
operator const hlsl_constant T&() const {
return *__builtin_hlsl_resource_getpointer(__handle);
}
// Copy operations copy the handle, not the underlying data
ConstantBuffer(const ConstantBuffer& other) {
__handle = other.__handle;
}
ConstantBuffer& operator=(const ConstantBuffer& other) {
__handle = other.__handle;
return *this;
}
};
Usage Examples and AST Generation
1. Member Access
When an HLSL developer accesses a member directly from the ConstantBuffer,
Sema intercepts the member lookup and injects a call to the implicit
conversion operator.
struct S { float a; float b; };
ConstantBuffer<S> cb;
float main() {
return cb.a;
}
AST Structure:
`-MemberExpr 'float' lvalue .a
`-CXXMemberCallExpr 'const hlsl_constant S' lvalue
`-MemberExpr '<bound member function type>' .operator const hlsl_constant S &
`-ImplicitCastExpr 'const hlsl::ConstantBuffer<S>' lvalue <NoOp>
`-DeclRefExpr 'cb'
2. Local Assignment
TODO: This needs to be updated based on the implementation in https://github.com/llvm/llvm-project/pull/190089. That PR will disable implicit copy constructors.
Assigning a ConstantBuffer<T> to a local variable of type T triggers the
implicit conversion operator, followed by T’s standard copy constructor.
S local = cb;
AST Structure:
`-CXXConstructExpr 'S' 'void (const S &)'
`-ImplicitCastExpr 'const S' lvalue <NoOp>
`-ImplicitCastExpr 'S' lvalue <UserDefinedConversion>
`-CXXMemberCallExpr 'const hlsl_constant S' lvalue
`-MemberExpr '<bound member function type>' .operator const hlsl_constant S &
`-ImplicitCastExpr 'const hlsl::ConstantBuffer<S>' lvalue <NoOp>
`-DeclRefExpr 'cb'
3. Function Parameters
TODO: This needs to be updated based on the implementation in https://github.com/llvm/llvm-project/pull/190089. That PR will disable implicit copy constructors.
Passing a ConstantBuffer<T> to a function expecting T invokes the implicit
conversion. Passing it to a function expecting ConstantBuffer<T> invokes the
handle-copying constructor.
void takes_s(S s) {}
void takes_cb(ConstantBuffer<S> c) {}
void test() {
takes_s(cb); // Calls operator const hlsl_constant S&() and copies data into argument
takes_cb(cb); // Calls ConstantBuffer(const ConstantBuffer&) and copies handle
}
4. Array Indexing
For arrays of ConstantBuffer, the subscript operator first resolves the
handle, and then the implicit conversion occurs on the indexed element. This
code will be handled the same way that other resource arrays are handled. No
specific code changes are necessary.
ConstantBuffer<S> cb_arr[2];
float f = cb_arr[1].a;
5. Template Support
ConstantBuffer<T> can be passed to templates. Because Sema intercepts member
access on the ConstantBuffer type itself, template functions that perform
member access on deduced ConstantBuffer types will work correctly.
template<typename Tm>
void foo(Tm t) {
float f = t.a; // Works even if Tm is ConstantBuffer<S>
}
void test() {
foo(cb); // Tm is deduced as ConstantBuffer<S>
}
In the primary template foo, the expression t.a is represented as a
CXXDependentScopeMemberExpr because the type of t is dependent:
`-CXXDependentScopeMemberExpr '<dependent type>' lvalue .a
`-DeclRefExpr 'Tm' lvalue ParmVar 't' 'Tm'
When foo is instantiated as foo<ConstantBuffer<S>>, Clang’s template
instantiation mechanism rebuilds the member expression. Since the type of t is
now known to be ConstantBuffer<S>, the standard member lookup logic in
Sema::LookupMemberExpr is triggered. Our interception logic then identifies
ConstantBuffer<S> and injects the call to the implicit conversion operator
operator const hlsl_constant S&(), resulting in the same AST structure as
non-templated member access:
`-MemberExpr 'float' lvalue .a
`-CXXMemberCallExpr 'const hlsl_constant S' lvalue
`-MemberExpr '<bound member function type>' .operator const hlsl_constant S &
`-ImplicitCastExpr 'const hlsl::ConstantBuffer<S>' lvalue <NoOp>
`-DeclRefExpr 't' 'hlsl::ConstantBuffer<S>'
6. Unsupported Types
ConstantBuffer<T> enforces the __is_constant_buffer_element_compatible
constraint. This constraint ensures that T is a valid struct or class and does
not contain any intangible types, such as other resource handles. Attempting to
instantiate a ConstantBuffer with a type that contains a resource (e.g.,
RWBuffer, StructuredBuffer) will result in a compile-time error.
struct S { RWBuffer<float> buf; };
// Error: constraints not satisfied for class template 'ConstantBuffer'
ConstantBuffer<S> cb;
This ensures that ConstantBuffer<T> remains a “drop-in” replacement for T
even in generic code, as the transformation happens seamlessly during template
instantiation.
CodeGen and LLVM IR
When Clang emits LLVM IR for the operator const hlsl_constant T&() conversion,
it utilizes the llvm.dx.resource.getbasepointer (or
llvm.spv.resource.getbasepointer) intrinsic to retrieve an address space
qualified pointer.
DXIL Example
For the member access cb.a, the target pointer is in addrspace(2) (the DXIL
constant address space).
; The handle type
%"class.hlsl::ConstantBuffer" = type { target("dx.CBuffer", %struct.S) }
; 1. The implicit conversion resolves to the getbasepointer intrinsic
%handle = load target("dx.CBuffer", %struct.S), ptr %cb, align 4
%base_ptr = call ptr addrspace(2) @llvm.dx.resource.getbasepointer.p2.tdx.CBuffer_s_Sst(target("dx.CBuffer", %struct.S) %handle)
; 2. The MemberExpr applies a GEP
%gep = getelementptr inbounds %struct.S, ptr addrspace(2) %base_ptr, i32 0, i32 0
; 3. Finally, the value is loaded
%val = load float, ptr addrspace(2) %gep, align 4
SPIR-V Example
For SPIR-V (Vulkan), the handle is a spirv.VulkanBuffer, and the uniform
pointer is in addrspace(12).
; The handle type
%"class.hlsl::ConstantBuffer" = type { target("spirv.VulkanBuffer", %struct.S, 2, 0) }
; 1. Pointer acquisition
%handle = load target("spirv.VulkanBuffer", %struct.S, 2, 0), ptr %cb, align 8
%base_ptr = call ptr addrspace(12) @llvm.spv.resource.getbasepointer.p12.tspirv.VulkanBuffer_s_Sst(target("spirv.VulkanBuffer", %struct.S, 2, 0) %handle)
; 2. GEP and load
%gep = getelementptr inbounds %struct.S, ptr addrspace(12) %base_ptr, i32 0, i32 0
%val = load float, ptr addrspace(12) %gep, align 4
Alternatives considered
Reusing getpointer with index 0
We considered reusing the existing llvm.[dx|spv].resource.getpointer intrinsic
with an index of 0. However, for this to work semantically in SPIR-V, we would
have to wrap T in another struct when defining the Vulkan.buffer type
because the 0 has a meaning to index into the type. We believe this cannot be
cleanly implemented. Attempting to wrap T when adding the handle to the
ConstantBuffer quickly turned into having many special cases. It couldn’t be
done when lowering the type of the handle to the SPIR-V target extension type
because we cannot distinguish between a cbuffer and ConstantBuffer at that
point, and we should be wrapping only one of them. The cleanest solution is to
avoid using an index entirely.
Reusing the Legacy cbuffer Model
In the legacy model, members of a cbuffer are emitted as individual global
variables in the hlsl_constant address space. These globals are then linked to
a resource handle via metadata (!hlsl.cbs).
While this works for flat, global cbuffer declarations, it is fundamentally
incompatible with ConstantBuffer<T> for several reasons:
- Encapsulation: In
ConstantBuffer<T>, members are scoped within the template instance. Treating them as global variables would violate this scoping and require complex name mangling and metadata schemes. - Dynamic Handles: Metadata-based linking is static. In modern HLSL,
ConstantBuffer<T>can be indexed (arrays) or passed as parameters, meaning the handle is often dynamic and cannot be linked to a global variable at compile time via static metadata. - Complexity: Reusing the legacy model for a modern resource type would
require significant “backward” modifications to Clang’s resource handling,
whereas the proposed pointer-based model aligns with how other modern
resources (like
RWBuffer) are implemented.
ConstantBuffer<T> inheriting from T
A significant alternative considered was to have ConstantBuffer<T> inherit
from T. This would allow standard C++ member lookup and implicit conversions
(via slicing) to work “out of the box” in Sema.
However, this approach was rejected for several technical and architectural reasons:
- AST Inaccuracy: The AST would imply that a
ConstantBufferis aT, which is not physically true.ConstantBufferis a small wrapper around a resource handle; it does not contain the data ofTinline. - Memory Layout: Inheritance would force the AST’s
sizeof(ConstantBuffer<T>)to be at leastsizeof(T). This bloat is misleading and could lead to bugs in parts of the compiler that rely on accurate record sizes (e.g., alignment, padding, or future features). - Special Case Overload: To achieve correct CodeGen, we would still need to
intercept
DerivedToBasecasts to prevent the compiler from attempting to access the (non-existent) base class data via standard pointer arithmetic. This effectively trades one type of Sema/CodeGen hack for another, more confusing one. - Maintainability: Creating a “fake” inheritance relationship introduces a fundamental lie into the AST that every future compiler developer would have to be aware of and handle as a special case. The proposed implicit conversion model is more honest and follows standard C++ patterns for wrapper types.
Open questions
- Layout Consistency: What needs to be done to ensure that the struct
Tused inConstantBuffer<T>is laid out correctly? It must match the layout rules of the legacycbuffer.
Solution: No extra changes are needed for this. The layout rules are triggered by the hlsl_constant address and type of the handle in the resource.
- Calls to member functions: The
thispointer in member functions expects a pointer to the default address space. The members of a ConstantBuffer are in an incompatible address space. How will we call member functions on members of a ConstantBuffer? The same applies for CBuffers.
Solution: Convert the struct from hlsl_constant address space and constant
buffer layout to a const struct in default address space and standard layout
before calling the member function.
- Const correctness: Should the conversion operator return a
constreference for HLSL 202x?
Solution: The conversion operator will return a const reference. This will allow errors from directly writing to a member of a ConstantBuffer to be emitted during Sema without additional changes.
- ConstantBuffer
where T contains a resource: Should we support nestingConstantBuffertypes or other resources?
Solution: ConstantBufferT and using it in a ConstantBuffer only when the
ConstantBuffer is a scalar in DXIL. It does not work when:
- The ConstantBuffer is in a struct: https://godbolt.org/z/cxnd9zaoa
- The ConstantBuffer is in an array: https://godbolt.org/z/zo34998h1
- When targeting SPIR-V: https://godbolt.org/z/j78oPPnax
Given the limited possible uses, we don’t expect that using resources in ConstantBuffers is common. We believe we can disallow it without causing too many problems.
Acknowledgments
Special thanks to the HLSL working group for examining the ideas in this proposal and suggesting alternatives.