Découverte de C++ AMP - Part 4
Le troisième billet vous a expliqué les bases de la programmation en C++ AMP. Je vous propose maintenant de conclure cette série à travers un dernier exemple, légèrement plus compliqué, mais bien plus intéressant.
L’exemple fil rouge : la multiplication matricielle
Toutes les introductions sur la programmation GPU utilisent l’algorithme de multiplication matricielle, car il est à la fois simple et démontre efficacement les performances du calcul sur un GPU.
Voici un rappel de l’algorithme via une feuille Excel :
L’objectif est de calculer le produit de deux matrices A et B et d’engendrer la matrice produit C. Dans notre exemple la matrice A est de dimension M x W, avec M=2 et W=4, la matrice B est dimension W x N avec W=4 et N=6 et enfin la matrice produit C est de dimension M x N.
· Chaque ligne A est multipliée par toutes les colonnes entières de B
· Chaque multiplication est sommée dans une variable temporaire
· Après avoir multiplié tous les éléments de la ligne avec tous les éléments de la colonne courante
· Le résultat temporaire est placé dans la cellule correspondante de la matrice produit C.
Implémentation
Dans Visual Studio 2012, vous créez un projet C++ en choisissant le modèle « Empty Project », puis vous ajoutez un fichier C++. Le code ci-dessous représente une implémentation de la multiplication matricielle.
#include <iostream>
#include <vector>
using std::vector;
void MatrixMultiply(vector<int>& vC, vector<int>& vA, vector<int>& vB, int M, int N, int W)
{
for (int row = 0; row < M; row++)
for (int col = 0; col < N; col++)
{
int sum = 0;
for (int k = 0; k < W; k++)
sum += vA[row * W + k] * vB[k * N + col];
vC[row * N + col] = sum;
}
}
void main()
{
// Dimensions des matrices
int M = 2;
int W = 4;
int N = 6;
// 3 vecteurs pour représenter les matrices
vector<int> A(M*W);
vector<int> B(W*N);
vector<int> C(M*N);
// Peupler la matrice A
for (int i = 0; i < A.size(); i++) A[i] = i + 1;
// Peupler la matrice B
for (int i = 0; i < B.size(); i++) B[i] = i + 1;
MatrixMultiply(C, A, B, M, N, W);
}
L’implémentation de l’algorithme est sans surprise et correspond à la description de l’algorithme. Nous allons, comme nous l’avions fait dans l’exemple « Hello World », introduire progressivement la parallélisation avec C++ AMP. Nous recopions l’algorithme original et renommons le nom de la méthode par MatrixMultiplyAMP. Nous devons ajouter le fichier « amp.h » ainsi que l’espace de nom « concurrency », puis nous introduisons trois array_view.
#include <vector>
#include <amp.h>
using std::vector;
using namespace concurrency;
void MatrixMultiplyAMP(vector<int>& vC, vector<int>& vA, vector<int>& vB, int M, int N, int W)
{
array_view<int> a(M*W, vA), b(W*N, vB);
array_view<int> c(M*N, vC);
for (int row = 0; row < M; row++)
for (int col = 0; col < N; col++)
{
int sum = 0;
for (int k = 0; k < W; k++)
sum += a[row * W + k] * b[k * N + col];
c[row * N + col] = sum;
}
}
À ce stade, le code compile correctement, mais cependant, il n'est pas encore parallélisé.
Nous passons le code à une dimension à deux dimensions. Puis nous remplaçons les deux boucles des lignes et des colonnes par la méthode parallel_for_each. Avec la variable idx de type index, nous réaffectons les variables row et col.
#include <vector>
#include <amp.h>
using std::vector;
using namespace concurrency;
void MatrixMultiplyAMP(vector<int>& vC, vector<int>& vA, vector<int>& vB, int M, int N, int W)
{
array_view<int, 2> a(M,W, vA), b(W,N, vB);
array_view<int, 2> c(M,N, vC);
//for (int row = 0; row < M; row++)
// for (int col = 0; col < N; col++)
parallel_for_each(c.extent, [=](index<2> idx) restrict(amp)
{
int row = idx[0];
int col = idx[1];
int sum = 0;
for (int k = 0; k < W; k++)
sum += a(row, k) * b(k, col);
c(row, col) = sum;
});
}
Cependant, il nous reste quelques modifications afin d’obtenir un code correct. Nous remplaçons dans le type du template pour les variables a et b, qui représentent les deux matrices d'entrées viennent nourrir le traitement, du type int à const int, ce qui apporte une optimisation au traitement. La variable c, représentant la matrice produit qui va recevoir les résultats, on appelle la méthode discard_data() afin de préciser que les valeurs en entrées de cet array_view, ne sont pas à copier sur le GPU, ceci ajoute une touche d’optimisation.
void MatrixMultiplyAMP(vector<int>& vC, vector<int>& vA, vector<int>& vB, int M, int N, int W)
{
array_view<const int,2> a(M, W, vA);
array_view<const int,2> b(W, N, vB);
array_view<int,2> c(M, N, vC);
c.discard_data();
parallel_for_each(
c.extent, [=](concurrency::index<2> idx)
restrict(amp)
{
int row = idx[0];
int col = idx[1];
int sum = 0.0f;
for(int k = 0; k < W; ++k)
sum += a(row, k) * b(k, col);
c[idx] = sum;
});
c.synchronize();
}
Nous avons à ce stade à disposition deux versions de l’algorithme, une en mode séquentiel et la seconde en mode parallèle. Pour mesurer les performances, il nous faut un minimum d’outillage pour mesurer les performances de traitements reposant sur la technologie GPU. Vous trouverez sur le blog Microsoft « Parallel Programming in Native Code », un billet sur « High-resolution timer for C++ » accompagné des sources de la classe Timer, https://blogs.msdn.com/b/nativeconcurrency/archive/2011/12/28/high-resolution-timer-for-c.aspx, qui correspond parfaitement à notre besoin.
Pour observer les performances de C++ AMP, nous avons révisé le code et introduit l’utilisation de la classe Timer via « timer.h ».
#include <iostream>
#include <vector>
#include <amp.h>
#include "timer.h"
using std::vector;
using namespace concurrency;
void MatrixMultiplyAMP(vector<int>& vC, vector<int>& vA, vector<int>& vB, int M, int N, int W)
{
array_view<const int,2> a(M, W, vA);
array_view<const int,2> b(W, N, vB);
array_view<int,2> c(M, N, vC);
c.discard_data();
parallel_for_each(
c.extent, [=](concurrency::index<2> idx)
restrict(amp)
{
int row = idx[0];
int col = idx[1];
int sum = 0;
for(int k = 0; k < W; ++k)
sum += a(row, k) * b(k, col);
c[idx] = sum;
});
c.synchronize();
}
void MatrixMultiply(vector<int>& vC, vector<int>& vA, vector<int>& vB, int M, int N, int W)
{
for (int row = 0; row < M; row++)
for (int col = 0; col < N; col++)
{
int sum = 0;
for (int k = 0; k < W; k++)
sum += vA[row * W + k] * vB[k * N + col];
vC[row * N + col] = sum;
}
}
void main()
{
// Dimensions des matrices
int M = 2;
int W = 4;
int N = 6;
// 3 vecteurs pour représenter les matrices
vector<int> A(M*W);
vector<int> B(W*N);
vector<int> C(M*N);
// Peupler la matrice A
for (unsigned int i = 0; i < A.size(); i++) A[i] = i + 1;
// Peupler la matrice B
for (unsigned int i = 0; i < B.size(); i++) B[i] = i + i;
accelerator acc;
std::wcout << acc.description << std::endl << std::endl;
Timer timer;
std::cout << "Matrix benchmark with M: " << M << ", N: "<< N << ", W: " << W << std::endl << std::endl;
timer.Start();
MatrixMultiply(C, A, B, M, N, W);
timer.Stop();
std::cout<< "MatrixMultiply Seq - elapsed time: " << timer.Elapsed() << std::endl << std::endl;
timer.Start();
MatrixMultiplyAMP(C, A, B, M, N, W);
timer.Stop();
std::cout<< "MatrixMultiply AMP - elapsed time: " << timer.Elapsed() << std::endl << std::endl;
std::cout<< "Hit \"Enter\" to exit:";
std::cin.get();
}
Vous compiler ce code et lancer l’exécutable.
Les résultats sont affligeants, l’algorithme séquentiel a pris 0 milliseconde et la version C++ AMP en a pris 23 ! C’est une excellente illustration de ce qu’il ne faut jamais faire : paralléliser des traitements dont le volume de données est trop faible. Ce principe est valable aussi pour du parallélisme sur CPU.
Mais si vous souhaitez observer des performances significatives, je vous propose de modifier les valeurs des dimensions en les valorisant à 1024.
// Dimensions des matrices
int M = 1024;
int W = 1024;
int N = 1024;
Nous obtenons ainsi des matrices carrées bien plus lourdes de taille 1024*1024, afin de montrer les gains obtenus avec la technologie GPU. Naturellement en fonction de votre matériel vous obtiendrez des résultats différents.
Pour le calcul en mode séquentiel, le traitement a pris environ 9 secondes, alors que le calcul parallèle avec C++ AMP a pris 142 millisecondes.
En résumé
Pour conclure, je vous propose de dresser le bilan des éléments que vous avez appris :
Sur le plan conceptuel :
· Sur des volumes de données immenses, le calcul sur GPU offre des performances extraordinaires
· Microsoft souhaite démocratiser la programmation GPU avec C++ AMP sur toutes les plateformes Windows et non Windows
Sur le plan de la programmation :
· class index<N> : pointe une valeur dans un conteneur multidimensionnel
· class extent<N> : taille d’un conteneur multidimensionnel
· restrict(amp, cpu) : restreint la syntaxe C++ pour exécuter une méthode ou une expression lambda en mode noyau
· parallel_for_each : exécute une boucle en parallèle sur des données multidimensionnelle
· class array_view<T,N> : wrapper multidimensionnel sur des données
· class accelerator : accélérateur utilisé en interne pour exécuter le code parallèle
· class accelerator_view : isole l’accélérateur des différents usages multithreads
· class array<T,N> : conteneur multidimensionnel sur des données
Avec ces quelques éléments, vous pouvez déjà produire des programmes utilisant la librairie C++ AMP et obtenir des exécutions très performantes. En adressant la programmation GPU avec autant d’abstractions, vous comprenez qu’il n’est pas nécessaire d’être un super expert pour paralléliser du code sur GPU. J’espère que cette découverte de C++ AMP, vous a mis l’eau à la bouche, et que vous allez vous essayer à construire des applications utilisant C++ AMP.
A bientôt
Bruno