Getting started with Unity DOTS — Part 3: Burst Compiler

Nikolay Karagyozov
7 min readMay 30, 2020

This is the final part of the Unity DOTS introduction series. You can also check out the Entity Component System introduction and the C# Job System introduction.

What is Burst?

Burst is a compiler that translates from IL/.NET bytecode to highly optimized native code using LLVM.

IL/.Net

The C# you write compiles to the Intermediate Language (or IL, CIL, MSIL). This is a platform independant language whose instructions are executed by a .NET / Mono runtime. Runtimes typically JIT (Just-In-Time) compile IL.

LLVM

LLVM provides infrastructure for building compilers. You can use it to make your own programming language that gets compiled down to native code. Its API provides implementations of common patterns found in languages — global variables, functions, etc. You generate your instructions in a format called IR (Intermediate Representation). You can then either choose to compile it to a standalone binary (Ahead-Of-Time) or perform JIT compilation. IR and IL are analogous concepts.

LLVM was designed for portability and can output your code to multiple platforms and architectures as well as provide architecture-specific optimizations.

Popular languages that use LLVM include Swift, Rust, Kotlin Native and more.

Burst

The Burst compiler is part of Unity DOTS. It is designed to work with the C# Job System. Basically, it compiles your jobs to improve their performance.

Note: Burst is very good at optimizing math from Unity.Mathematics by taking advantage of SIMD.

You can add Burst to your project with the Unity Package Manager.

How it works

Burst has two main compilation strategies— AOT and JIT.

Just-in-time (JIT) compilation

When you are working on your projects in the editor, Burst does JIT compilation. That means it starts compiling your job code the first time you reach it.

If you do this asynchronously, your code will use the default mono JIT until the Burst version is compiled.

On the other hand, if you choose to do it synchronously, execution will wait for the Burst version to be compiled and use it to continue. In this case you will not run any code under the default mono JIT.

Ahead-of-time (AOT) compilation

If you are building your project into a Standalone Player, Burst will compile everything in advance, hence the name Ahead-of-time.

However, currently AOT compilation requires access to linker tools for each respective platform that is built.

AOT compilation builds a DLL that the Job System runtime loads and starts using the first time a Burst-compiled method is invoked.

Note: For iOS, a static library is generated instead.

Usage

Burst can be easily enabled, however there are some requirements jobs have to meet to get it working.

Enabling Burst

Enabling Burst compilation of a particular job happens with an attribute.

[BurstCompile]
struct ExampleJob : IJob { ... }

Compile options

The [BurstCompile] attribute supports multiple options that affect the behaviour of the compiler.

FloatMode

FloatMode can be:

  • Strict — this is the default. No floating point optimizations are performed.
  • Fast optimizes operations by converting them to algebraically equivalent ones. For example, y/x is calculated as 1/x * y. Assumes you do not have any NaNs or infinities. May use fused instructions (madd instead of multiply and then add ). These optimizations sacrifice precision.

You can pass FloatMode as a parameter:

[BurstCompile(FloatMode = FloatMode.Fast)]

FloatPrecision

FloatPrecision can be:

  • Medium 3.5 ULP accuracy. Considered acceptable for most tasks.
  • High 1 ULP accuracy.

Note: ULP stands for unit in the last place or unit of least precision. In the hardware floating point numbers are not continuous, because we do not have infinite memory. They only provide precision up to a point. The difference between two ‘consecutive’ floating point numbers is equal to 1 ULP .

You can pass FloatPrecision as a parameter:

[BurstCompile(FloatPrecision = FloatPrecision.High)]

CompileSynchronous

By default, Burst compiles asynchronously. To use synchronous compilation set the CompileSynchronous flag to true .

[BurstCompile(CompileSynchronous = true)]
struct ExampleJob : IJob { ... }

Limitations

Compiling jobs with Burst often results in free performance. However, Burst has a few limitations and requirements you have to keep in mind if you want to use it.

  • You cannot use reference types — that includes classes, strings, etc.
  • You cannot access GameObject / Component code — ECS is a better fit for Burst-compiled jobs.
  • You cannot write to static variables.
  • There is no try / catch support.

Features

Here is a list of features Burst does support:

  • You can use structs and value types.
  • Great integration with ECS.
  • There is support for generics.
  • You can use throw, but only in the Editor.
  • Great performance booster.

Logging

Burst jobs support Debug.Log , Debug.LogWarning, Debug.LogError .

Though managed strings are not supported, you can still log string literals:

Debug.Log("This is a message");

and also interpolated strings:

int number = 123;
Debug.Log($"This number is {number}");

Common patterns

Here are some patterns and workarounds you can apply when integrating Burst into an existing project.

Converting matrices to arrays

The NativeArray type only supports one dimensional arrays. Therefore, if you need to keep your data in a matrix, you have to convert it to an array. So if previously you had:

var matrix = new int[height, width];
...
matrix[x, y] = ...;

Now you must convert it to:

var matrix = new NativeArray<int>(
height * width,
Allocator.Persistent
);
...
matrix[x * height + y] = ...;

The “Copy / Burst / Copy” pattern

The term “Copy / Burst / Copy” was first coined during Lee Hammerton’s 2019 Unite Copenhagen talk on Burst. The pattern is used when you have existing code that uses classes and / or other unsupported C# features and want to integrate it with Burst.

The main idea is the following:

  1. You create a job that would execute your logic.
  2. You create a struct that holds only the data you need for the job and strips away everything else that is unnecessary. The struct should contain only blittable types and native containers.
  3. Copy the data into the new struct.
  4. Pass the struct as job parameter.
  5. Schedule the job.
  6. Burst compiles it.
  7. Save the job result it a native container.
  8. Copy the native container back to the original classes.

Before

public List<Transform> GetData() { ... }public Vector3 IntensiveCalculation(List<Transform> values)
{
for (int i = 0; i < values.Count; i++)
{
values[i].position = Vector3.zero;
}
}
...// in Update()var data = GetData();
IntensiveCalculation(data);

After

[BurstCompile]
struct ExampleJob : IJob
{
public NativeArray<Vector3> Data;
public void Execute()
{
for (int i = 0; i < Data.Length; i++)
{
Data[i].position = Vector3.zero;
}
}
}
public List<Transform> GetData() { ... }...// in Update()var data = GetData();var jobData = new NativeArray<Vector3>(data.Count, Allocator.TempJob);// Copy
for (int = 0; i < jobData.Length; i++)
{
jobData[i].position = data[i].position
}
// Burst
var job = new ExampleJob
{
Data = jobData
}
job.Run();// Copy
for (int = 0; i < jobData.Length; i++)
{
data[i].position = jobData[i].position
}

Note: Although this may seem like more work at first, the performance boost you gain from Burst is enough to justify it. This is way faster than not using Burst at all.

Profiling

When benchmarking or measuring job performance you can use Unity’s profiler to find out how long it takes for your jobs to execute.

To start measuring use:

UnityEngine.Profiling.Profiler.BeginSample("SampleName");

To stop use:

UnityEngine.Profiling.Profiler.EndSample();

And sandwich your logic between these two functions:

UnityEngine.Profiling.Profiler.BeginSample("HealPlayer");HealPlayer();UnityEngine.Profiling.Profiler.EndSample();

You can view the profiler data by going to Window > Analysis > Profiler.

Burst inspector

The Burst inspector allows you to view all the jobs that can be compiled. You can also then check the generated native code.

Burst inspector

Note: Jobs that are grayed out have not been compiled with Burst.

Debugging Burst

When your build crashes runtime you get an output stack trace. You can copy the hash in orange and use it to find the job that is causing the problem.

Runtime crash stack trace

Look for a file name lib_burst_generated.txt and search inside for the hash.

The Execute() method should contain information about the job that caused the crash.

From then on, you can disable Burst compilation and debug the problem.

Conclusion

This is it for the overview of the Burst compiler. This is the last part of the Unity DOTS introduction series, you can check the previous one below:

DOTS: Part 1 — Entities

DOTS: Part 2 — C# Job System

Resources

Lee Hammerton’s 2019 Unite Copenhagen talk

What is LLVM?

Burst User Guide

--

--