Adding Unions to Burst
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 anint
was successfully read back as theint
with value123
- The write of
3.14f
as afloat
was read back as theint
version:1078523331
- The size is
4
, meaning theint
andfloat
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.
#1 by JamesM on July 8th, 2019 ·
What does the union show up as in C++? Type punning via unions in c++ is undefined behavior (even though most compilers support it)
#2 by jackson on July 8th, 2019 ·
Burst compiles IL directly to machine code, so the union is never in C++ and therefore never subject to any of its undefined behavior.