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 :

 
Sélectionnez
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
atomic<int> A;

chargement/stockage

 
Sélectionnez
A.store(1, memory_order_relaxed);
int a = A.load(memory_order_relaxed);

ordonnancement (barrières)

 
Sélectionnez
atomic_thread_fence(memory_order_acquire/release);
atomic_thread_fence(memory_order_seq_cst);

lecture /modification/écriture

 
Sélectionnez
A.fetch_add(1, memory_order_relaxed);
A.compare_exchange_strong(..., ..., memory_order_relaxed);
...

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 :

 
Sélectionnez
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.