Survol du Advanced Massive Parallelism (AMP)

La technologie AMP est le fruit de travaux de recherche chez Microsoft pour faciliter l'accès (de type massivement parallèle) aux ressources de calcul d'un ordinateur à partir de code source C++ conventionnel, à un mot clé près : restrict.

Il existe des implémentations tierces d'AMP, ce qui est en soi encourageant.

Le mot clé restrict, heureusement, a été choisi avec soin et est réservé en langage C, ce qui réduit les risques qu'il soit utilisé dans un programme C++.

Présentation succincte

Le modèle mis de l'avant par AMP vise à maximiser l'utilisation (parallèle) du matériel disponible.

Comment l'utiliser

En pratique, pour avoir recours à AMP, il suffit d'inclure <amp.h> et d'utiliser le presque mot clé restrict de manière adéquate.

Comme la plupart des moteurs de parallélisme, AMP profite au maximum de structures de données Cache-Friendly comme les tableaux et les vecteurs.

Exemple concret

Ce qui suit est directement inspiré de http://msdn.microsoft.com/fr-fr/magazine/hh882446.aspx. Le code ci-dessous a parfois recours à des extent; un extent représente un vecteur de entiers qui spécifient les limites d'un espace à dimensions dont l'origine est . Les valeurs du vecteur sont classées de plus significatif au moins significatif. Dans l'espace 3D cartésien, le vecteur d'étendue représente un espace dans lequel varie de à , est compris entre et , et varie de à .

#include <iostream>
#include <vector>
#include <chrono>
#include <string>
#include <amp.h>
using namespace concurrency;
using namespace std;
using namespace std::chrono;
void test_amp();

template <class F>
   void tester(F fct, const string &msg, ostream &out) {
      out << msg << "\n\t" << flush;
      auto avant = system_clock::now();
      fct();
      auto apres = system_clock::now();
      out << "Temps ecoule: " << duration_cast<milliseconds>(apres-avant).count() << " ms." << endl;
   }

void calculs_v0(vector<int>& vA, vector<int>& vB, vector<int>& vC, int M, int N) {
   for (int i = 0; i < M; i++)
      for (int j = 0; j < N; j++)
         vC[i * N + j] = vA[i * N + j] + vB[i * N + j];
}

void calculs_v1(vector<int>& vA, vector<int>& vB, vector<int>& vC, int M, int N) {
   array_view<int> a(M*N, vA), b(M*N, vB);
   array_view<int> c(M*N, vC);
   for (int i = 0; i < M; i++)
      for (int j = 0; j < N; j++)
         c(i * N + j) = a(i * N + j) + b(i * N + j);
}

void calculs_v2(vector<int>& vA, vector<int>& vB, vector<int>& vC, int M, int N) {
   concurrency::extent<1> e(M*N);
   array_view<int, 1> a(e, vA), b(e, vB);
   array_view<int, 1> c(e, vC);
   for (int i = 0; i < M; i++)
      for (int j = 0; j < N; j++)
         c(i * N + j) = a(i * N + j) + b(i * N + j);
}

void calculs_v3(vector<int>& vA, vector<int>& vB, vector<int>& vC, int M, int N) {
   concurrency::extent<2> e(M, N);
   array_view<int, 2> a(e, vA), b(e, vB);
   array_view<int, 2> c(e, vC);

   for (int i = 0; i < e[0]; i++)
      for (int j = 0; j < e[1]; j++)
         c(i, j) = a(i, j) + b(i, j);
}

void calculs_v4(vector<int>& vA, vector<int>& vB, vector<int>& vC, int M, int N) {
   concurrency::extent<2> e(M, N);
   array_view<int, 2> a(e, vA), b(e, vB);
   array_view<int, 2> c(e, vC);
   index<2> idx(0, 0);
   for (idx[0] = 0; idx[0] < e[0]; idx[0]++)
      for (idx[1] = 0; idx[1] < e[1]; idx[1]++)
         c[idx] = a[idx] + b[idx];
         //c(idx[0], idx[1]) = a(idx[0], idx[1]) + b(idx[0], idx[1]);
}

void calculs_v5(vector<int>& vA, vector<int>& vB, vector<int>& vC, int M, int N) {
   concurrency::extent<2> e(M, N);
   array_view<int, 2> a(e, vA), b(e, vB);
   array_view<int, 2> c(e, vC);
   c.discard_data(); // ICI: éviter de copier inutilement des données
   //
   // Le code dans la λ sera exécuté sur un accélérateur par défaut (il est possible
   // de choisir un accélérateur par programmation si on le souhaite, par exemple dans un
   // cas où on saurait que celui par défaut n'est pas idéal pour nos types de données)
   //
   // Par conséquent, cette λ est sollicitée par plusieurs threads concurrents. Le
   // paramètre «index» dans le «extent» guide le découpage
   //
   // Quelques assertions qui tiendront ici, pour tout idx passé à la lambda:
   //
   // - e.rank == idx.rank
   // - e.containx(idx) == true
   // - le nombre d'appels à la lambda sera e.size()
   //
   // Dans ce cas bien précis, e.rank vaut 2 et e.size() vaut 3 * 2, donc 6. Les six
   // threads qui appelleront la lambda utiliseront les idx suivants:
   //
   //    { 0,0 } { 0,1 } { 1,0 } { 1,1 } { 2,0 } { 2,1 }
   //
   // Notez que la capture d'une lambda assujettie à restrict(amp) ne peut se faire que
   // par valeur (une capture par référence serait illégale)
   //
   // Plus de détails: http://blogs.msdn.com/b/nativeconcurrency/archive/2011/12/19/restrict-amp-restrictions-part-0-of-n-introduction.aspx
   //
   parallel_for_each(e, [=](index<2> idx) restrict (amp) {
      c[idx] = a[idx] + b[idx];
   });
   // Le synchronize() ci-dessous complète le calcul. Cette étape est optionnelle (l'extent la
   // fait dans son destructeur), sauf si on veut traiter d'éventuelles exceptions
   c.synchronize();
}

void MatMulSeq(vector<int>& vC, const vector<int>& vA, const 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 i = 0; i < W; i++)
            sum += vA[row * W + i] * vB[i * N + col];
         vC[row * N + col] = sum;
      }
}

void MatMulAmp(vector<int>& vC, const vector<int>& vA, const vector<int>& vB, int M, int N, int W) {
   array_view<const int, 2> a(M, W, vA), b(W, N, vB);
   array_view<int, 2> c(M, N, vC);
   c.discard_data();
   parallel_for_each(c.extent, [=](index<2> idx) restrict(amp) {
      int row = idx[0]; int col = idx[1];
      int sum = 0;
      for(int i = 0; i < b.extent[0]; i++)
         sum += a(row, i) * b(i, col);
      c[idx] = sum;
   });
   c.synchronize();
}

int main() {
   enum { M = 1024, N = 1024, W = 1024 };

   vector<int> vA(M * N);
   vector<int> vB(M * N);

   int i = 0;
   generate(vA.begin(), vA.end(), [&i] {return i++;});
   generate(vB.begin(), vB.end(), [&i] {return i--;});

   vector<int> vC(M * N);

   tester([&](){ calculs_v0(vA, vB, vC, M, N); }, "Calculs " + to_string(M) + "x" + to_string(N) + ", v0", cout);
   tester([&](){ calculs_v1(vA, vB, vC, M, N); }, "Calculs " + to_string(M) + "x" + to_string(N) + ", v1", cout);
   tester([&](){ calculs_v2(vA, vB, vC, M, N); }, "Calculs " + to_string(M) + "x" + to_string(N) + ", v2", cout);
   tester([&](){ calculs_v3(vA, vB, vC, M, N); }, "Calculs " + to_string(M) + "x" + to_string(N) + ", v3", cout);
   tester([&](){ calculs_v4(vA, vB, vC, M, N); }, "Calculs " + to_string(M) + "x" + to_string(N) + ", v4", cout);
   tester([&](){ calculs_v5(vA, vB, vC, M, N); }, "Calculs " + to_string(M) + "x" + to_string(N) + ", v5", cout);

   tester([&](){ MatMulSeq(vA, vB, vC, M, N, W); }, "Multiplication de matrices " + to_string(M) + "x" + to_string(N) + " et " + to_string(N) + "x" + to_string(W) + ", sequentiel", cout);
   tester([&](){ MatMulAmp(vA, vB, vC, M, N, W); }, "Multiplication de matrices " + to_string(M) + "x" + to_string(N) + " et " + to_string(N) + "x" + to_string(W) + ", AMP", cout);
}

Ce que ça donne à l'exécution

Les chiffres parlent, et le font avec force :

Calculs 1024x1024, v0
        Temps ecoule: 1 ms.
Calculs 1024x1024, v1
        Temps ecoule: 327 ms.
Calculs 1024x1024, v2
        Temps ecoule: 2 ms.
Calculs 1024x1024, v3
        Temps ecoule: 4 ms.
Calculs 1024x1024, v4
        Temps ecoule: 4 ms.
Calculs 1024x1024, v5
        Temps ecoule: 9 ms.
Multiplication de matrices 1024x1024 et 1024x1024, sequentiel
        Temps ecoule: 7587 ms.
Multiplication de matrices 1024x1024 et 1024x1024, AMP
        Temps ecoule: 181 ms.

Évidemment, ces chiffres sont à titre indicatif seulement. Selon les architectures et selon les circonstances, la qualité des résultats variera.

Lectures complémentaires

Quelques liens pour en savoir plus.

L'API Mantle d'AMD :


Valid XHTML 1.0 Transitional

CSS Valide !