Ce qui suit est un exemple jouet, pas une application de calibre professionnel, alors utilisez-le en connaissance de cause. Merci aux gens de Square Enix/ EIDOS (automne 2013) pour lesquels j'ai écrit un premier jet (à titre de démonstrateur, en une vingtaine de minutes pendant le dîner) et avec lesquels j'ai réglé les bogues bêtes du premier jet en question. Merci particulier à Olivier Pomerleau qui, le premier, a remarqué que les benchmarks initiaux étaient incorrects.
Depuis C++ 11, C++ offre plusieurs mécanismes intéressants de multiprogrammation, mais n'offre pas de manière standardisée un regroupement de threads (Thread Pool). L'un de ces mécanismes, async(), couplé aux futures, permet de lancer facilement une tâche asynchrone et d'en récupérer ultérieurement les résultats (ou de gérer les exceptions survenues pendant le traitement, selon le cas). L'exemple ci-dessous montre d'ailleurs à quel point les versions parallèle et séquentielle d'un même calcul peuvent ce ressembler lorsque nous avons recours à ces mécanismes.
#include <future>
#include <chrono>
#include <thread>
#include <iostream>
#include <utility>
using namespace std;
using namespace std::chrono;
int f(int n) {
this_thread::sleep_for(2s);
return n + 1;
}
int g(int n) {
this_thread::sleep_for(3s);
return n + 2;
}
template <class F>
auto tester(F && f) {
auto pre = system_clock::now();
auto res f();
auto post = system_clock::now();
return make_pair(res, post - pre);
}
int main() {
cout << "Test sequentiel" << endl;
auto r0 = tester([] {
return f(1) + g(1);
});
cout << "f(1) + g(1) == " << r0.first << ", calcule en "
<< duration_cast<milliseconds>(r0.second).count() << " ms." << endl;
cout << "Test parallele" << endl;
auto r1 = tester([]{
auto f_ = async(f, 1);
auto g_ = async(g, 1);
return f_.get() + g_.get();
});
cout << "f(1) + g(1) == " << r1.first << ", calcule en "
<< duration_cast<milliseconds>(r1.second).count() << " ms." << endl;
}
Dans cet exemple, le temps requis pour le calcul séquentiel s'apparente à la somme des temps requis pour exécuter f(1) et g(1), donc environ cinq secondes, alors que pour une machine munie d'au moins deux unités de traitement, le temps requis s'apparentera plutôt au maximum des deux temps, donc environ trois secondes.
Toutefois, async() est susceptible de lancer un nouveau thread à chaque appel (le comportement exact de ce mécanisme varie selon les implémentations), ce qui peut être trop coûteux pour certains types d'applications ou dans certains contextes. Si nous prenons l'exemple ci-dessus, le temps d'une exécution séquentielle type est de ou ms. (la variation étant surtout dûe à la précision des outils de mesure et aux circonstances), alors que dans le cas d'une exécution parallèle, le temps d'une exécution séquentielle type est de ms., les ms. en surplus étant surtout dûes au démarrage des threads.
Il existe une technique simple pour atténuer ces variations, soit démarrer les threads au préalable et les regrouper ensemble pour assurer leur saine gestion. Il faut alors typiquement que les threads Par remplissent un mandat générique, pour qu'il soit par la suite possible de les alimenter avec diverses tâches qui devront être prises en charge et exécutées de manière asynchrone.
Ce qui suit décrit un exemple académique (pas de calibre industriel!) de regroupement de threads, pour présenter quelques-uns des enjeux et montrer que, pour l'essentiel, implémenter ce schéma de conception pour en arriver à des résultats décents n'est pas sorcier. Notez que le regroupement présenté ici a plusieurs défauts :
Commençons par le programme de test que nous utiliserons, pour situer les enjeux et les pratiques.
|
|
|
|
| |
Notre approche sera itérative de plusieurs manières :
Nous reviendrons plus bas sur l'impact d'utiliser des tâches de calcul pour « sérieuses » dans nos tests. |
|
Le test séquentiel sera simple : exécuter chacune des tâches l'une après l'autre, en séquence justement. Sans surprises, ce test offrira (de loin!) les pires performances, sauf peut-être sur des systèmes monoprocesseurs ou lorsque le système ne contient qu'un très petit nombre de tâches devant être menées à bien. |
|
Le test sur des async lancera chacune des tâches à réaliser avec async() et récupérera les futures résultantes pour les placer dans un vecteur. Par la suite, le test attendra la complétion des tâches à travers chacune des futures, utilisant wait() plutôt que get() puisque les tâches ont le type de retour void. |
|
Nous ferons trois tests sur notre regroupement de threads (classe pool, plus bas). Le premier utilisera un pool par défaut, dans lequel le nombre de threads suit le nombre d'unités de traitement disponibles sur l'ordinateur; cette quantité est un entier non-signé donné par la méthode de classe hardware_concurrency() de la classe std::thread. Ce comportement par défaut sera idéal si les tâches exigent toutes beaucoup de calcul, réduisant à presque rien le coût des changements de contexte et la contention pour les unités de traitement de l'ordinateur. Notre test, qui n'implique que des tâches un peu bidons ne consommant à peu près pas de véritable temps sur le processeur, fera en sorte que ce comportement par défaut ne soit pas optimal, mais de véritables situations de calcul pourraient mieux en profiter. |
|
Un deuxième test utilisera un nombre de threads équivalent au double du nombre d'unités de traitement disponibles sur l'ordinateur. Si les tâches soumises au pool ne sollicitent pas pleinement les capacités de calcul disponibles, il est possible que cette confiiguration donne de meilleurs résultats que ne le fait la configuration par défaut; si les unités de traitement sont sollicitées à plein, alors cette configuration peut les sursaturer et accroître la contention pour les unités de traitement, réduisant les performances d'ensemble de l'ordinateur. |
|
Un troisième test va plus loin et utilise un nombre de threads équivalent au quadruple du nombre d'unités de traitement disponibles sur l'ordinateur. Les effets sont les mêmes que pour le cas précédent, mais amplifiés, positivement ou négativement. Avec nos tâches un peu trop bidons, cette configuration est avantageuse, mais n'en concluez pas que tel sera toujours le cas. Chaque système a ses particularités, et mieux vaut valider les paramètres optimaux en fonction de vos besoins réels. |
|
Comment est implémenté notre regroupement de threads, notre classe pool? Voici. Comme vous le constaterez, c'est plus simple qu'on ne le croirait. Le concept général est :
L'implémentation (naïve, mais opérationnelle) suit.
Comme le démontre la liste d'inclusions à droite, la totalité de notre regroupement de threads reposera sur des outils standards. |
|
Une instance de pool entreposera des tâches. Bien que nous imposions la contrainte d'une signature void(void) par souci de simplicité, nous ne savons pas d'avance quelle sera la nature de chacune des tâches à exécuter (nous voulons accepter des fonctions, des foncteurs, des λ, ...) mais nous devons les entreposer, et les conteneurs sont typés (on ne parle pas d'un vecteur, mais bien d'un vecteur de quelque chose). Il nous faut donc une manière non-intrusive de loger ensemble toutes sortes d'opérations. Nous appliquerons une technique bien connue d'effacement de types, soit le recours à une interface (le parent, tache), polymorphique et exposant un service execute(), de laquelle dériveront autant que tache_impl<F> que le code client utilisera de types F d'opérations pour les tâches qu'il souhaite voir prises en charge par le pool. Pour une instance f d'un type F donné, appeler execute() pour la tache (donc la tache_impl<F>) correspondante équivaudra à appeler f(). Si nous voulions que f puisse accepter des paramètres à l'appel, il y aurait un peu de travail à faire ici. Je vous laisse y réfléchir... Notez aussi que j'ai privilégié la sémantique de mouvement pour mon implémentation, mais si vous estimez que la copie serait préférable, je vous laisse ajouter les opérations qui pourraient compléter le portrait en fonction de vos besoins. |
|
Du côté des états (des attributs), nous avons ce qui suit :
|
|
Le prédicat privé stop_working() s'avèrera dans le cas où une demande de fermeture du pool a été soumise et où il n'y a plus de tâches à prendre en charge. Cette deuxième condition a été proposée par Olivier Pomerleau lors d'une formation à l'automne 2013. L'ami en question m'a fait remarquer (avec raison!) qu'un test de vitesse comparant un pool à des async est illégitime si les async traitent toutes les tâches qui leur sont soumises alors que le pool conclut alors qu'il lui reste encore du travail à faire. Le test sur empty(), qui vérifie de manière synchronisée s'il reste des tâches à traiter, règle ce problème. |
|
La méthode privée empty() permet de vérifier sans risque d'encourir un problème de synchronisation si la liste de tâches à traiter est vide. Le mutex nommé m est pris en charge par un verrou RAII nommé verrou dans l'optique de protéger les accès à taches contre les conditions de course. |
|
La méthode privée next_task() permet à un travailleur de consommer la prochaine tâche disponible. Cette méthode, appelée par les travailleurs, est probablement la plus complexe de notre implémentation :
Une alternative à la tâche nulle serait de retourner un Null Object ici, ce qui simplifierait l'exécution du point de vue du code client. Serait-ce avantageux? Notez au passage que, pour éviter les interblocages, cette méthode n'obtient jamais deux mutex en même temps. |
|
La méthode privée closing() permet de savoir si un signal de fin d'exécution a été donné au pool. Notez que bien que closing_ soit de type atomic<bool>, le résultat de sa consultation est un bool tout simple (pour fins d'efficacité). |
|
Le constructeur d'un pool lance un nombre n de threads travailleurs. Par défaut n sera le nombre d'unités de traitement disponibles sur un ordinateur donné. Dans notre modèle simpliste, chaque travailleur opérera selon un cycle élémentaire :
La référence capturée à la construction de chaque λ est this. |
|
Pour simplifier l'instanciation d'une tache_impl<F> donnée, nous aurons recours à la méthode de classe make_tache(). Cette méthode accepte un F nommé f et retourne l'instance de tache_impl<F> correspondante, ce qui allège considérablement l'écriture du code client. |
|
Sur le plan de la synchronisation, la méthode add_task() est la plus complexe du lot. En effet :
|
|
La méthode publique meurs() permet à qui de droit de demander la fin de l'exécution du pool et de ses travailleurs. Ce signal est donné par un changement d'état de la variable atomique closing_, de même que par une notification généralisée (notify_all()) de la variable conditionnelle new_task. Dans ce cas, l'idée est de réveiller tous les threads en attente d'une nouvelle tâche pour que ceux-ci constatent qu'il est temps de fermer les livres, pas de leur donner du travail supplémentaire. |
|
La méthode wait_end() permet à un demandeur de se suspendre jusqu'à la fin de l'exécutiion de tous les travailleurs. Le test sur joinable() de chacun des threads permet d'appeler wait_end() plus d'une fois sans subir d'effets adverses. |
|
Le destructeur, comme il se doit, signale aux travailleurs qu'il est temps de fermer les livres, puis attend la fin de leur exécution avant de faire en sorte que celle du pool ne se termine elle aussi. |
|
Globalement, donc, rien de transcendant. Et pourtant, ça fonctionne!
Les temps obtenus sur mon ordinateur personnel (huit coeurs) pour l'exécution des tâches bidons suivent.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 1 tache
Execution sequentielle: 253 ms.
Execution parallele (async) : 254 ms.
Execution parallele (pool par defaut de 8 threads) : 253 ms.
Execution parallele (pool de 16 threads) : 253 ms.
Execution parallele (pool de 32 threads) : 253 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 2 taches
Execution sequentielle: 473 ms.
Execution parallele (async) : 254 ms.
Execution parallele (pool par defaut de 8 threads) : 253 ms.
Execution parallele (pool de 16 threads) : 253 ms.
Execution parallele (pool de 32 threads) : 253 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 3 taches
Execution sequentielle: 723 ms.
Execution parallele (async) : 253 ms.
Execution parallele (pool par defaut de 8 threads) : 253 ms.
Execution parallele (pool de 16 threads) : 253 ms.
Execution parallele (pool de 32 threads) : 253 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 4 taches
Execution sequentielle: 945 ms.
Execution parallele (async) : 253 ms.
Execution parallele (pool par defaut de 8 threads) : 254 ms.
Execution parallele (pool de 16 threads) : 253 ms.
Execution parallele (pool de 32 threads) : 270 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 5 taches
Execution sequentielle: 1208 ms.
Execution parallele (async) : 264 ms.
Execution parallele (pool par defaut de 8 threads) : 264 ms.
Execution parallele (pool de 16 threads) : 281 ms.
Execution parallele (pool de 32 threads) : 264 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 6 taches
Execution sequentielle: 1451 ms.
Execution parallele (async) : 263 ms.
Execution parallele (pool par defaut de 8 threads) : 266 ms.
Execution parallele (pool de 16 threads) : 273 ms.
Execution parallele (pool de 32 threads) : 263 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 7 taches
Execution sequentielle: 1694 ms.
Execution parallele (async) : 263 ms.
Execution parallele (pool par defaut de 8 threads) : 263 ms.
Execution parallele (pool de 16 threads) : 265 ms.
Execution parallele (pool de 32 threads) : 263 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 8 taches
Execution sequentielle: 1933 ms.
Execution parallele (async) : 263 ms.
Execution parallele (pool par defaut de 8 threads) : 308 ms.
Execution parallele (pool de 16 threads) : 263 ms.
Execution parallele (pool de 32 threads) : 263 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 9 taches
Execution sequentielle: 2216 ms.
Execution parallele (async) : 283 ms.
Execution parallele (pool par defaut de 8 threads) : 503 ms.
Execution parallele (pool de 16 threads) : 311 ms.
Execution parallele (pool de 32 threads) : 306 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 10 taches
Execution sequentielle: 2492 ms.
Execution parallele (async) : 283 ms.
Execution parallele (pool par defaut de 8 threads) : 509 ms.
Execution parallele (pool de 16 threads) : 290 ms.
Execution parallele (pool de 32 threads) : 304 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 11 taches
Execution sequentielle: 2778 ms.
Execution parallele (async) : 286 ms.
Execution parallele (pool par defaut de 8 threads) : 531 ms.
Execution parallele (pool de 16 threads) : 286 ms.
Execution parallele (pool de 32 threads) : 288 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 12 taches
Execution sequentielle: 3000 ms.
Execution parallele (async) : 286 ms.
Execution parallele (pool par defaut de 8 threads) : 533 ms.
Execution parallele (pool de 16 threads) : 306 ms.
Execution parallele (pool de 32 threads) : 286 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 13 taches
Execution sequentielle: 3263 ms.
Execution parallele (async) : 286 ms.
Execution parallele (pool par defaut de 8 threads) : 526 ms.
Execution parallele (pool de 16 threads) : 296 ms.
Execution parallele (pool de 32 threads) : 313 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 14 taches
Execution sequentielle: 3554 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 562 ms.
Execution parallele (pool de 16 threads) : 325 ms.
Execution parallele (pool de 32 threads) : 340 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 15 taches
Execution sequentielle: 3769 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 541 ms.
Execution parallele (pool de 16 threads) : 312 ms.
Execution parallele (pool de 32 threads) : 310 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 16 taches
Execution sequentielle: 3979 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 541 ms.
Execution parallele (pool de 16 threads) : 325 ms.
Execution parallele (pool de 32 threads) : 328 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 17 taches
Execution sequentielle: 4268 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 764 ms.
Execution parallele (pool de 16 threads) : 505 ms.
Execution parallele (pool de 32 threads) : 295 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 18 taches
Execution sequentielle: 4524 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 757 ms.
Execution parallele (pool de 16 threads) : 505 ms.
Execution parallele (pool de 32 threads) : 295 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 19 taches
Execution sequentielle: 4788 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 772 ms.
Execution parallele (pool de 16 threads) : 501 ms.
Execution parallele (pool de 32 threads) : 291 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 20 taches
Execution sequentielle: 5020 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 764 ms.
Execution parallele (pool de 16 threads) : 500 ms.
Execution parallele (pool de 32 threads) : 317 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 21 taches
Execution sequentielle: 5223 ms.
Execution parallele (async) : 292 ms.
Execution parallele (pool par defaut de 8 threads) : 762 ms.
Execution parallele (pool de 16 threads) : 505 ms.
Execution parallele (pool de 32 threads) : 300 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 22 taches
Execution sequentielle: 5445 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 755 ms.
Execution parallele (pool de 16 threads) : 510 ms.
Execution parallele (pool de 32 threads) : 301 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 23 taches
Execution sequentielle: 5685 ms.
Execution parallele (async) : 292 ms.
Execution parallele (pool par defaut de 8 threads) : 779 ms.
Execution parallele (pool de 16 threads) : 505 ms.
Execution parallele (pool de 32 threads) : 301 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 24 taches
Execution sequentielle: 5895 ms.
Execution parallele (async) : 292 ms.
Execution parallele (pool par defaut de 8 threads) : 772 ms.
Execution parallele (pool de 16 threads) : 523 ms.
Execution parallele (pool de 32 threads) : 299 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 25 taches
Execution sequentielle: 6164 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 988 ms.
Execution parallele (pool de 16 threads) : 528 ms.
Execution parallele (pool de 32 threads) : 329 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 26 taches
Execution sequentielle: 6382 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 975 ms.
Execution parallele (pool de 16 threads) : 535 ms.
Execution parallele (pool de 32 threads) : 316 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 27 taches
Execution sequentielle: 6596 ms.
Execution parallele (async) : 292 ms.
Execution parallele (pool par defaut de 8 threads) : 981 ms.
Execution parallele (pool de 16 threads) : 523 ms.
Execution parallele (pool de 32 threads) : 303 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 28 taches
Execution sequentielle: 6854 ms.
Execution parallele (async) : 292 ms.
Execution parallele (pool par defaut de 8 threads) : 990 ms.
Execution parallele (pool de 16 threads) : 586 ms.
Execution parallele (pool de 32 threads) : 312 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 29 taches
Execution sequentielle: 7145 ms.
Execution parallele (async) : 291 ms.
Execution parallele (pool par defaut de 8 threads) : 1028 ms.
Execution parallele (pool de 16 threads) : 583 ms.
Execution parallele (pool de 32 threads) : 303 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 30 taches
Execution sequentielle: 7409 ms.
Execution parallele (async) : 292 ms.
Execution parallele (pool par defaut de 8 threads) : 1030 ms.
Execution parallele (pool de 16 threads) : 576 ms.
Execution parallele (pool de 32 threads) : 309 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 31 taches
Execution sequentielle: 7686 ms.
Execution parallele (async) : 292 ms.
Execution parallele (pool par defaut de 8 threads) : 1061 ms.
Execution parallele (pool de 16 threads) : 593 ms.
Execution parallele (pool de 32 threads) : 304 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 32 taches
Execution sequentielle: 7981 ms.
Execution parallele (async) : 295 ms.
Execution parallele (pool par defaut de 8 threads) : 1073 ms.
Execution parallele (pool de 16 threads) : 587 ms.
Execution parallele (pool de 32 threads) : 311 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 33 taches
Execution sequentielle: 8253 ms.
Execution parallele (async) : 296 ms.
Execution parallele (pool par defaut de 8 threads) : 1216 ms.
Execution parallele (pool de 16 threads) : 704 ms.
Execution parallele (pool de 32 threads) : 492 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 34 taches
Execution sequentielle: 8482 ms.
Execution parallele (async) : 296 ms.
Execution parallele (pool par defaut de 8 threads) : 1223 ms.
Execution parallele (pool de 16 threads) : 718 ms.
Execution parallele (pool de 32 threads) : 486 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 35 taches
Execution sequentielle: 8779 ms.
Execution parallele (async) : 297 ms.
Execution parallele (pool par defaut de 8 threads) : 1272 ms.
Execution parallele (pool de 16 threads) : 751 ms.
Execution parallele (pool de 32 threads) : 523 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 36 taches
Execution sequentielle: 9003 ms.
Execution parallele (async) : 298 ms.
Execution parallele (pool par defaut de 8 threads) : 1275 ms.
Execution parallele (pool de 16 threads) : 773 ms.
Execution parallele (pool de 32 threads) : 526 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 37 taches
Execution sequentielle: 9293 ms.
Execution parallele (async) : 297 ms.
Execution parallele (pool par defaut de 8 threads) : 1305 ms.
Execution parallele (pool de 16 threads) : 792 ms.
Execution parallele (pool de 32 threads) : 537 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 38 taches
Execution sequentielle: 9525 ms.
Execution parallele (async) : 298 ms.
Execution parallele (pool par defaut de 8 threads) : 1320 ms.
Execution parallele (pool de 16 threads) : 797 ms.
Execution parallele (pool de 32 threads) : 520 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 39 taches
Execution sequentielle: 9745 ms.
Execution parallele (async) : 297 ms.
Execution parallele (pool par defaut de 8 threads) : 1309 ms.
Execution parallele (pool de 16 threads) : 765 ms.
Execution parallele (pool de 32 threads) : 529 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 40 taches
Execution sequentielle: 10006 ms.
Execution parallele (async) : 298 ms.
Execution parallele (pool par defaut de 8 threads) : 1330 ms.
Execution parallele (pool de 16 threads) : 780 ms.
Execution parallele (pool de 32 threads) : 534 ms.
Quelques remarques :
Utiliser des tâches bidons n'est pas sans intérêt, du moins pour un premier jet, mais qu'en est-il pour des tâches impliquant des calculs plutôt que des suspensions volontaires d'exécution (appels à this_thread::sleep_for())?
Tâches bidons | Tâches de calcul | |
---|---|---|
Dans cet exemple, nous remplacerons les tâches bidons (cas initial, répété à la droite immédiate pour faciliter la compréhension) par des tâches de « calcul » (ajouter répétitivement une constante à une valeur « globale », dans le but de forcer le compilateur à y voir un effet de bord). Pour ce faire, nous créons un vecteur de nombres à virgule flottante (l'état global à modifier) et nous conservons l'idée de poursuivre le traitement pour un certain temps (les calculs en ce sens sont les mêmes ici que dans l'exemple pour les tâches bidons). La différence clé entre cet exemple et le précédent est que maintenant, les travailleurs seront occupés pendant qu'ils prennent en charge une tâche (précédemment, ils passaient l'essentiel de leur temps suspendu de manière volontaire par des appels à this_thread::sleep_for()). |
|
|
En remplaçant des tâches bidons par des tâches de calcul, les temps obtenus sur mon ordinateur personnel (huit coeurs) suivent.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 1 tache
Execution sequentielle: 253 ms.
Execution parallele (async) : 253 ms.
Execution parallele (pool par defaut de 8 threads) : 253 ms.
Execution parallele (pool de 16 threads) : 253 ms.
Execution parallele (pool de 32 threads) : 253 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 2 taches
Execution sequentielle: 473 ms.
Execution parallele (async) : 253 ms.
Execution parallele (pool par defaut de 8 threads) : 253 ms.
Execution parallele (pool de 16 threads) : 253 ms.
Execution parallele (pool de 32 threads) : 253 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 3 taches
Execution sequentielle: 723 ms.
Execution parallele (async) : 254 ms.
Execution parallele (pool par defaut de 8 threads) : 253 ms.
Execution parallele (pool de 16 threads) : 253 ms.
Execution parallele (pool de 32 threads) : 253 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 4 taches
Execution sequentielle: 945 ms.
Execution parallele (async) : 253 ms.
Execution parallele (pool par defaut de 8 threads) : 312 ms.
Execution parallele (pool de 16 threads) : 253 ms.
Execution parallele (pool de 32 threads) : 301 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 5 taches
Execution sequentielle: 1208 ms.
Execution parallele (async) : 295 ms.
Execution parallele (pool par defaut de 8 threads) : 264 ms.
Execution parallele (pool de 16 threads) : 263 ms.
Execution parallele (pool de 32 threads) : 263 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 6 taches
Execution sequentielle: 1451 ms.
Execution parallele (async) : 290 ms.
Execution parallele (pool par defaut de 8 threads) : 263 ms.
Execution parallele (pool de 16 threads) : 390 ms.
Execution parallele (pool de 32 threads) : 268 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 7 taches
Execution sequentielle: 1694 ms.
Execution parallele (async) : 297 ms.
Execution parallele (pool par defaut de 8 threads) : 263 ms.
Execution parallele (pool de 16 threads) : 268 ms.
Execution parallele (pool de 32 threads) : 312 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 8 taches
Execution sequentielle: 1933 ms.
Execution parallele (async) : 305 ms.
Execution parallele (pool par defaut de 8 threads) : 334 ms.
Execution parallele (pool de 16 threads) : 276 ms.
Execution parallele (pool de 32 threads) : 333 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 9 taches
Execution sequentielle: 2216 ms.
Execution parallele (async) : 503 ms.
Execution parallele (pool par defaut de 8 threads) : 543 ms.
Execution parallele (pool de 16 threads) : 469 ms.
Execution parallele (pool de 32 threads) : 385 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 10 taches
Execution sequentielle: 2492 ms.
Execution parallele (async) : 503 ms.
Execution parallele (pool par defaut de 8 threads) : 527 ms.
Execution parallele (pool de 16 threads) : 389 ms.
Execution parallele (pool de 32 threads) : 523 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 11 taches
Execution sequentielle: 2778 ms.
Execution parallele (async) : 536 ms.
Execution parallele (pool par defaut de 8 threads) : 529 ms.
Execution parallele (pool de 16 threads) : 319 ms.
Execution parallele (pool de 32 threads) : 393 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 12 taches
Execution sequentielle: 3000 ms.
Execution parallele (async) : 539 ms.
Execution parallele (pool par defaut de 8 threads) : 605 ms.
Execution parallele (pool de 16 threads) : 367 ms.
Execution parallele (pool de 32 threads) : 332 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 13 taches
Execution sequentielle: 3263 ms.
Execution parallele (async) : 539 ms.
Execution parallele (pool par defaut de 8 threads) : 564 ms.
Execution parallele (pool de 16 threads) : 321 ms.
Execution parallele (pool de 32 threads) : 389 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 14 taches
Execution sequentielle: 3554 ms.
Execution parallele (async) : 566 ms.
Execution parallele (pool par defaut de 8 threads) : 632 ms.
Execution parallele (pool de 16 threads) : 329 ms.
Execution parallele (pool de 32 threads) : 326 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 15 taches
Execution sequentielle: 3769 ms.
Execution parallele (async) : 555 ms.
Execution parallele (pool par defaut de 8 threads) : 583 ms.
Execution parallele (pool de 16 threads) : 433 ms.
Execution parallele (pool de 32 threads) : 412 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 16 taches
Execution sequentielle: 3979 ms.
Execution parallele (async) : 575 ms.
Execution parallele (pool par defaut de 8 threads) : 544 ms.
Execution parallele (pool de 16 threads) : 420 ms.
Execution parallele (pool de 32 threads) : 375 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 17 taches
Execution sequentielle: 4268 ms.
Execution parallele (async) : 764 ms.
Execution parallele (pool par defaut de 8 threads) : 754 ms.
Execution parallele (pool de 16 threads) : 509 ms.
Execution parallele (pool de 32 threads) : 390 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 18 taches
Execution sequentielle: 4524 ms.
Execution parallele (async) : 764 ms.
Execution parallele (pool par defaut de 8 threads) : 775 ms.
Execution parallele (pool de 16 threads) : 628 ms.
Execution parallele (pool de 32 threads) : 374 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 19 taches
Execution sequentielle: 4788 ms.
Execution parallele (async) : 764 ms.
Execution parallele (pool par defaut de 8 threads) : 770 ms.
Execution parallele (pool de 16 threads) : 549 ms.
Execution parallele (pool de 32 threads) : 353 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 20 taches
Execution sequentielle: 5020 ms.
Execution parallele (async) : 767 ms.
Execution parallele (pool par defaut de 8 threads) : 762 ms.
Execution parallele (pool de 16 threads) : 548 ms.
Execution parallele (pool de 32 threads) : 478 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 21 taches
Execution sequentielle: 5223 ms.
Execution parallele (async) : 767 ms.
Execution parallele (pool par defaut de 8 threads) : 783 ms.
Execution parallele (pool de 16 threads) : 517 ms.
Execution parallele (pool de 32 threads) : 360 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 22 taches
Execution sequentielle: 5445 ms.
Execution parallele (async) : 790 ms.
Execution parallele (pool par defaut de 8 threads) : 772 ms.
Execution parallele (pool de 16 threads) : 546 ms.
Execution parallele (pool de 32 threads) : 443 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 23 taches
Execution sequentielle: 5685 ms.
Execution parallele (async) : 776 ms.
Execution parallele (pool par defaut de 8 threads) : 779 ms.
Execution parallele (pool de 16 threads) : 559 ms.
Execution parallele (pool de 32 threads) : 422 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 24 taches
Execution sequentielle: 5895 ms.
Execution parallele (async) : 782 ms.
Execution parallele (pool par defaut de 8 threads) : 765 ms.
Execution parallele (pool de 16 threads) : 531 ms.
Execution parallele (pool de 32 threads) : 353 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 25 taches
Execution sequentielle: 6164 ms.
Execution parallele (async) : 990 ms.
Execution parallele (pool par defaut de 8 threads) : 1012 ms.
Execution parallele (pool de 16 threads) : 591 ms.
Execution parallele (pool de 32 threads) : 447 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 26 taches
Execution sequentielle: 6382 ms.
Execution parallele (async) : 996 ms.
Execution parallele (pool par defaut de 8 threads) : 1138 ms.
Execution parallele (pool de 16 threads) : 557 ms.
Execution parallele (pool de 32 threads) : 443 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 27 taches
Execution sequentielle: 6596 ms.
Execution parallele (async) : 1008 ms.
Execution parallele (pool par defaut de 8 threads) : 999 ms.
Execution parallele (pool de 16 threads) : 660 ms.
Execution parallele (pool de 32 threads) : 864 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 28 taches
Execution sequentielle: 6854 ms.
Execution parallele (async) : 1022 ms.
Execution parallele (pool par defaut de 8 threads) : 1037 ms.
Execution parallele (pool de 16 threads) : 689 ms.
Execution parallele (pool de 32 threads) : 592 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 29 taches
Execution sequentielle: 7145 ms.
Execution parallele (async) : 1030 ms.
Execution parallele (pool par defaut de 8 threads) : 1055 ms.
Execution parallele (pool de 16 threads) : 606 ms.
Execution parallele (pool de 32 threads) : 422 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 30 taches
Execution sequentielle: 7409 ms.
Execution parallele (async) : 1056 ms.
Execution parallele (pool par defaut de 8 threads) : 1064 ms.
Execution parallele (pool de 16 threads) : 606 ms.
Execution parallele (pool de 32 threads) : 388 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 31 taches
Execution sequentielle: 7686 ms.
Execution parallele (async) : 1063 ms.
Execution parallele (pool par defaut de 8 threads) : 1045 ms.
Execution parallele (pool de 16 threads) : 605 ms.
Execution parallele (pool de 32 threads) : 406 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 32 taches
Execution sequentielle: 7982 ms.
Execution parallele (async) : 1072 ms.
Execution parallele (pool par defaut de 8 threads) : 1069 ms.
Execution parallele (pool de 16 threads) : 626 ms.
Execution parallele (pool de 32 threads) : 431 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 33 taches
Execution sequentielle: 8253 ms.
Execution parallele (async) : 1236 ms.
Execution parallele (pool par defaut de 8 threads) : 1220 ms.
Execution parallele (pool de 16 threads) : 746 ms.
Execution parallele (pool de 32 threads) : 515 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 34 taches
Execution sequentielle: 8482 ms.
Execution parallele (async) : 1239 ms.
Execution parallele (pool par defaut de 8 threads) : 1221 ms.
Execution parallele (pool de 16 threads) : 750 ms.
Execution parallele (pool de 32 threads) : 497 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 35 taches
Execution sequentielle: 8779 ms.
Execution parallele (async) : 1293 ms.
Execution parallele (pool par defaut de 8 threads) : 1342 ms.
Execution parallele (pool de 16 threads) : 809 ms.
Execution parallele (pool de 32 threads) : 554 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 36 taches
Execution sequentielle: 9003 ms.
Execution parallele (async) : 1296 ms.
Execution parallele (pool par defaut de 8 threads) : 1272 ms.
Execution parallele (pool de 16 threads) : 874 ms.
Execution parallele (pool de 32 threads) : 646 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 37 taches
Execution sequentielle: 9293 ms.
Execution parallele (async) : 1321 ms.
Execution parallele (pool par defaut de 8 threads) : 1318 ms.
Execution parallele (pool de 16 threads) : 847 ms.
Execution parallele (pool de 32 threads) : 557 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 38 taches
Execution sequentielle: 9525 ms.
Execution parallele (async) : 1321 ms.
Execution parallele (pool par defaut de 8 threads) : 1336 ms.
Execution parallele (pool de 16 threads) : 804 ms.
Execution parallele (pool de 32 threads) : 599 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 39 taches
Execution sequentielle: 9745 ms.
Execution parallele (async) : 1321 ms.
Execution parallele (pool par defaut de 8 threads) : 1322 ms.
Execution parallele (pool de 16 threads) : 808 ms.
Execution parallele (pool de 32 threads) : 659 ms.
----------------------------------------------------------------------
Test (sequentiel, async, pool...), 40 taches
Execution sequentielle: 10006 ms.
Execution parallele (async) : 1343 ms.
Execution parallele (pool par defaut de 8 threads) : 1335 ms.
Execution parallele (pool de 16 threads) : 802 ms.
Execution parallele (pool de 32 threads) : 558 ms.
Intéressant, n'est-ce pas? On constate ici que :
Ceci suggère que nous aurions avantage à investiguer la question plus à fond, n'est-ce pas?
Pour vous simplifier l'existence, voici une version complète du programme de test avec tâches bidons (les threads dorment) et tâches de calcul (les threads travaillent). La seule différence entre les deux tient à la fonction creer_taches().
Tâches bidons (les threads dorment) | Tâches de calcul (les threads travaillent) |
---|---|
|
|
Avec la classe de la cohorte 12 du DDJV, en 2017, j'ai fait l'exercice d'écrire collectivement un équivalent de la classe pool présentée ci-dessus, et nous en sommes arrivés à quelque chose qui, à bien des égards, est plus charmant encore. Entre autres :
Le code suit.
#include <condition_variable>
#include <thread>
#include <vector>
#include <atomic>
#include <deque>
#include <functional>
#include <mutex>
using namespace std;
class th_pool {
vector<thread> th;
deque<function<void()>> taches;
mutex mutex_taches;
atomic<bool> meurs;
condition_variable attente; // hum
public:
th_pool(unsigned int nthr = thread::hardware_concurrency()) : meurs{ false } {
for (decltype(nthr) i = 0; i != nthr; ++i)
th.emplace_back(thread{ [&] {
while (!meurs) {
auto f = extraire();
f();
}
} });
}
~th_pool() {
terminer();
attente.notify_all();
for (auto & thr : th)
thr.join();
}
void ajouter(function<void()> tache) {
lock_guard<mutex> _{ mutex_taches };
taches.push_back(tache);
attente.notify_one();
}
function<void()> extraire() {
unique_lock<mutex> verrou{ mutex_taches };
if (taches.empty())
attente.wait(verrou, [&] { return !taches.empty() || meurs; });
if (!taches.empty()) {
auto f = taches.front();
taches.pop_front();
return f;
}
return [] {}; // Null object
}
void terminer() {
meurs = true;
}
};
Quelques liens pour enrichir le propos :