(Russian translation from English by Maxim Voloshin)

Сегодня мы вернемся к основам и увидим как Burst компилирует некоторые фундаментальные особенности языка: оператор switch и ref параметры… с удивительными результатами!

Обычный switch

Давайте начнем с написания задачи с простым switch:

[BurstCompile]
struct PlainSwitchJob : IJob
{
    [ReadOnly] public int InVal;
    [WriteOnly] public NativeArray<int> OutVal;
 
    public void Execute()
    {
        int outVal;
        switch (InVal)
        {
            case 10: outVal = 20; break;
            case 20: outVal = 40; break;
            case 30: outVal = 50; break;
            case 40: outVal = 60; break;
            default: outVal = 100; break;
        }
        OutVal[0] = outVal;
    }
}

Теперь, посмотрим в Burst Инспекторе Unity 2019.1.10f1 и Burst 1.1.1 как эта задача была скомпилирована для 64 битной macOS:

    mov     r10d, dword ptr [rdi]
    cmp     r10d, 20
    mov     ecx, 40
    mov     r8d, 20
    mov     esi, 20
    cmovg   esi, ecx
    mov     edx, 60
    cmovle  edx, ecx
    mov     r9d, 30
    mov     ecx, 10
    cmovg   ecx, r9d
    mov     eax, 50
    cmovle  eax, r8d
    cmp     r10d, esi
    mov     esi, 100
    cmove   esi, edx
    cmp     r10d, ecx
    cmove   esi, eax
    mov     rax, qword ptr [rdi + 8]
    mov     dword ptr [rax], esi
    ret

Здесь мы видим серию инструкций сравнений (cmp) с InVal (r10d) и условного перехода зависящего от того, был ли результат больше (cmovg), меньше либо равно (cmovle), или равно (cmove). По сути это серия if и else, за исключением того, что нет инструкций перехода.

Switch с оператором when

Теперь воспользуемся фичей C# 7 и добавим к банальному case условие с ключевым словом when:

[BurstCompile]
struct WhereSwitchJob : IJob
{
    [ReadOnly] public int InVal;
    [WriteOnly] public NativeArray<int> OutVal;
 
    public void Execute()
    {
        int outVal;
        switch (InVal)
        {
            case int _ when InVal == 10: outVal = 20; break;
            case int _ when InVal == 20: outVal = 40; break;
            case int _ when InVal == 30: outVal = 50; break;
            case int _ when InVal == 40: outVal = 60; break;
            default: outVal = 100; break;
        }
        OutVal[0] = outVal;
    }
}

Обратите внимание, что в этой версии, само условие и результат идентичны предыдущему коду. Увидим ли мы идентичный код, сгенерированный Burst? Давайте проверим:

    mov     ecx, dword ptr [rdi]
    add     ecx, -10
    cmp     ecx, 30
    ja      .LBB0_5
    mov     eax, 20
    movabs  rdx, offset .LJTI0_0
    movsxd  rcx, dword ptr [rdx + 4*rcx]
    add     rcx, rdx
    jmp     rcx
.LBB0_2:
    mov     eax, 40
.LBB0_6:
    mov     rcx, qword ptr [rdi + 8]
    mov     dword ptr [rcx], eax
    ret
.LBB0_5:
    mov     eax, 100
    mov     rcx, qword ptr [rdi + 8]
    mov     dword ptr [rcx], eax
    ret
.LBB0_3:
    mov     eax, 50
    mov     rcx, qword ptr [rdi + 8]
    mov     dword ptr [rcx], eax
    ret
.LBB0_4:
    mov     eax, 60
    mov     rcx, qword ptr [rdi + 8]
    mov     dword ptr [rcx], eax
    ret

Результаты определенно не идентичны! В данном случае, Burst сгенерировал таблицу переходов для того чтобы выполнить только одно сравнение с InVal. Обратите внимание, что код OutVal[0] = inVal был продублирован в каждом case, слегка раздувая код.

Не ref параметры

Настало время посмотреть как Burst обращается с большими параметрами, когда их передают без использования ref:

[StructLayout(LayoutKind.Explicit)]
struct BigStruct
{
    [FieldOffset(128)] public int Val;
}
 
[BurstCompile]
struct NonRefJob : IJob
{
    [ReadOnly] public BigStruct Val1;
    [ReadOnly] public BigStruct Val2;
    [WriteOnly] public NativeArray<int> Result;
 
    public void Execute()
    {
        int f = Foo(Val1);
        f += Foo(Val2);
        Result[0] = f;
    }
 
    int Foo(BigStruct val)
    {
        int outVal;
        switch (val)
        {
            case BigStruct b when b.Val == 10: outVal = 20; break;
            case BigStruct b when b.Val == 20: outVal = 40; break;
            case BigStruct b when b.Val == 30: outVal = 50; break;
            case BigStruct b when b.Val == 40: outVal = 60; break;
            case BigStruct b when b.Val == 50: outVal = 70; break;
            case BigStruct b when b.Val == 60: outVal = 80; break;
            case BigStruct b when b.Val == 70: outVal = 90; break;
            case BigStruct b when b.Val == 80: outVal = 110; break;
            default: outVal = 100; break;
        }
        return outVal;
    }
}

Структура BigStruct сделана большой путем смещения Val на 128 байт. Это дает размер в 132 байта, что, несомненно, довольно много.

Далее, вы делаем два вызова Foo с BigStruct в качестве обычного, не ref параметра. Оператор switch внутри Foo – расширенная версия того, что мы только что видели. Добавляя значительное количество команд в Foo и вызывая ее дважды с различными параметрами, мы гарантируем, что Burst не встроит(inline) ее код и не завалит наш тест, полностью удалив параметры.

Теперь можно посмотреть во что это все скомпилировалось:

; Execute
    push    rbp
    push    r14
    push    rbx
    sub     rsp, 288
    mov     rbx, rdi
    movups  xmm0, xmmword ptr [rbx]
    movups  xmm1, xmmword ptr [rbx + 16]
    movups  xmm2, xmmword ptr [rbx + 32]
    movups  xmm3, xmmword ptr [rbx + 48]
    movups  xmm4, xmmword ptr [rbx + 64]
    movups  xmm5, xmmword ptr [rbx + 80]
    movups  xmm6, xmmword ptr [rbx + 96]
    movups  xmm7, xmmword ptr [rbx + 112]
    mov     eax, dword ptr [rbx + 128]
    movaps  xmmword ptr [rsp], xmm0
    movaps  xmmword ptr [rsp + 16], xmm1
    movaps  xmmword ptr [rsp + 32], xmm2
    movaps  xmmword ptr [rsp + 48], xmm3
    movaps  xmmword ptr [rsp + 64], xmm4
    movaps  xmmword ptr [rsp + 80], xmm5
    movaps  xmmword ptr [rsp + 96], xmm6
    movaps  xmmword ptr [rsp + 112], xmm7
    mov     dword ptr [rsp + 128], eax
    movabs  r14, offset ".LNonRefJob.Foo(NonRefJob* this, BigStruct val)_D56DDBCB4218697B"
    mov     rdi, rsp
    call    r14
    mov     ebp, eax
    movups  xmm0, xmmword ptr [rbx + 132]
    movups  xmm1, xmmword ptr [rbx + 148]
    movups  xmm2, xmmword ptr [rbx + 164]
    movups  xmm3, xmmword ptr [rbx + 180]
    movups  xmm4, xmmword ptr [rbx + 196]
    movups  xmm5, xmmword ptr [rbx + 212]
    movups  xmm6, xmmword ptr [rbx + 228]
    movups  xmm7, xmmword ptr [rbx + 244]
    mov     eax, dword ptr [rbx + 260]
    movaps  xmmword ptr [rsp + 144], xmm0
    movaps  xmmword ptr [rsp + 160], xmm1
    movaps  xmmword ptr [rsp + 176], xmm2
    movaps  xmmword ptr [rsp + 192], xmm3
    movaps  xmmword ptr [rsp + 208], xmm4
    movaps  xmmword ptr [rsp + 224], xmm5
    movaps  xmmword ptr [rsp + 240], xmm6
    movaps  xmmword ptr [rsp + 256], xmm7
    mov     dword ptr [rsp + 272], eax
    lea     rdi, [rsp + 144]
    call    r14
    add     eax, ebp
    mov     rcx, qword ptr [rbx + 264]
    mov     dword ptr [rcx], eax
    add     rsp, 288
    pop     rbx
    pop     r14
    pop     rbp
    ret
 
; Foo
    mov     ecx, dword ptr [rdi + 128]
    add     ecx, -10
    cmp     ecx, 70
    ja      .LBB1_10
    mov     eax, 20
    movabs  rdx, offset .LJTI1_0
    movsxd  rcx, dword ptr [rdx + 4*rcx]
    add     rcx, rdx
    jmp     rcx
.LBB1_2:
    mov     eax, 40
    ret
.LBB1_10:
    mov     eax, 100
    ret
.LBB1_3:
    mov     eax, 50
    ret
.LBB1_4:
    mov     eax, 60
    ret
.LBB1_5:
    mov     eax, 70
    ret
.LBB1_6:
    mov     eax, 80
    ret
.LBB1_7:
    mov     eax, 90
    ret
.LBB1_8:
    mov     eax, 110
.LBB1_9:
    ret

Тело функции Foo похоже на предыдущий код, поскольку оно все еще использует таблицу переходов.

Внутри Execute, мы видим две инструкции call, показывающие, что Foo была вызвана дважды и не была встроена. Для того чтобы сделать эти вызовы, Execute помещает в стек BigStruct целиком.

ref параметры

На этот раз передадим BigStruct как ref параметр:

[BurstCompile]
struct RefJob : IJob
{
    [ReadOnly] public BigStruct Val1;
    [ReadOnly] public BigStruct Val2;
    [WriteOnly] public NativeArray<int> Result;
 
    public void Execute()
    {
        int f = Foo(ref Val1);
        f += Foo(ref Val2);
        Result[0] = f;
    }
 
    int Foo(ref BigStruct val)
    {
        int outVal;
        switch (val)
        {
            case BigStruct b when b.Val == 10: outVal = 20; break;
            case BigStruct b when b.Val == 20: outVal = 40; break;
            case BigStruct b when b.Val == 30: outVal = 50; break;
            case BigStruct b when b.Val == 40: outVal = 60; break;
            case BigStruct b when b.Val == 50: outVal = 70; break;
            case BigStruct b when b.Val == 60: outVal = 80; break;
            case BigStruct b when b.Val == 70: outVal = 90; break;
            case BigStruct b when b.Val == 80: outVal = 110; break;
            default: outVal = 100; break;
        }
        return outVal;
    }
}

Все, что мы сделали – добавили ключевое слово ref, теперь посмотрим как это повлияет на вывод Burst:

; Execute
    push    rbp
    push    r14
    push    rbx
    mov     rbx, rdi
    movabs  r14, offset ".LRefJob.Foo(RefJob* this, ref BigStruct val)_765ED9C01E5BA6A1"
    call    r14
    mov     ebp, eax
    lea     rdi, [rbx + 132]
    call    r14
    add     eax, ebp
    mov     rcx, qword ptr [rbx + 264]
    mov     dword ptr [rcx], eax
    pop     rbx
    pop     r14
    pop     rbp
    ret
 
; Foo
    mov     ecx, dword ptr [rdi + 128]
    add     ecx, -10
    cmp     ecx, 70
    ja      .LBB1_10
    mov     eax, 20
    movabs  rdx, offset .LJTI1_0
    movsxd  rcx, dword ptr [rdx + 4*rcx]
    add     rcx, rdx
    jmp     rcx
.LBB1_2:
    mov     eax, 40
    ret
.LBB1_10:
    mov     eax, 100
    ret
.LBB1_3:
    mov     eax, 50
    ret
.LBB1_4:
    mov     eax, 60
    ret
.LBB1_5:
    mov     eax, 70
    ret
.LBB1_6:
    mov     eax, 80
    ret
.LBB1_7:
    mov     eax, 90
    ret
.LBB1_8:
    mov     eax, 110
.LBB1_9:
    ret

Foo не изменилась, однако, в этот раз Execute гораздо короче. Вместо записи каждого байта BigStruct в стек, Foo просто использует BigStruc который уже присутствует.

Заключение

Когда дело доходит до оператора switch, Burst не может понять, что обычные case идентичны варианту с when и, соответственно, не может сгенерировать одинаковый код. Он, точно так же, не добавляет автоматически ref к большим параметрам, даже если это было бы гораздо более эффективно. Эта ситуация доказывает необходимость почаще смотреть в Burst Инспектор, чтобы убедиться, что мы получаем именно тот код, который должен выполнять процессор.