Survol de Parallel Patterns Library (PPL)

Notez que des échanges privés avec des développeurs de Microsoft, en 2017, m'ont appris que le Concurrency Runtime, sur lequel reposait la PPL, a été abandonné, dû à de nombreux problèmes techniques. Il est donc sage de lire un peu sur l'état de la situation pour ce qui est de la PPL avant d'y investir temps et argent.

La bibliothèque PPL offre des équivalents parallèles de plusieurs algorithmes de la STL. La PPL est un produit de Microsoft, pas un standard.

Présentation succincte

La bibliothèque PPL est un groupe d'algorithmes et d'outils permettant d'exploiter les ressources (coeurs, threads, noeuds NUMA et autres) sur un ordinateur. Tristement, cette bibliothèque n'est (à ma connaissance) disponible que sur plateforme Microsoft; en retour, certains de ses auteurs travaillent présentement sur la version parallèle de la STL, donc les concepts et techniques de PPL sont susceptibles d'être standardisés, du moins partiellement.

Comment l'utiliser

Présumant que vous utilisiez Visual Studio, il suffit d'inclure <ppl.h> et d'utiliser les algorithmes et les outils en question, qui sont dans l'espace nommé concurrency. Il existe des livres pour vous guider, en particulier ../../Liens/Suggestions-lecture.html#patterns_for_parallel_programming qui est pas mal.

Exemple concret

Petit exemple illustratif :

#include <ppl.h>
#include <vector>
#include <numeric>
#include <iostream>
#include <algorithm>
#include <cassert>
#include <vector>
#include <cmath>
#include <sstream>
#include <string>
#include <numeric>
#include <thread>
#include <chrono>
using namespace std;
using namespace std::chrono;

template <class It>
   float somme_racines(It debut, It fin) {
      float somme = {};
      for (; debut != fin; ++debut)
         somme += sqrt(*debut);
      return somme;
   }
template <class It>
   float somme_racines_stl(It debut, It fin) {
      return accumulate(debut, fin, float{}, [](float so_far, float cur) {
         return so_far + sqrt(cur);
      });
   }
template <class It>
   void ajout(It debut, It fin, float ajout) {
      for (; debut != fin; ++debut)
         *debut += ajout;
   }
template <class It>
   void ajout_stl(It debut, It fin, float ajout) {
      transform(debut, fin, debut, [=](float p) {
         return p + ajout;
      });
   }

template <class It>
   float somme_racines_parallele(It debut, It fin) {
      using namespace concurrency;
      return parallel_reduce(debut, fin, float{}, 
         [](float so_far, float cur) {
         return so_far + sqrt(cur);
      });
   }
template <class It>
   void ajout_parallele(It debut, It fin, float ajout) {
      using namespace concurrency;
      parallel_transform(debut, fin, debut, [=](float p) {
         return p + ajout;
      });
   }

template <class F>
   auto tester(F f, const string & msg, ostream & out) {
      auto avant = system_clock::now();
      auto resultat = f();
      auto apres = system_clock::now();
      out << msg << ", " << duration_cast<milliseconds>(apres-avant).count() << " ms." << endl;
      return resultat;
   }

int main() {
   enum { N = 100'000'000 };
   vector<float> v(N);
   random_device rd;
   mt19937 prng{ rd() };
   uniform_int_distribution<int> d100{ 1, 100 };
   generate(begin(v), end(v), [&]{ return d100(prng) + 0.5f; });
   clog.rdbuf(nullptr);
   cout << "Tests sur " << N << " echantillons de type float, ordinateur muni de "
        << thread::hardware_concurrency() << " coeurs" << endl;
   //
   // Les affichages bidons servent a prevenir certaines optimisations
   // en provoquant un effet de bord mensonger
   //
   clog << tester([v]() mutable {
      auto somme = somme_racines_parallele(begin(v), end(v));
      ajout_parallele(begin(v), end(v), somme);
      somme = somme_racines_parallele(begin(v), end(v));
      ajout_parallele(begin(v), end(v), somme);
      somme = somme_racines_parallele(begin(v), end(v));
      return somme;
   }, "Operations paralleles, PPL", cout) << endl; // prévenir l'optimisation
   clog << tester([v]() mutable {
      auto somme = somme_racines(begin(v), end(v));
      ajout(begin(v), end(v), somme);
      somme = somme_racines(begin(v), end(v));
      ajout(begin(v), end(v), somme);
      somme = somme_racines(begin(v), end(v));
      return somme;
   }, "Operations sequentielles", cout) << endl; // prévenir l'optimisation
   clog << tester([v]() mutable {
      auto somme = somme_racines_stl(begin(v), end(v));
      ajout_stl(begin(v), end(v), somme);
      somme = somme_racines_stl(begin(v), end(v));
      ajout_stl(begin(v), end(v), somme);
      somme = somme_racines_stl(begin(v), end(v));
      return somme;
   }, "Operations sequentielles (saveur STL)", cout) << endl; // prévenir l'optimisation
}

Ceci compare les opérations avec PPL et selon d'autres approches. La beauté de PPL est qu'a priori, le code standard séquentiel ressemble beaucoup au code parallèle du point de vue du code client.

Ce que ça donne à l'exécution

Les chiffres montrent clairement les avantages de la version parallèle sur la version séquentielle :

Tests sur 100000000 echantillons de type float, ordinateur muni de 8 coeurs
Operations paralleles, PPL, 632 ms.
Operations sequentielles, 1591 ms.
Operations sequentielles (saveur STL), 1596 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 enrichir le propos.


Valid XHTML 1.0 Transitional

CSS Valide !