HLSL Working Group

[0046] - HLSL ConstantBuffer<T> Implementation

StatusUnder Consideration
Author
Sponsor

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)

  1. Built-in Template: Define ConstantBuffer<T> in HLSLExternalSemaSource.cpp as a template class containing a single __hlsl_resource_t member (the handle). The handle’s contained type is exactly T.
  2. Implicit Conversion Operator: Define an implicit conversion operator operator const hlsl_constant T &() const within the ConstantBuffer<T> template.
  3. Sema Member Lookup Interception: Modify Sema::LookupMemberExpr (in SemaExprMember.cpp) to detect member accesses on ConstantBuffer<T>. If detected, Sema will inject a call to the implicit conversion operator, effectively transforming cb.field into ((const hlsl_constant T &)cb).field.
  4. Sema Constraints: Enforce that T must be a user-defined struct or class, and reject primitive types, vectors, arrays, or matrices as T. This is implemented using a C++20 concept constraint named __is_constant_buffer_element_compatible applied to the ConstantBuffer template declaration in HLSLExternalSemaSource.cpp. This concept evaluates a new built-in type trait __builtin_hlsl_is_constant_buffer_element_compatible to verify that T is a complete struct/class (not a union) and does not contain any intangible types.

CodeGen (Clang)

  1. Handle Types: Emit instances of ConstantBuffer<T> using the appropriate target types.
  2. Conversion Operator Implementation: The conversion operator is implemented using a new overload of __builtin_hlsl_resource_getpointer that does not take an index.
  3. Intrinsic Redirection: In CodeGen, this builtin lowers to a new target intrinsic llvm.[dx|spv].resource.getbasepointer intrinsic. Unlike the version used for buffers, this overload does not take an offset index, as ConstantBuffer always represents a single instance of T at the start of the buffer.
  4. 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 ConstantBuffer is a T, which is not physically true. ConstantBuffer is a small wrapper around a resource handle; it does not contain the data of T inline.
  • Memory Layout: Inheritance would force the AST’s sizeof(ConstantBuffer<T>) to be at least sizeof(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 DerivedToBase casts 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

  1. Layout Consistency: What needs to be done to ensure that the struct T used in ConstantBuffer<T> is laid out correctly? It must match the layout rules of the legacy cbuffer.

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.

  1. Calls to member functions: The this pointer 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.

  1. Const correctness: Should the conversion operator return a const reference 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.

  1. ConstantBuffer where T contains a resource: Should we support nesting ConstantBuffer types or other resources?

Solution: ConstantBuffer was never supposed to have contained resources. Enabling it in limited situations was an oversight. DXC supports defining a resource in a type T and using it in a ConstantBuffer only when the ConstantBuffer is a scalar in DXIL. It does not work when:

  1. The ConstantBuffer is in a struct: https://godbolt.org/z/cxnd9zaoa
  2. The ConstantBuffer is in an array: https://godbolt.org/z/zo34998h1
  3. 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.