Concurrence et parallélisme
Concurrence == parallélisme ?
À l’oral, on mélange souvent concurrence et parallélisme. D’ailleurs, en première approche, à la question "C’est quoi un programme qui exécute des tâches concurrentes ?", on serait tenté de répondre :
— C’est un programme qui exécute plusieurs tâches en même temps.
Et à la question "C’est quoi un programme qui exécute des tâches en parallèle ?", on peut également fournir la même réponse :
— C’est un programme qui exécute plusieurs tâches en même temps.
Alors ? Concurrence == parallélisme ?
En fait, les deux réponses ci-dessus sont correctes… selon le sens qu’on attribue aux mots "en même temps" !
Concurrence
Voyons la définition qu’en donne Wikipedia :
Concurrent computing is a form of computing in which several computations are executed concurrently—during overlapping time periods—instead of sequentially, with one completing before the next starts.
Vue la définition, on va opposer une exécution concurrente à une exécution séquentielle. Comme toujours ça va mieux avec une illustration :
-
Supposons qu’ont ait deux tâches à compléter,
AetB. -
Chaque tâche est représentée par des "ticks" unitaire représentant un bout du travail.
-
Pour simplifier, on va supposer que l’exécution de chaque tick prend le même temps, peu importe la tâche.
-
Histoire d’introduire un peu de dissymétrie, on va supposer que
Anécessite 25 ticks pour se terminer, mais queBn’en nécessite que 15.
Exécution séquentielle
Si A et B sont exécutées séquentiellement, on pourra observer l’exécution suivante :
Tâche A |AAAAAAAAAAAAAAAAAAAAAAAAA---------------| (25 ticks A)
Tâche B |-------------------------BBBBBBBBBBBBBBB| (15 ticks B)
0 T (temps total = 40 ticks en tout)
début fin
Le point important, c’est que B ne commencera à s’exécuter QUE lorsque A sera terminée. Dit autrement, à tout instant, seule UNE tâche est en cours (commencée mais pas encore terminée) : soit A, soit B, mais on n’observe jamais de moment où A et B ont toutes deux commencé leur exécution, sans l’avoir encore terminée.
Exécution concurrente
À l’inverse, si A et B sont exécutées de façon concurrente, on pourra observer l’exécution suivante :
Tâche A |AAAA--AA---AAAA-AAAA-AA---AAA--AA-A-AA-A| (25 ticks A)
Tâche B |----BB--BBB----B----B--BBB---BB--B-B--B-| (15 ticks B)
0 T (temps total = 40 ticks en tout)
début fin
Ici, B n’a pas attendu la fin de A pour démarrer : il existe des moments (qui sont même majoritaires) où A et B ont toutes deux commencé leur exécution, sans l’avoir encore terminée.
Alors, est-ce que A et B s’exécutent "en même temps" ? Ça dépend du sens donné à "en même temps" :
-
OUI : au fur et à mesure que le temps passe, les deux tâches progressent tour à tour de sorte qu’après un certain temps (par exemple lorsqu’on est à mi-chemin), les DEUX tâches ont progressé.
-
NON : si un fait un cliché à un instant donné, seule UNE tâche progresse pendant que l’autre attend, on n’a jamais deux ticks
AetBqui s’exécutent réellement au même instant.
Le premier point est particulièrement vrai si l’alternance a lieu trop vite pour être suivie à l’oeil nu (par exemple si la durée de chaque tick est de 10 ms) : un observateur humain aura l’impression que les deux tâches s’exécutent "en même temps", sans voir qu’en réalité elles s’exécutent plutôt "chacune à leur tour".
Parallélisme
La théorie
Voyons la définition qu’en donne Wikipedia :
Parallel computing is a type of computation in which many calculations or the execution of processes are carried out simultaneously.
Ici aussi, une illustration aide à comprendre la différence :
Tâche A |AAAAAAAAAAAAAAAAAAAAAAAAA| (25 ticks A)
Tâche B |BBBBBBBBBBBBBBB----------| (15 ticks B)
0 T (temps total = 25 ticks en tout)
début fin
Il y a une différence de taille par rapport à la situation précédente : à un instant donné, les tâches A et B progressent réellement en même temps, et ce quelle que soit notre définition de en même temps :
-
OUI : au fur et à mesure que le temps passe, les deux tâches progressent tour à tour de sorte qu’après un certain temps (par exemple lorsqu’on est à mi-chemin), les DEUX tâches ont progressé.
-
OUI : si un fait un cliché à un instant donné, aucune tâche "n’attend son tour" : on a toujours deux ticks
AetBen cours d’exécution au même instant (sauf quand l’une des deux a fini de s’exécuter et pas l’autre, bien sûr).
Une exécution parallèle n’est donc possible que sur un processeur qui dispose d’au moins deux cœurs, afin qu’à un instant donné celui-ci puisse faire progresser A et B en même temps plutôt que tour à tour. Jusqu’à l’avènement des processeurs multi-cœurs, le fait pour un ordinateur de faire "plusieurs choses en même temps" (par exemple jouer au solitaire en attendant qu’un encodage d’une vidéo en tâche de fond se termine) n’était qu’une illusion : les deux tâches alternaient juste très vite, l’exécution était concurrente non-parallèle. Certes cette illusion ne fait pas gagner de temps par rapport à une exécution séquentielle (dans les deux cas, le temps total est de 40 ticks), mais on y gagne tout de même en utilisabilité : il ne serait pas très pratique qu’un ordinateur freeze complètement tant que l’encodage d’une vidéo n’est pas terminé…
parallélisme vs. concurrence
À noter que l’exécution parallèle illustrée ci-dessus est un cas particulier d’exécution concurrente : ici non plus, B n’a pas eu à "attendre" la fin de A pour progresser.
Il n’est donc pas du tout pertinent d’opposer exécution concurrente et exécution parallèle, on a plutôt les catégories suivantes :
-
exécution séquentielle
-
exécution concurrente, parmi lesquelles il faut distinguer :
-
exécution concurrente non-parallèle
-
exécution concurente parallèle
-
J’ai parallélisé mon programme, son temps d’exécution va être divisé par 2 !
En théorie, avec une exécution réellement parallèle, on peut espérer un temps d’exécution total plus rapide, ce qu’on voit sur l’illustration ci-dessus : pour le même nombre de ticks (25 A et 15 B), le temps total pour l’exécution parallèle est de 25 ticks contre 40 ticks pour les exécutions séquentielle et concurrente précédentes.
En fait, l’illustration ci-dessus est idéalisée. Dans la pratique, il se peut que l’exécution parallèle se rapproche plutôt de cette illustration plus réaliste :
Tâche A |AAAA-AA--AAAAA-AAAAAA--A-AAAAAAA| (25 ticks A)
Tâche B |BB-BBB---BBBB--BBB--BBB---------| (15 ticks B)
0 T (temps total = 32 ticks en tout)
début fin
On passe de 40 à 32 ticks, le temps total est divisé par 1,25.
En pratique, le gain de temps d’exécution théorique est donc à prendre avec des pincettes : on ne le divise pas toujours par deux, et comme illustré en fin d’article, on peut même ralentir un programme en le parallélisant ! Sans aller jusque là, de nombreux facteurs peuvent expliquer que le temps d’exécution observé est supérieur à celui attendu :
-
les tâches partagent de l’information : elles ne sont pas indépendantes et doivent s’attendre mutuellement
-
le travail n’est pas forcément équitablement réparti entre les tâches. Même pour l’illustration idéalisée un peu plus haut qui ignore tous les autres effets,
AetBn’ont pas le même nombre de ticks : le temps total n’est pas divisé par 2 mais par 1,6 -
l’exécution et la synchronisation de plusieurs tâches rajoute du travail qui n’existait pas dans le programme séquentiel : coût des context-switchs : virtual-memory bookkeeping, cache-eviction, exécution du scheduler, …
-
des effets bas-niveau comme le false-sharing, illustré plus bas, peuvent ralentir un programme multithreadé
-
les tâches concurrentes doivent se partager les IOs
-
des détails d’implémentation propre à chaque langage peuvent jouer, par exemple en python, le fameux GIL empêchera de tirer parti d’un programme multithreadé, même sur des processeurs à plusieurs cœurs
-
etc. j’en passe et des meilleurs
Comme partout ailleurs quand on parle de perfs, il faut mesurer et benchmarker son cas d’usage plutôt que de faire des prédictions ou des suppositions.
Un peu de code
Voyons voir tout ça concrètement, on va essayer de produire quelque chose qui ressemble aux illustrations ci-dessus.
Pour comparer ce qui est comparable, on va utiliser exactement le même code, mais l’exécuter de trois façons différentes :
-
de façon séquentielle
-
de façon concurrente parallèle
-
de façon concurrente non-parallèle
Vous trouverez en suivant ce lien les sources, de quoi les compiler, et de quoi mesurer leur temps d’exécution.
Nos briques de base
Tout d’abord, on va simuler un travail à réaliser : _heavy_work. Celui-ci va faire une série de amount calculs inutiles :
void _heavy_work(const unsigned long long amount) {
unsigned long long unused_result = 0;
for (int i = 0; i < amount; ++i) {
unused_result += (i % 2 == 0) ? 3 : -2;
}
}
Il serait plus illustratif de randomiser un peu, ou d’avoir une charge de travail asymétrique entre A et B, mais ça nuirait à la simplicité de l’exemple, et surtout aux mesures qu’on va faire : on va en rester à cet exemple simpliste.
Ce travail est exécuté en boucle dans une computer_task, identifiée par une letter (A ou B), et ce autant de fois qu’il y a de ticks nb_of_ticks. Lorsqu’elle a fini un tick, la task publie son identifiant dans une queue data_to_write, et notifie une condition-variable cv :
void computer_task(char letter,
size_t nb_of_ticks,
queue<char>& data_to_write,
const unsigned long long amount,
mutex& m,
condition_variable& cv) {
for (size_t x = 0; x < nb_of_ticks; ++x) {
_heavy_work(amount);
{
lock_guard<mutex> lock(m);
data_to_write.push(letter);
}
cv.notify_one();
}
}
Une autre tâche displayer_task est chargée de consommer les lettres publiées dans la queue lorsqu’elle est notifiée par la condition-variable cv, et convertit progressivement une string result depuis un état initial (initial_string) du type ------------ vers un état final indiquant comment le travail a progressé BAABBABBBAAA :
void displayer_task(const string& initial_string,
const atomic<bool>& is_finished,
queue<char>& data_to_write,
mutex& m,
condition_variable& cv) {
string result{initial_string};
size_t next_index_to_write = 0;
while (!is_finished.load()) {
// wait to be notified that there is something to write (or there is no more work to do) :
unique_lock<mutex> lock(m);
auto wakeup_predicate = [&data_to_write, &is_finished]() {
return is_finished.load() || !data_to_write.empty();
};
cv.wait(lock, wakeup_predicate);
// writes all the data to the result string :
while (!data_to_write.empty()) {
result[next_index_to_write++] = data_to_write.back();
data_to_write.pop();
}
lock.unlock();
// displays the written string :
cout << "\r" << result << flush; // might not work on windows/mac bc of EOL
}
cout << endl;
}
Exécution séquentielle
Pour l'exécution séquentielle, la displayer_task s’exécute en tâche de fond dans un thread displayer, mais les deux computer_task sont lancées successivement dans le thread principal :
// displayer thread runs in background :
auto displayer = thread(displayer_task, cref(initial_string), cref(is_finished), ref(data_to_write), ref(m), ref(cv));
// computer tasks run in foreground, sequentially :
computer_task('A', half_length, data_to_write, base_amount, m, cv);
computer_task('B', half_length, data_to_write, base_amount, m, cv);
{
lock_guard<mutex>{m};
is_finished.store(true);
}
cv.notify_one();
displayer.join();
Exécution concurrente parallèle
L'exécution concurrente parallèle est identique à l’exécution séquentielle, sauf que les deux computer_task sont lancées chacune dans un thread indépendant. Le thread principal ne fait rien d’autre que coordonner tout ce beau monde :
// displayer thread runs in background :
auto displayer = thread(displayer_task, cref(initial_string), cref(is_finished), ref(data_to_write), ref(m), ref(cv));
// computer tasks also run in background, concurrently (and maybe in parallel) :
auto taskA = thread(computer_task, 'A', half_length, ref(data_to_write), base_amount, ref(m), ref(cv));
auto taskB = thread(computer_task, 'B', half_length, ref(data_to_write), base_amount, ref(m), ref(cv));
taskA.join();
taskB.join();
{
lock_guard<mutex>{m};
is_finished.store(true);
}
cv.notify_one();
displayer.join();
Exécution concurrente non-parallèle
Pour l'exécution concurrente non-parallèle, soyons rusé comme le renard : on va réutiliser le code parallèle, mais on va le forcer à s’exécuter sur un seul cœur de processeur avec taskset :
taskset - set or retrieve a process’s CPU affinity
CPU affinity is a scheduler property that "bonds" a process to a given set of CPUs on the system. The Linux scheduler will honor the given CPU affinity and the process will not run on any other CPUs.
Du coup, si le binaire parallèle est bin_PARALLEL, pour tester le cas où l’exécution est concurrente non-parallèle, on peut lancer :
taskset -c 0 ./bin_PARALLEL
Ceci aura pour effet d’exécuter bin_PARALLEL sur un seul (le premier) cœur de processeur : à tout instant, le processeur ne pourra faire progresser qu’un seul thread, et il alternera entre les différents threads, c’est bien la définition d’une exécution concurrente non-parallèle. Une alternative est d’utiliser un mutex pour garantir que seul un thread s’exécute à chaque instant.
Observations
mesure du temps d’exécution
Il va être intéressant de mesurer le temps pris par les différentes exécutions ; j’utilise GNU time, qui en plus de mesurer précisément le temps d’exécution, présente l’avantage de donner d’autres infos bien utiles, comme le nombre de context-switchs.
Attention, il y a un loup, la commande time est souvent une shell builtin, et pour utiliser GNU time, il faut préciser le chemin explicitement /usr/bin/time ou mieux, utiliser env time :
env time -f "time took = %E" ./bin_PARALLEL
La valeur des mesures n’est pas particulièrement pertinente, puisqu’elle dépend de la quantité de travail (ici, amount = 50000000), du processeur (qui au passage est Intel® Core™ i3-6100U CPU @ 2.30GHz), et probablement de la version du noyau linux (ici, 4.15.0-112-generic). En revanche, l’ordre de grandeur des mesures les unes par rapport aux autres est intéressante.
résultats
Voici un exemple de ce que j’observe :
L’état final est :
+++ running SEQUENTIAL :
AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBB
time took = 0:06.97
+++ running CONCURRENT NON-PARALLEL :
ABABABBAABBAABBABABABABABABABAABBAABABAB
time took = 0:07.01
+++ running CONCURRENT PARALLEL :
ABABABABABABABABABABABABABABABABABABABAB
time took = 0:03.57
Les différentes exécutions se comportent bien comme souhaité :
-
dans l’exécution séquentielle, la tâche
Bne commence qu’une fois la tâcheAterminée -
dans les deux exécutions concurrentes, les deux tâches semblent s’exécuter "en même temps", et c’est surtout la mesure du temps d’exécution qui nous permet de voir qu’en non-parallèle, les tâches s’exécutent plutôt tour à tour
Côte mesure, si le temps d'exécution séquentielle est noté 100 :
-
le temps d’exécution concurrente non-parallèle est à
101 -
le temps d’exécution concurrente parallèle est à
51
Les temps d’exécution varient d’un lancement à l’autre, et vue la petitesse de l’écart, on est sans doute plutôt dans le bruit, mais on observe systématiquement que l’exécution concurrente non-parallèle est à peu près égale à l’exécution séquentielle, mais un chouïa plus lente. Comme expliqué plus haut il y a un peu de travail supplémentaire à effectuer rien que pour gérer le fait d’avoir plusieurs tâches.
Par exemple, on peut relancer notre test en comptant les context-switchs avec time, et on constatera qu’avec l’exécution séquentielle, on en a très peu, alors qu’avec l’exécution concurrente non-parallèle, on en environ 25 fois plus :
+++ running SEQUENTIAL :
AAAAAAAAAAAAAAAAAAAABBBBBBBBBBBBBBBBBBBB
time took = 0:06.96
ctx switch (involuntar) = 24
ctx switch (voluntar) = 42
+++ running CONCURRENT NON-PARALLEL :
BAABBABABABABABABABAABBABABABAABBABABAAB
time took = 0:07.04
ctx switch (involuntar) = 616
ctx switch (voluntar) = 44
+++ running CONCURRENT PARALLEL :
ABABABABABABABABABABABABABABABABABABABAB
time took = 0:03.57
ctx switch (involuntar) = 6
ctx switch (voluntar) = 47
Concernant l’exécution parallèle, on se trouve dans la situation idéale où répartir le travail sur deux threads a divisé le temps d’exécution par deux. Notre bonne fortune est probablement dûe à la simplicité du cas-test : comme dit plus haut, dans une situation réelle, c’est loin d’être toujours le cas.
BONUS = toi aussi, ralentis ton programme en le parallélisant
L’objectif du post était de préciser les notions de concurrence et de parallélisme, c’est chose faite. Le paragraphe qui suit est donc surtout donné en bonus : on va montrer qu’on peut ralentir l’exécution de son programme en le parallélisant.
Comme précédemment, on va travailler sur un cas bateau : on va simuler la répétition d’un calcul lourd, qui met à jour un résultat passé en référence :
void heavy_task(int& result, const int amount)
{
for (int i = 0; i < amount; ++i)
{
(i % 2 == 0) ? result += 3 : result -= 2;
}
}
On va supposer qu’on doit effectuer ce calcul deux fois, par exemple sur les cellules paires (even) et impaires (odd) d’un tableau.
séquentiel
En séquentiel, ça pourrait donner quelque chose comme :
int main(int argc, char* argv[])
{
if (argc < 2)
{
std::cerr << "USAGE: " << argv[0] << " AMOUNT" << std::endl;
return 1;
}
const int amount = std::stoi(argv[1]);
int result_even = 0;
int result_odd = 0;
heavy_task(result_even, amount);
heavy_task(result_odd, amount);
return 0;
}
Sur ma machine, lancé avec un amount de 500000000, time m’indique que ce programme séquentiel mets 2,71 secondes à s’exécuter :
# running SEQUENTIAL implementation as a REFERENCE :
time took = 0:02.71
Comme précédemment, les mesures individuelles n’ont pas d’importance en soi, seule leur importance relative compte.
en parallèle, ça ira forcément plus vite
Un dev vigilant remarquera que ce type de calcul, répété deux fois, où les entrées comme les sorties sont indépendantes, se prête particulièrement bien à la parallélisation, et il n’aurait pas tort. Lançons donc les deux calculs en parallèle, dans deux threads :
int result_even = 0;
int result_odd = 0;
auto th = std::thread(heavy_task, std::ref(result_even), amount);
heavy_task(result_odd, amount);
th.join();
Ce dev vigilant, mais un peu trop confiant, pourrait se dire qu’il est inutile de benchmarker son cas d’usage, car avec un exemple aussi simple, dans des conditions aussi idéales, on ne peut QUE accélérer le programme en le parallélisant. Pourtant, il aurait tort de ne pas refaire ses mesures :
# running PARALLEL NAIVE implementation :
time took = 0:04.09
Surprise ! Le temps d’exécution a AUGMENTÉ, et pas qu’un peu : on est à 151% du temps d’exécution séquentiel !
Incrédule ? Vous pouvez faire l’expérience chez vous, le code est ici. Vu que l’exemple est simpliste, attention à compiler en -O0 pour que g++ n’optimise pas notre code bateau.
false sharing
Expliquer en détail l’origine de ce ralentissement dépasse le cadre de ce post, mais en résumé, les variables result_even et result_odd étant contigües en mémoire, si un thread modifie la première variable, il invalide le cache du cœur de processeur de l’autre thread, qui doit donc effectuer de coûteuses lectures/écritures mémoire qu’il n’aurait pas eu à faire sinon.
Le surcoût apporté par ces opérations dépasse le gain obtenu par la parallélisation, et au final, on dégrade les performances. La partie contre-intuitive est que ce phénomène intervient alors même que les variables sont indépendantes dans le code, d’où le nom de false sharing. C’est un exemple de situation où les détails bas-niveau du fonctionnement d’un processeur ont un effet direct sur les performances d’un programme ; pour les curieux, ce repo est une mine d’or qui en contient bien d’autres.
Pour éviter ce phénomène, il suffit d’espacer les variables en mémoire, de sorte que la modification d’une variable n’ait pas d’impact sur le cache mémoire du processeur contenant l’autre variable. Voici un exemple d’implémentation avec l’attribut aligned de g++ :
int result_even __attribute__ ((aligned (64))) = 0;
int result_odd __attribute__ ((aligned (64))) = 0;
auto th = std::thread(heavy_task, std::ref(result_even), amount);
heavy_task(result_odd, amount);
th.join();
Avec cette modification, on obtient bien un temps d’exécution plus conforme à ce qu’on attendait, à 57% du programme séquentiel :
# running PARALLEL FAST ALIGNED implementation :
time took = 0:01.56
À noter que le code ci-dessus, s’il permet de montrer qu’on est bien en face d’un false sharing, n’est pas la meilleure façon d’adresse le problème. Mieux vaut en effet ne pas toucher à l’alignement des variables :
int result_even = 0;
int result_odd = 0;
auto th = std::thread(heavy_task, std::ref(result_even), amount);
heavy_task(result_odd, amount);
th.join();
Et à la place modifier la fonction heavy_task pour qu’elle travaille principalement sur une variable locale, et ne mute la variable "partagée" qu’une seule fois, à la fin du traitement :
void heavy_task(int& result, const int amount)
{
int acc = 0;
for (int i = 0; i < amount; ++i)
{
(i % 2 == 0) ? acc += 3 : acc -= 2;
}
result = acc;
}
On trouve alors un temps d’exécution encore meilleur, à 53% du programme séquentiel :
# running PARALLEL FAST OFFLINE implementation :
time took = 0:01.43
le mot de la fin
Ce qu’il faut retirer de ce bonus, ce n’est ni le phénomène de false sharing, ni la façon de l’éviter.
Ce qu’il faut retenir, c’est qu'il est indispensable de benchmarker son cas d’usage, plutôt que de supposer ou prévoir les résultats d’une parallélisation de son code. Et si possible, sur la machine qui exécutera le programme.
En effet, non seulement paralléliser son code apporte une tétra-chiée d’écueils pas toujours faciles à éviter, et encore moins faciles à reproduire et débugger, mais en plus, le gain en temps d’exécution n’est pas garanti.