Verbesserung der Leistungsskalierung des Jobsystems in 2022.2 – Teil 1: Hintergrund und API

In 2022.2 und 2021.3.14f1 haben wir die Planungskosten und die Leistungsskalierung des Unity Jobsystems verbessert. In diesem zweiteiligen Artikel gebe ich einen kurzen Rückblick auf parallele Programmierung und Jobsysteme, bespreche den Jobsystem-Overhead und erläutere Unitys Ansatz zur Minderung dieses Overheads.
Im ersten Teil behandeln wir Hintergrundinformationen zur parallelen Programmierung und zur Jobsystem API. Wenn Sie mit Parallelität bereits vertraut sind, können Sie diesen Artikel überfliegen und gleich zu Teil zweispringen.
In der Version 2017.3 wurde eine öffentliche C# API für das interne C++ Unity Jobsystem hinzugefügt, die es Benutzern ermöglicht, kleine Funktionen namens „Jobs“ zu schreiben , die asynchron ausgeführt werden. Die Absicht hinter der Verwendung von Jobs anstelle einfacher alter Funktionen besteht darin, eine API bereitzustellen, die es einfach, sicher und effizient macht, Code, der ansonsten auf dem Hauptthread ausgeführt würde, stattdessen auf Job-„Worker“-Threads auszuführen, idealerweise parallel. Dies trägt dazu bei, die Gesamtzeit zu reduzieren, die der Hauptthread zum Abschließen einer Spielesimulation benötigt. Die Verwendung des Jobsystems für Ihre CPU-Arbeit kann zu erheblichen Leistungsverbesserungen führen und eine natürliche Skalierung der Leistung Ihres Spiels ermöglichen, wenn sich die Hardware, auf der Ihr Spiel läuft, verbessert.
Wenn Sie sich Rechenleistung als begrenzte Ressource vorstellen, kann ein einzelner CPU-Kern in einem bestimmten Zeitraum nur eine bestimmte Menge an Rechenarbeit leisten. Wenn beispielsweise die Update() -Simulation eines Single-Thread-Spiels nicht länger als 16 ms dauern darf, aktuell aber 24 ms dauert, hat die CPU zu viel zu tun – es wird mehr Zeit benötigt. Um das Ziel von 16 ms zu erreichen, gibt es nur zwei Möglichkeiten: die CPU schneller machen (z. B. indem Sie die Mindestanforderungen für Ihr Spiel erhöhen – normalerweise keine gute Option) oder weniger Arbeit erledigen.
void Update()
{
// <lots of simulation logic...>
}
Letztendlich müssen Sie 8 ms Rechenarbeit einsparen. Das bedeutet normalerweise, dass Sie Algorithmen verbessern, die Arbeit an Subsystemen auf mehrere Frames verteilen, redundante Arbeit entfernen, die sich während der Entwicklung ansammeln kann, usw. Wenn Sie Ihr Leistungsziel damit immer noch nicht erreichen, müssen Sie möglicherweise die Komplexität der Spielsimulation durch Einschnitte in Inhalt und Gameplay reduzieren, zum Beispiel indem Sie die Zahl der gleichzeitig erscheinenden Feinde verringern – was sicherlich nicht ideal ist.
Was wäre, wenn wir die Arbeit nicht eliminieren, sondern sie einem anderen CPU-Kern zur Ausführung überlassen würden? Heutzutage sind die meisten CPUs Multi-Core-CPUs, was bedeutet, dass die verfügbare Single-Thread-Rechenleistung mit der Anzahl der Kerne der CPU multipliziert werden kann. Wenn wir die gesamte Arbeit, die sich derzeit in der Funktion Update() befindet, auf magische und sichere Weise zwischen zwei CPU-Kernen aufteilen könnten, könnte die 24-ms-Arbeit von Update() in zwei gleichzeitigen 12-ms-Blöcken ausgeführt werden. Damit lägen wir deutlich unter dem Ziel von 16 ms. Wenn wir die Arbeit außerdem in vier parallele Teile aufteilen und diese auf vier Kernen ausführen könnten, würde das Update() nur 6 ms dauern!
Diese Art der Arbeitsteilung und Ausführung auf allen verfügbaren Kernen wird als Leistungsskalierungbezeichnet. Wenn Sie weitere Kerne hinzufügen, können Sie im Idealfall mehr Arbeit parallel ausführen und so die Realzeit von Update() ohne Codeänderungen reduzieren.
void Update()
{
// Some magic has split our logic into 4 equal parts
// that can run in parallel. Wowee!
PartialUpdateA();
PartialUpdateB();
PartialUpdateC();
PartialUpdateD();
}
Leider ist das Fantasie. Ohne Hilfe ist es nicht möglich, die Update()-Funktion in Teile aufzuteilen und diese auf separaten Kernen auszuführen. Selbst wenn wir zu einer CPU mit 128 Kernen wechseln würden, würde das obige 24-ms-Update() immer noch 24 ms dauern, vorausgesetzt, beide CPUs haben die gleiche Taktrate. Was für eine Verschwendung von Potenzial! Wie können wir also Anwendungen schreiben, um alle verfügbaren CPU-Kerne zu nutzen und die Parallelität zu erhöhen?
Ein Ansatz ist Multithreading. Das heißt, Ihr Programm erstellt Threads , um eine Funktion auszuführen, deren Ausführung das Betriebssystem für Sie plant. Wenn Ihre CPU über mehrere Kerne verfügt, können mehrere Threads gleichzeitig ausgeführt werden, jeder auf seinem eigenen Kern. Wenn mehr Threads als verfügbare Kerne vorhanden sind, muss das Betriebssystem bestimmen, welcher Thread auf einem Kern ausgeführt werden darf – und wie lange –, bevor zu einem anderen Thread gewechselt wird. Dieser Vorgang wird als Kontextwechselbezeichnet.
Allerdings bringt die Multithread-Programmierung eine Reihe von Komplikationen mit sich. Im magischen Szenario oben wurde die Funktion Update() gleichmäßig in vier Teilaktualisierungen aufgeteilt. Aber in Wirklichkeit wären Sie wahrscheinlich nicht in der Lage, so etwas Einfaches zu tun. Da die Threads gleichzeitig ausgeführt werden, müssen Sie vorsichtig sein, wenn sie gleichzeitig dieselben Daten lesen und schreiben, um zu verhindern, dass sie ihre Berechnungen gegenseitig beschädigen .
Dabei werden normalerweise sperrende Synchronisierungsprimitivewie Mutex oder Semaphor verwendet, um den Zugriff auf gemeinsame Zustände zwischen Threads zu steuern. Diese Grundelemente begrenzen normalerweise die Parallelität bestimmter Codeabschnitte (normalerweise wird gar keine gewählt), indem sie andere Threads „sperren“ und sie daran hindern, den Abschnitt auszuführen, bis der Sperrhalter fertig ist und den Abschnitt für alle wartenden Threads „entsperrt“. Dadurch wird die Leistung durch die Verwendung mehrerer Threads verringert, da diese nicht die ganze Zeit parallel ausgeführt werden. Allerdings wird dadurch sichergestellt, dass die Programme korrekt bleiben.
Aufgrund von Datenabhängigkeiten ist es wahrscheinlich auch nicht sinnvoll, einige Teile Ihres Updates parallel auszuführen. Beispielsweise müssen fast alle Spiele Eingaben von einem Controller lesen, diese Eingaben in einem Eingabepuffer speichern und dann den Eingabepuffer lesen und basierend auf den Werten reagieren.
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();
}Es wäre nicht sinnvoll, wenn Code, der den Eingabepuffer liest, um zu entscheiden, ob ein Zeichen ausgeführt werden soll, zur selben Zeit ausgeführt würde, während der Code für die Aktualisierung dieses Frames in den Eingabepuffer schreibt. Auch wenn Sie ein Mutex verwendet haben, um sicherzustellen, dass das Lesen und Schreiben in m_InputBuffer sicher ist, möchten Sie immer, dass zuerst in m_InputBuffer geschrieben wird und dann der m_InputBuffer- Lesecode als Zweites ausgeführt wird, damit Sie wissen, ob die Sprungtaste für das aktuelle Frame gedrückt wurde (und nicht für ein Frame in der Vergangenheit). Solche Datenabhängigkeiten sind üblich und normal, verringern jedoch den möglichen Grad der Parallelität.
Es gibt viele Ansätze zum Schreiben eines Multithread-Programms. Sie können plattformspezifische APIs zum direkten Erstellen und Verwalten von Threads verwenden oder verschiedene APIs nutzen, die eine Abstraktion bereitstellen, um einige der Komplikationen der Multithread-Programmierung zu bewältigen.
Ein Jobsystem ist eine solche Abstraktion. Es bietet die Möglichkeit, Teile Ihres Single-Thread-Codes in logische Blöcke aufzuteilen, die von diesem Code benötigten Daten zu isolieren, zu steuern, wer gleichzeitig auf diese Daten zugreift, und so viele Codeblöcke wie möglich parallel auszuführen, um zu versuchen, die gesamte auf der CPU verfügbare Rechenleistung nach Bedarf zu nutzen.
Heutzutage können wir beliebige Funktionen nicht automatisch in Teile aufteilen. Daher bietet Unity eine Job- API , mit der Benutzer Funktionen in kleine logische Blöcke umwandeln können. Von dort aus sorgt das Jobsystem dafür, dass diese Teile parallel ausgeführt werden.
Das Jobsystem besteht aus einigen Kernkomponenten:
- Jobs
- Auftragskennzeichen
- Auftragsplaner
public struct MyJob : IJob
{
public NativeArray<int> Data;
public void Execute()
{
// Do some work using our Data member
}
}Wie bereits erwähnt, besteht ein Job lediglich aus einer Funktion und einigen Daten. Diese Kapselung ist jedoch nützlich, da sie den Umfang der spezifischen Daten einschränkt, aus denen der Job liest oder in die er schreibt.
var myJob = new MyJob() { Data = someNativeArray };
var jobHandle = myJob.Schedule();Sobald eine Jobinstanz erstellt wurde, muss sie mit dem Jobsystem geplant werden. Dies geschieht mit der Methode .Schedule(), die über den Erweiterungsmechanismus von C# allen Jobtypen hinzugefügt wird. Um den geplanten Job zu identifizieren und zu verfolgen, wird ein JobHandle bereitgestellt.
Da Job-Handles geplante Jobs identifizieren, können sie zum Einrichten von Job-Abhängigkeiten verwendet werden. Auftragsabhängigkeiten garantieren, dass die Ausführung eines geplanten Auftrags erst beginnt, wenn seine Abhängigkeiten abgeschlossen sind. Als direkte Folge teilen sie uns auch mit, wann verschiedene Jobs parallel ausgeführt werden dürfen, indem sie einen gerichteten azyklischen Jobgraphenerstellen.
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);Beim Planen von Jobs ist der Job-Scheduler schließlich dafür verantwortlich, die geplanten Jobs zu verfolgen (durch Zuordnen von JobHandles zu den geplanten Jobinstanzen) und sicherzustellen, dass die Jobs so schnell wie möglich mit der Ausführung beginnen. Die Art und Weise, wie dies geschieht, ist wichtig, da es zwischen dem Design und den Nutzungsmustern des Jobsystems möglicherweise zu nicht offensichtlichen Konflikten kommen kann, die zu Mehrkosten führen, die die Leistungssteigerungen der Multithread-Programmierung aufzehren. Als die Benutzer begannen, das C#-Jobsystem zu übernehmen, traten bei uns Szenarien auf, in denen der Jobsystem-Overhead höher war als gewünscht. Dies führte zu Verbesserungen an der Implementierung des internen Jobsystems von Unity im Tech Stream 2022.2.
Bleiben Sie dran für Teil zwei, in dem untersucht wird, woher der Overhead im C#-Jobsystem kommt und wie er in Unity 2022.2 reduziert wurde.
Wenn Sie Fragen haben oder mehr erfahren möchten, besuchen Sie uns im C# Job System-Forum. Sie können mich auch direkt über Unity Discord unter dem Benutzernamen @Antifreeze#2763 erreichen. Achten Sie auf neue technische Blogs von anderen Unity -Entwicklern im Rahmen der laufenden Technik aus der Trenches -Reihe.
