Skip to content
Working Around a Proposed Break in HLSL Initialization with Variadic Templates

Working Around a Proposed Break in HLSL Initialization with Variadic Templates

November 14, 2026

TL;DR

HLSL is potentially going to break vector initialization syntax. This gives us the motivation to implement a function that will do the same thing in the event that that does happen (it probably won’t). We do this by using some new features coming to HLSL such as variadic templates, auto return type deduction, and fold expressions. We then compare the generated DXIL to find that our new function generates bytecode identical to that produced by native vector initializers in some cases. You can find the code at Variadic Vector Init.

TC-57

Recently I joined the TC-57 committee. This is the standardization committee for HLSL (High Level Shading Language).

Tip

You can find out more about TC-57’s processes and activities at these links:

  1. Standardizing HLSL - Chris Bieneman’s blog announcing TC-57
  2. TC-57 site with monthly status reports
  3. TC-57 GitHub

This has given me a peek into what the challenges are with HLSL, the direction HLSL is heading in, and all the cool new features being implemented. It has also allowed me to have some small influence on things that are going into the standard draft. All very cool stuff, and I am excited to be taking part in the standardization effort! A couple of recent topics that have been discussed during committee meetings have inspired me to write this blog.

HLSL Initialization

One of the proposals that is currently under consideration is 0011 - Strict Initializer Lists. This will effectively bring C++ aggregate initialization rules to HLSL.

Info

It is the intent for HLSL to adopt many C++ features. See 0010 - Modern C++ Features for more information on this.

This is pretty great, and it will solve a lot of weirdness around HLSL initialization that is in the language. However, there is a large issue with adopting this proposal, and the proposal is up front about it:

Extension of Vectors

A common pattern in shading languages is to zero-extend or one-extend vectors. Commonly used syntaxes for such extensions are:

float4 vec4 = float4(vec3, 1.0);
float4 vec4 = {vec3, 0.0};

Both of these syntaxes would be ill-formed with this feature.

This is a bit of an issue in my mind. I would not be surprised if this would break a huge majority of HLSL shaders in existence right now… Especially vertex shaders. Any vertex shader that I have written that’s done actual work has had a line in it like:

float4 OutPosition = float4(VertexPosition, 1.0);

Swizzles to the Rescue?

Thankfully, the same proposal that introduces this breaking change also has an idea on how to mitigate the issue. The proposal does go on to say:

A separate feature that could be considered to mitigate this would be allowing 0 and 1 as swizzle operator elements. This was suggested in HLSL in microsoft/hlsl-specs#70, and similarly in WebGPU in gpuweb/gpuweb#732.

and an issue has been made to add 0/1 swizzle syntax. I personally think that this is a really cool feature to add. It would turn our previous example of

float4 OutPosition = float4(VertexPosition, 1.0);

into

float4 OutPosition = VertexPosition.xyz1;

This is more expressive in my opinion and leans on the well-known swizzle syntax that every shading language has. BUT, I don’t agree that it is an adequate mitigation for the break that is being proposed in 0011 - Strict Initializer Lists.

I will concede that a large portion of the code that will break will solely involve just adding 0 or 1 to the end of a vector, which is what this new syntax hopes to improve. However, I don’t think it is enough of a mitigation for two reasons:

  1. We don’t just add 0s and 1s to the end of vectors. We can concatenate arbitrary scalars and vectors.
  2. Even if we did just concatenate 0s and 1s, I don’t particularly like how different the new syntax is from the old. The code transformation to fix all the broken code is therefore as complex with or without the new swizzle syntax.

So, is there a better workaround?

Constructors in HLSL

We don’t have constructors in HLSL, and that is why adopting the C++ aggregate initialization rules will break vector initialization as we know it. Vector initialization, currently, is effectively just aggregate initialization with slightly different rules to allow for flattening structures.

However, constructor support is being aimed for with HLSL 202y which is also when 0011 - Strict Initializer Lists is proposed to land. This is interesting because I think it gives us an opportunity to completely mitigate this entire issue without anyone actually knowing it was a problem in the first place!

If the language supports constructors, then we can define constructors for vector and matrix types that can support the current behaviour. And the beauty is that we can test whether this is a good workaround with the DXC we have today… kinda.

What is the current vector initialization behaviour?

Before we try and make a workaround, we need to know what the current behaviour is. So I have thrown together a project that makes use of my Shader Test Framework. This will allow me to write simple unit tests to quickly iterate and verify results. What follows is not much code, but it does warrant a lot of explanation:

#include "/Test/TTL/models.hlsli"

struct CValidVectorInit
{
    template<typename VecType, typename... InArgs>
    __decltype(
        VecType(ttl::declval<InArgs>()...)
    ) requires();
};

static_assert(ttl::models_v<CValidVectorInit, float4, float3, float>, "Expected float4(float3, float) to be valid");
static_assert(!ttl::models_v<CValidVectorInit, float4, float3, float2>, "Expected float4(float3, float2) to be invalid");

Important

ALL HLSL code samples from here on out will be compiled with HLSL 202x. This is all going to be latest and greatest HLSL :)

Variadic Templates in my HLSL?!

The first weird thing you might have noticed in the above code is:

template<typename VecType, typename... InArgs>

This is a variadic template in HLSL. Variadic templates are what will make this workaround actually feasible. Without them, we would have to define constructors for every possible permutation of scalar and vector arguments allowed.

Info

Unfortunately, as of writing this article, the latest release of DXC does not have support for variadic templates. BUT, a recent commit to DXC has enabled them! So we can get variadic templates by building DXC from main ourselves which isn’t too much of an extra step to prove out the plan. I am sure that this feature will be properly released Soon™.

They work in basically the same way that they work in C++11. So not much more to say on this other than “Yay! We have variadic templates!”.

static_assert in my HLSL?!

Just like variadic templates, static_assert is a new feature which will hopefully come with an upcoming release.

Info

Technically we have had static_assert since we have had DXC except it was spelled _Static_assert which is the C spelling. But it is still nice that we have the C++ spelling now too.

As an example, if I was to change the second static_assert to expect the expression to return true like so:

static_assert(ttl::models_v<CValidVectorInit, float4, float3, float2>, "float4(float3, float2) is invalid");

and run the test, I will get the following output:

F:\Projects\TC57\variadic-vector-init\test\ShaderTests.cpp(45): FAILED:
  REQUIRE( result )
with expansion:
  DXC shader compilation error
  ------------------------
  hlsl.hlsl:13:1: error: static_assert failed "float4(float3, float2) is
  invalid"
  static_assert(ttl::models_v<CValidVectorInit, float4, float3, float2>,
  "float4(float3, float2) is invalid");
  ^             ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

What even is that struct?!

This struct:

struct CValidVectorInit
{
    template<typename VecType, typename... InArgs>
    __decltype(
        VecType(ttl::declval<InArgs>()...)
    ) requires();
};

is what I call a “pseudo concept”. Pseudo concepts are a way of approximating the functionality of C++20 concepts in HLSL. I have a library of these pseudo concepts and metafunctions for working with them in Shader Test Framework’s Test Template Library. Link again to read more.

CValidVectorInit allows us to validate whether a vector initialization expression is valid or not using the ttl::models_v metafunction. You can read:

ttl::models_v<CValidVectorInit, float4, float3, float>

as

CValidVectorInit is modelled successfully when it is passed a float4, float3, and a float. With these arguments CValidVectorInit determines if the expression float4(float3, float) is a valid expression.

A potentially unexpected result

What people might not be expecting is that the following static_assert succeeds:

struct A
{
    int a;
};

struct B
{
    float b[2];
};

struct C
{
    bool c;
};

static_assert(ttl::models_v<CValidVectorInit, float4, A, B, C>, "Expected float4(A, B, C) to be valid");

This succeeds because of the current initialization rules. HLSL will essentially unpack (or flatten) any array or structure into its constituent elements to initialize the vector. And those elements need not be floats. They can be ints or even bools. The same rules exist for arrays and structs.

Tip

This is what Chris is pointing out in How many initializers does it take

Just to make sure that we understand how this is working, we can write some runtime asserts as well to validate where the values go during initialization:

[numthreads(1, 1, 1)]
void CurrentVectorInit()
{
    A a = { 1 };
    B b = { 2.0f, 3.0f };
    C c = { true };

    float4 value = float4(a, b, c);
    ASSERT(AreEqual, value, float4(1.0, 2.0, 3.0, 1.0));
}

Unfortunately, I don’t think there is a (good) way of supporting this particular case in the future. I say “unfortunately” because I am sure there is some HLSL shader out there that depends on this, and that is… well… unfortunate. However, I do think that this particular case is fine to break… This is just silly, which again, is why TC-57 is proposing to adopt the C++ aggregate initialization rules.

What behaviour do we want to support?

We have now established with some compile-time and run-time tests what the behaviour of HLSL vector initialization is. It will flatten everything given to it into a series of scalar values and if the number of scalars matches the expected number of scalars for that initializer, it will cast those scalars to the type that is requested.

So the question becomes, what behaviour do we actually care about preserving here? I feel like it would require a conversation or two to determine the answer to that question. However, for the purposes of this article, I am going to say that I want to implement the following behaviour:

  1. No implicit casts -> to make a float4, you must supply only floats or float-based vectors.
  2. No flattening structures into their individual scalar components -> Even if I could do this flattening in HLSL, I would still be against it.

Ok so now that I have set out the behaviour that I want, let’s implement this!

Step 1: Determining if all types have the same fundamental base type

This first step will be used to help us fulfill the “no implicit casts” rule that we have set out. We need to be able to look at all the types that we are passed and determine if they all have the same “fundamental base type”. The following table demonstrates what I mean by “Fundamental Base Type”:

TypeFundamental Base Type
intint
int3int
floatfloat
float3float
MyStructNOT DEFINED

So basically it is a built-in type that can be held in a vector<T, N>.

Step 1.1 Defining conjunction

Before we can actually make a type trait for determining if a pack does contain types of all the same fundamental type, it would be good to define a utility metafunction that will make defining it easier. Fundamentally, we want to be able to do the following:

if (Pack.size() == 0 || !Pack[0].is_fundamental)
{
    return false;
}

for (int i = 1; i < Pack.size(); ++i)
{
    if (Pack[0].base_type != Pack[i].base_type)
    {
        return false;
    }
}

return true;

However, we can’t just iterate over a variadic pack and compare types as if we had C++26 static reflection. So we can’t write it like that. The fundamental thing we are doing in the pseudocode above is simply the following:

return Condition(Type0) && Condition(Type1) && ... && Condition(TypeN);

This is essentially what C++17’s std::conjunction does. So let’s just take that:

template<typename... InArgTypes>
struct conjunction : ttl::true_type {};

template<typename Type>
struct conjunction <Type> : Type {};

template<typename Type, typename... InArgTypes>
struct conjunction<Type, InArgTypes...> : ttl::conditional_t<(bool)Type::value, conjunction<InArgTypes...>, Type> {};

template<typename... InArgTypes>
static const bool conjunction_v = conjunction<InArgTypes...>::value;

static_assert(conjunction_v<>, "Expected conjunction_v<> to be true");
static_assert(conjunction_v<ttl::true_type>, "Expected conjunction_v<ttl::true_type> to be true");
static_assert(!conjunction_v<ttl::false_type>, "Expected conjunction_v<ttl::false_type> to be false");
static_assert(!conjunction_v<ttl::true_type, ttl::true_type, ttl::false_type>, "Expected conjunction_v<ttl::true_type, ttl::true_type, ttl::false_type> to be false");
static_assert(!conjunction_v<ttl::false_type, ttl::true_type, ttl::true_type>, "Expected conjunction_v<ttl::false_type, ttl::true_type, ttl::true_type> to be false");
static_assert(!conjunction_v<ttl::false_type, ttl::false_type, ttl::false_type>, "Expected conjunction_v<ttl::false_type, ttl::false_type, ttl::false_type> to be false");

We are building off ttl::true_type and ttl::false_type to make this type trait. These are effectively the same as std::true_type and std::false_type in C++, and provide a convenient way of representing true and false with types. conjunction will basically take a variadic pack, and recurse through the pack until it either finds a falsey type, or the end of the list. If it finds a falsey, it returns false. If it makes it to the end of the pack, it returns true.

Note

By “returns true” and “returns false”, I mean that the base class is true_type and false_type respectively, which gives us the values of true and false

Step 1.2 Defining all_same_fundamental_base_type

Using our previously defined conjunction, we can now use that to implement our type trait with some tests:

template<typename FirstType, typename... OtherTypes>
static const bool all_same_fundamental_base_type_v = 
    conjunction_v<
        ttl::integral_constant<bool, ttl::fundamental_type_traits<FirstType>::is_fundamental>,
        ttl::integral_constant<bool, 
            ttl::is_same_v<typename ttl::fundamental_type_traits<FirstType>::base_type, typename ttl::fundamental_type_traits<OtherTypes>::base_type>
        >...>;

struct Empty{};
struct HasMember{ int a; };

static_assert(all_same_fundamental_base_type_v<int>, "Expected all_same_fundamental_base_type_v<int> to be true");
static_assert(all_same_fundamental_base_type_v<int, int2, int3>, "Expected all_same_fundamental_base_type_v<int, int2, int3> to be true");
static_assert(!all_same_fundamental_base_type_v<int, float>, "Expected all_same_fundamental_base_type_v<int, float> to be false");
static_assert(!all_same_fundamental_base_type_v<int2, float4>, "Expected all_same_fundamental_base_type_v<int2, float4> to be false");
static_assert(!all_same_fundamental_base_type_v<Empty>, "Expected all_same_fundamental_base_type_v<Empty> to be false");
static_assert(!all_same_fundamental_base_type_v<HasMember>, "Expected all_same_fundamental_base_type_v<HasMember> to be false");
static_assert(!all_same_fundamental_base_type_v<int, HasMember>, "Expected all_same_fundamental_base_type_v<int, HasMember> to be false");

It is quite terse, but the logic in all_same_fundamental_base_type_v is exactly the same logic as shown in pseudocode above. It is building off two type traits in the Test Template Library:

  1. ttl::fundamental_type_traits
  2. ttl::is_same

Step 2: Calculating how many scalars we have in a pack

We need to know the number of scalars in the pack, so that we know what size of vector we are creating. Again we can lean on ttl::fundamental_type_traits for this. ttl::fundamental_type_traits defines a member (dim0) which gives the size of the vector. So we just need to be able to iterate through the type pack, and sum the result of evaluating ttl::fundamental_type_traits<T>::dim0 on every T in a variadic pack.

Info

Obviously this method won’t work for matrix types. I am focussing on vectors here, but everything I am writing about here could easily be extended to matrix types too.

One way to implement a metafunction that does this is the following:

template<typename... InArgTypes>
struct total_fundamental_scalars : ttl::integral_constant<uint, 0>{};

template<typename FirstType, typename... OtherTypes>
struct total_fundamental_scalars<FirstType, OtherTypes...> 
    : ttl::integral_constant<uint, ttl::fundamental_type_traits<FirstType>::dim0 + total_fundamental_scalars<OtherTypes...>::value>{};

template<typename... InArgTypes>
static const uint total_fundamental_scalars_v = total_fundamental_scalars<InArgTypes...>::value;

This is simply just recursively going through the pack and adding up all dim0s until we hit the empty pack base case. This works… but there is another way…

Step 2.1: Fold expressions in my HLSL?!

It turns out that the commit message for “Enable variadic templates” somewhat undersold what it was delivering. The commit also enabled fold expressions from C++17.

Warning

Fold expressions will compile, however there are things to be aware of with them.

  1. There are no proposals for fold expressions in TC-57… yet. You could view this as it not being “Standard HLSL”
  2. There are bugs with the implementation of fold expressions e.g. DXC accepts a unary fold over + with an empty pack, which C++17 requires to be ill-formed.
  3. To make the above 2 points clear, a new compiler warning (-Whlsl-fold-expressions) has been added which will be emitted when a fold expression is compiled.

If you are like me, and are happy to use fold expressions regardless of the potential issues then you can simplify total_fundamental_scalars_v considerably:

template<typename... InArgTypes>
static const uint total_fundamental_scalars_v = (uint(0) + ... + ttl::fundamental_type_traits<InArgTypes>::dim0);

A variable template is all that is needed, and we can just do regular addition without having to wrap the numbers in types. So much nicer!

Step 3: Defining the vec function signature

Now we get to the point where we can put the previous 2 steps to good use. vec will be our function that can make any vector type that we want based on the arguments that we provide. A first stab at the function might look like this:

template<typename FirstType, typename... OtherTypes>
vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>> 
vec(FirstType InFirst, OtherTypes... InOthers)
{
    using RetType = vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>>;

    RetType ret = (RetType)0;
    // Work this out later
    return ret;
}

Here we are defining the return type using the underlying type of the first argument passed in, and we calculate the number of scalar components using our total_fundamental_scalars_v type trait.

Step 3.1: auto in my HLSL?!

One of the more obvious issues with this function is that the return type is LONG. Like super long. And we have to say it twice. It would be 3 times if we didn’t use a type alias here. Luckily, we have also got auto return type deduction to look forward to in an upcoming version of DXC.

Info

Though I do appreciate that some people (like my lead) hate auto in C++, and those people may also groan a little now that HLSL is getting it. I, for one, love it.

Enable auto return types was submitted around the same time as variadic templates. With auto we only have to say this outrageously long return type once:

template<typename FirstType, typename... OtherTypes>
auto vec(FirstType InFirst, OtherTypes... InOthers)
{
    auto ret = (vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>>)0;
    // Work this out later
    return ret;
}

Even if you dislike auto, I think you would still like this particular use of it.

Step 4: Erroring when not all arguments have the same underlying type

One thing I said above is that I would like our function to reject any arguments that would result in an implicit conversion. However, with our function above, we could write:

float3 myVec = vec(1.0, 2, false);

and everything would compile just fine. And this might be fine for some people! But for me, I would like this to be a compile error. You can do an explicit cast if you want to coerce a bool into a float. As far as I can see there are 2 ways that you could do this.

Step 4.1: static_assert

template<typename FirstType, typename... OtherTypes>
auto vec(FirstType InFirst, OtherTypes... InOthers)
{
    static_assert(all_same_fundamental_base_type_v<FirstType, OtherTypes...>, "All types must have the same fundamental base type");

    auto ret = (vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>>)0;
    // Work this out later
    return ret;
}

This will work, and it will give us a nice error message when it fails:

hlsl.hlsl:91:9: error: static_assert failed "All types must have the same
  fundamental base type"
          static_assert(all_same_fundamental_base_type_v<FirstType, OtherTypes.
  ..>, "All types must have the same fundamental base type");
          ^
  ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
  hlsl.hlsl:185:20: note: in instantiation of function template specialization
  'vec<float, int, bool>' requested here
      float3 d = vec(1.0, 2, false);

However, there is a downside to static_assert that has always annoyed me… You can’t test if it will fail. As soon as a static_assert evaluates its condition to be false, the program fails compilation. So there is no way to actually test if the static_assert catches what it should catch.

Tip

This isn’t strictly true. You could create a test harness that does some string matching for expected static_assert failures… I would rather not, however. I would rather it be testable within the language itself.

This brings us on to the second way of erroring…

Step 4.2: SFINAE (Substitution Failure Is Not An Error)

We can change our vec function’s return type to be defined as the following:

template<typename FirstType, typename... OtherTypes>
ttl::enable_if_t<
    (all_same_fundamental_base_type_v<FirstType, OtherTypes...>),
    vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>>
> vec(FirstType InFirst, OtherTypes... InOthers)
{
    auto ret = (vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>>)0;
    // Work this out later
    return ret;
}

By doing this, there are some negatives:

  1. We lose our nice error message.
hlsl.hlsl:185:16: error: no matching function for call to 'vec'
      float3 d = vec(1.0, 2, false);
                 ^~~~~~~
  .//Test/TTL/type_traits.hlsli:73:44: note: candidate template ignored:
  disabled by 'enable_if' [with FirstType = float, OtherTypes = <int, bool>]
      using enable_if_t = typename enable_if<InCond, T>::type;

It’s an error message, and it does point to the bit of code that produces it, but it doesn’t say why it is an error. You have to discern that from the return type itself, which isn’t great.

  1. Our return type is even longer, and we have to say it twice.

However, we do gain some things:

  1. IntelliSense can produce an error squiggle -> if you are using a language server with your HLSL code you will see a red squiggle at the error site, because the function that you are calling doesn’t technically exist.

Tip

Shameless plug. If you want IntelliSense in VS or VSCode, then you can find my extension here

  1. We can now test to make sure that the error occurs without having to create a string matching test harness.

We can use pseudo-concepts to test whether a call to vec is valid or not:

struct CVectorInitable
{
    template<typename ExpectedVecType, typename... InArgTypes>
    __decltype(
        ttl::models_if_same<ExpectedVecType, __decltype(vec(ttl::declval<InArgTypes>()...))>()
    ) requires();
};

static_assert(!ttl::models<CVectorInitable, float3, float, int, float>::value, "Expected to not be able to call vec with different underlying types");

This will allow our shader to compile successfully, while also testing that we should not be able to call vec with a mixture of floats and ints! If we were to do the same as above but use a static_assert instead of SFINAE, we would get the following error:

hlsl.hlsl:91:9: error: static_assert failed "All types must have the same
  fundamental base type"
          static_assert(all_same_fundamental_base_type_v<FirstType, OtherTypes.
  ..>, "All types must have the same fundamental base type");

This happens because when a function template uses auto as its return type, then the function template must be instantiated to deduce its return type. To do that the static_assert must be evaluated, and as soon as that happens, it fires, and our compilation fails.

Step 4.3: Choosing between the two

What this choice comes down to is whether you favour good error messages over testability and tooling support. I will say that I will be favouring the testability route for the remainder of this article, so I will be using SFINAE. I don’t think it is a bad choice to go the static_assert route for better error messages. That is completely valid. In a perfect world, we could have the best of both worlds, but we don’t right now.

Tip

This is what C++20-style concepts promise. Readable error messages, with testable code. From my use of concepts so far, I do think they are good… but I really would like a way of formatting my own error messages on top of that… Something like an optional error handler for concepts would be cool!

Step 5: Implementing CopyToVector

Before we implement vec we need to implement CopyToVector. CopyToVector is the fundamental building block that we will use to copy the contents of each initializer into the destination vector.

template<typename DestType, typename SourceType>
ttl::enable_if_t<
    (ttl::fundamental_type_traits<SourceType>::dim0 >  1)
> CopyToVector(inout DestType InOutVec, SourceType InSource, inout uint InOutStartIndex)
{
    static const uint numIndices = ttl::fundamental_type_traits<SourceType>::dim0;
    for (uint i = 0; i < numIndices; ++i)
    {
        InOutVec[InOutStartIndex + i] = InSource[i];
    }
    InOutStartIndex += numIndices;
}

template<typename DestType, typename SourceType>
ttl::enable_if_t<
    (ttl::fundamental_type_traits<SourceType>::dim0 ==  1)
> CopyToVector(inout DestType InOutVec, SourceType InSource, inout uint InOutStartIndex)
{
    InOutVec[InOutStartIndex] = InSource;
    ++InOutStartIndex;
}

We need two versions of CopyToVector. The first is for vector types such as float2 and int4. The second is for scalar types such as float and int. We select which one should be taken by using SFINAE on the return type. In both cases we are accessing the vectors using operator[] which can look a little weird in HLSL. We are used to accessing vectors with swizzling operations like myVec.xy. There aren’t many use cases where using operator[] would be preferred. But this is one case where it is essential.

Step 6: Implementing vec

This is the point at which we can put all the previous steps together to actually implement our vec function. However, just like with the error handling of vec, we have a choice to make in the implementation of vec. This choice comes down to how we want to iterate through the list of initializers. We can’t iterate through variadic arguments with a for loop I am afraid.

Info

C++26 has template for that will allow you to do this. Perhaps we will get it in HLSL. Who knows!

So we have 2 options.

Step 6.1: Recursive function calls

Recursion is almost always the answer when it comes to processing anything to do with variadic templates. So what we can do is create a recursive function that will place each initializer into our destination vector using CopyToVector.

template<typename DestType, typename... SourceTypes>
void Cat(inout DestType InOutVec, uint InStartIndex)
{

}

template<typename DestType, typename SourceType, typename... OtherSourceTypes>
void Cat(inout DestType InOutVec, uint InStartIndex, SourceType InSource, OtherSourceTypes... InOtherSources)
{
    CopyToVector(InOutVec, InSource, InStartIndex);
    Cat(InOutVec, InStartIndex, InOtherSources...);
}

I am calling this function Cat which is short for “concatenate” which is basically what we are doing here. We are just concatenating all of these initializers into one vector. Cat has 2 overloads. The first overload is the base case when we have an empty argument list. In this case we do nothing because there is nothing left to do. The second overload will take an initializer from the front of the variadic arguments, call CopyToVector with it, and then call Cat again with the remaining arguments. Eventually this will lead to there being zero arguments left, which will stop the recursion by calling the first overload.

We can use this to complete our vec implementation:

template<typename FirstType, typename... OtherTypes>
ttl::enable_if_t<
    (all_same_fundamental_base_type_v<FirstType, OtherTypes...>),
    vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>>
> vec(FirstType InFirst, OtherTypes... InOthers)
{
    auto ret = (vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>>)0;
    Cat(ret, 0, InFirst, InOthers...);
    return ret;
}

And we can test it to make sure that it is working with some asserts:

ASSERT(AreEqual, vec(1.0, 2.0, 3.0), float3(1.0, 2.0, 3.0));
ASSERT(AreEqual, vec(1, 2, 3), int3(1, 2, 3));
ASSERT(AreEqual, vec(1, int2(2, 3)), int3(1, int2(2, 3)));

One thing to note here (and in CopyToVector) is that we are using inout to copy the destination vector in and out of the function. So for each initializer we are potentially doing 4 copies of the vector which isn’t great. Can we do better?

Step 6.2: Folding over the comma operator

An improvement to the above can be made by using the comma operator in a fold expression.

Tip

As mentioned above, the use of fold expressions does emit a warning. So even though it is technically possible, you might want to go with the recursive function call method.

The comma operator first evaluates the left-hand side expression, then the right, and returns the result of the right-hand side expression. With this knowledge we can change our implementation to the following:

template<typename FirstType, typename... OtherTypes>
ttl::enable_if_t<
    all_same_fundamental_base_type_v<FirstType, OtherTypes...>,
    vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>>
> vec(FirstType InFirst, OtherTypes... InOthers)
{
    auto ret = (vector<typename ttl::fundamental_type_traits<FirstType>::base_type, total_fundamental_scalars_v<FirstType, OtherTypes...>>)0;
    uint startIndex = 0;
    CopyToVector(ret, InFirst, startIndex);
    (CopyToVector(ret, InOthers, startIndex), ...);
    return ret;
}

We call CopyToVector on the first argument passed to us. And then fold over the rest. This removes the need to use Cat to recursively process each argument in the variadic pack. Instead the fold expression will stamp out a call to CopyToVector for each argument in the pack.

Comparing vec with the built-in initializers

At this point we have 2 possible implementations of vec:

  1. Recursive
  2. Fold expression

And these do work! They don’t behave exactly like the vector initializers we have today because they don’t allow flattening of struct and array objects, but they at least have all of the sane behaviour of vector initializers!

Info

Because we are inferring the width of the vector from the arguments, we are also not getting the same behaviour as writing float4(1.0). In this case we get a float4 with all components initialized to 1.0. If we were to do the same with vec we would get a vector<float, 1>. But, we could implement some functions called vec2, vec3 and vec4 to do the same thing. I shall leave that as an exercise to the reader!

However, now the question is, do they have the same performance? You will note that our implementations make use of:

  1. recursive function calls
  2. loops
  3. inout which copies in and copies out. It is not a reference.

These features of our implementations might make people think twice about using them instead of vector initializers. So let’s do a comparison to see if they do in fact yield worse DXIL.

For this test, I am going to use Microsoft’s MiniEngine. This repo has an open source set of shaders that we can use as the test cases. I am going to compile a subset of these shaders as they are at -O3. Then I will do the same by replacing every vector initializer with each of the 2 implementations of vec. I will use shader reflection and the generated DXIL to compare each method. The following is my DXC invocation for each shader:

-E ENTRYPOINT -T Xs_6_8 -HV 202x
   -O3
   -enable-16bit-types
   -Wno-hlsl-fold-expressions
   -Wno-c++14-extensions
   -Qstrip_debug -Qstrip_reflect

where:

  1. ENTRYPOINT is the entry point of the shader.
  2. X is the shader stage. e.g. cs_6_8 for a compute shader using shader model 6.8.

Instruction counts

First up is just querying what the instruction count is of each shader compilation. Since the only thing we are changing is the code that creates vectors, this should give us a good indication into any differences that there might be in the methods.

ShaderNativeRecursionFold expression
CameraVelocityCS.hlsl888888
MotionBlurFinalPassCS.hlsl155155155
GenerateMipsLinearOddCS.hlsl244244244
GenerateMipsGammaOddCS.hlsl364364364
ScreenQuadCommonVS.hlsl171717
TextAntialiasPS.hlsl292929

What is clear here is that in an -O3 build there is no difference in the number of instructions between using the native vector initializers, and either of our two vec implementations. That is a pretty great start!

Info

We get much more interesting numbers when compiling with -Od… Though those results are probably worth another blog by themselves!

Comparing the DXIL

We know that the instruction count doesn’t change, which is a pretty good indicator that the compiler can see through all of our extra code, and optimize it all away. However, that doesn’t necessarily mean that the generated code is the same. So, let’s just look at the generated DXIL to see if there are any differences.

ShaderNative vs RecursionNative vs Fold expression
CameraVelocityCS.hlslByte-IdenticalByte-Identical
MotionBlurFinalPassCS.hlslDifferentDifferent
GenerateMipsLinearOddCS.hlslDifferentDifferent
GenerateMipsGammaOddCS.hlslDifferentDifferent
ScreenQuadCommonVS.hlslByte-IdenticalByte-Identical
TextAntialiasPS.hlslByte-IdenticalByte-Identical

In some cases, we are seeing byte-identical DXIL which is brilliant. It means that it is entirely possible for our new vec implementation to just be a drop-in replacement for regular vector initializers… in the cases where vec would have the same behaviour as a native initializer that is.

BUT, there are equally as many cases where we are getting different DXIL generated, so let’s have a look into what is changing.

MotionBlurFinalPassCS.hlsl

The code in question that generates different DXIL is:

float3 UnpackVelocity(packed_velocity_t Velocity)
{
    // return float3(
    return vec(
        UnpackXY(Velocity & 0x3FF),
        UnpackXY((Velocity >> 10) & 0x3FF),
        UnpackZ(Velocity >> 20));
}

When using the float3 initializer, the DXIL initializes ret.x first and then ret.y. For our vec implementations, it does it in the opposite order. This difference in ordering causes more slight differences further down the shader, as the different orderings change the local identifiers of the values. In my compilation, the first initializer gets the identifier %27 and the second gets %34. So these effectively get swapped further down the shader when they are used. So there are no technical differences in the instructions produced. There are just differences in the values that the instructions operate on.

This doesn’t look like something that could cause differences in performance but I haven’t actually done any performance tests to back this conclusion up. As to why this happens, I have no idea.

Tip

You could find out by stepping through DXC while it is compiling this shader. I shall leave that as an exercise to the reader!

A wild guess is that perhaps the differences in code hit the optimization passes in different ways, so parts of the code get optimized at different times, and therefore yield slightly different, but comparable DXIL. But again… no idea.

GenerateMipsX.hlsl

I am putting GenerateMipsLinearOddCS.hlsl and GenerateMipsGammaOddCS.hlsl together, because they are different for the same reason. They both include and use the same code that causes the difference in DXIL:

groupshared float gs_R[64];
groupshared float gs_G[64];
groupshared float gs_B[64];
groupshared float gs_A[64];

float4 LoadColor( uint Index )
{
    // return float4( gs_R[Index], gs_G[Index], gs_B[Index], gs_A[Index]);
    return vec( gs_R[Index], gs_G[Index], gs_B[Index], gs_A[Index]);
}

In this case, the float4 initializer loads from groupshared memory in RGBA order. Our vec implementations cause the reverse to happen. They load from groupshared memory in ABGR order. So again, it seems to be a different order of operations, and again I don’t think this should cause a difference in performance. However, I would be less shocked about this causing differences in performance seeing as groupshared memory is involved.

Just like with MotionBlurFinalPassCS.hlsl this ordering causes the local identifiers to swap around, which causes similar differences later on down in the DXIL. So again, the instructions remain the same, but the identifier numbers that they operate on differ according to the order in which LoadColor evaluated them.

Wrapping up

This was a wild ride of maybe not very conventional shader code, so if you made it this far, thanks! We started off with the issue of TC-57 potentially breaking any code that makes use of vector initializers like float4(dir.xyz, otherFloat). This is obviously something that we should not do.

We then went on to implement vec which shows that we can at least implement a vector initializing function ourselves in modern HLSL that can do the vector initialization that we all know and love. So in the event that we do actually get a version of HLSL where native vector initialization is broken, we can at least have something that does the same thing, or at least close to the same thing in most scenarios.

This vec function can be implemented in a couple of ways, but both ways seem to compile down to the exact same (or nearly exact same) DXIL as native initializers. This demonstrates that even right now, vec could be used as a drop-in replacement for vector initializers (in most use cases) if people wanted to use it.

Seeing as we have proven that we can write this in HLSL, and that constructors are scheduled to come in HLSL 202y, I feel like we should define vector types as having constructors which operate in a similar way in the language. Obviously no sane person would actually implement them in the exact same way as I have here, but we should use the “as if” rule to get the behaviour we want, and make sure that compile times do not suffer.

I had a lot of fun writing this article. I learned a lot in terms of the new capabilities of the language, and perhaps some of this article will make it into a proposal for TC-57 so that we do not break vector initialization.

If you want to see the code that was written to create this article, you can find it here. It provides a test harness, and will grab the same pinned version of DXC as I was using for this blog, so that you should get identical results to me.

Last updated on