Sollte ich separate Threads für Rendering und Logik haben oder noch mehr?
Mir ist der enorme Leistungsabfall bekannt, der durch die Datensynchronisation verursacht wird (geschweige denn durch Mutex-Sperren).
Ich habe darüber nachgedacht, dies auf das Äußerste zu heben und Threads für jedes erdenkliche Subsystem zu erstellen. Aber ich mache mir Sorgen, dass die Dinge auch langsamer werden könnten. (Ist es zum Beispiel vernünftig, den Eingabethread von Rendering- oder Game Logic-Threads zu trennen?) Würde die erforderliche Datensynchronisierung ihn sinnlos oder sogar langsamer machen?
performance
multithreading
j riv
quelle
quelle
Antworten:
Der übliche Ansatz, mehrere Kerne auszunutzen, ist offen gesagt einfach falsch. Wenn Sie Ihre Subsysteme in verschiedene Threads unterteilen, wird ein Teil der Arbeit zwar auf mehrere Kerne verteilt, es treten jedoch einige schwerwiegende Probleme auf. Erstens ist es sehr schwer, damit zu arbeiten. Wer möchte sich mit Sperren, Synchronisierung, Kommunikation und anderen Dingen beschäftigen, wenn er stattdessen einfach nur Rendering- oder Physik-Code schreiben könnte? Zweitens skaliert der Ansatz nicht wirklich. Bestenfalls können Sie so drei oder vier Kerne ausnutzen, und das ist, wenn Sie wirklich wissen, was Sie tun. Es gibt nur so viele Subsysteme in einem Spiel, und von diesen gibt es noch weniger, die viel CPU-Zeit in Anspruch nehmen. Es gibt ein paar gute Alternativen, die ich kenne.
Einer ist, einen Haupt-Thread zusammen mit einem Worker-Thread für jede zusätzliche CPU zu haben. Unabhängig vom Subsystem delegiert der Haupt-Thread isolierte Aufgaben über eine oder mehrere Warteschlangen an die Worker-Threads. Diese Aufgaben können selbst noch andere Aufgaben erzeugen. Der einzige Zweck der Worker-Threads besteht darin, die einzelnen Aufgaben nacheinander aus der Warteschlange zu holen und auszuführen. Das Wichtigste ist jedoch, dass ein Thread, sobald er das Ergebnis einer Aufgabe benötigt, das Ergebnis erhalten kann, wenn die Aufgabe abgeschlossen ist. Andernfalls kann er die Aufgabe sicher aus der Warteschlange entfernen und die Aufgabe ausführen Aufgabe selbst. Das heißt, nicht alle Aufgaben werden parallel zueinander geplant. Es ist gut, mehr Aufgaben zu haben, als gleichzeitig ausgeführt werden könnenwas in diesem Fall; Dies bedeutet, dass es wahrscheinlich skaliert, wenn Sie weitere Kerne hinzufügen. Ein Nachteil dabei ist, dass das Entwerfen einer anständigen Warteschlange und einer Worker-Schleife im Voraus viel Arbeit erfordert, es sei denn, Sie haben Zugriff auf eine Bibliothek oder eine Sprachlaufzeit, die dies bereits für Sie bereitstellt. Am schwierigsten ist es, sicherzustellen, dass Ihre Aufgaben wirklich isoliert und threadsicher sind und dass Ihre Aufgaben in einem glücklichen Mittelfeld zwischen grobkörnig und feinkörnig liegen.
Eine andere Alternative zu Subsystem-Threads besteht darin, jedes Subsystem isoliert zu parallelisieren. Das heißt, anstatt Rendering und Physik in ihren eigenen Threads auszuführen, schreiben Sie das Physik-Subsystem so, dass alle Ihre Kerne gleichzeitig verwendet werden. Schreiben Sie das Rendering-Subsystem so, dass alle Ihre Kerne gleichzeitig verwendet werden. abhängig von anderen Aspekten Ihrer Spielarchitektur). Im Physik-Subsystem können Sie beispielsweise alle Punktmassen im Spiel nehmen, sie auf Ihre Kerne aufteilen und dann alle Kerne gleichzeitig aktualisieren lassen. Jeder Kern kann dann Ihre Daten in engen Schleifen mit guter Lokalität bearbeiten. Dieser Lock-Step-Parallelitätsstil ähnelt dem einer GPU. Das Schwierigste dabei ist, sicherzustellen, dass Sie Ihre Arbeit in feinkörnige Stücke aufteilen, sodass sie gleichmäßig verteilt wirdtatsächlich führt zu einer gleichen Menge Arbeit auf alle Prozessoren.
Manchmal ist es jedoch aufgrund der Politik, des vorhandenen Codes oder anderer frustrierender Umstände am einfachsten, jedem Subsystem einen Thread zuzuweisen. In diesem Fall ist es am besten zu vermeiden, mehr Betriebssystem-Threads als Kerne für CPU-schwere Workloads zu erstellen (wenn Sie eine Laufzeit mit Lightweight-Threads haben, die sich zufällig über Ihre Kerne verteilen, ist dies keine so große Sache). Vermeiden Sie außerdem übermäßige Kommunikation. Ein guter Trick ist, Pipelining zu versuchen. Jedes wichtige Subsystem kann gleichzeitig an einem anderen Spielstatus arbeiten. Pipelining reduziert den Kommunikationsaufwand zwischen Ihren Subsystemen, da nicht alle gleichzeitig auf dieselben Daten zugreifen müssen, und es kann auch einige der durch Engpässe verursachten Schäden aufheben. Zum Beispiel, Wenn die Fertigstellung Ihres Physik-Subsystems in der Regel sehr lange dauert und Ihr Rendering-Subsystem immer darauf wartet, kann Ihre absolute Bildrate höher sein, wenn Sie das Physik-Subsystem für das nächste Bild ausführen, während das Rendering-Subsystem noch am vorherigen arbeitet Rahmen. Wenn Sie solche Engpässe haben und sie nicht auf andere Weise beseitigen können, kann Pipelining der legitimste Grund sein, sich mit Subsystem-Threads zu beschäftigen.
quelle
Es gibt ein paar Dinge zu beachten. Die Thread-pro-Subsystem-Route ist leicht zu überlegen, da die Codetrennung von Anfang an ziemlich offensichtlich ist. Abhängig davon, wie viel Interkommunikation Ihre Subsysteme benötigen, kann die Inter-Thread-Kommunikation Ihre Leistung beeinträchtigen. Außerdem skaliert dies nur auf N Kerne, wobei N die Anzahl der Subsysteme ist, die Sie in Threads abstrahieren.
Wenn Sie nur ein vorhandenes Spiel mit mehreren Threads betreiben möchten, ist dies wahrscheinlich der Weg des geringsten Widerstands. Wenn Sie jedoch an einigen Low-Level-Engine-Systemen arbeiten, die möglicherweise von mehreren Spielen oder Projekten gemeinsam genutzt werden, würde ich einen anderen Ansatz in Betracht ziehen.
Es kann ein wenig Kopfzerbrechen erfordern, aber wenn Sie Dinge als Jobwarteschlange mit einer Reihe von Arbeitsthreads aufteilen können, ist die Skalierung auf lange Sicht viel besser. Da die neuesten und besten Chips mit Millionen Kernen herauskommen, wird die Leistung Ihres Spiels entsprechend skaliert. Starten Sie einfach mehr Worker-Threads.
Wenn Sie also eine gewisse Parallelität zu einem vorhandenen Projekt herstellen möchten, würde ich eine Parallelisierung zwischen Subsystemen durchführen. Wenn Sie eine neue Engine von Grund auf mit Blick auf die parallele Skalierbarkeit erstellen, würde ich eine Jobwarteschlange untersuchen.
quelle
Diese Frage kann nicht am besten beantwortet werden, da es davon abhängt, was Sie erreichen möchten.
Die Xbox hat drei Kerne und kann einige Threads verarbeiten, bevor der Overhead beim Kontextwechsel zum Problem wird. Der PC kann mit einigen mehr fertig werden.
Viele Spiele wurden zur Vereinfachung der Programmierung in der Regel mit einem Thread erstellt. Dies ist für die meisten persönlichen Spiele in Ordnung. Das einzige, wofür Sie wahrscheinlich einen anderen Thread benötigen, sind Netzwerk und Audio.
Unreal hat einen Spiel-Thread, einen Render-Thread, einen Netzwerk-Thread und einen Audio-Thread (wenn ich mich richtig erinnere). Dies ist für viele Engines der aktuellen Generation ein ziemlicher Standard. Die Unterstützung eines separaten Rendering-Threads kann jedoch mühsam und mit viel Vorarbeit verbunden sein.
Die für Rage entwickelte idTech5-Engine verwendet tatsächlich eine beliebige Anzahl von Threads, indem sie Spielaufgaben in "Jobs" aufteilt, die mit einem Tasking-System verarbeitet werden. Ihr explizites Ziel ist es, die Game Engine ansprechend zu skalieren, wenn die Anzahl der Kerne im durchschnittlichen Spielsystem springt.
Die Technologie, die ich verwende (und geschrieben habe), hat einen eigenen Thread für Netzwerk, Eingabe, Audio, Rendern und Zeitplanung. Es verfügt dann über eine beliebige Anzahl von Threads, die zum Ausführen von Spielaufgaben verwendet werden können. Dies wird vom Planungsthread verwaltet. Es wurde viel Arbeit darauf verwendet, dass alle Threads gut zusammenspielen, aber es scheint, als würde es gut funktionieren und Multicore-Systeme sehr gut ausnutzen. Vielleicht ist es also eine Mission, die erfüllt ist (im Moment kann es sein, dass ich Audio / Networking störe) / Arbeit in nur 'Aufgaben' eingeben, die die Worker-Threads aktualisieren können).
Es hängt wirklich von Ihrem Endziel ab.
quelle
Ein Thread pro Subsystem ist der falsche Weg. Plötzlich lässt sich Ihre App nicht mehr skalieren, da einige Subsysteme viel mehr verlangen als andere. Dies war der Threading-Ansatz von Supreme Commander, der nicht über zwei Kerne hinaus skaliert werden konnte, da nur zwei Subsysteme viel CPU-Rendering und Physik / Spielelogik in Anspruch nahmen, obwohl sie 16 Threads hatten, die anderen Threads Das Spiel war kaum zu bewältigen und wurde daher nur auf zwei Kerne skaliert.
Sie sollten einen sogenannten Thread-Pool verwenden. Dies spiegelt in gewisser Weise den Ansatz wider, den Sie bei GPUs verfolgen - das heißt, Sie veröffentlichen die Arbeit, und jeder verfügbare Thread kommt einfach und erledigt dies und kehrt dann zum Warten auf die Arbeit zurück. Stellen Sie sich das wie einen Ringpuffer von Threads vor. Dieser Ansatz hat den Vorteil der N-Kern-Skalierung und ist sowohl für niedrige als auch für hohe Kernzahlen sehr gut skalierbar. Der Nachteil ist, dass es ziemlich schwierig ist, die Thread-Inhaberschaft für diesen Ansatz zu bearbeiten, da unmöglich zu wissen ist, welcher Thread welche Arbeit zu einem bestimmten Zeitpunkt ausführt. Es macht es auch sehr schwierig, Technologien wie Direct3D9 zu verwenden, die nicht mehrere Threads unterstützen.
Thread-Pools sind sehr schwer zu verwenden, liefern jedoch die bestmöglichen Ergebnisse. Verwenden Sie einen Thread-Pool, wenn Sie eine extrem gute Skalierung benötigen oder genügend Zeit haben, um daran zu arbeiten. Wenn Sie versuchen, Parallelität in ein vorhandenes Projekt mit unbekannten Abhängigkeitsproblemen und Single-Thread-Technologien einzuführen, ist dies nicht die Lösung für Sie.
quelle
Sie haben Recht, dass der wichtigste Teil darin besteht, eine Synchronisierung zu vermeiden, wo immer dies möglich ist. Es gibt einige Möglichkeiten, dies zu erreichen.
Kennen Sie Ihre Daten und speichern Sie sie entsprechend Ihren Verarbeitungsanforderungen. Auf diese Weise können Sie parallele Berechnungen planen, ohne dass eine Synchronisierung erforderlich ist. Leider ist dies die meiste Zeit nur schwer zu erreichen, da auf die Daten häufig zu unvorhersehbaren Zeiten von verschiedenen Systemen aus zugegriffen wird.
Definieren Sie eindeutige Zugriffszeiten für Daten. Sie können Ihr Haupt-Tick in x Phasen unterteilen. Wenn Sie sicher sind, dass Thread X die Daten nur in einer bestimmten Phase liest, wissen Sie auch, dass diese Daten von anderen Threads in einer anderen Phase geändert werden können.
Doppelpuffer deine Daten. Dies ist der einfachste Ansatz, erhöht jedoch die Latenz, da Thread X mit den Daten des letzten Frames arbeitet, während Thread Y die Daten für den nächsten Frame vorbereitet.
Meine persönliche Erfahrung zeigt, dass feinkörnige Berechnungen der effektivste Weg sind, da diese weitaus besser skaliert werden können als subsystembasierte Lösungen. Wenn Sie Ihre Subsysteme einbinden, wird die Frame-Zeit an das teuerste Subsystem gebunden. Dies kann dazu führen, dass alle Threads außer einem im Leerlauf laufen, bis das teure Subsystem seine Arbeit abgeschlossen hat. Wenn Sie in der Lage sind, große Teile Ihres Spiels in kleine Aufgaben zu unterteilen, können diese Aufgaben entsprechend geplant werden, um Leerlauf-Kerne zu vermeiden. Dies ist jedoch schwer zu erreichen, wenn Sie bereits über eine große Codebasis verfügen.
Um einige Hardwareeinschränkungen zu berücksichtigen, sollten Sie versuchen, Ihre Hardware niemals zu überbeanspruchen. Mit Überbestellung meine ich mehr Software-Threads als Ihre Plattform-Hardware-Threads. Besonders auf PPC-Architekturen (Xbox360, PS3) ist ein Task-Switch sehr teuer. Es ist natürlich völlig in Ordnung, wenn Sie ein paar überzeichnete Threads haben, die nur für eine kurze Zeit ausgelöst werden (z. B. einmal pro Frame) -Threads) wächst stetig, so dass Sie eine skalierbare Lösung finden möchten, die die zusätzliche CPU-Leistung ausnutzt. In diesem Bereich sollten Sie versuchen, Ihren Code so aufgabenbasiert wie möglich zu gestalten.
quelle
Allgemeine Faustregel für das Threading einer Anwendung: 1 Thread pro CPU-Kern. Auf einem Quad-Core-PC bedeutet dies 4. Wie bereits erwähnt, verfügt die XBox 360 jedoch über 3 Kerne, jedoch jeweils 2 Hardware-Threads, in diesem Fall also über 6 Threads. Auf einem System wie der PS3 ... nun, viel Glück damit :) Die Leute versuchen immer noch, es herauszufinden.
Ich würde vorschlagen, jedes System als eigenständiges Modul zu entwerfen, das Sie auf Wunsch einbinden können. Dies bedeutet normalerweise sehr klar definierte Kommunikationswege zwischen dem Modul und dem Rest des Motors. Ich mag sowohl schreibgeschützte Prozesse wie Rendering und Audio als auch Prozesse wie das Lesen von Player-Eingaben, damit Dinge abgespielt werden können. Wenn Sie auf die Antwort von AttackingHobo eingehen und 30-60 fps rendern, werden Ihre Daten, wenn sie 1/30 bis 1/60 einer Sekunde veraltet sind, das Reaktionsverhalten Ihres Spiels nicht beeinträchtigen. Denken Sie immer daran, dass der Hauptunterschied zwischen Anwendungssoftware und Videospielen darin besteht, 30-60 Mal pro Sekunde zu arbeiten. In diesem Sinne jedoch
Wenn Sie die Systeme Ihrer Engine gut genug entworfen haben, kann jedes von ihnen von Thread zu Thread verschoben werden, um den Lastausgleich Ihrer Engine auf spielspezifischer Basis und dergleichen zu optimieren. Theoretisch könnten Sie Ihre Engine auch in einem verteilten System einsetzen, wenn die einzelnen Komponenten auf vollständig separaten Computersystemen ausgeführt werden.
quelle
Ich erstelle einen Thread pro logischem Kern (minus einen, um den Hauptthread zu berücksichtigen, der im Übrigen für das Rendern verantwortlich ist, ansonsten aber auch als Arbeitsthread fungiert).
Ich sammle Ereignisse von Eingabegeräten in Echtzeit in einem Frame, aber wende sie erst am Ende des Frames an: Sie werden im nächsten Frame wirksam. Und ich verwende eine ähnliche Logik für das Rendern (alter Zustand) gegenüber dem Aktualisieren (neuer Zustand).
Ich verwende atomare Ereignisse, um unsichere Vorgänge bis zu einem späteren Zeitpunkt im selben Frame aufzuschieben, und ich verwende mehr als eine Ereigniswarteschlange (Jobwarteschlange), um eine Speichersperre zu implementieren, die eine eiserne Garantie für die Reihenfolge der Vorgänge bietet, ohne zu sperren oder zu warten (Gleichzeitige Warteschlangen nach Jobpriorität sperren).
Es ist zu erwähnen, dass jeder Job Unteraufträge (die feiner sind und sich der Atomizität nähern) an dieselbe oder eine höhere Prioritätswarteschlange (die später im Frame ausgeführt wird) senden kann.
Wenn ich drei solcher Warteschlangen habe, können alle Threads mit Ausnahme einer genau dreimal pro Frame angehalten werden (während darauf gewartet wird, dass andere Threads alle ausstehenden Jobs abschließen, die auf der aktuellen Prioritätsstufe ausgegeben wurden).
Dies scheint ein akzeptables Maß an Thread-Inaktivität zu sein!
quelle
Normalerweise verwende ich (offensichtlich) einen Haupt-Thread und füge jedes Mal einen Thread hinzu, wenn ich einen Leistungsabfall von etwa 10 bis 20 Prozent feststelle. Um einen solchen Tropfen zu lokalisieren, benutze ich die Performance-Tools von Visual Studio. Häufige Ereignisse sind das (Ent-) Laden einiger Bereiche der Karte oder das Durchführen umfangreicher Berechnungen.
quelle