C# Tasks vs. Unity Jobs
Two weeks ago we tested the performance of the async
and await
keywords plus the C# Task
system against Unity’s new C# jobs system. This tested the usual combination of async
and await
with the Task
system, but didn’t test the Task
system directly against Unity’s C# jobs system. Today we’ll test that and, in so doing, see how to use the Task
system without the async
and await
keywords.
As we saw last week, async
and await
don’t require the use of Task
or Task<T>
. We can make our own custom objects to await
on instead of a Task
. As we’ll see today, the opposite is also true: Task
doesn’t require async
or await
.
Let’s look at just about the simplest task we could create:
void MultithreadedIncrement(out int val) { // Run a task that increments 'val' Task task = Task.Run(() => val++); // Wait for the task to complete task.Wait(); }
Nowhere here are we using async
or await
. We simply create and run a task with Task.Run
and then block execution of the main thread until it’s done by calling Wait
.
Next, let’s create a Task
that creates a “child” Task
:
void MultithreadedDoubleIncrement(out int val) { // Run a task that increments 'val' and runs a task that increments 'val' Task task = Task.Run( () => { val++; Task.Run(() => val++); }); // Wait for the task to complete task.Wait(); }
However, Task.Run
doesn’t allow for the “child” task to “attach” to the “parent” task. This means that the child task won’t wait for the parent task to complete. While that’s not an issue for this simple task, more complex work will require that we express dependencies this way. To do that, we need to use a TaskFactory
. Here’s the first step toward that:
void MultithreadedDoubleIncrement(out int val) { // Run a task that increments 'val' and runs a task that increments 'val' Task task = Task.Factory.StartNew( () => { val++; Task.Factory.StartNew(() => val++); }); // Wait for the task to complete task.Wait(); }
Still, StartNew
doesn’t automatically attach the child task to the parent task. We need to pass a parameter to explicitly request that:
void MultithreadedDoubleIncrement(out int val) { // Run a task that increments 'val' and runs a task that increments 'val' Task task = Task.Factory.StartNew( () => { val++; Task.Factory.StartNew( () => val++, TaskCreationOptions.AttachedToParent); }); // Wait for the task to complete task.Wait(); }
Unfortunately, Unity’s Task.Factory
is configured by default to not allow attaching child tasks to parent tasks. To work around this, we can create our own TaskFactory
:
void MultithreadedDoubleIncrement(out int val) { // Create a TaskFactory that allows attaching child tasks to parent tasks TaskFactory taskFactory = new TaskFactory( TaskCreationOptions.AttachedToParent, TaskContinuationOptions.ExecuteSynchronously); // Run a task that increments 'val' and runs a task that increments 'val' Task task = taskFactory.StartNew( () => { val++; taskFactory.StartNew( () => val++, TaskCreationOptions.AttachedToParent); }); // Wait for the task to complete task.Wait(); }
Finally, this code will run the parent task then the child task when it’s done.
For today’s performance test, we’ll run a chain of 1000 no-op tasks where each is a child of the previous. To do this without hard-coding 1000 lambdas, we can use a simple countdown:
void RunTasks(TaskFactory taskFactory, int numRuns) { Action act = null; act = () => { numRuns--; if (numRuns > 0) { taskFactory.StartNew( act, TaskCreationOptions.AttachedToParent); } }; Task task = taskFactory.StartNew(act); task.Wait(); }
In comparison, here’s how we’ll run the equivalent 1000 no-op jobs:
struct TestJob : IJob { public void Execute() { } } void RunJobs(int numRuns) { TestJob job = new TestJob(); JobHandle jobHandle = job.Schedule(); for (int i = 1; i < numRuns; ++i) { jobHandle = job.Schedule(jobHandle); } JobHandle.ScheduleBatchedJobs(); jobHandle.Complete(); }
Putting it all together, we end up with this test script that runs four simultaneous chains of 1000 tasks:
using System; using UnityEngine; using System.Threading.Tasks; using System.Diagnostics; using Unity.Jobs; public class TestScript : MonoBehaviour { Task RunTasks(TaskFactory taskFactory, int numRuns) { Action act = null; act = () => { numRuns--; if (numRuns > 0) { taskFactory.StartNew( act, TaskCreationOptions.AttachedToParent); } }; return taskFactory.StartNew(act); } struct TestJob : IJob { public void Execute() { } } JobHandle RunJobs(int numRuns) { TestJob job = new TestJob(); JobHandle jobHandle = job.Schedule(); for (int i = 1; i < numRuns; ++i) { jobHandle = job.Schedule(jobHandle); } return jobHandle; } void Awake() { const int numRuns = 1000; Stopwatch sw = new Stopwatch(); TaskFactory taskFactory = new TaskFactory( TaskCreationOptions.AttachedToParent, TaskContinuationOptions.ExecuteSynchronously); sw.Restart(); Task task1 = RunTasks(taskFactory, numRuns); Task task2 = RunTasks(taskFactory, numRuns); Task task3 = RunTasks(taskFactory, numRuns); Task task4 = RunTasks(taskFactory, numRuns); task1.Wait(); task2.Wait(); task3.Wait(); task4.Wait(); long taskTime = sw.ElapsedTicks; sw.Restart(); JobHandle jobHandle1 = RunJobs(numRuns); JobHandle jobHandle2 = RunJobs(numRuns); JobHandle jobHandle3 = RunJobs(numRuns); JobHandle jobHandle4 = RunJobs(numRuns); JobHandle.ScheduleBatchedJobs(); jobHandle1.Complete(); jobHandle2.Complete(); jobHandle3.Complete(); jobHandle4.Complete(); long jobTime = sw.ElapsedTicks; print("System,TimenTask," + taskTime + "nJob," + jobTime); } }
I ran the performance test in this environment:
- 2.7 Ghz Intel Core i7-6820HQ
- macOS 10.13.6
- Unity 2018.2.9f1
- 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 | Time |
---|---|
Task | 150360 |
Job | 54110 |
C#’s Task
system took 2.78x longer than Unity’s Job system. That’s a better showing than the ~4x difference seen when using async
and await
too, but still quite a bit slower. Since the tasks and jobs aren’t performing any work at all, this is purely a measurement of the overhead of the two systems.
As usual, it’s best to profile the specific project performing the work as there will be a lot of variability due to the work load, dependencies, hardware, OS, and so forth.
#1 by JamesM on September 24th, 2018 ·
Your tests are not equivalent
* Within the Task code you have logic in the background thread, and none in the main thread along with a captured variable inside a lambda, you should use an equivalent static function
* The equivalent of dependencies within Tasks would probably be Task.ContinueWith, not spawning a secondary task within the task (Unless your going to do the same for Unity)
* Are your Unity Job System dependencies correct? Doesn’t Schedule() take two arguments the first one being ‘jobData’ the second being the dependency? If this is the case then you might not have them depend on each other but just be passing another job as ‘jobData’ (I don’t currently have a copy of Unity setup where I am to confirm this, but just looking at the API docs)
* Your batching jobs for Unity not adding them immediately, you should use the equivalent for both of these, this might involve constructing the task using the constructor then calling Run() when necessary
Depending on how much time I get free today I might run these tests myself.
#2 by jackson on September 24th, 2018 ·
The only logic in the task is to start the next task. There is a captured variable, but the countdown seems necessary to avoid creating 1000 lambdas.
ContinueWith
actually performs worse. Here’s the modified version ofRunTasks
I used:And here’s the performance result on the same machine:
Task
took about 1⁄3 longer than the parent-child version in the article.As for scheduling jobs from within a job’s execution, this isn’t possible according to Unity.
Schedule
is an extension function (UnityEngine.Jobs.IJobExtensions.Schedule) so the first parameter is the job that it’s acting on. The second parameter is the job it depends on. So inRunJobs
I have each job depend on the previous and then return and callComplete
on the final job of the chain.It’s true that I could create all the tasks up front and then call
Start
. It seems like I’d need to useContinueWith
in order to do this though, and that seems to entail a large slowdown as per the above results. Here’s one attempt at it anyhow:And here are the results:
This is basically the same slowdown as above with jobs now being ~4.5x faster than tasks. If you want to post any test code you think would be more fair or run faster with the
Task
system, I’d be happy to run it on the same machine as in the article.#3 by JamesM on September 24th, 2018 ·
Thanks for the further follow-up and testing.
It’s interesting to see the impact of ContinueWith, another approach you could take would be defining a sequential TaskScheduler which would run the tasks ( MS provides an example of a limited concurrency scheduler – https://docs.microsoft.com/en-us/dotnet/api/system.threading.tasks.taskscheduler?view=netframework-4.7.2 )
Looks like I was incorrect when reading the unity API docs (those extension methods really should have ‘this T’ to make it more clear.
It might also be worth looking at how many threads are running, Task.Run() and friends might end up starting entirely new OS threads instead of using a thread pool, which is where the Unity Job system might use a thread pool (Which could even differ in IL2CPP vs Mono)
#4 by JamesM on September 24th, 2018 ·
Do the test times vary much with/without IL2CPP?
#5 by jackson on September 24th, 2018 ·
I hadn’t tested with Mono as IL2CPP has almost replaced it at this point, but here are results from the same machine:
Task
took about 1.7x longer andJob
took about 5.8x longer. It’s clearly the slower environment and not what Unity had in mind for the job system, but interesting to note for anyone still stuck on Mono.#6 by JamesM on September 24th, 2018 ·
This interesting to know that it has nearly replaced it, I have always been viewing it as an iOS thing. But makes sense to use it everywhere, I’ve kind of stepped away from Unity stuff to more Unreal recently.
#7 by nxrighthere on October 5th, 2018 ·
TPL is slower in Mono than in .NET Framework/Core in general, and it was much slower in Unity’s scripting backends.
On my machine with your test, I’ve got the following results:
Unity 2018.2.10f1 Jobs System (IL2CPP) -> 162,137
Unity 2018.2.10f1 TPL (IL2CPP) -> 190,657
.NET Framework 4.7.2 TPL -> 28,958
.NET Core 2.1 TPL -> 25,790
7 months ago I made this demo – https://vimeo.com/258801264 to demonstrate a true power of the TPL in .NET Core.
#8 by jackson on October 5th, 2018 ·
Thanks for posting these stats. Some readers of this site are using non-Unity C# and .NET, so this may be helpful to expand the performance picture for them.
#9 by David on February 24th, 2023 ·
Hi Jackson,
I was wondering if you ever tried to use de C# Job System in an external C# console application.
I’m trying to optimize some code that is not within a Unity project but can’t seem to add the dependencies correctly.
Thanks for your post and help.
Kind regards.
#10 by jackson on February 24th, 2023 ·
Hi David, no I never tried that as Unity’s job system is inextricably tied to Unity itself and can’t run without it.
#11 by Bobo on December 29th, 2023 ·
Very useful thanks