Job System. Review on the other hand

In the new version of unity 2018, finally, they officially added a new system Entity component system or abbreviated ECS, which allows instead of the usual work with object components to work only with their data.

An additional system of tasks suggests that you use parallel computing power to improve the performance of your code.

Together, these two new systems ( ECS and Job Systems ) offer a new level of data processing.

Specifically, in this article I will not disassemble the entire ECS system , which is currently available as a separately downloaded set of tools in the unity , and I will consider only the task system and how it can be used outside the ECS package .

New system


Initially, in unity and earlier, it was possible to use multi-threaded calculations, but the developer needed to create all this on his own, solve the problems himself and bypass the pitfalls. And if earlier it was necessary to work directly with such things as creating threads, closing threads, pools, synchronization, now all this work fell on the shoulders of the engine, and all that is needed from the developer is the creation of tasks and their execution.

Tasks


To perform any calculations in the new system, it is necessary to use tasks that are objects consisting of methods and data for calculation.

Like any other data in the ECS system , tasks in the Job System are also represented as structures that inherit from one of the three interfaces.

IJob


The simplest task interface contains one Execute method that takes nothing in the form of parameters and returns nothing.

The task itself looks like this:

IJob
public struct JobStruct : IJob {
 publicvoid Execute() {}
}


In the Execute method, you can perform the necessary calculations.

IJobParallelFor


Another interface with the same Execute method, which already in turn takes the index numeric parameter .

IJobParallelFor
public struct JobStruct : IJobParallelFor {
 publicvoid Execute(int index) {}
}


This IJobParallelFor interface , in contrast to the IJob interface , offers to perform a task several times and not just to perform, but to break this implementation into blocks that will be distributed between the streams.

Unclear? Do not worry about this, I'll tell you more.

IJobParallelForTransform


And the last, special interface, which, as the name implies, is designed to work with the object's transform. Also contains the Execute method , with a numeric index parameter and a TransformAccess parameter where the position, size and rotation of the transform are located.

IJobParallelForTransform
public struct JobStruct : IJobParallelForTransform {
 publicvoid Execute(int index, TransformAccess transform) {}
}


Due to the fact that you cannot work with unity objects directly in the task , this interface can only process the transformed data as a separate TransformAccess structure .

Done, now you know how task structures are created, you can proceed to practice.

Task performance


Let's create a simple task inherited from the IJob interface and execute it. To do this, we need any simple MonoBehaviour script and the task structure itself.

Testjob
publicclassTestJob : MonoBehaviour{
 void Start() {}
}


Now cast this script on some object on the scene. In the same script ( TestJob ) below we will write the structure of the task and do not forget to import the necessary libraries.

SimpleJob
using Unity.Jobs;
public struct SimpleJob : IJob {
 publicvoid Execute() {
  Debug.Log("Hello parallel world!");
 }
}


In the Execute method , for example, we will display a simple line in the console.

Now let's move on to the Start method of the TestJob script , where we will create an instance of the task and then execute it.

Testjob
publicclassTestJob : MonoBehaviour{
 void Start() {
  SimpleJob job = new SimpleJob();
  job.Schedule().Complete();
 }
}


If you did everything as in the example, then after starting the game you will receive a simple message to the console as in the picture.

image

What happens here: after calling the Schedule method , the scheduler places the task in the handle and now it can be done by calling the Complete method .

It was an example of a task that simply output text to the console. In order for the task to perform any parallel computing, it is necessary to fill it with data.

Data in the task


As in the ECS system in tasks there is no access to unity objects , you cannot transfer to the GameObject task and change its name there. All you can do is pass some individual object parameters to the task, change these parameters, and after completing the task, apply these changes back to the object.

There are also several limitations to the data in the task itself: firstly, it must be structures, secondly, it must be non-convertible data types, that is, you cannot transfer the same boolean or string to the task.

SimpleJob
public struct SimpleJob : IJob {
 public float a, b;
 publicvoid Execute() {
  float result = a + b;
  Debug.Log(result);
 }
}


And the main condition: the data not enclosed in the container can be available only inside the task!

Containers


When working with multithreaded computing, there is a need to somehow exchange data between threads. In order to transfer data to them and read them back into the task system, containers exist for these purposes. These containers are presented in the form of conventional structures and operate on the principle of a bridge according to which elementary data is synchronized between threads.

There are several types of containers:
NativeArray . The simplest and most frequently used type of container is represented as a simple array with a fixed size.
NativeSlice . Another container - an array, as is clear from the translation, is designed to cut NativeArray into pieces.

These are the two main containers available without connecting an ECS system.. In a more advanced version, there are several types of containers.

NativeList . It is a regular list of data.
NativeHashMap . Analogue dictionary with key and value.
NativeMultiHashMap . The same NativeHashMap with only several values ​​under one key.
NativeQueue . List data queue.

Since we work without connecting the ECS system , only NativeArray and NativeSlice are available to us .

Before proceeding to the practical part, it is necessary to disassemble the most important point - the creation of copies.

Creating containers


As I said before, these containers are a bridge over which data is synchronized between threads. The task system opens this bridge before starting work and closes it after it is completed. The process of discovery is called “ allocation ” ( Allocation ) or else “memory allocation” , the closing process is “ resource release ” ( Dispose ).

It is the location that determines how long the task can use the data in the container — in other words, how long the bridge will be open.

In order to better understand these two processes, let's take a look at the picture below.

image

The lower part shows the life cycle of the main thread ( Main thread), which is calculated in the number of frames, in the first frame we create another parallel thread ( New thread) which exists a certain number of frames and then closes safely.
In the same New thread and comes the task with the container.

Now take a look at the top of the picture.

image

The white Allocation bar shows the lifetime of the container. In the first frame, the container is allocated — the opening of the bridge, until this point the container did not exist, after all the calculations in the task have been completed, the container is released from memory and in the 9th frame the bridge is closed.

Also on this strip ( Allocation ) there are time periods ( Temp ,TempJob and Presistent ), each of these segments shows the estimated lifetime of the container.

What are these segments for !? The fact is that the task can be different in duration, we can perform them directly in the same method where we created it, or we can stretch the time to complete the task if it is rather complicated, and these segments show how urgent and how long the task can use the data in the container.

If it is still not clear, I will analyze each type of allocation using an example.

Now you can go to the practical part of creating containers. To do this, go back to the Start method of the TestJob script and create a new instance of the NativeArray container .and do not forget to connect the necessary libraries.

Temp


Testjob
using Unity.Jobs;
using Unity.Collections;
publicclassTestJob : MonoBehaviour{
 void Start() {
  NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp);
 }
}


To create a new container instance, you need to specify the size and type of allocation in its constructor. In this example, the Temp type is used , since the task will be executed only in the Start method .

Now we initialize the exact same array variable in the SimpleJob task structure itself .

SimpleJob
public struct SimpleJob : IJob {
 public NativeArray<int> array;
 publicvoid Execute() {}
}


Is done. Now you can create the task itself and pass an array instance to it.

Start
void Start() {
 NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp);
 SimpleJob job = new SimpleJob();
 job.array = array;
}


To start the task this time, we will use its JobHandle handle to get it. Call the same Schedule method .

Start
void Start() {
 NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp);
 SimpleJob job = new SimpleJob();
 job.array = array;
 JobHandle handle = job.Schedule();
}


Now you can call the Complete method on its handle and check whether the task is completed to display the text in the console.

Start
void Start() {
 NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp);
 SimpleJob job = new SimpleJob();
 job.array = array;
 JobHandle handle = job.Schedule();
 handle.Complete();
 if (handle.IsCompleted) print("Задача выполнена");
}


If you run the task in this form, then after launching the game you will get a fat red error stating that you did not release the array container from the resources after completing the task.

About this.

image

To avoid this, call the Dispose method on the container after completing the task.

Start
void Start() {
 NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp);
 SimpleJob job = new SimpleJob();
 job.array = array;
 JobHandle handle = job.Schedule();
 handle.Complete();
 if (handle.IsCompleted) print("Complete");
 array.Dispose();
}


Then you can safely restart.
But the task does not do anything! - then add a couple of actions to it.

SimpleJob
public struct SimpleJob : IJob {
 public NativeArray<int> array;
 publicvoid Execute() {
  for(int i = 0; i < array.Length; i++) {
   array[i] = i * i;
  }
 }
}


In the method Execute I multiply the index of each element of the array to itself and is written back to the array of array , to display the result in the console method Etpu Start .

Start
void Start() {
 NativeArray<int> array = new NativeArray<int>(10, Allocator.Temp);
 SimpleJob job = new SimpleJob();
 job.array = array;
 JobHandle handle = job.Schedule();
 handle.Complete();
 if (handle.IsCompleted) print(job.array[job.array.Length - 1]);
 array.Dispose();
}


What will be the result in the console if we display the last element of the array squared?

This is how you can create containers, put them in tasks and perform actions on them.

This was an example using the Temp allocation type , which involves performing the task within one frame. This type is better to use when you need to quickly perform calculations without loading the main thread, but you need to be careful if the task is too complicated or if there are too many of them, then sagging may occur, in this case it is better to use the TempJob type which I will analyze further.

TempJob


In this example, I will slightly modify the structure of the SimpleJob task and inherit it from another IJobParallelFor interface .

SimpleJob
public struct SimpleJob : IJobParallelFor {
 public NativeArray<Vector2> array;
 publicvoid Execute(int index) {}
}


Also, since the task will be performed longer than one frame, we will execute and collect the results of the task in different Awake and Start methods presented in the form of coroutine. To do this, change the appearance of the TestJob class a bit .

Testjob
publicclassTestJob : MonoBehaviour{
 private NativeArray<Vector2> array;
 private JobHandle handle;
 void Awake() {}
 IEnumerator Start() {}
}


In the Awake method, we will create a task and a container of vectors, and in the Start method we will display the received data and release resources.

Awake
void Awake() {
 this.array = new NativeArray<Vector2>(100, Allocator.TempJob);
 SimpleJob job = new SimpleJob();
 job.array = this.array;
}


Here again the container array is created with the type of allocation TempJob , after which we create the task and get its handle by calling the Schedule method with small changes.

Awake
void Awake() {
 this.array = new NativeArray<Vector2>(100, Allocator.TempJob);
 SimpleJob job = new SimpleJob();
 job.array = this.array;
 this.handle = job.Schedule(100, 5)
}


The first parameter in the Schedule method indicates how many times the task is executed, here the same number as the size of the array .
The second parameter indicates the number of blocks to share the task.

What other blocks?
Previously, in order to execute a task, the thread just called the Execute method once , but now it is necessary to call this method 100 times, so the scheduler, in order not to load any particular thread, breaks these 100 times the repetitions into blocks that it distributes between the threads. In the example, a hundred repetitions will be divided into 5 blocks of 20 repetitions each, that is, the scheduler will presumably distribute these 5 blocks into 5 threads, where each thread will call the Execute method20 times. In practice, of course, not the fact that the scheduler will do just that, it all depends on the system load, so maybe all 100 repetitions will occur in one stream.

Now you can call the Complete method on the task handle.

Awake
void Awake() {
 this.array = new NativeArray<Vector2>(100, Allocator.TempJob);
 SimpleJob job = new SimpleJob();
 job.array = this.array;
 this.handle = job.Schedule(100, 5);
 this.handle.Complete();
}


In Corutin Start, we will check the execution of the task and then we will clean the container.

Start
IEnumerator Start() {
 while(this.handle.isCompleted == false){
  yield returnnew WaitForEndOfFrame();
 }
 this.array.Dispose();
}


We now turn to actions in the task itself.

SimpleJob
public struct SimpleJob : IJobParallelFor {
 public NativeArray<Vector2> array;
 publicvoid Execute(int index) {
  float x = index;
  float y = index;
  Vector2 vector = new Vector2(x * x, y * y / (y * 2));
  this.array[index] = vector;
 }
}


After completing the task in the Start method, we will output all the elements of the array to the console.

Start
IEnumerator Start() {
 while(this.handle.IsCompleted == false){
  yield returnnew WaitForEndOfFrame();
 }
 foreach(Vector2 vector inthis.array) {
  print(vector);
 }
 this.array.Dispose();
}


Done, you can run and see the result.

To understand what the difference between IJob and IJobParallelFor is, take a look at the images below.
For example, in IJob , you can use a simple for loop to perform calculations several times, but in any case, the thread can call the Execute method only once for the entire time the task runs - this is how to force one person to perform hundreds of the same actions in a row.

image

IJobParallelFor offers not only to perform the task in one stream several times, but also to distribute these repetitions among other threads.

image

In general, the TempJob allocation typegreat for most tasks that are performed in a few frames.

But what if you need to store data even after completing the task, what if after getting the result you don’t need to destroy it immediately. For this it is necessary to use the type of allocation Persistent , which implies the release of resources then “ when it will be necessary!” .

Persistent


Again, go back to the TestJob class and change it. Now we will create tasks in the OnEnable method , check their execution in the Update method and clean up resources in the OnDisable method .
In the example, we will move the object in the Update method , to calculate the trajectory we will use two vector containers - inputArray into which we will place the current position and outputArray from which we will receive the results.

Testjob
publicclassTestJob : MonoBehaviour{
 private NativeArray<Vector2> inputArray;
 private NativeArray<Vector2> outputArray;
 private JobHandle handle;
 void OnEnable() {}
 void Update() {}
 void OnDisable() {}
}


We will also slightly modify the structure of the SimpleJob task by inheriting it from the IJob interface to execute it once.

SimpleJob
public struct SimpleJob : IJob {
 publicvoid Execute() {}
}


In the task itself, we will also betray two vector containers, one position vector and a numeric delta, which will displace the object to the target.

SimpleJob
public struct SimpleJob : IJob {
 [ReadOnly]
 public NativeArray<Vector2> inputArray;
 [WriteOnly]
 public NativeArray<Vector2> outputArray;
 public Vector2 position;
 public float delta;
 publicvoid Execute() {}
}


The ReadOnly and WriteOnly attributes show the flow of restrictions on the actions associated with the data inside containers. ReadOnly offers the stream only read data from the container, the WriteOnly attribute is the opposite - it allows the stream to only write data to the container. If you need to perform two of these actions at once with one container then you do not need to mark it with an attribute at all.

Let us turn to the method OnEnable class TestJob where the containers will be initialized.

OnEnable
void OnEnable() {
 this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent);
 this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent);
}


The sizes of containers will be single since it is necessary to transmit and receive parameters only once. The type of location will be Persistent .
In the OnDisable method, we will free up container resources.

Ondisable
void OnDisable() {
 this.inputArray.Dispose();
 this.outputArray.Dispose();
}


Let's create a separate method CreateJob where we will create a task with its handle and in the same place we will fill it with data.

CreateJob
void CreateJob() {
 SimpleJob job = new SimpleJob();
 job.delta = Time.deltaTime;
 Vector2 position = this.transform.position;
 job.position = position;
 Vector2 newPosition = position + Vector2.right;
 this.inputArray[0] = newPosition;
 job.inputArray = this.inputArray;
 job.outputArray = this.outputArray;
 this.handle = job.Schedule();
 this.handle.Complete();
}


In fact, inputArray is not really needed here, since you can transfer to the task and just the direction vector, but I think it will be better to understand why these ReadOnly and WriteOnly attributes are needed at all .

In the Update method, we will check whether the task is completed, then apply the result obtained to the object's transform and then run it again.

Update
void Update() {
 if (this.handle.IsCompleted) {
  Vector2 newPosition = this.outputArray[0];
  this.transform.position = newPosition;
  CreateJob();
 }
}


Before launching, we will slightly correct the OnEnable method so that the task is created immediately after the containers are initialized.

OnEnable
void OnEnable() {
 this.inputArray = new NativeArray<Vector2>(1, Allocator.Persistent);
 this.outputArray = new NativeArray<Vector2>(1, Allocator.Persistent);
 CreateJob();
}


Done, now you can go to the task itself and perform the necessary calculations in the Execute method .

Execute
publicvoid Execute() {
 Vector2 newPosition = this.inputArray[0];
 newPosition = Vector2.Lerp(this.position, newPosition, this.delta);
 this.outputArray[0] = newPosition;
}


To see the result of the work, you can throw the TestJob script on some object and start the game.

For example, my sprite just shifts to the right.

Animation
image

In general, the type of persistent allocation is great for reusable containers, which are not necessary to destroy and re-create each time.

So what type to use !?
The Temp type is best used for quick calculations, but if the task is too complex and large, sagging may occur.
Type TempJob is great for working with unity objects , so you can change the parameters of objects and apply them, for example, in the next frame.
The Persistent type can be used when speed is not important to you, but you just need to constantly calculate some data on the side, for example, process data over the network, or AI work.

Invalid and None
Существует еще два типа аллокации Invalid и None, но они нужны больше для отладки, и в работе участия не принимают.


Jobhandle


Separately, it is worthwhile to sort out the possibilities of the task handle, because apart from checking the process of completing the task, this small handle can still create entire networks of tasks through dependencies (although I prefer to call them queues).

For example, if you need to perform two tasks in a certain sequence, then for this you just need to attach the handle of one task to the handle of another.

It looks like this.

image

Each individual handle initially contains its own task, but when combined we get a new handle with two tasks.

Start
void Start() {
 Job jobA = new Job();
 JobHandle handleA = jobA.Schedule();
 Job jobB = new Job();
 JobHandle handleB = jobB.Schedule();
 JobHandle result = JobHandle.CombineDependecies(handleA, handleB);
 result.Complete();
}


Or so.

Start
void Start() {
 JobHandle handle;
 for(int i = 0; i < 10; i++) {
  Job job = new Job();
  handle = job.Schedule(handle);
 }
 handle.Complete();
}


The execution sequence is saved and the scheduler will not start performing the next task until it is satisfied with the previous one, but it is important to remember that the property of the IsCompleted handle will wait for all the tasks in it to be completed.

Conclusion


Containers


  1. When working with data in containers, do not forget that these are structures, so any rewriting of data in a container does not change them, but creates them again.
  2. What will happen if I set the type of location Temp and do not clear the resources after the task is completed? Mistake.
  3. Can I create my own containers? It is possible, the units described in detail the process of creating custom containers here, but it's better to think a few times: is it worth it, maybe there are enough ordinary containers !?

Security!


Static data.

Do not try to use static data in the task ( Random and others), any access to static data will violate the security of the system. In fact, at the moment, you can access the static data, but only if you are sure that they do not change during work - that is, they are completely static and read-only.

When to use the task system?

All these examples are given here in the article only conditional, and show how to work with this system, and not when to use it. The task system can be used without ECS,you need to understand that the system also consumes resources when working and that for any reason to immediately write tasks, creating heaps of containers is simply meaningless - everything will get worse. For example, recalculating an array of 10 thousand elements will not be correct - you will have more time to work with the scheduler, but recalculate all the polygons of the huge terrain or generate it altogether - the right solution, you can break the terrain into tasks and process each one in a separate stream.

In general, if you are constantly engaged in complex calculations in projects and are constantly looking for new opportunities how to make this process less resource-intensive, then Job Systemthis is exactly what you need. If you constantly work with complex calculations inseparable from objects and you want your code to run faster and be supported on most platforms, then ECS will help you with this. If you create projects only for WebGL then this is not for you, at the moment the Job System does not support work in browsers, although this is already a problem not for the developers, but for the browser developers themselves.

Source code with all the examples

Also popular now: