I. Introduction

Nicolas Fleury, architecte logiciel à Ubisoft Montréal, nous montre l'utilisation faite du C++ dans 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…. Nicolas travaille à Ubisoft depuis huit ans et son point de vue sur le C++ a changé lors de son arrivée dans le monde du jeu vidéo.

II. Vidéo


CppCon 2014 - Le C++ dans les jeux AAA


III. Résumé

III-A. Situation

Ubisoft Montréal, c'est l'un des plus grands studios de développement avec plus de 2600 employés. Le studio travaille sur des projets énormes dans lesquels plus de 1000 employés éparpillés à travers le monde (dont 400 à Montréal) travaillent ensemble.
Le développement est très centré autour de Microsoft Windows. Les outils pour le développement console s'intègrent habituellement avec Microsoft Visual Studio.

Un jeu comme Assassin's Creed Unity comprend 6,5 millions de lignes de code C++, 9 millions proviennent des partenaires externes. Il y a aussi 5 millions de lignes de code C#.
Pour Rainbow Six: Siege, le code du moteur comprend 3,5 millions de lignes de code, 4,5 millions venant du groupe technologique. Une recompilation du projet prend trois à cinq minutes.

Pour les projets, l'équipe privilégie les solutions performantes. Le choix du C++ est d'ailleurs basé sur ce point. Malgré le fait qu'il ne soit pas le code le plus efficace en terme de productivité, il est le plus performant. Lorsque la performance n'est pas nécessaire, les développeurs d'Ubisoft Montréal utilisent du C# (pour l'éditeur).

Du point de vue du C++, ils n'utilisent pas le Run-Time Type Information. Ils utilisent leur propre système de réflexion. Cela leur permet de contrôler précisément l'utilisation de la mémoire. De plus, les hackers pourraient utiliser certaines des informations qui ne sont pas nécessairement utiles pour le studio.
Ils n'utilisent pas non plus les exceptions, notamment car dans le passé, cela avait un coût à l'exécution et même de nos jours, cela a un impact dans le temps de compilation. Pour remplacer cela, ils ont leur propre sauvegarde de la pile d'appels. De plus, l'utilisation d'analyseur statique de code leur permet de détecter où les codes de retour ne sont pas vérifiés.
Le studio n'utilise pas les conteneurs de la STL, mais utilise bien les algorithmes exposés par la STL. Leurs classes ont notamment des fonctionnalités de débogage supplémentaires.
Finalement, ils n'utilisent aucun fichier d'en-tête de Boost afin d'améliorer les temps de compilation.

III-B. Temps d'itération

Afin de réduire les temps de compilation, le studio utilise FastBuild pour remplacer MSBuild. FastBuild est un outil open source développé par Franta Fullin. Il possède une meilleure gestion des dépendances des DLL, consomme moins de ressources CPU, gère la distribution et la mise en cache. Chaque machine de développeur participe à la compilation.
De plus, ils utilisent les fichiers d'en-têtes précompilés, l'option /Ob1 pour les compilations de débogage et uniquement des classes templates ayant des classes de base non templates.

Ubisoft utilise du code généré automatiquement. Pour cela, ils ont créé leur propre langage intermédiaire. Ainsi, ils peuvent éviter certains morceaux de méta programmation.

Ils ont aussi développé leurs outils :

  • un analyseur de fichier .obj ;
  • un nettoyeur de fichier d'en-tête inutile (mais celui de Google est mieux).

III-C. Performance

La notion de performance est très importante dans les jeux vidéo. 10 % du code s'exécute 90 % du temps. De plus, le taux de rafraîchissement est une réalité à respecter. Les derniers mois d'un projet passent par une grosse phase d'optimisation afin de pouvoir exécuter le jeu à 30 FPS (soit 33.3 ms par image).

III-C-1. Cache miss

Voici un exemple de programmation d'un parcours de tableau provoquant une grande différence :

 
Sélectionnez
struct Data
{
    Data() 
    { 
        for (int i = 0; i < 64; ++i)
            values[i] = i;
    }
    int values[64];
}

// Utilisation
int total = 0;

for (int i = 0; i < size1; ++i)
    for (int j = 0; j < size2; ++j)
        total += data[j].values[i];
        
// Ce code va huit fois plus vite
for (int j = 0; j < size2; ++j)
    for (int i = 0; i < size1; ++i)
        total += data[j].values[i]

Le second code est plus rapide, car il utilise le cache des CPU alors que le premier code forçait le programme à accéder à la mémoire vive pour chaque itération en effectuant des accès mémoire non linéaires.

III-C-2. Registres

Voici un second exemple :

 
Sélectionnez
struct MyClass {
    int64_t m_Total = 0;
    void UpdateTotal(int* values, int count);
};

// Utilisation
for (int i = 0; i < count; ++i)
{
    m_Total += values[i];
}

// 12 fois plus rapide
int64_t total = 0;
for (int i = 0; i < count; ++i)
{
    total += values[i];
}
m_Total = total;

Le second code est plus rapide, car le travail est effectué à l'aide d'un registre CPU.

Pour ce cas-là, les compilateurs récents peuvent prendre la décision de remanier le premier code afin d'utiliser un registre.

III-C-3. Singletons

 
Sélectionnez
struct MyLibSingletonStorer
{
    MyManager m_MyManager;
    MyOtherManager m_MyOtherManager;
    ...
};

template <typename T>
class Singleton {
    protected: Singleton() { ms_Inst = this; }
    private: static T* ms_Inst = nullptr;
    public: static T* GetInst() { return m_Inst; }
};

class MyManager : public Singleton<MyManager>

struct MyLibSingletonStorer
{
    GlobalSingleton<MyManager>::Scope m_MyManager;
    MyOtherManager m_MyOtherManager;
};

void Construct() { new (&m_Data.m_Buffer)T(); }
void Destroy() { GetInst().~T(); }
T&GetInst() { return *(T*)&m_Data.m_Buffer; }

Chez Ubisoft, afin de contrôler la construction de leurs singletons, ils utilisent une structure dans laquelle les singletons sont construits dans l'ordre.

De plus, les singletons peuvent avoir un impact sur les performances. Donc, ils ont créé la structure MyLibSingletonStorer permettant d'avoir un singleton tout en ayant les performances d'une variable globale.

III-C-4. Fonctions virtuelles

Si vous avez une liste de Shape avec une fonction virtuelle Draw(), il y a de grandes chances que vous fassiez un cache miss à chaque appel de fonction, car le programme doit résoudre la vtable afin de retrouver la bonne fonction à appeler.

Pour résoudre cela, les développeurs pourraient trier les objets par type, mais le problème ne serait que diminué et non supprimé. Le mieux est donc de se passer du mécanisme des fonctions virtuelles.

Cela est d'autant plus vrai pour les jeux tournant sur consoles, mais sur PC une telle optimisation peut être superflue. Le conseil habituel de mesurer les performances avant de faire une quelconque optimisation est toujours d'actualité.

III-C-5. Éviter le tas

Pour Ubisoft Montréal, il est utile d'éviter le tas à cause de sa lourdeur, de son aspect global (alloué pour chaque thread) et souffre de fragmentation, notamment dans les jeux vidéo où beaucoup d'allocations sont effectuées à chaque image.

III-D. Débogage

Le débogage est un autre défi pour les développeurs. Chez Ubisoft Montréal, ils ont une base de code multithreadé énorme dont des bogues qui ne sont reproductibles que sur des cibles optimisées. De plus, ils doivent éviter de recompiler le projet juste pour de simples options de débogage et les versions de débogage doivent être rapides pour être utilisables.

Pour cela, ils ont désactivé :

  • le débogage des itérateurs ;
  • le tas de débogage de Microsoft Visual Studio (_NO_DEBUG_HEAP=1) ;
  • le tas de Windows tolérant aux fautes.

III-D-1. Pile d'appels

Il arrive que la pile d'appels soit inutilisable et il devient donc impossible de comprendre comment le programme est arrivé à ce bogue. La méthode conseillée est de regarder les registres et d'essayer d'afficher chaque registre comme s'il contenait la pile d'appels.

III-D-2. Tags

Chaque allocation est taguée afin de pouvoir mieux repérer des éléments dans la mémoire.

III-D-3. Corruption de mémoire

Lors d'une allocation, Ubisoft force l'allocation de l'objet en fin de page. Ainsi, si un accès invalide est fait, celui-ci va directement faire crasher le programme et donc, activer le débogueur. De même, la page de mémoire est replacée en lecture seule dès que l'objet est retiré.

IV. Commenter

Vous pouvez commenter et donner vos avis dans la discussion associée sur le forum.