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++.
Le modèle mis de l'avant par AMP vise à maximiser l'utilisation (parallèle) du matériel disponible.
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.
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);
}
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.
Quelques liens pour en savoir plus.
L'API Mantle d'AMD :