I. Introduction▲
Jeff Preshing, architecte logiciel à Ubisoft Montréal, nous explique l'utilisation du C++ dans la programmation multicœur pour les jeux AAA. Le studio de Montréal a produit les très connus Assasin's Creed, FarCry 3, Splinter Cell, Watch Dogs, Prince of Persia…
II. Vidéo▲
CppCon 2014 - La programmation multicore dans les jeux C++
III. Résumé▲
III-A. Introduction▲
Chaque cœur dans un CPU possède le même ensemble d'instructions, partage le même espace mémoire et les threads peuvent s'exécuter sur n'importe quel cœur. Ce type de CPU existe depuis 2005.
III-B. Programmation multicœur à Ubisoft▲
Les consoles ont intégré les CPU multicoeurs à partir de la PS3 et Xbox 360.
III-B-1. Boucle principale (version monothread)▲
Pour une machine n'ayant qu'un unique cœur, la boucle de jeu se décompose ainsi :
-
Moteur :
- entrées utilisateur ;
- logique du jeu ;
- physique ;
- animation.
-
Graphismes :
- visibilité ;
- appel pour l'affichage.
III-B-2. Boucle principale (version multithread)▲
Trois techniques peuvent être utilisées pour la boucle de jeu sur une machine multicœur :
- le travail parallèle sur un fil ;
- l'utilisation de threads dédiés ;
- l'ordonnancement de tâches.
Afin de pouvoir mettre en place l'une de ces techniques, il devient obligatoire d'avoir des objets concurrents qui peuvent être implémentés soit avec des mécanismes provenant de la plateforme (sémaphores...), soit à l'aide d'opérations atomiques.
III-B-2-a. Le travail en parallèle (sur un fil)▲
Grâce à cette méthode, il devient possible d'exécuter la partie « graphismes » en parallèle de la partie « moteur » de la boucle de jeu. Pour synchroniser les deux fils, il est possible d'utiliser les sémaphores.
Toutefois, les objets du jeu peuvent être modifiés en même temps que le second thread ira les lire. Une première solution est d'avoir deux copies des objets partagés par les deux threads et de les alterner. Une seconde approche est d'avoir des objets complètement séparés, notamment, pour les objets liés aux graphismes. Les données devront être copiées à chaque image.
III-B-2-b. Les threads dédiés▲
Un thread peut être dédié au chargement du jeu. En effet, le jeu n'est jamais complètement en mémoire : celui-ci est streamé au fur et à mesure des déplacements du joueur. Le thread dédié recevra des requêtes provenant du thread gérant la logique du jeu pour charger les informations nécessaires à la suite du niveau. Si le thread n'a rien à faire, il est important qu'il dorme (libère le CPU).
III-B-2-c. Ordonnanceur de tâches▲
Celui-ci va diviser les tâches logiques (physique, animation…) et les paralléliser. Pour le mettre en place, il est possible d'utiliser une queue où les travaux seront insérés et exécutés par les threads libres. Toutefois, dans le monde du jeu vidéo, il est utile de regrouper chaque tâche en groupe et de traiter les groupes parallèlement.
III-B-3. Opérations atomiques▲
Un jeu multicœur nécessite les opérations atomiques suivantes :
- déclarations ;
- chargement/stockage ;
- ordonnancement (barrières) ;
- lecture /modification/écriture.
III-B-4. Exemples▲
Voici l'exemple d'une queue n'acceptant qu'un seul lecteur et qu'un seul écrivain et ayant une taille limitée :
template
<
class
T, int
size>
class
CappedSPSCQueue
{
private
:
T m_times[size];
volatile
int
m_writePos;
int
m_readPos;
public
:
CappedSPSCQueue() : m_writePos(0
), m_readPos(0
) {}
bool
tryPush(const
T&
item)
{
int
w =
m_writePos;
if
(w >=
size)
return
false
;
m_items[w] =
item;
m_writePos =
w +
1
;
return
true
;
}
bool
tryPop(T&
item)
{
int
w =
m_writePos;
if
(m_readPos >=
w)
return
false
;
item =
m_items[m_readPos];
m_readPos++
;
return
true
;
}
}
;
Toutefois, ce code peut ne pas fonctionner. En effet, le compilateur PowerPC pour la XBox 360 peut réordonner les instructions et donc placer l'insertion de l'objet après l'incrémentation du compteur d'écriture.
Pour corriger ce problème, il est nécessaire d'ajouter des barrières mémoire après l'écriture de l'objet et avant sa lecture.
III-C. La bibliothèque atomique du C++11▲
Si plusieurs threads doivent accéder à une même variable, et que l'un d'eux la modifie, alors tous les threads doivent utiliser les opérations atomiques du C++11.
À vrai dire, la bibliothèque atomique du C++11 est en réalité le rassemblement de deux bibliothèques :
- les opérations atomiques séquentiellement consistantes (similaires aux variables volatiles en JAVA) ;
- les opérations atomiques bas niveau (similaires aux variables volatiles du C/C++).
Les opérations vues précédemment sont effectuées en C++11 de la manière suivante :
déclarations |
Sélectionnez
|
chargement/stockage |
Sélectionnez
|
ordonnancement (barrières) |
Sélectionnez
|
lecture /modification/écriture |
Sélectionnez
|
Revoici l'exemple de la queue n'acceptant qu'un seul lecteur et qu'un seul écrivain et ayant une taille limitée en C++11 :
template
<
class
T, int
size>
class
CappedSPSCQueue
{
private
:
T m_items[size];
atomic<
int
>
m_writePos;
int
m_readPos;
public
:
CappedSPSCQueue() : m_writePos(0
, m_redPos(0
) {}
bool
tryPush(const
T&
item)
{
int
w =
m_writePos
if
(w >=
size)
return
false
;
m_items[w] =
item;
m_writePos =
w +
1
;
return
true
;
}
bool
tryPop(T&
item)
{
int
w =
m_writePos
if
(m_readPos >=
w)
return
false
;
item =
m_items[m_readPos];
m_readPos++
;
return
true
;
}
}
IV. Commenter▲
Vous pouvez commenter et donner vos avis dans la discussion associée sur le forum.