Unity 2018.1 brought us two asynchronous code systems. First, there’s C# 5’s async and await keywords in conjunction with the Task and Task<T> types. Second, there’s Unity’s own C# jobs system. There are many differences, but which is faster? Today’s article puts them to the test to find out!

The goal of today’s test is to stress both asynchronous systems. As such, we’ll minimize the amount of work performed in each unit of work so that the system overhead is maximized.

To this end, we’ll use this super simple function with C#’s async and await keywords:

public static Task MyTask(NativeArray<int> result)
{
      return Task.Run(() => result[0]++);
}

This function just creates a Task, but doesn’t run it. When the task is invoked, it runs the lambda delegate we provided. All the delegate does is increment the first element of a given NativeArray<int>. That’s about as little work as we can encapsulate into a C# Task and will allow us to compare directly to Unity’s jobs system.

Next, we need a way to run many of these tasks. This is where the async and await keywords come in:

public static async Task RunTasks(NativeArray<int> result, int num)
{
      for (int i = 0; i < num; ++i)
      {
            await MyTask(result);
      }
}

This function uses await on MyTask which schedules it for execution and then stops execution of RunTasks until MyTask is finished. As an async function, RunTasks is itself a Task that can be run asynchronously.

Now let’s look at how to test this. First, we need to set up the test:

const int numRuns = 1000;
NativeArray<int> result = new NativeArray<int>(1, Allocator.Temp);

Then we can actually run the test. We’ll again use Task.Run but this time call Wait on the resulting Task. This blocks execution of the thread until the task is finished.

var sw = System.Diagnostics.Stopwatch.StartNew();
Task task = Task.Run(() => RunTasks(result, numRuns));
task.Wait();
long asyncTicks = sw.ElapsedTicks;

We now have the amount of time to sequentially run a bunch of tasks with async and await.

Next, let’s define the equivalent of a Task in Unity’s C# job system:

public struct MyJob : IJob
{
      public NativeArray<int> Result;
 
      public void Execute()
      {
            Result[0]++;
      }
}

Just as with MyTask, MyJob simply takes a NativeArray<int> and increments its first element. Now let’s make the equivalent of RunTasks to run these jobs:

public static void RunJobs(NativeArray<int> result, int num)
{
      MyJob job = new MyJob { Result = result };
      for (int i = 0; i < num; ++i)
      {
            JobHandle handle = job.Schedule();
            JobHandle.ScheduleBatchedJobs();
            handle.Complete();
      }
}

This function creates the job just once as each job is the same. Unity’s job system will make copies of the job struct for each execution. Then we call Schedule and JobHandle.ScheduleBatchedJobs to schedule the job for execution on another thread. Finally, we call JobHandle.Complete to wait for the job to finish. The result is the same sequential execution that we got with async and await.

To test this, we just call RunJobs:

result[0] = 0;
sw.Reset();
sw.Start();
RunJobs(result, numRuns);
long jobTicks = sw.ElapsedTicks;
CheckResult(result, numRuns);

Finally, we tear down the test and print the results:

result.Dispose();
 
Debug.Log(
      "System,Ticks\n"
      + "Async+Await," + asyncTicks + "\n"
      + "Unity Jobs," + jobTicks);

I ran the performance test in this environment:

  • 2.7 Ghz Intel Core i7-6820HQ
  • macOS 10.13.6
  • Unity 2018.2.0f2
  • macOS Standalone
  • .NET 4.x scripting runtime version and API compatibility level
  • IL2CPP
  • Non-development
  • 640×480, Fastest, Windowed

Here are the results I got:

System Ticks
Async+Await 525900
Jobs 128710

Async & Await vs. Unity Jobs

This test shows that Unity’s jobs system is about 4x faster than C#’s async, await, and Task system.

Keep in mind that this won’t always be the case as the work being done in each task, complexity of dependencies, and variability in hardware will have a substantial effect. There are also a lot more restrictions when using Unity jobs, such as the inability to use reference types.

It’s also worth noting that the testing methodology used here executes all tasks one-at-a-time until completion. Many real-world uses will involve multiple tasks executing in parallel, which may change the results. As usual, it’s best to measure your own specific application or game, such as with Unity’s profiler. That said, the overhead required by async and await does seem quite a bit more substantial than Unity’s job system.