(Russian translation from English by Maxim Voloshin)

Примечание: Assertions или утверждения, по тексту зовутся просто ассерты т.к. Это устоявшееся и широко употребляемое понятие в русскоязычной среде.

Ассерты невероятно нужный инструмент, но работают ли они в задачах Burst компилятора? Сегодня мы это узнаем!

Ассерты Unity

Для начала попробуем использовать систему ассертов, поставляемую Unity и находящуюся в области видимости UnityEngine.Assertions:

[BurstCompile]
struct UnityAssertJob : IJob
{
    public int A;
 
    public void Execute()
    {
        Assert.IsTrue(A != 0);
    }
}

Откроем Unity 2019.1.8f1 с установленным Burst 1.0.4, Burst инспектор показывает на macOS такой код:

ret

Весь код метода Execute скомпилировался в эквивалент оператора return. Вызов функции Assert.IsTrue и сравнение A != 0 было удалено без каких-либо предупреждений или ошибок. Это опасно, ведь мы ожидали увидеть рабочие ассерты, что кажется разумным предположением.

Ручные Ассерты

Учитывая, что Ассерты Unity не работают, давайте соберем свою собственную версию Assert.IsTrue. В конце концов это довольно тривиально:

[BurstCompile]
struct DirectAssertJob : IJob
{
    public int A;
 
    public void Execute()
    {
        AssertIsTrue(A != 0);
    }
 
    private static void AssertIsTrue(bool truth)
    {
        if (!truth)
        {
            throw new Exception("Assertion failed");
        }
    }
}

Теперь Burst инспектор показывает, что сгенерированы актуальные инструкции: (закомментировано мной)

    cmp     dword ptr [rdi], 0   # Сравнение A и 0
    je      .LBB0_2              # Если равно, перейти к .LBB0_2
    ret                          # В противном случае, выйти
.LBB0_2:
    movabs  rax, offset .Lburst_abort_Ptr   # Код бросания исключения...
    mov     rax, qword ptr [rax]
    movabs  rdi, offset .Lburst_abort.error.id
    movabs  rsi, offset .Lburst_abort.error.message
    jmp     rax

Как мы видели ранее, это инструкции, сгенерированные чтобы бросить исключение. Я специально опустил секцию данных, которая включает сообщение об ошибке и ID т.к. это не важно в данном случае.

Внешние Ассерты

Добавлять код ассертов каждый раз очень утомительно, давайте попробуем переместить его в статический класс, который может быть переиспользован в любой задаче:

static class OutsideAssert
{
    public static void IsTrue(bool truth)
    {
        if (!truth)
        {
            throw new Exception("Assertion failed");
        }
    }
}
 
[BurstCompile]
struct OutsideAssertJob : IJob
{
    public int A;
 
    public void Execute()
    {
        OutsideAssert.IsTrue(A != 0);
    }
}

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

    cmp     dword ptr [rdi], 0   # Сравнение A и 0
    je      .LBB0_2              # Если равно, перейти к .LBB0_2
    ret                          # В противном случае, выйти
.LBB0_2:
    movabs  rax, offset .Lburst_abort_Ptr   # Kод бросания исключения...
    mov     rax, qword ptr [rax]
    movabs  rdi, offset .Lburst_abort.error.id
    movabs  rsi, offset .Lburst_abort.error.message
    jmp     rax
Условия и #if

Раз уж у нас все прекрасно получается, попробуем сделать следующий шаг к созданию хорошей функции ассерта. Определяющий аспект в реализации функции ассерта – тот факт, что она выполняется только для нескольких типов сборок. Unity предоставляет директиву препроцессора UNITY_ASSERTIONS, для того чтобы сообщить, когда должны вызываться ассерты. Изначально, это означает, что они выполняются в редакторе, но не в релизных сборках, но это может быть переопределено. Теперь добавим классическое комбо из [Conditional] и #if для того, чтобы убрать само тело функции и все ее вызовы:

static class OutsideAssertConditionalAndIf
{
    [Conditional("UNITY_ASSERTIONS")]
    public static void IsTrue(bool truth)
    {
#if UNITY_ASSERTIONS
        if (!truth)
        {
            throw new Exception("Assertion failed");
        }
#endif
    }
}
 
[BurstCompile]
struct OutsideAssertConditionalAndIfJob : IJob
{
    public int A;
 
    public void Execute()
    {
        OutsideAssertConditionalAndIf.IsTrue(A != 0);
    }
}

Открыв Burst инспектор, мы увидим, что вернулись к тому, с чего начинали со встроенными ассертами Unity:

ret

Учитывая, что мы получили это в редакторе, а не в релизной сборке, нормально было бы ожидать увидеть код функции ассертов.

Только условие

Попробуем это вылечить. Для начала уберем #if, таким образом, тело функции останется, но ее вызовы будут удалены. Вызовы, на самом деле, самая важная часть, поэтому мы можем пойти на небольшой компромисс:

static class OutsideAssertConditional
{
    [Conditional("UNITY_ASSERTIONS")]
    public static void IsTrue(bool truth)
    {
        if (!truth)
        {
            throw new Exception("Assertion failed");
        }
    }
}
 
[BurstCompile]
struct OutsideAssertConditionalJob : IJob
{
    public int A;
 
    public void Execute()
    {
        OutsideAssertConditional.IsTrue(A != 0);
    }
}

И снова, Burst инспектор показывает, что код функции ассертов был удален:

ret
Only #if

На этот раз, зайдем с другой стороны и удалим [Conditional] вместо #if. Это оставит вызовы функции, но они будут пустыми. К счастью, Burst удалит все вызовы т.к. поймет, что они бессмысленны:

static class OutsideAssertIf
{
    public static void IsTrue(bool truth)
    {
#if UNITY_ASSERTIONS
        if (!truth)
        {
            throw new Exception("Assertion failed");
        }
#endif
    }
}
 
[BurstCompile]
struct OutsideAssertIfJob : IJob
{
    public int A;
 
    public void Execute()
    {
        OutsideAssertIf.IsTrue(A != 0);
    }
}

Теперь инструкции снова видны в Burst инспекторе!

    cmp     dword ptr [rdi], 0   # Сравнение A и 0
    je      .LBB0_2              # Если равно, перейти к .LBB0_2
    ret                          # В противном случае, выйти
.LBB0_2:
    movabs  rax, offset .Lburst_abort_Ptr   # Kод бросания исключения...
    mov     rax, qword ptr [rax]
    movabs  rdi, offset .Lburst_abort.error.id
    movabs  rsi, offset .Lburst_abort.error.message
    jmp     rax
Подтверждение работоспособности

Осталось убедиться, что у нас есть действительно рабочий ассерт. Для этого напишем крошечный скрипт, который запускает задачу. Мы оставим A в его изначальном значении 0, которое вызовет ассерт.

class TestScript : MonoBehaviour
{
    void Start()
    {
        new OutsideAssertIfJob().Run();
    }
}

Запустив его в редакторе, мы получим следующее исключение:

Exception: Assertion failed
OutsideAssertIf.IsTrue (System.Boolean truth) (at Assets/TestScript.cs:116)
OutsideAssertIfJob.Execute () (at Assets/TestScript.cs:129)
Unity.Jobs.IJobExtensions+JobStruct`1[T].Execute (T& data, System.IntPtr additionalPtr, System.IntPtr bufferRangePatchData, Unity.Jobs.LowLevel.Unsafe.JobRanges& ranges, System.Int32 jobIndex) (at /Users/builduser/buildslave/unity/build/Runtime/Jobs/Managed/IJob.cs:30)
Unity.Jobs.LowLevel.Unsafe.JobsUtility:Schedule_Injected(JobScheduleParameters&, JobHandle&)
Unity.Jobs.LowLevel.Unsafe.JobsUtility:Schedule(JobScheduleParameters&)
Unity.Jobs.IJobExtensions:Run(OutsideAssertIfJob) (at /Users/builduser/buildslave/unity/build/Runtime/Jobs/Managed/IJob.cs:43)
TestScript:Start() (at Assets/TestScript.cs:137)

И вот, что мы увидим в файле ~/Library/Logs/Unity/Player.log, если запустим релизную сборку для macOS :

 

Хорошо, что мы получили пустоту, поскольку это означает, что ассерт был удален как и должно происходить в релизной сборке.

Для того, чтобы убедиться, что функция ассерта работает в сборках с участием Burst компилятора, например, когда используется BuildOptions.ForceEnableAssertions, закомментируем #if:

static class OutsideAssertIf
{
    public static void IsTrue(bool truth)
    {
//#if UNITY_ASSERTIONS
        if (!truth)
        {
            throw new Exception("Assertion failed");
        }
//#endif
    }
}

Теперь запустим новый билд для macOS и посмотрим Player.log:

System.Exception: Assertion failed
This Exception was thrown from a job compiled with Burst, which has limited exception support. Turn off burst (Jobs -> Enable Burst Compiler) to inspect full exceptions & stacktraces.
 
(Filename:  Line: -1)

Теперь мы доказали, что функция ассерта работает и в редакторе, и в билде для macOS с использованием Burst компилятора, не зависимо от того включена их поддержка или выключена. Мы также подтвердили что #if не включает в сборку код, когда объявлен UNITY_ASSERTIONS.

Проверка производительности

Последнее подтверждение, которое мы должны получить это то, что Burst удалит вызов функции IsTrue в случае, если #if оставит ее тело пустым. Чтобы проверить это, удалим его вручную:

static class OutsideAssertIf
{
    public static void IsTrue(bool truth)
    {
    }
}

Теперь Burst инспектор показывает только return:

ret

Вызов пустой функции не был сгенерирован, поэтому не будет дополнительных накладных расходов, когда UNITY_ASSERTIONS не определен.

Заключение

К сожалению, ассерты Unity не работают совместно с Burst. И отвратительно, что об этом нет никакой информации в документации и даже компилятор не предупреждает, что они не будут работать. Burst просто удаляет их, не обеспечивая вообще никаких проверок на ошибки. Вероятно, это связано с тем, что они использовали [Conditional] который работал аналогично в наших собственных тестах.

К счастью, мы можем довольно просто реализовать свои собственные функции ассертов. Такие функции работают и в редакторе, и в Burst. Они полностью удаляются когда ассерты отключены, и не влияют на производительность в релизных сборках.