Améliorer l'évaluation des performances du système d'emploi en 2022.2 - partie 1 : Contexte et API

Dans les versions 2022.2 et 2021.3.14f1, nous avons amélioré les coûts d'ordonnancement et la mise à l'échelle des performances du système de tâches d'Unity. Dans cet article en deux parties, je ferai un bref rappel de la programmation parallèle et des systèmes de tâches, je parlerai de la surcharge des systèmes de tâches et je partagerai l'approche d'Unity pour l'atténuer.
Dans la première partie, nous présentons des informations générales sur la programmation parallèle et l'API du système de tâches. Si vous êtes déjà familiarisé avec le parallélisme, n'hésitez pas à passer directement à la deuxième partie.
Dans la version 2017.3, une API C# publique a été ajoutée pour le système interne de jobs C++ Unity, permettant aux utilisateurs d'écrire de petites fonctions appelées "jobs" qui sont exécutées de manière asynchrone. L'objectif de l'utilisation de jobs au lieu de simples fonctions est de fournir une API qui facilite, sécurise et rend efficace l'exécution de codes qui s'exécuteraient autrement sur le thread principal et qui s'exécutent à la place sur des threads "travailleurs", idéalement en parallèle. Cela permet de réduire la durée totale du temps mural dont le fil d'exécution principal a besoin pour terminer la simulation d'un jeu. L'utilisation du système de tâches pour le travail de l'unité centrale peut améliorer considérablement les performances et permettre à votre jeu de s'adapter naturellement à l'amélioration du matériel sur lequel il tourne.
Si vous considérez le calcul comme une ressource finie, un seul cœur d'unité centrale ne peut effectuer qu'une quantité limitée de "travail" de calcul dans un laps de temps donné. Par exemple, si un jeu à un seul thread nécessite que sa simulation Update() ne prenne pas plus de 16 ms, mais qu'elle prend actuellement 24 ms, c'est que l'unité centrale a trop de travail à faire - il faut plus de temps. Pour atteindre un objectif de 16 ms, il n'y a que deux options : accélérer le processeur (par exemple, augmenter les spécifications minimales de votre jeu - ce qui n'est généralement pas une bonne option) ou réduire la quantité de travail.
void Update()
{
// <lots of simulation logic...>
}
En fin de compte, vous devez éliminer 8 ms de travail de calcul, ce qui implique généralement d'améliorer les algorithmes, de répartir le travail du sous-système sur plusieurs trames, de supprimer le travail redondant qui peut s'accumuler au cours du développement, etc. Si cela ne vous permet toujours pas d'atteindre votre objectif de performance, vous devrez peut-être réduire la complexité de la simulation du jeu en diminuant le contenu et la jouabilité, par exemple en réduisant le nombre d'ennemis autorisés à apparaître en même temps - ce qui n'est certainement pas l'idéal.
Et si, au lieu d'éliminer le travail, nous le confiions à un autre cœur de processeur ? Aujourd'hui, la plupart des CPU sont multicœurs, ce qui signifie que la puissance de calcul disponible pour un seul thread peut être multipliée par le nombre de cœurs dont dispose le CPU. Si nous pouvions, comme par magie et en toute sécurité, répartir tout le travail actuellement effectué dans la fonction Update() entre deux cœurs de processeur, le travail de 24 ms de la fonction Update() pourrait être exécuté en deux tranches simultanées de 12 ms. Cela nous amènerait bien en deçà de l'objectif de 16 ms. De plus, si nous pouvions diviser le travail en quatre parties parallèles et les exécuter sur quatre cœurs, la mise à jour ne prendrait que 6 ms !
Ce type de division du travail et d'exécution sur tous les cœurs disponibles est connu sous le nom de mise à l'échelle des performances. Si vous ajoutez des cœurs, vous pouvez idéalement exécuter davantage de tâches en parallèle, ce qui réduit le temps de latence de la fonction Update() sans modifier le code.
void Update()
{
// Some magic has split our logic into 4 equal parts
// that can run in parallel. Wowee!
PartialUpdateA();
PartialUpdateB();
PartialUpdateC();
PartialUpdateD();
}
Hélas, il s'agit d'un fantasme. Rien ne permet de diviser la fonction Update() en plusieurs parties et de les exécuter sur des cœurs distincts sans aide. Même si nous passions à une unité centrale dotée de 128 cœurs, la fonction Update() ci-dessus prendrait toujours 24 ms, à condition que les deux unités centrales aient la même fréquence d'horloge. Quel gâchis de potentiel ! Comment, dès lors, pouvons-nous écrire des applications qui tirent parti de tous les cœurs de processeur disponibles et augmentent le parallélisme ?
L'une des approches est le multithreading. En d'autres termes, votre programme crée des threads pour exécuter une fonction que le système d'exploitation programmera pour vous. Si votre unité centrale possède plusieurs cœurs, plusieurs threads peuvent être exécutés en même temps, chacun sur son propre cœur. S'il y a plus de threads que de cœurs disponibles, le système d'exploitation est chargé de déterminer quel thread doit s'exécuter sur un cœur - et pendant combien de temps - avant de passer à un autre thread, un processus appelé commutation de contexte.
La programmation multithread s'accompagne toutefois d'un certain nombre de complications. Dans le scénario magique ci-dessus, la fonction Update() a été divisée en quatre mises à jour partielles. Mais en réalité, vous ne seriez probablement pas en mesure de faire quelque chose d'aussi simple. Étant donné que les threads s'exécutent simultanément, vous devez faire attention lorsqu'ils lisent et écrivent sur les mêmes données en même temps, afin d'éviter qu'ils ne corrompent les calculs les uns des autres.
Cela implique généralement l'utilisation de primitives de synchronisation de verrouillage, comme un mutex ou un sémaphore, pour contrôler l'accès à l'état partagé entre les threads. Ces primitives limitent généralement le degré de parallélisme de certaines sections du code (en optant généralement pour l'absence de parallélisme) en "verrouillant" d'autres threads, ce qui les empêche d'exécuter la section jusqu'à ce que le détenteur du verrou ait terminé et "déverrouille" la section pour tous les threads en attente. Cela réduit les performances que vous obtenez en utilisant plusieurs threads puisque vous ne travaillez pas en parallèle tout le temps, mais cela garantit que les programmes restent corrects.
Il n'est probablement pas non plus judicieux d'exécuter certaines parties de votre mise à jour en parallèle en raison de dépendances de données. Par exemple, presque tous les jeux doivent lire les données d'un contrôleur, les stocker dans un tampon d'entrée, puis lire le tampon d'entrée et réagir en fonction des valeurs.
void PartialUpdateA()
{
// Write to m_InputBuffer with the controller state
ReadControllerState(out m_InputBuffer);
}
void PartialUpdateB()
{
// Read m_InputBuffer and start a player
// jump animation if the jump button was pressed
if(m_InputBuffer.IsJumpPressed())
PlayerJump();
}Il serait absurde que le code qui lit le tampon d'entrée pour décider si un personnage doit sauter s'exécute en même temps que le code qui écrit dans le tampon d'entrée pour la mise à jour de cette image. Même si vous avez utilisé un mutex pour vous assurer que la lecture et l'écriture dans m_InputBuffer sont sûres, vous voulez toujours que m_InputBuffer soit écrit en premier et que le code de lecture de m_InputBuffer soit exécuté en second, afin que vous sachiez si le bouton de saut a été pressé pour l'image en cours (et non pour une image antérieure). Ces dépendances sont courantes et normales, mais elles réduisent le degré de parallélisme possible.
Il existe de nombreuses approches pour écrire un programme multithread. Vous pouvez utiliser les API spécifiques à la plateforme pour créer et gérer directement les threads, ou utiliser diverses API qui fournissent une abstraction pour aider à gérer certaines des complications de la programmation multithread.
Un système d'emploi est l'une de ces abstractions. Il permet de diviser des parties de votre code à fil unique en blocs logiques, d'isoler les données nécessaires à ce code, de contrôler qui accède simultanément à ces données et d'exécuter autant de blocs de code en parallèle que possible pour essayer d'utiliser toute la puissance de calcul disponible sur l'unité centrale en fonction des besoins.
Aujourd'hui, nous ne pouvons pas diviser automatiquement des fonctions arbitraires en morceaux, c'est pourquoi Unity fournit une API de travail qui permet aux utilisateurs de convertir les fonctions en petits blocs logiques. À partir de là, le système des tâches se charge de faire fonctionner ces éléments en parallèle.
Le système d'emploi se compose de quelques éléments essentiels :
- Jobs
- Manches de travail
- Planificateur de tâches
public struct MyJob : IJob
{
public NativeArray<int> Data;
public void Execute()
{
// Do some work using our Data member
}
}Comme nous l'avons déjà mentionné, un job n'est qu'une fonction et des données, mais cette encapsulation est utile, car elle réduit la portée des données spécifiques que le job lira ou sur lesquelles il écrira.
var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();Une fois qu'une instance de travail est créée, elle doit être programmée avec le système de travail. Pour ce faire, la méthode Schedule() est ajoutée à tous les types de tâches par le biais du mécanisme d'extension de C#. Pour identifier et suivre le travail programmé, un JobHandle est fourni.
Étant donné que les gestionnaires de tâches identifient les tâches planifiées, ils peuvent être utilisés pour établir des dépendances entre les tâches. Les dépendances d'un travail garantissent qu'un travail planifié ne commencera pas à s'exécuter tant que ses dépendances ne seront pas terminées. En conséquence directe, ils nous indiquent également quand différents travaux sont autorisés à s'exécuter en parallèle en créant un graphe de travaux acyclique dirigé.
var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();
// WritingJob writes to someNativeArray so make sure it runs
// after MyJob is done (since it uses someNativeArray as well).
// That is, declare writingJob to have a dependency on myJob by
// passing in the JobHandle for MyJob to writingJob.Schedule
var writingJob = new WritingJob() { Data = someNativeArray };
var writingJobHandle = writingJob.Schedule(jobHandle);Enfin, lorsque les travaux sont programmés, le planificateur de travaux est chargé d'en assurer le suivi (en associant les JobHandles aux instances de travaux programmés) et de veiller à ce que les travaux commencent à s'exécuter le plus rapidement possible. La manière de procéder est importante, car les modèles de conception et d'utilisation du système de travail peuvent potentiellement entrer en conflit de manière non évidente, entraînant des frais généraux qui grugent les gains de performance de la programmation multithread. Au fur et à mesure que les utilisateurs adoptaient le système de travail C#, nous avons commencé à voir des scénarios où la surcharge du système de travail était plus importante que nous le souhaitions, ce qui a conduit à l'amélioration de l'implémentation du système de travail interne d'Unity dans la version 2022.2 de la Tech Stream.
Restez à l'écoute pour la deuxième partie, qui explorera l'origine de la surcharge dans le système de travail C# et la façon dont elle a été réduite dans Unity 2022.2.
Si vous avez des questions ou si vous souhaitez en savoir plus, rendez-vous sur le forum C# Job System. Vous pouvez également vous connecter avec moi directement via le Discord d'Unity au nom d'utilisateur @Antifreeze#2763. Ne manquez pas les nouveaux blogs techniques d'autres développeurs Unity dans le cadre de lasérie permanente Tech from the Trenches.
