We’ve seen how to add unions to C#, but does this work with the new Burst compiler? Today we’ll put it to the test and see if it can handle some of the more advanced struct customization features in C#!

Update: A Russian translation of this article is available.

A Simple Union

Let’s start with a union that can be either an int or a float. This can be useful to improve performance for treating the bits as either type without performing any conversion. It’s an especially handy tool for getting the bits of a float in the form of an int for tasks such as serialization. Here’s how the union looks:

[StructLayout(LayoutKind.Explicit)]
struct IntFloatUnion
{
    [FieldOffset(0)] public int I;
    [FieldOffset(0)] public float F;
}

The key ingredients are the [StructLayout] and [FieldOffset] attributes. By specifying LayoutKind.Explicit, we take manual control over where the bits representing the struct’s fields are located within the memory that the struct takes up. We express those memory location as byte offsets from the start of the struct. By passing 0 for both, we’re saying that both fields should occupy the exact same bits.

Now we can treat the struct’s bits as an int by using union.I or as a float by using union.F. This works for both reading and writing. To test this out, let’s create a Burst-compiled job that does just that:

[BurstCompile]
unsafe struct UnionTestJobJob : IJob
{
    [ReadOnly] public int InIntValue;
    [ReadOnly] public float InFloatValue;
    [WriteOnly] public NativeArray<int> OutIntValue;
    [WriteOnly] public NativeArray<int> OutFloatValue;
    [WriteOnly] public NativeArray<int> OutSize;
 
    public void Execute()
    {
        IntFloatUnion u;
        u.I = InIntValue;
        OutIntValue[0] = u.I;
        u.F = InFloatValue;
        OutFloatValue[0] = u.I;
        OutSize[0] = sizeof(IntFloatUnion);
    }
}

All we’re doing here is writing the int value and reading it back, then writing the float value and reading that back as an int. We also take the size of the struct to confirm that its not big enough to be holding both the int and the float and is really reusing the bits for both types.

Now let’s run the job like this:

NativeArray<int> outIntValue = new NativeArray<int>(1, Allocator.TempJob);
NativeArray<int> outFloatValue = new NativeArray<int>(1, Allocator.TempJob);
NativeArray<int> outSize = new NativeArray<int>(1, Allocator.TempJob);
new UnionTestJobJob
{
    InIntValue = 123,
    InFloatValue = 3.14f,
    OutIntValue = outIntValue,
    OutFloatValue = outFloatValue,
    OutSize = outSize
}.Run();
Debug.Log(outIntValue[0]);
Debug.Log(outFloatValue[0]);
Debug.Log(outSize[0]);
outIntValue.Dispose();
outFloatValue.Dispose();
outSize.Dispose();

We get this output:

123
1078523331
4

That’s what we expected, which means the simple union is working in Burst!

  • The write of 123 as an int was successfully read back as the int with value 123
  • The write of 3.14f as a float was read back as the int version: 1078523331
  • The size is 4, meaning the int and float are sharing the same four bytes

Now let's take a peek under the hood and see what assembly code Burst compiled the job down to. Here's what Burst Inspector shows:

mov     rax, qword ptr [rdi + 8]
mov     rcx, qword ptr [rdi + 64]
mov     rdx, qword ptr [rdi + 120]
mov     esi, dword ptr [rdi]
mov     dword ptr [rax], esi
mov     eax, dword ptr [rdi + 4]
mov     dword ptr [rcx], eax
mov     dword ptr [rdx], 4

All we see here are mov instructions. We're not seeing any conversion between int and float. This is what we were looking for from a union like this!

A Tagged Union

Now let's increase the complexity of the union a little bit and add a "tag" field. This is a field that indicates what state the union is in. For this case, the tag will indicate whether the union is an int or a float. We can define the tag as an int-based enum like this:

enum IntFloatTaggedUnionType : int
{
    Int,
    Float
}

We could have gotten away with a bool, but this shows the general technique used for enums with more than two states.

Next, let's define the union struct:

[StructLayout(LayoutKind.Explicit)]
struct IntFloatTaggedUnion
{
    [FieldOffset(0)] public IntFloatTaggedUnionType Type;
    [FieldOffset(4)] public int I;
    [FieldOffset(4)] public float F;
}

This is very similar to the first union, except that the int and float have been shifted back to 4 to make room for the tag. The tag now resides at the start of the struct and doesn't overlap its bits with the union part of the struct. This means the struct is partially a union and partially a struct, hence the additional complexity.

Now let's write a job to try out this union:

[BurstCompile]
unsafe struct TaggedUnionTestJobJob : IJob
{
    [ReadOnly] public int InIntValue;
    [ReadOnly] public float InFloatValue;
    [ReadOnly] public NativeArray<IntFloatTaggedUnionType> InTypeValues;
    [WriteOnly] public NativeArray<int> OutIntValue;
    [WriteOnly] public NativeArray<int> OutFloatValue;
    [WriteOnly] public NativeArray<IntFloatTaggedUnionType> OutTypeValues;
    [WriteOnly] public NativeArray<int> OutSize;
 
    public void Execute()
    {
        IntFloatTaggedUnion u;
        u.I = InIntValue;
        u.Type = InTypeValues[0];
        OutIntValue[0] = u.I;
        OutTypeValues[0] = u.Type;
        u.F = InFloatValue;
        u.Type = InTypeValues[1];
        OutFloatValue[0] = u.I;
        OutTypeValues[1] = u.Type;
        OutSize[0] = sizeof(IntFloatTaggedUnion);
    }
}

We're doing almost exactly the same test here as with the simple union, except that we're also reading and writing the Type tag.

To test this job, let's write a little non-job code:

NativeArray<IntFloatTaggedUnionType> inTypeValues
    = new NativeArray<IntFloatTaggedUnionType>(2, Allocator.TempJob);
inTypeValues[0] = IntFloatTaggedUnionType.Int;
inTypeValues[1] = IntFloatTaggedUnionType.Float;
NativeArray<int> outIntValue = new NativeArray<int>(1, Allocator.TempJob);
NativeArray<int> outFloatValue = new NativeArray<int>(1, Allocator.TempJob);
NativeArray<IntFloatTaggedUnionType> outTypeValues
    = new NativeArray<IntFloatTaggedUnionType>(2, Allocator.TempJob);
NativeArray<int> outSize = new NativeArray<int>(1, Allocator.TempJob);
new TaggedUnionTestJobJob
{
    InIntValue = 123,
    InFloatValue = 3.14f,
    InTypeValues = inTypeValues,
    OutIntValue = outIntValue,
    OutFloatValue = outFloatValue,
    OutTypeValues = outTypeValues,
    OutSize = outSize
}.Run();
Debug.Log(outIntValue[0]);
Debug.Log(outFloatValue[0]);
Debug.Log(outTypeValues[0]);
Debug.Log(outTypeValues[1]);
Debug.Log(outSize[0]);
inTypeValues.Dispose();
outIntValue.Dispose();
outFloatValue.Dispose();
outTypeValues.Dispose();
outSize.Dispose();

Now let's run it and see the output:

123
1078523331
Int
Float
8

This is, again, exactly what we wanted to see. The same 123 and 1078523331 values are present for the 123 and 3.14f inputs, just like with the simple union. They haven't been corrupted by the presence of the tag field. Speaking of the tag, we're clearly reading it back out as the Int and Float enumerators that we set. The size is 8, which accounts for the tag (4) and the union part (4).

Everything worked great, so let's finish up by diving into the assembly code shown in the Burst Inspector:

mov     rax, qword ptr [rdi + 8]
mov     r10, qword ptr [rdi + 64]
mov     rdx, qword ptr [rdi + 176]
mov     r9, qword ptr [rdi + 120]
mov     r8, qword ptr [rdi + 232]
mov     esi, dword ptr [rdi]
mov     ecx, dword ptr [rax]
mov     dword ptr [r10], esi
mov     dword ptr [rdx], ecx
mov     ecx, dword ptr [rdi + 4]
mov     eax, dword ptr [rax + 4]
mov     dword ptr [r9], ecx
mov     dword ptr [rdx + 4], eax
mov     dword ptr [r8], 8

Again, we're just seeing a bunch of mov instructions with no type conversion between int and float. Everything except the sizeof constant is just an index into some NativeArray or the union by a 4-byte aligned value.

Conclusion

Unions work just as well in Burst as they do in IL2CPP and Mono. We can use both simple unions and tagged unions to good effect. Otherwise tricky operations like getting the bits of a float in the form of an int are trivial with a union. Memory savings using an either-or paradigm are also easily within grasp.