(Russian translation from English by Maxim Voloshin)

Мы знаем как использовать объединения(union) в C#, но работает ли это с новым Burst компилятором? Сегодня мы это протестируем и увидим сможет ли он справиться с некоторыми более продвинутыми особенностями кастомизации структур C#!

Простое объединение

Давайте начнем с объединения, которое может содержать или int, или float. Это может быть полезно для увеличения производительности при битовых операциях без выполнения какого-либо преобразования. Это особенно полезно для представления битов float в виде int для таких задач как сериализация. Вот как выглядит наше объединение:

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

Ключевыми моментами являются атрибуты [StructLayout] и [FieldOffset]. Указывая LayoutKind.Explicit, мы получаем контроль над тем, где биты, представляющие поля структуры, расположены в памяти, которую занимает структура. Мы указываем расположение памяти как смещение в байтах, от начала структуры. Передавая 0 в обоих случаях, мы говорим, что оба поля должны занять те же самые биты.

Теперь мы можем обращаться к битам структуры типа intиспользуя union.I или float используя union.F. Это работает и на чтение и на запись. Для того чтобы протестировать это, создадим задачу для Burst компилятора, которая делает следующее:

[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);
    }
}

Все, что мы делаем здесь – записываем значение int и считываем его, затем пишем значение float и читаем его как int. Мы, также проверяем размер структуры, чтобы подтвердить, что она не может содержать одновременно int и float и на самом деле использует одну и ту же память для обоих типов.

Теперь запустим задачу:

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();

Мы получили на выходе:

123
1078523331
4

Это именно то, что мы ожидали и это означает, что простые объединения работают в Burst!

  • Записали 123 как int и успешно прочитали как int со значением 123
  • Записали 3.14f как float и прочитали как int в виде: 1078523331
  • Размер структуры равен 4, и это означает, что int и float используют одни и те же четыре байта

Теперь заглянем под капот и посмотрим в какой ассемблерный код Burst скомпилировал эту задачу. Вот, что показывает Burst инспектор:

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

Все что мы видим здесь это mov инструкции. Мы не видим никаких преобразований между int и float. Это именно то, что мы ожидаем от подобных объединений!

Объединение с тегом

Теперь немного увеличим сложность объединения добавив поле “тег”. Это поле, которое отображает в каком состоянии находится объединение. В данном случае, тег указывает чем оно является int или float. Мы можем определить тег как перечисление, тип констант которого int:

enum IntFloatTaggedUnionType : int
{
    Int,
    Float
}

Мы могли бы сделать тоже самое используя bool, но этот пример показывает общепринятую технику, используемую для перечислений с больше чем двумя состояниями.

Наконец, определим структуру объединения:

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

Это очень похоже на первое объединение, за исключением того, что из-за тега int и float сдвинуты на 4 байта. Тег находится в начале структуры и не перекрывает собой часть структуры, выполняющую функцию объединения. Это означает, что структура частично ведет себя как объединение и частично как структура, отсюда и дополнительная сложность.

Теперь напишем задачу протестировать наше объединение:

[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);
    }
}

Мы делаем почти такой же тест, что и с простым объединением, если не считать чтение и запись тега Type.

И небольшой скрипт, запускающий задачу:

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();

Запустим и посмотрим вывод:

123
1078523331
Int
Float
8

Это снова, именно то, что мы ожидали увидеть. Те же 123 и 1078523331 значения соответствуют 123 и 3.14f, так же как с простым объединением. Они не были повреждены присутствием поля с тегом. Сам тег, после присвоения, был корректно прочитан как значения перечисления Int и Float. Размер равен 8, что соответствует тегу (4) и объединению (4).

Все отлично работает, осталось проверить ассемблерный код в Burst инспекторе:

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

Снова мы видим несколько mov инструкций без преобразований между int и float. Все кроме константы sizeof является индексом NativeArray или объединением выровненных значений по 4 байта.

Заключение

Объединения работают в Burst так же хорошо как и в IL2CPP или Mono. Мы можем успешно использовать объединения обоих видов: простые и с тегами. Сложные операции, такие как получение битов float в виде int, тривиальны с использованием объединений. Также немаловажно, что при использовании парадигмы или-или достигается экономия памяти.