Découverte de C++ AMP - Part 3

Le second billet vous a expliqué les motivations de C++ AMP. Je vous propose maintenant de découvrir les bases de programmation en C++ AMP.

Hello world en C++ AMP

Dans ce premier exemple de code, l’objectif est d’introduire les bases de C++ AMP progressivement. Si vous cherchez un intérêt vis-à-vis de la parallélisation : il n’y en a pas ! En revanche, vous apprendrez l’essentiel des éléments pour exploiter C++ AMP correctement. Pour convaincre de la simplicité de C++ AMP, démarrer Visual Studio 2012 et dans le Template C++, choisissez de créer une application, un projet vide que vous pouvez appeler « HelloWorld ». Puis sur le projet, ajouter (clique droit) un nouvel élément source.cpp. Puis vous pouvez saisir ce code.

Si vous exécutez ce code, vous obtiendrez le fameux « Hello World. ». Le code se compose d’un point d’entrée; La fonction main et d’une méthode do_it qui reçoit le tableau d’entier initialisé dans la fonction main et un entier to_add valorisé à un. La méthode do_it contient deux boucles. La première calcule l’ajout de l’entier to_add sur tous éléments du tableau, la seconde affiche tous les éléments une fois altérés. Naturellement, la seconde boucle ne fait qu’afficher et ne nous concerne pas sur plan de la parallélisation. Pour introduire C++ AMP, ajouter le ficher header « amp.h » et l’espace de nom « concurrency » .

#include <iostream>

#include <amp.h>

using namespace concurrency;

Dans la méthode do_it, nous allons remplacer la première boucle par la méthode parallel_for_each. Cette méthode existe aussi dans l’offre parallèle CPU (Parallel Pattern Library ou PPL), mais ce n’est pas cette version que nous allons utiliser, mais celle du fichier « amp.h ». Le premier paramètre réclame la taille de tableau à itérer, puis une expression lambda C++ 11.

void do_it(int* v, int size, int to_add)

{

    parallel_for_each(size, [=](int i)

    {

        v[i] += to_add;

    });

 

    for(unsigned int i = 0; i < size; i++)

        std::cout << static_cast<char>(v[i]);

}

La méthode parallel_for_each, n’accepte pas une taille sous la forme d’un entier, mais d’un type permettant de traiter des données multidimensionnelles via le type template extent<N> . Dans notre exemple, nous n’avons qu’une seule dimension, mais le prochain exemple disposera de deux dimensions.

void do_it(int* v, int size, int to_add)

{

    extent<1> e(size);

 

    parallel_for_each(e, [=](int i)

    {

        v[i] += to_add;

    });

 

    for(unsigned int i = 0; i < size; i++)

        std::cout << static_cast<char>(v[i]);

}

À ce stade, notre méthode est presque correcte, mais les données de notre tableau ne peuvent toujours pas être chargées dans le GPU. Nous allons utiliser un nouveau type template C++ AMP : array_view<T,N> . C’est un « wrapper » sur les données, ici notre pointeur d’entiers, permettant de les charger à la demande. Le type array_view est naturellement capable de wrapper des conteneurs STL. De plus, le type du paramètre de l’expression lambda doit être remplacé par le type : index<N> . C’est une sorte d’indice multidimensionnel, car il arrive souvent sur des projets plus réalistes que les données se représentent de manière multidimensionnelle.

void do_it(int* v, int size, int to_add)

{

    extent<1> e(size);

    array_view<int> av(1, v);

    parallel_for_each(e, [=](index<1> i)

    {

        av[i[0]] += to_add;

    });

 

    for(unsigned int i = 0; i < size; i++)

        std::cout << static_cast<char>(v[i]);

}

nouveau mot clef C++ : restrict

Notre code n’est pas encore correct, car le corps de l’expression lambda constitue le code dit « noyau », c’est à dire, celui qui est chargé dans le GPU. Le code "noyau" ne peut pas exécuter toutes les syntaxes supportées par le C++. Pour que le compilateur puisse vérifier ces restrictions, nous devons ajouter un nouveau mot clef ajouté au C++ par C++ AMP : restrict(amp) . Nous remplaçons le tableau v par l’instance av, dans la seconde boucle.

void do_it(int* v, int size, int to_add)

{

    extent<1> e(size);

    array_view<int> av(size, v);

 

    parallel_for_each(av.extent, [=](index<1> i) restrict(amp)

    {

        av[i[0]] += to_add;

    });

 

    for(unsigned int i = 0; i < size; i++)

        std::cout << static_cast<char>(av[i]);

Le principe de restriction peut s’appliquer sur une expression lambda (comme dans notre exemple) ou bien sur une méthode. Noter que le mot clé restrict modifie la signature de la méthode ou de l’expression lambda, ce qui signifie que nous pourrions ajouter par exemple une méthode nommée foo() marquée par le mot clef restrict(amp) sans soucis.

#include <iostream>

#include <amp.h>

using namespace concurrency;

 

void foo() restrict(amp) {}

 

void do_it(int* v, int size, int to_add)

{

    array_view<int> av(size, v);

 

    parallel_for_each(av.extent, [=](index<1> idx) restrict(amp)

    {

        foo();

        av[idx] += to_add;

    });

 

    for(unsigned int i = 0; i < size; i++)

        std::cout << static_cast<char>(av[i]);

}

À ce stade de l'article, nous pouvons prendre le temps d’expliquer de manière macroscopique le déroulement de notre calcul. La variable idx est renseignée automatiquement par le moteur d’exécution C++ AMP, car elle représente le thread GPU qui exécutera le code « noyau » (le corps de l’expression lambda). Chaque itération de l’expression est donc calculée indépendamment et en parallèle.

image

Pour l’instant l’instruction restrict ne supporte que les deux identifiants : amp et/ou cpu. Son rôle est d’informer le compilateur pour qu'il applique des restrictions au langage (restrictions spécifiques, optimisations, génération de code). Pour l’instant, le mot clef restrict(amp) interdit les éléments suivants :

· récusions

· mot clef volatile

· fonctions virtuelles

· pointeurs de fonctions

· pointeurs vers des fonctions membres

· pointeurs dans des structures

· pointeurs de pointeurs

· champs de bits

· goto ou labels

· throw, try, catch

· Variables globales ou statiques

· dynamic_cast ou typeid

· déclarations __asm__

· varargs

· types non supportés: char, short, long double

Si le code d’une méthode est à la fois compatible amp et cpu, vous pouvez marquer celle-ci des deux identifiants.

void foo() restrict(amp, cpu) {}

 

void do_it(int* v, int size, int to_add)

{

    array_view<int> av(size, v);

 

    parallel_for_each(av.extent, [=](index<1> idx) restrict(amp)

    {

        foo();

        av[idx] += to_add;

    });

 

    for(unsigned int i = 0; i < size; i++)

     std::cout << static_cast<char>(av[i]);

}

Par défaut tout votre code est compatible à la mode cpu, il est donc inutile de marquer votre code restrict(cpu) excepté si vous souhaitez surcharger une méthode portant une signature équivalente, mais marquée avec restrict(amp)

void foo() restrict(amp) {}

void foo() {}

 

void do_it(int* v, int size, int to_add)

{

    array_view<int> av(size, v);

 

    parallel_for_each(av.extent, [=](index<1> idx) restrict(amp)

    {

        foo();

        av[idx] += to_add;

  });

 

    for(unsigned int i = 0; i < size; i++)

        std::cout << static_cast<char>(av[i]);

}

Nous pouvons remanier un peu ce code, afin d’obtenir un code un peu plus élégant. Nous pouvons noter que le code final est à peine plus grand que le code d’origine.

void do_it(int* v, int size, int to_add)

{

    array_view<int> av(size, v);

 

    parallel_for_each(av.extent, [=](index<1> idx) restrict(amp)

    {

        av[idx] += to_add;

    });

 

    for(unsigned int i = 0; i < size; i++)

        std::cout << static_cast<char>(av[i]);

}

Comment gérer l’exécution asynchrone du code GPU

Le code de la fonction do_it peut vous sembler parfaitement séquentiel, alors que toute exécution sur un GPU est par définition asynchrone. Alors qui est responsable de la synchronisation ? À la fin de la première boucle, nous avons fini d’écrire dans la variable av, dans la seconde boucle on parcoure l’array_view à nouveau, mais dans ce cas, qui synchronise la première lecture de la première boucle vis-à-vis de la lecture dans la seconde : la classe array_view assure la synchronisation et évite tous problèmes asynchrones.

Prenons un autre exemple, où cette fois nous ajoutons une seconde boucle d’écriture.

void do_it(int* v, int size, int to_add)

{

    array_view<int> av(size, v);

 

    parallel_for_each(av.extent, [=](index<1> idx) restrict(amp)

    {

        av[idx] += to_add;

    });

 

    parallel_for_each(av.extent, [=](index<1> idx) restrict(amp)

    {

        av[idx] += 2;

    });

 

    for(int i = 0; i < size; i++)

        std::cout << static_cast<char>(v[i]);

}

 

À la fin de la première boucle, la seconde boucle reprend à zéro l’écriture dans l’array_view, et une fois encore l’array_view va se synchroniser de manière à entamer la première écriture une fois que toutes écritures de boucle précédente seront terminées.

 

Dans l’exemple suivant, nous avons déplacé le code d’affichage dans la fonction main. La classe array_view est instanciée sur la pile de la méthode do_it, ainsi à la sortie de méthode, le destructeur de l’array_view est appelé. Ce destructeur contient un code de synchronisation, permettant de « flusher » dans l’objet wrappé, ici, le pointeur d’entier, tous les éléments traités dans la méthode noyau.

 

void do_it(int* v, int size, int to_add)

{

    array_view<int> av(size, v);

 

    parallel_for_each(av.extent, [=](index<1> idx) restrict(amp)

    {

        av[idx] += to_add;

    });

}

 

int main()

{

    int v[12] = {'G', 'd', 'k', 'k', 'n', 31, 'v', 'n', 'q', 'k', 'c', '-'};

    int to_add = 1;

    int size = sizeof(v)/sizeof(int);

    do_it(v, size, to_add);

 

    for(int i = 0; i < size; i++)

        std::cout << static_cast<char>(v[i]);

    std::cin.get();

}

Cependant, il est aussi possible d’appeler sur l’instance de l’array_view, la méthode synchronize qui assure la synchronisation des données.

void do_it(int* v, int size, int to_add)

{

    array_view<int> av(size, v);

 

    parallel_for_each(av.extent, [=](index<1> idx) restrict(amp)

    {

        av[idx] += to_add;

    });

 

    av.synchronize();

}

Remarque : Il n’est pas nécessaire d’appeler la méthode synchronize car comme nous l’avons dit précédemment la classe array_view contient dans son destructeur le code pour synchroniser les données à la sortie de ma méthode do_it. Cependant, l’usage de cette méthode vous assure que votre code peut rester correct si vous ajoutez du code à la fin de la méthode exploitant les données du tampon d’entier v.

accelerator et accelerator_view

Pour s’exécuter un programme C++ AMP se doit d’utiliser un accélérateur (rappelez-vous la démonstration de l’introduction), c’est-à-dire une cible d’exécution en d’autres mots un périphérique d’exécution. Actuellement dans la version 1.0 de C++ AMP, nous disposons des accélérateurs suivants :

· accelerator::default_accelerator

· accelerator:: direct3d_warp

· accelerator::direct3d_ref

· accelerator:: cpu_accelerator

Un accélérateur est généralement attaché à une carte graphique, mais il peut arriver que l’accélérateur cible votre CPU car aucune carte graphique compatible DirectX 11 n’a été trouvée au démarrage de l’application. Dans ce cas, c’est l’accélérateur WARP ou Microsoft Basic Render Driver qui est utilisé. Rappelez-vous dans la première démonstration, l'exécution avec le mode WRAP nous avions obtenu des performances supérieures au code parallèle CPU. Pour comprendre cet exploit, sachez que l'accélérateur WARP utilise à la fois le parallélisme multicœurs et les instructions Intel SSE qui permettent de réaliser du SIMD pour Single Instruction Multiple Data afin exécuter efficacement la parallélisation de vos données sur CPU. Sur le plan de l’implémentation cet accélérateur repose sur le service WARP software rasterizer, fourni par le moteur d'exécution Direct3D 11.

Par défaut c’est l’accélérateur accelerator::default_accelerator, que le moteur d’exécution C++ AMP sélectionne, en sélectionnant la solution la plus performante vis-à-vis de votre configuration matérielle. Si vous souhaitez faire de la mise au point du code noyau, vous ne pourrez pas utiliser l’accélérateur le plus performant, mais l’opposé, le plus lent : accelerator::direct3d_ref. Le nom REF vient de "Reference Rasterizer" qui est une implémentation logicielle que le SDK DirectX a toujours fournie. C’est effectivement, le plus lent des accélérateurs, car c’est un émulateur d’exécution GPU. C’est cet accélérateur qui est utilisée si vous choisissez de debugger le code noyau. Nous reviendrons plus en détail sur la manière de debugger du code C++ AMP dans un prochain article. L’accélérateur CPU, accelerator:: cpu_accelerator, est seulement utilisé dans un cadre avancé de transfert de données depuis le noyau vers l’host que l’on appelle aussi « staging array ». L’utilisation de cet accélérateur CPU dépasse le cadre de cette introduction.

Vous pouvez afficher facilement toutes les propriétés des accélérateurs disponibles sur votre machine, il vous suffit de frapper ces quelques lignes de code.

std::for_each(accs.begin(), accs.end(), [&](accelerator acc)

{

        std::wcout << "New accelerator: " << acc.description << std::endl;

        std::wcout << "device_path = " << acc.device_path << std::endl;

        std::wcout << "version = " << (acc.version >> 16) << '.' << (acc.version & 0xFFFF) << std::endl;

        std::wcout << "dedicated_memory = " << acc.dedicated_memory << " KB" << std::endl;

        std::wcout << "doubles = " << ((acc.supports_double_precision) ? "true" : "false") << std::endl;

        std::wcout << "limited_doubles = " << ((acc.supports_limited_double_precision) ? "true" : "false") << std::endl;

        std::wcout << "has_display = " << ((acc.has_display) ? "true" : "false") << std::endl;

        std::wcout << "is_emulated = " << ((acc.is_emulated) ? "true" : "false") << std::endl;

        std::wcout << "is_debug = " << ((acc.is_debug) ? "true" : "false") << std::endl;

        std::wcout << std::endl;

});

Si j’exécute ce code sur ma machine, j’obtiens les résultats suivants :

image

On retrouve tous les types d’accélérateurs et leurs propriétés respectives. Ces informations sont très utiles si vous souhaitez connaitre les capacités de vos accélérateurs. En général, c’est l’accélérateur attaché à votre carte graphique qui vous importe, car c’est généralement celle-ci qui offrira le plus de performances.

Si nous revenons à notre démonstration, nous n’avons jusqu’à présent jamais présenté un exemple qui utilisait un accélérateur. Et pourtant, il y a toujours un accélérateur utilisé. Dans le code ci-dessous, nous faisons apparaitre l’usage d’un accélérateur.

accelerator pick_accelerator()

{

    accelerator rc;

    vector<accelerator> accelerators = accelerator::get_all();

    auto it = std::find_if(accelerators.begin(), accelerators.end(), [&](accelerator accel)

                { return !accel.is_emulated; });

 

    if (it != accelerators.end()) rc = *it;

               

    return rc;

}

 

void do_it(int* v, int size, int to_add)

{

  accelerator acc = pick_accelerator();

 

    array_view<int> av(size, v);

 

    parallel_for_each(acc.default_view, av.extent, [=](index<1> idx) restrict(amp)

    {

        av[idx] += to_add;

    });

}

Vous noterez que la méthode parallel_for_each ne prend pas en paramètre un accélérateur, mais une vue sur un accélérateur : accelerator_view. Le rôle de la classe accelerator_view est d’encapsuler l’accélérateur afin de le protéger des différents usages multithreads dans votre code. Pour conclure cette parenthèse sur les accélérateurs, ils apparaissent généralement lorsque vous souhaitez gérer plusieurs cartes graphiques pour paralléliser vos traitements sur plusieurs cartes graphiques.

array

Nous avons utilisé le type array_view qui se prête bien à notre exemple, mais il existe un autre type de conteneur, qui cette fois n’est pas wrapper, mais un « vrai » conteneur, array<T,N> . D’un point vu de l’usage, le type array<T, N> est un peu différent d’un array_view, mais l’instanciation est équivalente. La capture vis-à-vis de l’expression lambda de la méthode parallel_for_each change : contrairement à l’instance array_view, le type array n’est pas un wrapper sur des données, mais un vrai conteneur qui ne doit pas être passé par valeur, mais par référence. En revanche, la variable to_add doit être passée par valeur, ce qui nous donne ce type de capture au niveau de l’expression lambda [=,&arr] .

void do_it(int* v, int size, int to_add)

{

    accelerator acc = pick_accelerator();

 

    array<int> arr(size, v);

 

    parallel_for_each(acc.default_view, arr.extent, [=,&arr](index<1> idx) restrict(amp)

    {

        arr[idx] += to_add;

    });

 

    copy(arr, v);

}

Une fois la boucle parallel_for_each lancée, nous devons attendre le retour du traitement sur la carte graphique puis copier les données dans le buffer d’origine nous-même, une fois la boucle terminée. Vous l’aurez compris, la fonction copy assure exactement ce comportement.

Avec ces quelques éléments, vous pouvez déjà produire des programmes utilisant la librairie C++ AMP. La prochaine fois, nous aborderons un autre exemple, un classique du genre : la multiplication matricielle.

A bientôt,

Bruno

boucard.bruno@free.fr