Tutoriel simplifié du système de Jobs Unity et Burst : Comment les utiliser correctement ?
Si l’objectif est simplement le multithreading, utiliser Async pour changer de thread ou System.Threading est la méthode la plus pratique et claire. Burst peut aussi être appelé directement, sans forcément passer par les Jobs, comme expliqué dans les articles Asynchrone et Appel direct. Mais si vous avez un grand nombre de petits calculs, c’est là que vous devez envisager le système de Jobs.
La documentation Unity sur les Jobs est extrêmement incomplète. Cet article est le fruit d’une longue expérience d’utilisation et devrait être assez complet.
Objectif et limitations des Jobs
Contrairement à l’approche générale du multithreading, le système de Jobs Unity est conçu pour simuler le haut débit du GPU via le multithreading, ce qui signifie que le coût de distribution des tâches est extrêmement faible.
Un Job ne peut contenir que la quantité de calcul d’une seule frame, comme les petits noyaux des fonctions CUDA/shader. Les calculs lourds affecteront toujours les FPS, car les Jobs sont parfois exécutés sur le thread principal. Si vous souhaitez exécuter des calculs de longue durée, soit vous les divisez en nombreux petits Jobs, soit les threads .Net restent la meilleure option.
Alors la question se pose : s’ils sont similaires au GPU, pourquoi ne pas utiliser directement les Compute Shaders ? En effet, les Compute Shaders peuvent faire tout ce que font les Jobs, et de manière plus pratique. Sauf dans les cas suivants :
- Si des échanges de données fréquents avec le CPU sont nécessaires, ce que le GPU ne gère pas bien, les Jobs basés sur le CPU ont alors un avantage.
- Le système ECS (DOTS) est basé sur les Jobs, pour la même raison.
Notez que WebGL ne supporte ni les Jobs ni les Compute Shaders, ni l’accélération Burst, mais :
- WebGL est en train d’être remplacé par WebGPU, qui supporte les Compute Shaders, et les navigateurs récents le supportent déjà (sur iOS 17, il faut l’activer dans les paramètres ; il est activé par défaut dans la préversion 19).
- L’option WebAssembly 2023 dans les paramètres peut activer le support multithread pour les Jobs, et le support des navigateurs est plus complet. Mais Unity indique que ce n’est même pas considéré comme une fonctionnalité expérimentale et ne doit pas être utilisé ; l’activer sous Unity6 LTS provoque un Crash. Le multithread wasm les préoccupe depuis des années, d’autant que DOTS est actuellement prioritaire. Avec le support du multithread wasm par .Net8, ils attendent peut-être qu’Unity7 supporte .Net8.
Performances
Fournir des indicateurs de performance aide à comprendre l’intention de conception et l’applicabilité. Vous pouvez aussi lire l’article d’abord et revenir voir ces tests de performance plus tard.
J’ai testé 8 projets, en commençant par les performances de 3 types d’allocateurs :
- BenchAllocatorTemp : Exécute 100 000 allocations avec Allocator.Temp
- BenchAllocatorTempJob : Idem, avec l’allocateur Allocator.TempJob
- BenchAllocatorPersistent : Idem, avec l’allocateur Allocator.Persistent Le résultat montre que TempJob est le plus rapide, suivi de Persistent.
Ensuite, j’ai testé les performances de 4 modes de Job :
- BenchBaseLine : Exécute 100 000 calculs simples avec une boucle For comme référence de base
- BenchIJob : Temps de planification (schedule) de 100 000 Jobs
- BenchIJobParallelFor : Temps de planification par lots de 100 000 Jobs en mode parallèle
- BenchIJobParallelForBurst : Idem, mais avec Burst activé
- BenchIJobParallelForBurstLoopVectorization : Planifie 10 Jobs, chacun calculant 10 000 fois avec une boucle For, avec vectorisation Burst activée
Voici les résultats sur mon PC :

Median est la médiane du temps d’exécution du projet de test, en millisecondes.
On voit que le coût de planification des Jobs est faible, car ils sont conçus pour exécuter un grand nombre de tâches. Bien sûr, je n’ai effectué ici que de simples multiplications, donc le gain des Jobs est limité.
Types de données
Premièrement, Burst ne supporte pas les types managés de C#. Seuls les types de longueur identique au C, pouvant être memcpy directement (sans sérialisation/marshalling), appelés blittables, sont utilisables. Cela inclut les types de base comme int, etc. (char, string et bool sont parfois managés, ne les utilisez pas), ainsi que les tableaux de style C à 1 dimension de types blittables (new int[5]). Comme les Jobs sont nécessairement utilisés avec Burst, ils suivent cette restriction.
Unity a encapsulé un type thread-safe NativeArray spécialement pour les Jobs. Ces types peuvent partager des données avec le thread principal sans copie, car lors de la copie, seul le pointeur de données est passé, et plusieurs copies référencent la même zone mémoire. Il en dérive NativeList, NativeQueue, NativeHashMap, NativeHashSet, NativeText, etc., mais ceux-ci ne peuvent être utilisés qu’en thread unique.
Attention : Vous ne pouvez pas utiliser du code comme nativeArray[0].x = 1.0f ou nativeArray[0]++;, la valeur ne changera pas car ce qui est retourné n’est pas une référence.
Sécurité des threads (Thread Safety)
La sécurité des threads est assurée par des restrictions de planification. Une même instance de NativeArray ne peut être écrite que par un seul Job à la fois, sinon une exception est levée. Si les données peuvent être parallélisées par segmentation, vous pouvez utiliser IJobParallelFor pour exécuter des lots sur le NativeArray. Pour des données en lecture seule, vous pouvez les identifier en définissant la variable membre avec, par exemple, [ReadOnly] public NativeArray<int> input;.
Lorsqu’un Job écrit, le thread principal ne peut pas lire le NativeArray, cela générera une erreur. Il faut attendre la fin de l’exécution.
Allocation mémoire
Premièrement, les types Native doivent être manuellement Dispose() après utilisation, ils ne sont pas détruits automatiquement. Pour cela, Unity a ajouté un suivi des fuites mémoire.
Lors de la création (new) d’un type Native, vous devez choisir parmi 3 types d’allocateurs : Temp, TempJob, Persistent. La vitesse d’allocation va du plus rapide au plus lent. Temp a une durée de vie d’une frame, TempJob de 4 frames. Qu’est-ce que cela signifie ?
Tempsignifie que vous l’utilisez dans la fonction courante et devez leDispose()avant la fin de la fonction. Si vous oubliez le Dispose, Unity signalera une erreur immédiatement au prochain rendu. Mais cette allocation est en réalité assez lente.TempJoba des conditions d’erreur plus souples. En pratique, vous devez toujours l’utiliser dans une frame, mais vous pouvez le Dispose à la frame suivante.Persistentne génère pas d’erreur, c’est à vous d’être vigilant.
Le projet de test de performance BenchAllocator précédent testait justement ces 3 allocateurs. On peut voir que Allocator.Temp prend 4 fois plus de temps que TempJob. La documentation dit que Temp est le plus rapide, c’est soit un bug, soit un problème spécifique au mode Éditeur.
Exécuter un Job monothread
Le processus complet consiste à écrire votre propre classe IJob, à la planifier (Schedule) depuis le thread principal, puis à appeler Complete pour attendre (bloquer) la fin du Job.
public struct MyJob : IJob {
public NativeArray<float> result;
public void Execute() {
for (int j = 0; j < result.Length; j++)
result[j] = result[j] * result[j];
}
}
void Update() {
result = new NativeArray<float>(100000, Allocator.TempJob);
MyJob jobData = new MyJob{
result = result
};
handle = jobData.Schedule();
}
private void LateUpdate() {
handle.Complete();
result.Dispose();
}Mais le problème est que nous utilisons les Jobs pour un grand nombre de tâches. Une tâche unique n’est pas très utile. Le mode parallèle, inspiré du GPU, est plus pertinent.
Mode parallèle (Job parallèle)
Pour passer au mode parallèle, modifiez le code ci-dessus pour hériter de IJobParallelFor au lieu de IJob.
public struct MyJob : IJobParallelFor {
public NativeArray<float> result;
public void Execute(int i) {
result[i] = result[i] * result[i];
}
}
void Update() {
result = new NativeArray<float>(100000, Allocator.TempJob);
MyJob jobData = new MyJob{
result = result
};
handle = jobData.Schedule(result.Length, result.Length / 10);
}
private void LateUpdate() {
handle.Complete();
result.Dispose();
}Le mode parallèle ne nécessite pas d’écrire vous-même la boucle For. Il exécutera Execute une fois pour chaque élément, comme un Shader.
Schedule(result.Length, result.Length / 10) signifie exécuter Execute pour chaque unité de l’index 0 à result.Length, réparti sur 10 workers.
Pour la différence de performance entre IJob et IJobParallelFor, reportez-vous aux tests de performance précédents.
Limitations du parallélisme
Dans IJobParallelFor, vous ne pouvez écrire que dans l’élément i. De plus, il ne sait pas dans quel Array membre vous voulez écrire, donc tous les Arrays ne peuvent écrire que l’élément i. Cependant, vous pouvez ajouter l’attribut [NativeDisableParallelForRestriction] au NativeArray pour désactiver la vérification de sécurité, à condition de garantir vous-même l’absence de conflits d’écriture.
Le mode lecture seule n’a aucune restriction sur tous les conteneurs Native.
De plus, IJobParallelFor ne peut pas activer la vectorisation de boucle, sauf si votre calcul utilise déjà la vectorisation (en appelant d’autres fonctions déjà vectorisées). Sinon, les performances ne seront pas optimales.
Utiliser des conteneurs comme NativeList en parallèle
Les conteneurs autres que les Array, comme NativeList, ne peuvent être qu’en lecture seule en parallèle. Alors, comment écrire dedans ?
En réalité, la conception de NativeList sépare les opérations Add et Set. Le bon mode d’utilisation est qu’un Job fasse l’opération Add, et un second Job fasse l’opération Set.
Pour Add, vous pouvez utiliser ParallelWriter et AsParallelWriter, comme suit :
public struct AddListJob : IJobParallelFor {
public NativeList<float>.ParallelWriter result;
public void Execute(int i) {
result.AddNoResize(i);
}
}
public void RunIJobParallelForList() {
var results = new NativeList<float>(10, Allocator.TempJob);
var jobData = new AddListJob() {
result = results.AsParallelWriter(),
};
var handle = jobData.Schedule(10, 1);
handle.Complete();
Debug.Log(String.Join(",", results.ToArray(Allocator.TempJob)));
results.Dispose();
}Dans cet état, NativeList a une capacité fixe. Vous devez pré-allouer la mémoire avant de lancer, et vous ne pouvez utiliser que AddNoResize(). Cette méthode est implémentée via un verrou atomique sur la propriété Length, ce qui entraîne une perte de performance significative.
Ensuite, utilisez la conversion sans perte de NativeList vers NativeArray : NativeList.AsDeferredJobArray(). Cette méthode retourne un NativeArray paresseux (lazy), la conversion n’ayant lieu que lorsque le Job s’exécute réellement. Vous pouvez donc le passer avant l’exécution des 2 Jobs :
var addJob = new AddListJob { result = results.AsParallelWriter() };
var jobHandle = addJob.Schedule(10, 1);
var setJob = new SetListJob { array = results.AsDeferredJobArray() };
setJob.Schedule(10, 1, jobHandle).Complete();Notez que AsDeferredJobArray ou AsArray retournent une vue (View) des données originales. Les données sources doivent toujours être Dispose.
Mode parallèle pour tableaux 2D
IJobParallelFor ne peut paralléliser que sur des éléments individuels d’un Array. Mais paralléliser chaque ligne d’un tableau 2D est plus utile et permet d’activer la vectorisation de boucle, offrant de meilleures performances. Vous pouvez utiliser IJobParallelForBatch pour cela.
Nous créons d’abord un tableau 2D aplati [10*15], puis nous le planifions avec IJobParallelForBatch.Schedule(int length, int batchCount). batchCount indique combien de données chaque Job traite. Execute sera appelé length/batchCount fois.
var results = new NativeArray<float>(10*15, Allocator.TempJob);
var jobData = new MyJob2D {
result = results
};
var handle = jobData.Schedule(10*15, 15);
handle.Complete();
Debug.Log(String.Join(",", results));
results.Dispose();Voici l’implémentation de MyJob2D.
[BurstCompile]
public struct MyJob2D : IJobParallelForBatch {
public NativeArray<float> result;
public void Execute(int i, int count) {
for (int j = i; j < i + count; j++) {
result[j] = i;
}
}
}Résultat d’exécution :
0,0,0,0,0,0,0,0,0,0,0,0,0,0,0,
1,1,1,1,1,1,1,1,1,1,1,1,1,1,1,
2,2,2,2,2,2,2,2,2,2,2,2,2,2,2,
3,3,3,3,3,3,3,3,3,3,3,3,3,3,3,
4,4,4,4,4,4,4,4,4,4,4,4,4,4,4,
5,5,5,5,5,5,5,5,5,5,5,5,5,5,5,
6,6,6,6,6,6,6,6,6,6,6,6,6,6,6,
7,7,7,7,7,7,7,7,7,7,7,7,7,7,7,
8,8,8,8,8,8,8,8,8,8,8,8,8,8,8,
9,9,9,9,9,9,9,9,9,9,9,9,9,9,9
UnityEngine.Debug:Log (object)Cette méthode permet à Burst d’activer automatiquement la vectorisation de boucle. C’est pourquoi le temps de calcul pour 100 000 itérations dans les tests de performance était de 0.09 ms, le plus rapide.
Autres limitations
- Vous ne pouvez pas lancer un Job depuis un Job.
Combinaison avec Async
Les exemples ci-dessus planifient le Job dans Update et le complètent dans LateUpdate, dans le but d’accélérer le code d’Update. Pour une tâche unique, ce n’est pas si compliqué. Vous pouvez utiliser une approche Async pour attendre directement sans bloquer le rendu. Utilisez la méthode d’extension CompleteAsync du package :
async void GenerateMesh() {
result = new NativeArray<float>(100000, Allocator.Persistent);
MyJob jobData = new MyJob{
result = result
};
handle = jobData.Schedule();
await handle.CompleteAsync();
}Notez que ce mode nécessite l’allocateur Persistent, car vous ne finirez pas nécessairement en une seule frame.
Burst
Burst, basé sur LLVM, est un sous-ensemble du C# appelé “High-Performance C#”, qui est pratiquement du code C. Il est généralement 10 à 100 fois plus rapide que Mono, ce qui montre aussi que Mono est lent.
Burst peut encore améliorer la vitesse d’exécution des Jobs. Pour l’exemple ci-dessus, il suffit