Combien de fils devrais-je avoir et pour quoi?

81

Devrais-je avoir des threads séparés pour le rendu et la logique, voire plus?

Je suis conscient de la perte de performances considérable provoquée par la synchronisation des données (sans parler des verrous mutex).

J'ai pensé prendre cela à l'extrême et créer des threads pour tous les sous-systèmes imaginables. Mais je crains que cela ne ralentisse les choses aussi. (Par exemple, est-il normal de séparer le thread d'entrée des threads de rendu ou de logique de jeu?) La synchronisation des données requise la rend-elle inutile ou même plus lente?

j riv
la source
6
quelle plateforme? PC, console NextGen, smartphones?
Ellis
Il y a une chose à laquelle je peux penser qui nécessiterait le multi-threading; la mise en réseau.
Savonneux
Pour arrêter les exagérations, il n’ya pas de ralentissement "immense" lorsque des verrous sont en jeu. c'est une légende urbaine et un préjugé.
v.oddou

Réponses:

61

L’approche commune pour tirer parti des multiples cœurs est, franchement, tout simplement erronée. Si vous séparez vos sous-systèmes en différents threads, vous aurez en fait une partie du travail dans plusieurs cœurs, mais cela pose des problèmes majeurs. Premièrement, il est très difficile de travailler avec. Qui veut s'amuser avec des verrous, la synchronisation, la communication et d'autres choses alors qu'il pourrait simplement écrire du code de rendu ou de physique à la place? Deuxièmement, l'approche n'augmente pas réellement. Au mieux, cela vous permettra de tirer avantage de trois ou quatre cœurs, et ce, si vous savez vraiment ce que vous faites. Un jeu ne contient qu'un nombre limité de sous-systèmes, et il y en a même moins qui prennent beaucoup de temps CPU. Il y a quelques bonnes alternatives que je connais.

L'une consiste à avoir un thread principal avec un thread de travail pour chaque CPU supplémentaire. Quel que soit le sous-système, le thread principal délègue des tâches isolées aux threads de travail via une sorte de file d'attente; ces tâches peuvent elles-mêmes créer encore d'autres tâches. Les threads de travail ont pour seul objectif de saisir et d'exécuter les tâches de la file d'attente, une par une. La chose la plus importante, cependant, est que dès qu'un thread a besoin du résultat d'une tâche, si la tâche est terminée, il peut obtenir le résultat et si ce n'est pas le cas, il peut supprimer la tâche en toute sécurité de la file d'attente et continuer tâche elle-même. En d'autres termes, toutes les tâches ne seront pas planifiées en parallèle les unes avec les autres. Avoir plus de tâches que ce qui peut être exécuté en parallèle est un bonchose dans ce cas; cela signifie qu'il est susceptible d'évoluer à mesure que vous ajoutez plus de cœurs. Un inconvénient est qu’il faut beaucoup de travail en amont pour concevoir une file d’attente et une boucle de travail correctes, sauf si vous avez accès à une bibliothèque ou à un environnement d’exécution linguistique qui vous en fournit déjà un. La partie la plus difficile consiste à vous assurer que vos tâches sont vraiment isolées et sécurisées, et à vous assurer que vos tâches se situent dans un juste milieu entre le grain grossier et le grain fin.

Une autre alternative aux threads de sous-système consiste à paralléliser chaque sous-système de manière isolée. Autrement dit, au lieu d'exécuter le rendu et la physique dans leurs propres threads, écrivez le sous-système physique pour utiliser tous vos cœurs en même temps, écrivez le sous-système de rendu pour utiliser tous vos cœurs en même temps, puis laissez les deux systèmes s'exécuter simplement de manière séquentielle (ou entrelacée). en fonction d'autres aspects de votre architecture de jeu). Par exemple, dans le sous-système physique, vous pouvez regrouper toutes les masses de points du jeu, les répartir entre vos cœurs, puis les mettre à jour simultanément. Chaque noyau peut alors travailler sur vos données en boucles serrées avec une bonne localisation. Ce style de parallélisme à verrouillage est similaire à ce que fait un GPU. La partie la plus difficile ici consiste à vous assurer que vous divisez votre travail en morceaux fins comme ceux-ci.se traduit par une charge de travail égale pour tous les processeurs.

Cependant, il est parfois plus facile, en raison de la politique, du code existant ou d’autres circonstances frustrantes, de donner un fil à chaque sous-système. Dans ce cas, il est préférable d'éviter de créer plus de threads de système d'exploitation que de cœurs pour les charges de travail lourdes du processeur (si vous avez un environnement d'exécution avec des threads légers qui équilibrent simplement vos cores, le problème n'est pas aussi grave). Évitez également les communications excessives. Une bonne astuce consiste à essayer de pipeliner; chaque sous-système majeur peut travailler sur un état de jeu différent à la fois. Le traitement en pipeline réduit la quantité de communication nécessaire entre vos sous-systèmes, car ils n'ont pas tous besoin d'accéder aux mêmes données en même temps et peut également annuler certains des dommages causés par les goulots d'étranglement. Par exemple, si votre sous-système physique a tendance à prendre beaucoup de temps et que votre sous-système de rendu finit toujours par l'attendre, votre fréquence d'images absolue pourrait être supérieure si vous exécutez le sous-système physique pour la trame suivante pendant que le sous-système de rendu fonctionne toujours sur le précédent. Cadre. En fait, si vous avez de tels goulots d'étranglement et que vous ne pouvez pas les supprimer, le traitement en pipeline peut être le motif le plus légitime de s'inquiéter des threads de sous-système.

Jake McArthur
la source
"dès qu'un thread a besoin du résultat d'une tâche, si la tâche est terminée, il peut obtenir le résultat et sinon, il peut supprimer la tâche en toute sécurité de la file d'attente et aller de l'avant et effectuer cette tâche elle-même". Parlez-vous d'une tâche générée par le même fil? Si tel est le cas, cela n'aurait-il pas plus de sens si cette tâche est exécutée par le thread qui l'a générée?
Jmp97
c'est-à-dire que le thread pourrait, sans planifier la tâche, l'exécuter immédiatement.
jmp97
3
Le fait est que le fil de discussion ne sait pas nécessairement à l’avance s’il serait préférable d’exécuter la tâche en parallèle ou non. L'idée est de susciter de manière spéculative le travail dont vous aurez éventuellement besoin et si un autre thread se trouve inactif, il peut continuer et effectuer ce travail pour vous. Si cela ne se produit pas au moment où vous avez besoin du résultat, vous pouvez simplement extraire vous-même la tâche de la file d'attente. Ce schéma permet d' équilibrer de manière dynamique une charge de travail sur plusieurs cœurs plutôt que de manière statique.
Jake McArthur
Désolé de prendre si longtemps pour revenir à ce fil. Je ne fais pas attention à gamedev ces derniers temps. C’est probablement la meilleure réponse, directe mais directe et exhaustive.
j riv
1
Vous avez raison en ce sens que j'ai négligé de parler de charges de travail lourdes en E / S. Mon interprétation de la question était qu'il ne s'agissait que de charges de travail lourdes en ressources processeur.
Jake McArthur
30

Il y a deux choses à considérer. Il est facile de penser à la route thread par sous-système car la séparation du code est assez évidente au début. Toutefois, en fonction des besoins en intercommunication de vos sous-systèmes, les communications inter-threads pourraient réellement réduire vos performances. En outre, cela ne concerne que N noyaux, N étant le nombre de sous-systèmes que vous résumez en threads.

Si vous cherchez simplement à multithreader un jeu existant, c'est probablement le chemin de la moindre résistance. Cependant, si vous travaillez sur des systèmes de moteur de bas niveau pouvant être partagés entre plusieurs jeux ou projets, je considérerais une autre approche.

Cela peut prendre un peu de temps, mais si vous parvenez à séparer une tâche en file d'attente avec un ensemble de tâches de travail, sa taille sera bien meilleure à long terme. Alors que les derniers et meilleurs jetons sortent avec des milliards de cœurs, les performances de votre jeu évolueront parallèlement, il ne vous reste plus qu'à lancer plus de threads de travail.

Donc, fondamentalement, si vous souhaitez intégrer un certain parallélisme à un projet existant, je paralléliserais à travers des sous-systèmes. Si vous construisez un nouveau moteur en partant de zéro avec une évolutivité parallèle en tête, je me lancerai dans une file d'attente de tâches.

Bob Somers
la source
Le système que vous mentionnez est très similaire à un système de planification mentionné dans la réponse donnée par l’Autre James, mais il contient encore de bons détails dans ce domaine, ce qui ajoute +1 à la discussion.
James le
3
un wiki de communauté sur la configuration d'une file d'attente de travail et de threads de travail serait bien.
bot_bot
23

Cette question n’a pas de meilleure réponse car elle dépend de ce que vous essayez d’accomplir.

La xbox a trois cœurs et peut gérer quelques threads avant que la surcharge de contexte ne devienne un problème. Le PC peut en traiter un peu plus.

Un grand nombre de jeux ont généralement été mono-thread pour faciliter la programmation. C'est bien pour la plupart des jeux personnels. La seule chose pour laquelle vous auriez probablement besoin d'un autre thread est Réseau et audio.

Unreal a un fil de jeu, un fil de rendu, un fil de réseau et un fil audio (si je me souviens bien). C'est assez standard pour beaucoup de moteurs de génération actuelle, bien que pouvoir supporter un fil de rendu séparé puisse être pénible et impliquer beaucoup de travail de base.

Le moteur idTech5 en cours de développement pour Rage utilise en fait un nombre illimité de threads, et il le fait en décomposant les tâches du jeu en "travaux" traités avec un système de tâches. Leur objectif explicite est de bien faire évoluer leur moteur de jeu lorsque le nombre de cœurs sur le système de jeu moyen augmente.

La technologie que j'utilise (et que j'ai écrite) comporte un fil séparé pour la mise en réseau, l'entrée, l'audio, le rendu et la planification. Il a ensuite un nombre quelconque de threads pouvant être utilisés pour effectuer des tâches de jeu, et ceci est géré par le thread de planification. Il a fallu déployer beaucoup d'efforts pour que tous les fils fonctionnent correctement, mais cela semble bien fonctionner et utiliser très bien les systèmes multicœurs, alors c'est peut-être une mission accomplie (pour l'instant, je risque de casser l'audio / la gestion de réseau / input travaille uniquement dans des 'tâches' que les threads de travail peuvent mettre à jour).

Cela dépend vraiment de votre objectif final.

James
la source
+1 pour la mention d'un système de planification .. généralement un bon endroit pour centrer la communication fil / système :)
James
Pourquoi le vote négatif, vote négatif?
jcora
12

Un thread par sous-système n'est pas la bonne solution. Tout à coup, votre application ne sera pas mise à l'échelle car certains sous-systèmes exigent beaucoup plus que d'autres. C’est l’approche de threading adoptée par Supreme Commander et elle n’a pas dépassé les deux cœurs, car ils ne possédaient que deux sous-systèmes qui prenaient une quantité substantielle de processeur et de logique physique / jeu, bien qu’ils aient 16 threads, les autres threads. à peine équivalait à un travail et par conséquent, le jeu a été réduit à deux cœurs.

Ce que vous devriez faire est d'utiliser quelque chose appelé un pool de threads. Cela reflète quelque peu l'approche adoptée sur les GPU, c'est-à-dire que vous publiez du travail et que tout thread disponible le fait et le fait, puis qu'il retourne à l'attente du travail. Pensez-y comme à un tampon circulaire de threads. Cette approche présente l’avantage de la mise à l’échelle N-core et est très efficace pour la mise à l’échelle des comptages de cœur bas et élevé. L'inconvénient est qu'il est assez difficile de gérer la propriété de fil pour cette approche, car il est impossible de savoir quel fil fonctionne quel travail à un moment donné. Vous devez donc régler les problèmes de propriété très étroitement. Il est également très difficile d’utiliser des technologies telles que Direct3D9 qui ne prennent pas en charge plusieurs threads.

Les pools de threads sont très difficiles à utiliser, mais ils fournissent les meilleurs résultats possibles. Si vous avez besoin d'une mise à l'échelle extrêmement bonne ou si vous avez suffisamment de temps pour travailler dessus, utilisez un pool de threads. Si vous essayez d'introduire le parallélisme dans un projet existant avec des problèmes de dépendance inconnus et des technologies mono-thread, ce n'est pas la solution pour vous.

DeadMG
la source
Pour être un peu plus précis: les GPU n'utilisent pas de pools de threads, mais le planificateur de threads est implémenté dans le matériel, ce qui rend très économique la création de nouveaux threads et de commutateurs de threads, contrairement aux CPU où la création de threads et les changements de contexte coûtent chers. Voir le Guide du programmeur Nvidias CUDA pour un exemple.
Nils
2
+1: meilleure réponse ici. J'utiliserais même plus de constructions abstraites que de pools de threads (par exemple, les files d'attente et les travailleurs) si votre infrastructure le permettait. Il est beaucoup plus facile de penser / programmer en ce sens que dans les threads / verrous / etc. De plus, diviser votre jeu en rendu, logique, etc. est un non-sens, car le rendu doit attendre la fin de la logique. Créez plutôt des travaux qui peuvent réellement être exécutés en parallèle (par exemple: calculez l'IA d'un npc pour la prochaine image).
Dave O.
@ DaveO. Votre point "Plus" est tellement, tellement vrai.
Ingénieur
11

Vous avez raison de dire que la partie la plus critique consiste à éviter la synchronisation dans la mesure du possible. Il y a plusieurs façons d'y parvenir.

  1. Connaissez vos données et stockez-les en mémoire en fonction de vos besoins en matière de traitement. Cela vous permet de planifier des calculs parallèles sans nécessiter de synchronisation. Malheureusement, cela est la plupart du temps assez difficile à réaliser car les données sont souvent accédées depuis différents systèmes à des moments imprévisibles.

  2. Définissez des temps d'accès clairs pour les données. Vous pouvez séparer votre tick principal en x phases. Si vous êtes sûr que Thread X lit les données uniquement dans une phase spécifique, vous savez également que ces données peuvent être modifiées par d'autres threads dans une phase différente.

  3. Double-Buffer vos données. C'est l'approche la plus simple, mais elle augmente la latence, car Thread X utilise les données de la dernière image, tandis que Thread Y prépare les données pour l'image suivante.

Mon expérience personnelle montre que les calculs les plus fins sont le moyen le plus efficace, car ils peuvent évoluer beaucoup mieux que les solutions basées sur un sous-système. Si vous enfilez vos sous-systèmes, le temps-cadre sera lié au sous-système le plus cher. Cela peut entraîner tous les threads sauf un inactif jusqu'à ce que le coûteux sous-système ait finalement terminé son travail. Si vous êtes en mesure de séparer de grandes parties de votre jeu en petites tâches, vous pouvez les planifier en conséquence pour éviter les noyaux inactifs. Mais c'est quelque chose qui est difficile à accomplir si vous avez déjà une grande base de code.

Pour prendre en compte certaines contraintes matérielles, essayez de ne jamais sursouscrire votre matériel. Par sursouscription, je veux dire avoir plus de threads logiciels que ceux de votre plate-forme. Surtout sur les architectures PPC (Xbox360, PS3), un commutateur de tâches est vraiment coûteux. C’est bien sûr parfaitement acceptable si vous avez quelques threads surabonnés qui ne sont déclenchés que peu de temps (une fois par image, par exemple). Si vous ciblez le PC, vous devez garder à l’esprit que le nombre de cœurs (ou mieux -Threads) ne cesse de croître. Vous souhaitez donc trouver une solution évolutive, qui tire parti de la puissance supplémentaire du processeur. Donc, dans ce domaine, vous devriez essayer de concevoir votre code aussi basé que possible sur les tâches.

DarthCoder
la source
3

Règle générale pour le threading d'une application: 1 thread par cœur de CPU. Sur un PC quad core, cela signifie 4. Comme on l'a noté, la XBox 360 a cependant 3 cœurs mais 2 threads matériels chacun, donc 6 threads dans ce cas. Sur un système comme la PS3 ... bon courage pour celui-là :) Les gens essaient encore de le comprendre.

Je suggèrerais de concevoir chaque système comme un module autonome que vous pourriez enfiler si vous le souhaitez. Cela signifie généralement que les voies de communication entre le module et le reste du moteur sont très clairement définies. J'apprécie particulièrement les processus en lecture seule tels que le rendu et l'audio, ainsi que les processus "sommes-nous encore là", comme la lecture des entrées du lecteur pour les éléments à supprimer. Pour aborder la réponse donnée par AttackingHobo, lorsque vous convertissez en 30-60 images par seconde, si vos données sont à 1 / 30e / 1 / 60e de seconde, cela ne va vraiment pas nuire à la réactivité de votre jeu. Rappelez-vous toujours que la principale différence entre un logiciel d'application et un jeu vidéo consiste à tout faire 30 à 60 fois par seconde. Sur cette même note cependant,

Si vous concevez suffisamment les systèmes de votre moteur, vous pouvez en déplacer un à un pour équilibrer la charge de votre moteur de manière plus appropriée, jeu par match, etc. En théorie, vous pouvez également utiliser votre moteur dans un système distribué, le cas échéant, lorsque des systèmes informatiques entièrement distincts exécutent chaque composant.

James
la source
2
La Xbox360 a 2
pistes de disque dur
Ah, +1 :) J'ai toujours été limité aux zones de réseautage de la 360 et de la ps3, hé :)
James
0

Je crée un fil par noyau logique (moins un, pour prendre en compte le fil principal, qui est accessoirement responsable du rendu, mais agit également comme un fil de travail).

Je collecte les événements de périphérique d'entrée en temps réel tout au long d'une image, mais ne les applique pas jusqu'à la fin de l'image: ils auront un effet dans l'image suivante. Et j'utilise une logique similaire pour le rendu (ancien état) par rapport à la mise à jour (nouvel état).

J'utilise des événements atomiques pour différer des opérations dangereuses jusqu'à une date ultérieure dans la même trame et j'utilise plus d'une file d'attente d'événements (file d'attente de travaux) afin d'implémenter une barrière de mémoire qui donne une garantie absolue en ce qui concerne l'ordre des opérations, sans verrouillage ni attente. (files d'attente concurrentes libres de verrouillage dans l'ordre de priorité des tâches).

Il est à noter que tout travail peut émettre des sous-travaux (qui sont plus fins et qui s'approchent de l'atomicité) à la même file d'attente prioritaire ou à une file supérieure (servie plus tard dans la trame).

Étant donné que j'ai trois files d'attente de ce type, tous les threads, sauf un, peuvent potentiellement être bloqués exactement trois fois par image (en attendant que d'autres threads terminent tous les travaux en attente émis au niveau de priorité actuel).

Cela semble un niveau acceptable d'inactivité du fil!

Homère
la source
Mon cadre commence par le rendu MAIN du VIEIL ÉTAT à partir de la passe de mise à jour du précédent; pendant que tous les autres threads commencent immédiatement à calculer l'état de la monture SUIVANTE, j'utilise simplement Events pour doubler les changements d'état de la mémoire tampon jusqu'à atteindre un point dans lequel personne ne lit plus .
Homer
0

J'utilise habituellement un fil principal (évidemment) et je vais en ajouter un à chaque fois que je remarque une chute de performances d'environ 10 à 20%. Pour faire une telle chute, j'utilise les outils de performance de Visual Studio. Les événements courants consistent à (dé) charger certaines zones de la carte ou à effectuer des calculs lourds.

Lenard Arquin
la source