Introduction à C++ AMP avec Visual Studio 11 – Part 1

image

Introduction

C++ AMP (Accelerated Massive Parallelism) est une nouvelle libraire C++ intégrée à Visual Studio 11. La motivation de cette libraire est d'exécuter massivement parallèle des portions de code sur une ou plusieurs cartes graphiques. C’est à l’occasion de la conférence AMD Fusion 2011 qu’Herb Sutter et Daniel Moth ont révélé publiquement cette nouvelle technologie, inscrivant le thème de la programmation hétérogène comme le fer de lance de cette nouvelle librairie. Cependant, si vous suivez l’actualité informatique, vous savez sans doute que des technologies permettant de programmer massivement parallèles existent déjà depuis quelques années, par exemple NVidia CUDA et Open CL pour ne citer que les plus connues. Le mandat de C++ AMP est différent, son objectif est d’offrir via le jeu minimum APIs un moyen simple de programmer massivement parallèle dans un langage moderne tel que C++ 11.

Mais ce n’est pas tout, cette nouvelle librairie permet aussi de cibler toutes les cartes graphiques compatibles DirectX 11, et si aucune carte n’est disponible, elle est aussi capable d’exploiter les cœurs CPU de votre machine via la technologie WARP: un mélange de parallélisme et de vectorisation accessible via Direct3D. Vous l’aurez compris C++ AMP vise une portabilité binaire exceptionnelle. Toutes les machines disposant de DirectX 11 peuvent exécuter votre programme en reconnaissant à chaud (Just In Time) le matériel graphique sous-jacent au démarrage de l’application et ainsi adapter le code au mieux des performances du matériel utilisé. Dans le dialecte C++ AMP, on désigne le périphérique d’exécution sous le nom « d’accélérateur ». Par nature C++ AMP n’est pas attaché à un type de carte graphique ou un type de processeur CPU particulier. En fonction du matériel sous-jacent, le moteur d’exécution C++ AMP sélectionnera automatiquement le meilleur accélérateur sur le plan des performances comme l’accélérateur par défaut.

Pour conclure cette introduction, il est important de signaler que Microsoft a souhaité publier les spécifications de C++ AMP afin d’offrir la possibilité à d’autres éditeurs d’implémenter C++ AMP sur d’autres plateformes. Par exemple, il semblerait qu’AMD implémenterait la libraire sous la plateforme Linux en remplaçant la couche DirectX 11 par Open CL.

Créer une simple application C++ avec Visual Studio 11

Armé de ces quelques informations il est temps de vous lancer dans cette introduction par le code. Si vous avez déjà installé la version bêta de Visual Studio 11, vous pouvez démarrer immédiatement ce petit exemple sinon vous pouvez installer une des versions disponibles (noter que la version express spécifique à l’interface Metro de Windows 8, contient aussi la librairie C++ AMP).

 clip_image004[4] 

Figure 1 : télécharger Visual Studio 11

              

  • Lancer Visual Studio 11. Créer un nouveau projet C++ vide nommé Intro_AMP.

clip_image006[4]

Figure 2 : création d’un nouveau projet C++ vide

  •  Puis ajoutez un nouvel item au projet.

clip_image008[4]

Figure 3 : ajouter un nouvel item au projet

  • Puis sélectionner la section de code et choisissezl’item C++ File(.cpp) puis nommez le fichier Intro_AMP.cpp

 

clip_image010[4]

Figure 4 : sélectionner la section de code, C++ File

  • Dans le nouveau fichier C++ vide, recopier le code ci-dessous.
    1: #include <iostream>
    2: #include <vector>
    3: void AddArray(std::vector<int>& a, std::vector<int>& b, std::vector<int>& c)
    4: {
    5:     for (int i = 0; i < (int)c.size(); i++)
    6:     {
    7:         c[i] = a[i] + b[i];
    8:     }
    9: }
   10:  
   11: void main()
   12: {
   13:        static const unsigned int count = 1024*1024;
   14:        std::vector<int> va(count);
   15:        std::vector<int> vb(count);
   16:        std::vector<int> vc(count); 
   17:  
   18:        int i = 0;
   19:        std::generate(va.begin(), va.end(), [&i]() { return i++;});
   20:        std::generate(vb.begin(), vb.end(), [&i]() { return i--;});
   21:  
   22:        AddArray(va, vb, vc);
   23:              
   24:        std::cout<< "Hit \"Enter\" to exit" << std::endl;
   25:        std::cin.get();
   26: }

Listing 1: code source d'origine sans C++ AMP

Vous pouvez compiler ce code si vous le souhaitez, ilest opérationnel.

Introduction aux expressions lambda C++ 11

                Si vous connaissez déjà les expressions lambda C++, je vous invite à passer directement à la section« Comment exécuter ce code sur un GPU ».

Depuis la version 2010 de Visual Studio, vous disposez des expressions lambda C++. Depuis, le comité de normalisation a intégré officiellement les lambdas dans la version 11 de C++.

En C++ 11, les expressions lambda reposent sur des objets de type fonction anonyme. Pour commencer, je me limiterai à des exemples très simples qui vous permettront de comprendre plus facilement la syntaxe des expressions lambda de C++ 11.

    1: #include <algorithm>
    2: using namespace std;
    3:  
    4: int main() {
    5:  
    6:     vector<int> v;
    7:  
    8:     for (int i = 0; i < 10; ++i) {
    9:         v.push_back(i);
   10:     }
   11:  
   12:    // Lambda 1
   13:     for_each(v.begin(), v.end(), [](int n) { 
   14:         cout << n << " "; 
   15:     });
   16:     cout << endl;
   17: }

Dans le code ci-dessus, nous découvrons une première expression lambda. Celle-ci est introduite avec la syntaxe crochet/crochet,[], ce qui indique au compilateur le début de l’expression. La déclaration du paramètre (int n), fournit au compilateur le paramètre de la pseudo fonctionanonyme. Enfin, le corps de la méthode, { cout << n << " "; }.

 

Par défaut la méthode anonyme ne retourne pas de valeur. Cependant, si l’expression retourne une valeur, le type de retour sera déduit par le compilateur. En revanche, si la déduction n’est pas implicite vous pouvez indiquer le type de retour.

    1: // Lambda 2
    2: for_each(v.begin(), v.end(), [](int n)->double { 
    3:  
    4:     if (n % 2 == 0) {
    5:          return n * n * n;
    6:     } 
    7:     else {
    8:          return n / 2.0;
    9:     }
   10: });

Dans le code ci-dessus, nous introduisons une seconde expression lambda qui précise le type de retour, ->double, afin d'éliminer toute ambigüité pour le compilateur.

Jusqu’ici nous avons introduit des expressions lambda sans état, cependant il est parfaitement possible de capturer les valeurs courantes de la pile où se trouve plongée l’expression. Si la partie préface de l'expression lambda est vide [], on dit que l’expression lambda est sans état. Mais il est aussi parfaitement correct de spécifier les éléments capturés.

    1: // Lambda 3
    2: for_each(v.begin(), v.end(), [x, y](int n)->double { 
    3:  
    4:     if (n % x == 0) {
    5:          return n * n * n;
    6:     } 
    7:     else {
    8:          return n / y; 
    9:     }
   10: });

Dans le code ci-dessus, nous apportons à notre expression lambda deux paramètres [x, y], permettant de capturer depuis la pile les variables x et y. cependant il est aussi possible de capturer toutes les variables de la pile courante par valeur.

    1: // Lambda 4
    2: for_each(v.begin(), v.end(), [=](int n)->double { 
    3:  
    4:     if (n % x == 0) {
    5:          return n * n * n;
    6:     } 
    7:     else {
    8:          return n / y; 
    9:     }
   10: });

Dans le code ci-dessus, nous apportons à notre expression lambda une capture complète de la pile via la syntaxe [=].

Par défaut une méthode lambda est déclarée comme const. Si vous souhaitez modifier la valeur des paramètres dans le corps de l’expression, vous aurez recours au mot clef mutable.

    1: // Lambda 5
    2: for_each(v.begin(), v.end(), [x, y](int &n) mutable { 
    3:     const int old = n;
    4:     n *= x * y;
    5:     x = y;
    6:     y = old;    
    7: });

Dans le code ci-dessus, nous apportons à notre expression lambda la capture de variable par référence via le symbole & (int &n).

Cependant, il est aussi possible d’éviter de passer des éléments capturés par copie en utilisant des références avec quelques variables passées par référence ce qui nous permet de nous passer du mot clef mutable.

    1: // Lambda 6
    2: for_each(v.begin(), v.end(), [&x, &y](int &n) { 
    3:     const int old = n;
    4:     n *= x * y;
    5:     x = y;
    6:     y = old;    
    7: });

Enfin, il est aussi possible d’exprimer une capture de toutes les variables de la pile courante par référence en utilisant la syntaxe [&]. Dans l'exemple ci-dessous, nous illustrons la méthode parallel_for permettant de paralléliser une collection de données très facilement en capturant toute la pile par référence.

    1: // Lambda 7
    2: parallel_for(0, size, 1, [&](int i) {
    3:  
    4:      for (int j = 0; j < size; j++) {
    5:          double temp = 0;
    6:          for(int k = 0; k < size; k++) {
    7:              temp += m1[i][k] * m2[k][j];
    8:          }
    9:  
   10:          result[i][j] = temp;
   11:      } 
   12: });

Comment exécuter ce code sur un GPU ?

Pour répondre à cette question, rien de plus simple. Une petite mise à jour du corps de la méthode AddArray, puis l’ajout de l’inclusion du fichier « amp.h » et enfin l’ajout de l’espace de nom « concurrency » et hop, le tour est joué !

Remarque

Notez au passage que l’inclusion du ficher« vector » a disparu, car il est lui-même inclus dans« amp.h ».

    1: #include <iostream>
    2: #include <amp.h>
    3: using namespace concurrency;
    4:  
    5: void AddArray(std::vector<int>& va, std::vector<int>& vb, std::vector<int>& vc)
    6: {
    7:        array_view<int, 1> a(va.size(), va);
    8:        array_view<int, 1> b(vb.size(), vb);
    9:        array_view<int, 1> c(vc.size(), vc);
   10:  
   11:        parallel_for_each(c.extent, [=](index<1> i) restrict(amp)
   12:        {
   13:              c[i] = a[i] + b[i];
   14:        });
   15:  
   16: }
   17:  
   18: void main()
   19: {
   20:        static const unsigned int count = 1024*1024;
   21:        std::vector<int> va(count);
   22:        std::vector<int> vb(count);
   23:        std::vector<int> vc(count); 
   24:  
   25:        int i = 0;
   26:        std::generate(va.begin(), va.end(), [&i]() { return i++;});
   27:        std::generate(vb.begin(), vb.end(), [&i]() { return i--;});
   28:  
   29:        AddArray(va, vb, vc);
   30:              
   31:        std::cout<< "Hit \"Enter\" to exit" << std::endl;
   32:        std::cin.get();
   33: }

Listing 2: code source mise à jour avec C++ AMP

Et c’est tout. Ce code est opérationnel, vous pouvez le compiler puis le lancer de préférence en mode release. Ce petit programme devrait à la fois exécuter la méthode CPU et C++ AMP et s’exécuter sur le meilleur accélérateur de votre ordinateur. Si le moteur d'exécution C++ AMP ne trouve pas de carte graphique compatible directX 11 sur votre machine, alors le programme s’exécute dans un mode de repli sur le CPU que nous avons déjà cité en introduction.

Explication sur la transformation en C++ AMP

Il est temps de revenir sur le code pour vous donner quelques explications sur la transformation en C++ AMP :

clip_image012[4]

Figure 5 : explication sur la transformation en C++ AMP

parallel_for_each

Le point d’entrée de l’exécution sur le GPU est la méthode parallel_for_each. Cette méthode est essentielle en C++ AMP, c’est à travers elle que l’on exprime l’exécution d’un code C++ AMP : le nombre de threads et le code associé qui sera chargé sur une carte graphique est ventilé sur chacun des threads GPU définis précédemment. Vous avez peut-être déjà rencontré cette méthode à travers la librairie C++ Parallel Pattern Library (PPL). Cette librairie est apparue avec Visual Studio 2010 et permet de gérer des tâches bien plus légèresque des threads tout en offrant aussi quelques algorithmes comme parallel_for_each. Mais ici c’est une surcharge spécifique à C++ AMP.

L’expression lambda

La zone de capture de l’expression lambda contient le symbole =, ce qui signifie que nous capturons les variables par valeur. L’argument de l’expression lambda est de type index, ce qui correspond ici au thread ID passé à tous les codes qui s’exécutent sur le GPU, qu’on appellegénéralement code noyau.

extent

Le premier paramètre de la méthode parallel_for_each est un objet de type extent issu de l’objet c (vue sur le conteneur de résultats, vc). Dans ce contexte, l’objet extent permet de définir à la fois le nombre de threads et leur topologie en termes de dimensions (1, 2 , 3 … 128). Par exemple une grille ou un cube de threads peut exécuter le corps d’une expression lambda . Dans notre exemple nous exécutons simplement un « vecteur » car nous avons défini un array_view avec une seule dimension.

resctrict(amp)

Le mot clef restrict est un nouveau mot clef en C++ (certains se rappellent sans doute de ce mot clef en langage C) qui a été introduit en C++ dans le cadre de C++ AMP. Il s’applique à la fois aux fonctions et aux expressions lambda. La motivation de ce nouveau mot est similaire aux attributs des compilateurs sur la plateforme .NET : ce mot clef permet via un identifiant d’indiquer des actions diverses aux compilateurs, par exemple une action d’optimisation ou de génération de code ... La restriction restrict(amp) permet d’informer le compilateur C++ de vérifier la conformité du code vis-à-vis des contraintes de C++ AMP. Nous reviendrons sur ce point au peu plus loin.

array_view<T, R>

Le type array_view<T, R> est un véhicule de données entre la mémoire CPU et la mémoire GPU pour un type T et un rang R. Le rang permet de définir une vue multidimensionnelle même si le conteneur observé ne l’est pas. Dans notre exemple nous utilisons une seule dimension car adaptée à notre algorithme. Par nature, le type array_view peut créer une vue sur tout type de conteneur. Dans notre cas, les variables va, vb transfèrent leurs données depuis la mémoire de l’host vers la mémoire de la carte graphique alors que la variable vc fait l’inverse. On utilise souvent le type array_view<T,R> car il est simple à utiliser, les copies des données se déroulent de manière implicite.

Comment ça marche en vrai ?

                Pour comprendre l’exécution de notre petit exemple, je vous propose de repartir au niveau de notre boucle parallel_for_each. C’est le point d’entrée d’une exécution C++ AMP. Si vous êtes néophyte en programmation GPU, je comprends parfaitement votre désir de connaitre ce qui se passe sous le capot. En programmation GPU, nous appelons le corps de fonction engagé dans le calcul GPU, le code noyau, car ce code va être utilisé par chacun de threads CPU.

La propriété extent issu de l’objet « c » passé en 1er argument définit combien de threads seront lancés sur la carte GPU.

Lorsque le code va s’exécuter chaque thread GPU en parallèle, chacun va recevoir une copie du code noyau accompagné d’un index automatiquement généré pour chacun des threads engendrés. Cet index permet de retrouver les données capturées par la fonction lambda. C’est ainsi que fonctionne le code noyau. Ce principe est d’ailleurs identique dans d’autres technologies par exemple CUDA ou Open CL.

clip_image014[4]

Figure 6 : comment ça marche en vrai ?

Représentation du fonctionnement de la boucle parallel_for_each

Le processus de compilation de C++ AMP

                En introduction nous avons expliqué que la librairie C++ AMP reposait sur le service Direct3D de DirectX 11. Mais alors comment se déroulent les échanges entre le code noyau et le service Direct3D ? La combinaison du nouveau mot clef avec identifiant « amp », resctrit(amp), indique au compilateur de certes vérifier la conformité du code noyau vis-à-vis des contraintes GPU, mais aussi de traduire le code noyau en code HLSL. Ce qui signifie que deux compilateurs vont êtres utilisés : le compilateur C++ pour le code CPU et le compilateur fxc shader pour le code HLSL généré. L’édition de lien engendre alors un binaire hétérogène contenant à la fois de l’assembleur standard plus une fonction un peu spéciale __kernel_stub contenant le “byte code” issu de la compilation du code HLSL. L’exécutable engendré ne contient pas de spécificités vis-à-vis d’une carte graphique. C’est au lancement de l’application que le “byte code” est fourni à la carte graphique courante afin d’obtenir une optimisation adaptée à la carte sélectionnée. On obtient donc une solution portable d’un point vue binaire.

Conclusion

Dans cette première partie, vous avez vu les premiers jalons de la programmation C++ AMP. Que vous soyez complètement néophyte, débutant ou bien même expert en programmation GPU, l’utilisation de C++ AMP vous semblera à la fois simple et élégante. Nous sommes partis d’un simple fichier C++ additionnant deux vecteurs et nous avons incorporé des APIs C++ AMP sans défigurer le code d’origine. L’utilisation d'expressions lambda avec la méthode parallel_for_each permet d’incorporer le code noyau juste à la place du code d’origine ce qui augmente la similitude avec l’algorithme d’origine. Enfin, la portabilité matérielle est un avantage certain que vous avez surement noté. Si vous souhaitez vérifier que vos machines sont compatibles C++ AMP, Microsoft fournit un petit utilitaire à cet effet, VerifyAmpDevices que vous pouvez télécharger à cette adresse : https://www.danielmoth.com/Blog/verifyampdevices.exe

Naturellement, les performances de C++ AMP, n’ont pas été évaluées dans ce premier article, nous ne manquerons pas d’en parler dans le prochain, en utilisant un algorithme un peu moins simpliste, mais très didactique en programmation GPU : l’incontournable multiplication matricielle.

A bientôt,

Bruno

boucard.bruno@free.fr