I. Introduction

Mike Acton est directeur du moteur chez Insomniac Games (Sony). Il a notamment travaillé sur Resistance, Rachet & Clanck, Fuse. Le studio où il travaille, constitué d'une centaine de personnes est capable de produire deux jeux par an, ce qui est énorme pour l'industrie du jeu vidéo. Il partage aussi ses connaissances à propos des performances du Cell (CPU de la PlayStation 3) sur son site, mais aussi à travers de Altdevblogaday.

II. Vidéo


CppCon 2014 - Conception orientée données et C++


III. Résumé

III-A. Introduction

L'équipe de développement moteur travaille sur :

  • le rendu ;
  • les animations et gestes ;
  • le streaming ;
  • les cinématiques ;
  • les effets visuels ;
  • les effets de post process ;
  • la navigation ;
  • la localisation ;
  • et plus encore…

Mais c'est aussi l'équipe en charge des outils pour les autres membres du studio de développement (programmeurs, comme artistes, game designers…) :

  • création de niveaux ;
  • éclairage ;
  • édition des matériaux ;
  • création des effets visuels ;
  • édition des animations et machines à états ;
  • éditeur graphique de script ;
  • remplissage de la scène ;
  • création des cinématiques ;
  • et plus encore…

Du coup, ils font face à plusieurs préoccupations :

  • délais très stricts ;
  • les performances logicielles temps réels (33 ms) ;
  • utilisabilité (compilation rapide et efficace/facile) ;
  • performance en général ;
  • maintenance ;
  • débogabilité.

Les langages utilisés dans le monde du jeu vidéo, sont :

  • C ;
  • C++ (~ 70 %) ;
  • ASM (des outils les aident pour le produire, même s'ils souhaiteraient travailler avec tout le temps) ;
  • Perl ;
  • JavaScript ;
  • C# ;
  • pixel shaders, vertes shaders, geometry shaders, compute shaders…

Finalement, on peut comparer certains aspects de la programmation d'un jeu vidéo, à la programmation d'un robot pour Mars :

  • éviter les exceptions ;
  • éviter les templates, car souvent, leur utilisation est mauvaise et tue les temps de compilation ;
  • aucune utilisation des iostream ;
  • l'héritage multiple est juste banni ;
  • pas de surcharge des opérateurs (ou uniquement pour les cas très évidents, comme pour les vecteurs) ;
  • RTTI ;
  • pas de STL (ne solutionnent pas les problèmes qu'ils ont) ;
  • allocations de mémoire personnalisées (hiérarchie pour la mémoire, appropriée pour leurs systèmes) ;
  • outils de débogages personnalisés (en direct et hors ligne).

III-B. Qu'est-ce que la programmation orientée données ?

III-B-1. Principes

Le but de n'importe quel programme et de toutes les sections de ce programme est de passer les données d'une forme à une autre.

Si vous ne comprenez pas les données que vous transformez, vous ne comprenez donc pas le problème.

Réciproquement, vous comprenez le problème en comprenant les données.

Différents problèmes nécessitent différentes solutions.

Si vous avez des données différentes, alors vous avez différents problèmes.

Si vous ne comprenez pas le coût de résolution d'un problème, vous ne comprenez pas le problème.

Si vous ne comprenez pas le matériel, vous ne pouvez déterminer le coût pour résoudre le problème.

Tout est un problème de données. Que ce soit l'utilisabilité, la maintenance, les possibilités de débogage, etc.

La latence et le débit ne sont identiques que si vous avez un système séquentiel.

Règles générales :

  • lorsqu'il y a un, il y a plusieurs. Essayez de regarder l'axe du temps ;
  • plus vous avez de contexte, mieux vous pouvez créer une solution. Ne jetez pas des données dont vous avez besoin ;
  • NUMA étend les entrées sorties et reconstruit les données, le chemin dans le temps jusqu'à revenir à l'origine de la création ;
  • les logiciels ne fonctionnent pas dans un conte de fées et ne sont pas générés par des docteurs en informatique.

La raison doit prévaloir.

III-B-2. C++

Ces principes ne sont pas nouveaux, mais la culture C++ a engendré trois grands mensonges :

  • les logiciels sont une plateforme ;
  • une conception du code suivant la modélisation du monde ;
  • le code est plus important que les données.
III-B-2-a. Mensonge 1

Les logiciels ne sont pas une plateforme ! Évidemment, la plateforme c'est le matériel. Lorsque le matériel change, la solution change.

La réalité n'est pas une bidouille. Vous êtes obligé de gérer cela pour résoudre votre problème théorique.

La réalité est le vrai problème.

III-B-2-b. Mensonge 2

Le fait de masquer les données, car elles sont implicites dans la modélisation du monde est faux, car :

  • cela embrouille la maintenance (permet des changements sur les accès) ;
  • cela embrouille la compréhension des propriétés des données (critique pour trouver les solutions aux problèmes).

La modélisation du monde implique des relations entre les vraies données ou les transformations, mais dans la vraie vie, les classes sont fondamentalement similaires : une chaise est une chaise, mais dans le jeu vidéo, on a une chaise physique, une chaise statique, une chaise cassable. Comment peuvent-elles être similaires ?

La modélisation du monde mène à un monolithe, sans liens entre les structures de données et les transformations.

La modélisation du monde essaie d'idéaliser le problème, mais vous ne pouvez rendre un problème plus simple que ce qu'il est.

La modélisation du monde est équivalente aux livres pour apprendre la programmation seul.

III-B-2-c. Mensonge 3

Trop de temps de développement est perdu à parler du code. Le seul but du code est de transformer une donnée d'une forme à une autre. Le programmeur est fondamentalement responsable des données, et non du code. Son travail n'est pas d'écrire du code, mais de solutionner des problèmes de transformation de données.

Écrire du code, seulement si cela a une valeur prouvable directe, comme la transformation de données d'une manière utile.

Vous ne pouvez pas vous protéger du futur ni avoir de solution idéale.

III-B-2-d. Conséquences

Ces mensonges provoquent :

  • mauvaise performance ;
  • mauvais parallélisme ;
  • mauvaise optimisation ;
  • mauvaise stabilité ;
  • difficulté pour tester.

Il faut donc trouver la solution pour la transformation des données que vous avez, suivant les contraintes de la plateforme (et rien d'autre).

III-B-3. Exemples

III-B-3-a. Accès dans un dictionnaire

Lorsque l'on pense au code avant tout, on imaginera la table en mémoire.

Par contre, la plupart du temps, on parcourt la liste des clés, sans avoir besoin de la valeur. Ne chargez donc pas la valeur dans le cache. De plus, vous pouvez charger les clés les plus souvent utilisées dans le cache.

Il faut trouver la solution du cas le plus commun avant le cas le plus générique.

III-B-3-b. Les compilateurs

Est-ce que les compilateurs peuvent faire ce type d'optimisation eux-mêmes ?

Le compilateur ne peut régler que 10 % des cas. Il ne peut pas trouver de solution pour les problèmes réellement significatifs.

III-B-3-c. Améliorations des performances avec les caches CPU

Il est absolument nécessaire d'optimiser les accès à la mémoire, notamment pour éviter que le CPU ait besoin de chercher les données dans la mémoire vive. Par exemple, vous pouvez empaqueter vos données pour n'avoir que les données nécessaires à disposition.

Code non optimisé
Sélectionnez
class GameObject {
    float m_Pos[2];
    float m_Velocity[2];
    char m_Name[32];
    Model* m_Model;
    // ... autres variables membres ...
    float m_Foo;
    
    void UpdateFoo(float f)
    {
        float mag = sqrtf(
            m_Velocity[0] * m_Velocity[0] +
            m_Velocity[1] * m_Velocity[1]);
        m_Foo += mag * f;
    }
};

Le problème étant que lors de l'appel à la fonction UpdateFoo(), l'intégralité des variables membres vont être copiées dans les registres et le souci est d'autant plus énorme lorsque vous voulez appeler la fonction sur de multiples instances.

La solution est de créer une fonction UpdateFoo() qui traite directement un tableau, et que ce tableau ne contienne que les données réellement utiles :

 
Sélectionnez
struct FooUpdateIn {
    float m_velocity[2];
    float m_Foo;
};

struct FooUpdateOut {
    float m_Foo;
};

void UpdateFoos(const FooUpdateIn* in, size_t count, FooUpdateOut* out, float f)
{
    for (size_t i = 0 ; i < count ; ++i) {
        float mag = sqrtf(
            in[i].m_Velocity[0] * in[i].m_Velocity[0] +
            in[i].m_Velocity[1] * in[i].m_Velocity[1]);
        out[i].m_Foo = in[i].m_Foo + mag * f;
    }
}

Ce nouveau code est maintenable, facile à déboguer et on peut estimer le coût d'une modification dans celui-ci.

III-B-3-d. Cas récurrents

Les variables booléennes dans les structures provoquent une diminution de la densité d'informations dans la structure et augmente le risque d'accès aux données en mémoire vive.

De plus, les variables booléennes sont utilisées pour les décisions de dernières minutes :

 
Sélectionnez
int Foo::Bar( int count )
{
    int value = 0;
    for (int i=0;i<count;i++)
    {
        if ( m_NeedParentUpdate )
        {
            value++;
        }
    }
    return (value);
}

Certains compilateurs peuvent avoir du mal à optimiser ce code. Il y a encore moins de chances que le code soit optimisé dans le cas où la variable booléenne est retournée par une fonction.

III-B-3-e. Aider le compilateur

Pour aider le compilateur à optimiser le code, vous pouvez :

  • placer les variables membres, dans des variables locales à la fonction ;
  • les valeurs invariables et les embranchements doivent être gardés hors des boucles.

III-B-4. Application sur Ogre

Mike Acton a lu le code source de Ogre (la version 1.9) dont voici un exemple : https://bitbucket.org/sinbad/ogre/src/4578da5bf5b00fdf023b87e98099d647c5cb92ab/OgreMain/src/OgreNode.cpp?at=v1-9-0.

Entretemps, le développeur de Ogre, Matias N. Goldberg a amélioré la classe Ogre::Node en appliquant les principes de la conception orientée données. Suite à ce changement, il a constaté une amélioration d'un facteur 5 de la performance du moteur. Voici la nouvelle version du fichier : https://bitbucket.org/sinbad/ogre/src/458d2e2eb2c39a01677a24cf0c3435bd2d20202e/OgreMain/src/OgreNode.cpp?at=v2-0.

Vous pouvez trouver plus d'informations en suivant ce lien : http://www.yosoygames.com.ar/wp/2013/11/on-mike-actons-review-of-ogrenode-cpp/

III-C. Conclusion

La plupart des problèmes sont faciles à voir.

Corriger les 90 % de cas que le compilateur ne sait pas optimiser pourra l'aider à optimiser les derniers 10 %.

Des données organisées permettent une meilleure maintenance, un meilleur débogage et un meilleur parallélisme.

La bonne programmation est difficile, mais la mauvaise est facile.

III-C-1. En C++

Dans le monde C++, il y a assez d'outils pour estimer quelles sont les parties les plus importantes du problème.

Par contre, la culture du C++ imagine qu'ignorer le vrai problème est une bonne chose.

IV. Commenter

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