🌐 Translate:
Table of contents

In the previous post, I gave a brief introduction to the advantages and disadvantages of multithreaded programming and the architecture of the Job System. This post introduces Unity’s different Job interfaces and their basic usage. I hope that after reading it you can write not only multithreaded code, but also code that actually performs parallel computation.

Contents

  1. Terminology
  2. The first Job—implementing IJob
  3. Implementing IJobFor
  4. IJobParallelFor
  5. Comparing results
  6. Further performance optimization—Burst Compiler

Is using the Job System and parallel computation the same thing as DOTS?

DOTS—Data-Oriented Technology Stack—is Unity’s data-oriented programming package. It includes the ECS architecture, the C# Job System, and the Burst Compiler. These three pieces can exist independently, so even if a project does not use ECS, the C# Job System and Burst Compiler can be used in any existing project.

Unity’s documentation lists three different Job interfaces: IJob, IJobFor, and IJobParallelFor. This post introduces each one while implementing a simple demo.

Left: additional Job interfaces in the Job package. Right: Job interfaces in the current official documentation.

These three Jobs are very commonly used, but a quick look through the Job package reveals several other interfaces. Job may not have reached version 1.0 yet, so not every API is in the official documentation. Some of the missing interfaces are very useful; I plan to introduce them in the next post because this one is already long.

Terminology

Before writing code, let us quickly review some terms in the Job System.

Job

A Job is a unit of work executed on a worker thread, similar to a function that can run on another thread, except that a Job is a struct.

Job System

The Job System contains a manager that creates worker threads based on the current hardware. It places Jobs waiting to run into its queue and schedules them on suitable idle worker threads. It also manages dependencies and ordering between Jobs.

Safety System

The previous post discussed the disadvantages of multithreaded programming. To avoid race conditions, data passed to a Job is always passed by value (a value type), preventing code on different threads from modifying the same data through a reference.

Native Container

The Safety System’s value-only approach means that each Job has a copy of the data rather than the original data. To overcome this limitation, Unity defines shared memory that can be read and stored across threads: the Native Container.

A Native Container is a C#-level wrapper around a pointer to unmanaged memory. It allows the main thread and Jobs to access the same data. Unity also reports errors for code that could produce a race condition when using a Native Container. The built-in containers include NativeArray, NativeList, NativeQueue, NativeHashMap, and so on.

Remember that Native Container memory is unmanaged and therefore not controlled by C#’s GC; it must be disposed of manually. The Job System therefore has the additional benefit of avoiding GC allocations.

Because it is unmanaged memory, a Native Container can hold only blittable value types. bool and char receive special Unity-specific handling.

NativeContainer Allocator

Creating each Native Container requires an Allocator type. The allocator describes the lifetime of the memory:

  • Temp—a lifetime shorter than one frame; fastest allocation; cannot be passed between Jobs.
  • TempJob—a lifetime of four frames; the second-fastest allocation.
  • Persistent—no lifetime limit; lasts until manually disposed; slowest allocation.

Except for Temp allocations, all Native Containers must be disposed of after use or they will leak memory.

JobHandle

Scheduling each Job returns a JobHandle struct. We can use a JobHandle to check whether the Job has completed and pass it as an argument when scheduling the next Job, creating a dependency between Jobs (for example, Job B starts only after Job A finishes).

Common API—Schedule()

Passes the Job to the Job System manager for scheduling. With job.Schedule(), the manager assigns it to a worker thread other than the main thread. With job.Run(), the Job executes directly on the main thread.

Common API—Complete()

After scheduling a Job, we must wait for it to finish before reading the Native Container it wrote on the main thread. Calling JobHandle.Complete() makes the main thread wait until all Jobs that the handle depends on have finished, after which execution continues. You can think of this as a synchronization point that brings a multithreaded task back to the main thread.

Burst Compiler

Black magic; I will explain it later, but it is extremely good.

For now, just use it.

A traditional program running on the main thread

To make the comparison easier, I first created a simple test scene that creates 15 × 15 × 15 cubes when the script starts.

Each frame, the cubes move over time according to Perlin noise and a sine value.

Compared with a typical MonoBehaviour script, this program already includes a basic optimization: all cube updates are gathered into one script and performed in a single for loop. For issues caused by distributing logic across many Update() calls, see Unity’s official blog, 10000 Update() Calls.

Let us inspect the program with the Profiler.

Aside from rendering and physics (the built-in cubes have Box Colliders, which we can ignore), the logic in Update() is the largest CPU cost in the scene. The blue section in the upper-right image is the time spent by the for loop in Update(). The worker threads are idle, showing that all computation is concentrated on the main thread.

The first Job—implementing IJob

Let us change the main-thread program above to run through a Job. Starting with the simplest Job, a basic Job requires three steps:

  • Create a struct that implements IJob.
  • Define the Native Container member variables that must be passed between threads.
  • Implement the Execute function required by IJob.

The code we want the Job to execute goes inside Execute(). Since we want a single Job to perform all the logic that was previously in Update(), simply move the code from Update() into Execute().

The code has not changed much. We cannot directly manipulate a cube’s transform because it is a reference, so every cube’s displacement is stored in a NativeArray<Vector3>. Unity APIs cannot be called from another thread, so values such as Time.deltaTime and Time.time are copied into the Job when it is created on the main thread.

Because this Job needs two Native Arrays, prepare them at the start of the program:

The only difference from the original is that the Vector3[] used in Update() is replaced with NativeArray<Vector3>. For readability, I prefix Native Container variables in the code with m_native.

In each Update(), create a BackgroundNoiseJob, pass in the required data (including the two Native Containers), and call Schedule().

Do not immediately call m_jobHandle.Complete() after Schedule().

To let the Job run in the background for a while, wait until LateUpdate() to call m_jobHandle.Complete(). Once execution reaches the code below Complete(), it is safe to read the calculated offsets array and update all transform positions.

Remember to call Dispose() on every Native Container when the program finishes.

The update logic runs inside a Job on another worker thread

The scene looks exactly the same, but the Profiler shows that the computation has moved from the main thread to a worker thread. The first Job is complete.

Implementing IJobFor

The next interface, IJobFor, is a Job that internally calls Execute() in a for loop and passes the corresponding loop index as an argument. The original Execute() becomes Execute(int index).

Here is the previous IJob rewritten as IJobFor:

Because IJobFor passes the loop index in, there is no need to write another for loop inside Execute(). Use the index to access the corresponding data in the Native Arrays.

Schedule() has one more parameter than the IJob version: an integer representing the length of the loop. If there are N cubes, pass N; the program calls Execute(0), Execute(1), Execute(2), … Execute(N-1).

m_jobHandle = noiseJob.Schedule(m_cubes.Length, m_jobHandle);

The rest of the code is exactly the same as the first IJob.

Implementing true parallel computation

Current Profiler result

At this point we have implemented two Jobs and moved the heavy calculation from Update() on the main thread to another worker thread. But looking closely at the Profiler screenshot, most worker threads are still idle.

Ideally, every worker thread receives a portion of the task and all data is processed as quickly as possible. To use these idle worker threads, we need true parallel computation.

The third Job, IJobParallelFor, makes this easy.

IJobParallelFor

First, change the previous IJobFor implementation to implement IJobParallelFor.

That is all! In fact, IJobFor and IJobParallelFor are nearly identical. The only difference is that the ParallelFor version packages each Execute(i) call as a smaller Job; the Job System Manager distributes these smaller Jobs among worker threads for scheduling and execution.

This diagram shows the distribution more clearly:

Behind the scenes, the Job System packages each Execute in ParallelFor as a smaller Job and sends them to different worker threads

When calling Schedule(), note that IJobParallelFor takes one more parameter than IJobFor: the batch size. Batch size affects both the number of native Jobs created and how widely they are distributed.

Examples

  • Batch size 1: each batch contains one Execute() call.
  • Batch size 2: each batch contains two Execute() calls. The first batch contains Execute(0) and Execute(1), the second contains Execute(2) and Execute(3), and so on.

In principle we want Jobs to be distributed as widely as possible (a small batch size). However, excessive distribution can sometimes hurt performance between threads, so it is generally best to start at 1 and adjust gradually based on the situation.

Finally, let us inspect the Profiler after using an IJobParallelFor Job.

The upper-right screenshot shows that many worker threads now have tasks. Each worker thread finishes its Job in about 0.25 ms, and after the main thread calls Complete() it barely waits at all. The pressure on the main thread has been completely relieved.

Comparing performance

For 15 × 15 × 15 = 3,375 cubes, let us compare the execution time of the noise and sine calculations in each implementation from the main thread’s perspective:

  • Traditional calculation in Update() (main thread only)—1.49 ms
  • IJob (one worker thread)—1.51 ms (waiting for the Job takes 1.37 ms; the main thread calculates for another 0.14 ms after it completes)
  • IJobFor (one worker thread)—1.49 ms (waiting for the Job takes 1.37 ms; the main thread calculates for another 0.12 ms after it completes)
  • IJobParallelFor (parallel calculation on multiple worker threads)—0.4 ms (waiting for the Job takes 0.16 ms; the main thread calculates for another 0.24 ms)

Apart from IJobParallelFor, which splits the work across multiple worker threads, the other Job implementations produce no significant CPU difference. Even when all calculations move into one Job, if the main thread needs the result it still has to wait for the Job to finish. The worker thread also does not run faster than the main thread, so the original 1.49 ms of main-thread computation simply becomes 1.37 ms of waiting for the Job.

IJobParallelFor distributes the work across more worker threads, so the task finishes sooner and main-thread waiting falls from 1.37 ms to 0.16 ms.

Double buffering can avoid making the main thread wait for the Job. Prepare two copies of the data: one for the Job to calculate and one cache copied out when each Job finishes. The main-thread code reads only the cache, so it does not wait for the current calculation. The data on the main thread is simply a snapshot from the previous completed Job.

Further performance optimization—Burst Compiler

Multithreading and parallel computation alone are not enough to make Unity DOTS outstanding. Any Job in the Job System can add a [BurstCompile] attribute above its struct. The Burst Compiler compiles the Job code into native code optimized for the CPU and friendly to SIMD.

C# is a slow language compared with C and C++. Even though IL2CPP generates platform-specific C++ code for builds, it still passes through layers of C# wrapping and is difficult to compare with an application written directly in C++. Code compiled by Burst can even be faster than code written purely in C++.

Add [BurstCompile] to every Job

After adding it, test every case again:

  • Traditional calculation in Update() (main thread only)—1.50 ms. (No change is expected because the Update() calculation does not use a Job and is unrelated to Burst.)
  • IJob: 1.51 ms → 0.31 ms (Job time 1.37 ms → 0.11 ms)
  • IJobFor: 1.49 ms → 0.33 ms (Job time 1.49 ms → 0.13 ms)
  • IJobParallelFor: 0.4 ms → 0.23 ms (Job time 0.16 ms → 0.04 ms)

For the user, the only change is adding [BurstCompile] to the Job, yet the calculation becomes ten times faster. It is practically black magic.

Depending on the code architecture and whether the math is written in a more SIMD-friendly way, performance can be improved further. Combining the Job System with Burst Compiler is definitely the key to extracting CPU performance in Unity.

Unity’s vector math is notoriously slow; see the GDC talk about Playdead’s performance optimization for Inside. Unity also provides the Mathematics package, which replaces Math and includes operations specially optimized for Burst Compiler, providing another speedup.

What next?

This post introduced three Jobs: IJob, IJobFor, and IJobParallelFor. Basic multithreading tasks—sending a calculation to another worker thread or calculating N data items in parallel across multiple worker threads—are now possible. Real programs are more complicated, however, and dependencies between Jobs become increasingly complex.

In the next post I will introduce other useful Job interfaces that are not in the official documentation, such as forBatch, forDefer, and ForFilter.

Once these are covered, we can start writing genuinely data-oriented code. Combined with GPU Instancing APIs and more advanced algorithms such as trees, it is possible to create a truly high-performance program.

Sample project

For the code, see this repository: GitHub - EricHu33/Examples-Job-System

References

C# Job System

Custom job types

Job Types in the Unity Job System

Job System API explanation