Assertions are an incredibly handy tool, but do they work in Burst-compiled jobs? Today we’ll find out!

Update: A Russian translation of this article is available.

Unity Assert

Let’s start by trying to use the assertion system provided by Unity in the UnityEngine.Assertions namespace:

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

Opening up Unity 2019.1.8f1 with Burst 1.0.4 installed, the Burst Inspector shows us this assembly for macOS:

ret

So all that Execute compiled to was the equivalent of a return statement. The Assert.IsTrue function call and the A != 0 comparison was removed without any compiler warning or error and simply doesn’t take place. That’s dangerous if we were expecting the assert to work, which seems like a reasonable assumption.

Direct Assert

Given that Unity’s assertions aren’t working, let’s build our own version of Assert.IsTrue. It’s a trivial effort, after all:

[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 Inspector now shows some actual instructions being generated: (annotated by me)

    cmp     dword ptr [rdi], 0   # Compare A and 0
    je      .LBB0_2              # If equal, go to the code after .LBB0_2
    ret                          # Else, return
.LBB0_2:
    movabs  rax, offset .Lburst_abort_Ptr   # Exception throwing code...
    mov     rax, qword ptr [rax]
    movabs  rdi, offset .Lburst_abort.error.id
    movabs  rsi, offset .Lburst_abort.error.message
    jmp     rax

As we’ve seen before, these are the instructions that get generated to throw an exception. I’ve omitted the data section of the output that includes the error message and ID as it’s not really relevant here.

Outside Assert

Directly building the assert code into every job is tedious, so let’s try to move it out into a static class so it’s reusable by all our jobs:

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

Looking at this in the Burst Inspector, we see the exact same assembly output:

    cmp     dword ptr [rdi], 0   # Compare A and 0
    je      .LBB0_2              # If equal, go to the code after .LBB0_2
    ret                          # Else, return
.LBB0_2:
    movabs  rax, offset .Lburst_abort_Ptr   # Exception throwing code...
    mov     rax, qword ptr [rax]
    movabs  rdi, offset .Lburst_abort.error.id
    movabs  rsi, offset .Lburst_abort.error.message
    jmp     rax
Conditional and #if

With this success under our belts, let’s try for the next step toward making this a good assert function. The defining aspect of an assert function is that it only executes in some types of builds. Unity provides the UNITY_ASSERTIONS preprocessor symbol to tell us when assertions should be run. Typically this means they execute in the editor but not in production builds, but this can be overridden. So let’s add the classic combo of [Conditional] and #if to strip out all calls to the assert function and the body of the assert function itself:

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

Opening up the Burst Inspector, we see that we’re back to where we were with Unity’s asserts:

ret

Given that we’re looking at this in the editor, we would expect to see the assertion code.

Conditional Only

To try to remedy this, let’s take out the #if so the body of the assert function remains but the calls to it are removed. The calls are really the most important part, so this should be an acceptable compromise:

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

Again, Burst Inspector shows that the assert was removed:

ret
Only #if

Let’s take another shot at a remedy and remove the [Conditional] instead of the #if. This will leave in the call to the assertion function, but it will be empty. Hopefully Burst will then remove the call altogether after it realizes the function call serves no purpose:

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

Now Burst Inspector shows us the assert instructions again!

    cmp     dword ptr [rdi], 0   # Compare A and 0
    je      .LBB0_2              # If equal, go to the code after .LBB0_2
    ret                          # Else, return
.LBB0_2:
    movabs  rax, offset .Lburst_abort_Ptr   # Exception throwing code...
    mov     rax, qword ptr [rax]
    movabs  rdi, offset .Lburst_abort.error.id
    movabs  rsi, offset .Lburst_abort.error.message
    jmp     rax
Confirming Functionality

Let’s confirm that we have a working assert. To do so, let’s make a tiny script that runs the job. We’ll leave A at its default 0 value which will trigger the assert.

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

Running this in the editor, we get the following exception:

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)

Running a non-development macOS build, we see this in ~/Library/Logs/Unity/Player.log:

 

That nothingness is a good thing because the point is that the assert should be removed.

To make sure that the assertion code works in Burst-compiled builds such as when BuildOptions.ForceEnableAssertions is used, let’s comment out the #if:

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

Now let’s run another macOS build and check 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)

So we’ve now confirmed that the assertion function works in editor and in a Burst-compiled macOS build, regardless of whether assertions are enabled or disabled. We’ve also confirmed that the #if is being respected and is effectively removing the assertion when UNITY_ASSERTIONS isn’t defined.

Confirming Efficiency

Finally, the last confirmation we need is that Burst will remove the function call to IsTrue when the #if empties out its body. To do that, let’s manually delete the body:

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

Burst Inspector now shows just a return:

ret

No function call to the empty function was generated, so we won’t have any overhead at all when UNITY_ASSERTIONS isn’t defined.

Conclusion

Unfortunately, Unity assertions don’t work with Burst. Worse, there’s no warning by either the documentation or the compiler to alert us that they don’t work. They’re simply removed by Burst, providing no error-checking at all. This is likely due to their use of [Conditional] which didn’t work in our own tests either.

Fortunately, we can trivially build our own assert functions. Such functions work in the editor and in Burst-compiled builds. They’re completely removed when assertions are disabled, so there’s zero overhead to using them in production builds.