2 votes

Pourquoi le verrouillage des mutex en C++ affecte-t-il si gravement l'efficacité du multithreading ?

Voici un code que j'ai écrit pour tester les performances du multithreading. En résumé, il effectue un long calcul dans la boucle, accumule les résultats et mesure le temps que cela prend. L'accumulation des résultats nécessite de placer le verrou à un endroit. Le problème est que l'utilisation du verrou sur cette seule ligne tue les performances du multithreading. Pourquoi ?

J'ai également mesuré le temps qu'il faut pour verrouiller/déverrouiller le mutex. Je compile le code avec g++ +O3 option.

#include <chrono>
#include <cmath>
#include <functional>
#include <iomanip>
#include <iostream>
#include <mutex>
#include <vector>
#include <thread>

long double store;
std::mutex lock;

using ftype=std::function<long double(long int)>;
using loop_type=std::function<void(long int, long int, ftype)>;

///simple class to time the execution and print result.
struct time_n_print
{
  time_n_print() : 
    start(std::chrono::high_resolution_clock::now())
  {}

  ~time_n_print()
  {
    auto elapsed = std::chrono::high_resolution_clock::now() - start;
    auto ms = std::chrono::duration_cast<std::chrono::microseconds>(elapsed);
    std::cout << "Elapsed(ms)=" << std::setw(7) << ms.count();
    std::cout << "; Result: " << (long int)(store);
  }
  std::chrono::high_resolution_clock::time_point start;
};//class time_n_print

///do long and pointless calculations which result in 1.0
long double slow(long int i)
{
    long double pi=3.1415926536;
    long double i_rad  = (long double)(i) * pi / 180;
    long double sin_i  = std::sin(i_rad);
    long double cos_i  = std::cos(i_rad);
    long double sin_sq = sin_i * sin_i;
    long double cos_sq = cos_i * cos_i;
    long double log_sin_sq = std::log(sin_sq);
    long double log_cos_sq = std::log(cos_sq);
    sin_sq = std::exp(log_sin_sq);
    cos_sq = std::exp(log_cos_sq);
    long double sum_sq = sin_sq + cos_sq;
    long double result = std::sqrt(sum_sq);
    return result;
}

///just return 1
long double fast(long int)
{
    return 1.0;
}

///sum everything up with mutex
void loop_guarded(long int a, long int b, ftype increment)
{
  for(long int i = a; i < b; ++i)
  {
    long double inc = increment(i);
    {
      std::lock_guard<std::mutex> guard(lock);
      store += inc;
    }
  }
}//loop_guarded

///sum everything up without locks
void loop_unguarded(long int a, long int b, ftype increment)
{
  for(long int i = a; i < b; ++i)
  {
    long double inc = increment(i);
    {
      store += inc;
    }
  }
}//loop_unguarded

//run calculations on multiple threads.
void run_calculations(int size, 
                      int nthreads, 
                loop_type loop, 
                    ftype increment)
{
  store = 0.0;
  std::vector<std::thread> tv;
  long a(0), b(0);
  for(int n = 0; n < nthreads; ++n)
  {
    a = b;
    b = n < nthreads - 1 ? a + size / nthreads : size;
    tv.push_back(std::thread(loop, a, b, increment));
  }
  //Wait, until all threads finish
  for(auto& t : tv)
  {
    t.join();
  }
}//run_calculations

int main()
{
  long int size = 10000000;
  {
    std::cout << "\n1 thread  - fast, unguarded : ";
    time_n_print t;
    run_calculations(size, 1, loop_unguarded, fast);
  }
  {
    std::cout << "\n1 thread  - fast, guarded   : ";
    time_n_print t;
    run_calculations(size, 1, loop_guarded, fast);
  }
  std::cout << std::endl;
  {
    std::cout << "\n1 thread  - slow, unguarded : ";
    time_n_print t;
    run_calculations(size, 1, loop_unguarded, slow);
  }
  {
    std::cout << "\n2 threads - slow, unguarded : ";
    time_n_print t;
    run_calculations(size, 2, loop_unguarded, slow);
  }
  {
    std::cout << "\n3 threads - slow, unguarded : ";
    time_n_print t;
    run_calculations(size, 3, loop_unguarded, slow);
  }
  {
    std::cout << "\n4 threads - slow, unguarded : ";
    time_n_print t;
    run_calculations(size, 4, loop_unguarded, slow);
  }
  std::cout << std::endl;
  {
    std::cout << "\n1 thread  - slow, guarded   : ";
    time_n_print t;
    run_calculations(size, 1, loop_guarded, slow);
  }
  {
    std::cout << "\n2 threads - slow, guarded   : ";
    time_n_print t;
    run_calculations(size, 2, loop_guarded, slow);
  }
  {
    std::cout << "\n3 threads - slow, guarded   : ";
    time_n_print t;
    run_calculations(size, 3, loop_guarded, slow);
  }
  {
    std::cout << "\n4 threads - slow, guarded   : ";
    time_n_print t;
    run_calculations(size, 4, loop_guarded, slow);
  }
  std::cout << std::endl;
  return 0;
}

Voici le résultat typique sur une machine Linux avec 4 cœurs :

>1 thread  - fast, unguarded : Elapsed(ms)=  32826; Result: 10000000  
>1 thread  - fast, guarded   : Elapsed(ms)= 172208; Result: 10000000
>
>1 thread  - slow, unguarded : Elapsed(ms)=2131659; Result: 10000000  
>2 threads - slow, unguarded : Elapsed(ms)=1079671; Result: 9079646  
>3 threads - slow, unguarded : Elapsed(ms)= 739284; Result: 8059758  
>4 threads - slow, unguarded : Elapsed(ms)= 564641; Result: 7137484  
>
>1 thread  - slow, guarded   : Elapsed(ms)=2198650; Result: 10000000  
>2 threads - slow, guarded   : Elapsed(ms)=1468137; Result: 10000000  
>3 threads - slow, guarded   : Elapsed(ms)=1306659; Result: 10000000  
>4 threads - slow, guarded   : Elapsed(ms)=1549214; Result: 10000000

Donc ce que nous pouvons voir

  • Le verrouillage/déverrouillage du mutex prend en fait un temps assez long, comparé, par exemple, à l'incrémentation de la valeur du double long ;
  • Sans mutex, le gain pour le multithreading est très bon, comme prévu. Et, comme prévu, nous perdons beaucoup d'incréments, à cause de la course ;
  • Avec le mutex, il n'y a pas de gain au-delà de 2 threads ;

La question principale est la suivante : pourquoi une partie du code qui prend moins de 10 % du temps d'exécution réduit-elle les performances de façon si spectaculaire ?

Je comprends que je peux contourner ce problème en accumulant les résultats dans chaque fil séparément, puis en les additionnant à la fin. Mais pourquoi ce problème apparaît-il en premier lieu ?

UPDATE : Merci pour les réponses et les commentaires. En gros, ce que cela signifie, c'est que nous ne pouvons pas obtenir un bon gain de performance si chaque thread passe 7-8% du temps en état verrouillé. Si, dans le code ci-dessus, j'ajoute 10-boucles à la fonction slow alors le gain de performance entre les versions gardées et non gardées est identique jusqu'à 4 threads. Donc, pour moi, la règle de base maintenant - le temps passé dans l'état verrouillé ne devrait pas dépasser 1% du temps total d'exécution.

2voto

Wintermute Points 34993

Verrouillage d'une contestation 1 mutex nécessite un appel système et tout ce que cela implique : un changement de contexte vers le système d'exploitation, qui va éventuellement programmer un autre processus de sorte que lorsque vous revenez, tous vos caches sont invalidés, etc. C'est par nature une opération assez coûteuse. Parce que votre slow n'est pas que cher, tout bien considéré, il semble rester suffisamment de contention sur le mutex pour que le code doive se rendre relativement souvent au système d'exploitation, ce qui entraîne une baisse sensible des performances.

Une bonne pratique consisterait à demander à chaque thread d'agréger ses résultats dans une variable qui lui est propre, puis de les mettre à jour une fois en vrac à la fin, de sorte que vous n'ayez besoin que d'un seul verrou mutex pour l'ensemble à la fin. En général, si vous devez synchroniser avec un mutex et que vous vous souciez des performances, vous devrez trouver des moyens de diviser votre travail en morceaux suffisamment grossiers pour que le mutex ne devienne pas un frein important. J'ai bien peur que ce ne soit que normal.

Sinon, les structures de données sans verrou offrent une alternative. Elles évitent de faire appel au système d'exploitation pour le verrouillage, mais beaucoup d'entre elles s'occuperont d'attendre les unes les autres si la contention devient trop importante. Si ce n'est pas le cas, elles valent la peine d'être examinées si vous recherchez la performance.

1 Comme Solomon le souligne dans les commentaires, si le mutex n'est pas contesté, l'opération de verrouillage (avec les CPU et les systèmes d'exploitation modernes) peut être effectuée dans l'espace utilisateur et est donc beaucoup, beaucoup plus simple.

Prograide.com

Prograide est une communauté de développeurs qui cherche à élargir la connaissance de la programmation au-delà de l'anglais.
Pour cela nous avons les plus grands doutes résolus en français et vous pouvez aussi poser vos propres questions ou résoudre celles des autres.

Powered by:

X