Assertions in Burst
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.
#1 by David Wu on July 3rd, 2019 ·
One thing worth mentioning is that the conditional form can be a lot better than commenting out the body if the compiler isn’t clever enough.
I.e.:
Assert.IsTrue( Calculation(), BuildErrorString() )
In this case, even if the body is stripped, you might still end up performing the calculation and building the error string if those functions are not trivial to inline or not side effect free.
#2 by jackson on July 4th, 2019 ·
Very good point! The article didn’t test whether Burst would always strip out the code used to create the arguments to the function. This is indeed one of the benefits of
[Conditional]
that’s sadly not supported. We’ve seen that theA != 0
comparison was stripped out in the article, but I’d recommend looking in the Burst Inspector to confirm that any larger calculations such as yourCalculation()
function call are getting removed.#3 by Petr HouÅ¡ka on July 9th, 2020 ·
Hi, firstly amazing post! Secondly, I’ve looked into how to add custom message and it’s been more elaborate process than expected, hence I wrote this short post, might be useful:
https://devblog.petrroll.cz/asserts-with-custom-messages-in-burst-unity/
#4 by jackson on July 9th, 2020 ·
Thanks! Glad to see you were able to get custom messages working.
#5 by Azmi Shah on October 5th, 2021 ·
Thank you for the post. How about wrapping the [Conditional] attribute like so:
It keeps the benefit of [Conditional] stripping out argument computation on release while working normally in development.
#6 by jackson on October 9th, 2021 ·
It seems like that would be kind of like writing a compile-time version of this:
The inner
if
, equivalent to the[Conditional]
, would never be true so the code would always be stripped out.