Single-Threaded: Summe der Elemente eines Vektors

Wie lassen sich die Werte eines std::vector am schnellsten zusammenaddieren? Diese Frage will ich in mehreren Artikeln nachgehen. Als Referenzwert soll dabei die Addition in einem Thread dienen. In weiteren Artikel gehe ich auf atomare Variablen, Locks, Tasks und threadlokale Daten ein.

 

Meine Strategie

Ich werde einen std::vector mit hundert Millionen zufällig erzeugte natürliche Zahlen zwischen 1 und 10 füllen. Die Zufallszahlen erzeuge ich mit Hilfe der Gleichverteilung. Die Rechenaufgabe besteht dann darin, die Summe aller Zahlen zu berechnen.

Als Hardware kommen wieder mein Linux Desktop PC und mein Windows Laptop zum Einsatz. Mein Linux Rechner besitzt vier, mein Windows 2 Kerne. Genaueres zu den Compilern kannst du unter Threadsicheres Initialisieren eines Singleton nachlesen. Alle Programme werde ich ohne und mit maximaler Optimierung übersetzen.

Eine einfache Schleife

Die naheliegendste Strategie ist es, in einer Range-basierte for-Anweisung die Summe aller Elemente zu berechnen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// calculateWithLoop.cpp

#include <chrono>
#include <iostream>
#include <random>
#include <vector>

constexpr long long size= 100000000;

int main(){

  std::cout << std::endl;

  std::vector<int> randValues;
  randValues.reserve(size);

  // random values
  std::random_device seed;
  std::mt19937 engine(seed());
  std::uniform_int_distribution<> uniformDist(1,10);
  for ( long long i=0 ; i< size ; ++i) randValues.push_back(uniformDist(engine));
 
  auto start = std::chrono::system_clock::now();
  
  unsigned long long add= {};
  for (auto n: randValues) add+= n;
 
  std::chrono::duration<double> dur= std::chrono::system_clock::now() - start;
  std::cout << "Time for addition " << dur.count() << " seconds" << std::endl;
  std::cout << "Result: " << add << std::endl;

  std::cout << std::endl;

}

 

In der Zeile 26 findet die eigentliche Arbeit statt. Wie schnell sind meine Rechner?

Ohne Optimierung

 CalculateWithLoopCalculateWithLoop win

Maximale Optimierung

 CalculateWithLoopOptCalculateWithLoopOpt win

Explizite Schleifen sollten nicht verwendet werden. Meist ist ein Algorithmus der Standard Template Library (STL) die bessere Lösung. Diese Aussage gilt insbesondere für die Range-basierte for-Anweisung unter Windows, da sie noch einiges an Optimierungspotential besitzt.

Die STL mit std::accumulate

std::accumulate ist das Mittel der Wahl, wenn es darum geht, die Summe zu berechnen.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// calculateWithStd.cpp

#include <algorithm>
#include <chrono>
#include <iostream>
#include <numeric>
#include <random>
#include <vector>

constexpr long long size= 100000000;

int main(){

  std::cout << std::endl;

  std::vector<int> randValues;
  randValues.reserve(size);

  // random values
  std::random_device seed;
  std::mt19937 engine(seed());
  std::uniform_int_distribution<> uniformDist(1,10);
  for ( long long i=0 ; i< size ; ++i) randValues.push_back(uniformDist(engine));
 
  auto start = std::chrono::system_clock::now();
  
  unsigned long long add= std::accumulate(randValues.begin(),randValues.end(),0);
 
  std::chrono::duration<double> dur= std::chrono::system_clock::now() - start;
  std::cout << "Time for addition " << dur.count() << " seconds" << std::endl;
  std::cout << "Result: " << add << std::endl;

  std::cout << std::endl;

}

 

Die Zeile 27 ist die entscheidende Zeile in dem Beispiel. Die Performanz von std::accumulate entspricht der der Range-basierten for-Schleife. Dies gilt nicht für Windows.

Keine Optimierung

 CalculateWithStdCalculateWithStd win

Maximale Optimierung

 CalculateWithStdOptCalculateWithStdOpt win

Eigentlich waren dies schon die zwei Programme, die als Referenz von die Multithreading Lösung dienen sollen. Ich finde es aber sehr interessant, die Summationsvariable add durch einen Lock bzw. eine atomaren Datentyp zu schützen.

Schutz durch ein Lock

Schütze ich den Zugriff auf die Summationsvariable add durch ein Lock, so beantwortet dies zwei Fragen.

  1. Wie hoch ist de Synchronisationsaufwand, den ein Lock benötigt?
  2. Wie performant kann ein Lock maximal sein, wenn keine konkurrierenden Zugriffe auf die gemeinsame Variable stattfinden?

Der Punkt 2 kann auch negativ formuliert werden. Sobald mehrere Threads um den gemeinsamen Zugriff auf eine geteilte Variable konkurrieren, wird der Zugriff langsamer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// calculateWithLock.cpp

#include <chrono>
#include <iostream>
#include <mutex>
#include <numeric>
#include <random>
#include <vector>

constexpr long long size= 100000000;

int main(){

  std::cout << std::endl;

  std::vector<int> randValues;
  randValues.reserve(size);

  // random values
  std::random_device seed;
  std::mt19937 engine(seed());
  std::uniform_int_distribution<> uniformDist(1,10);
  for ( long long i=0 ; i< size ; ++i) randValues.push_back(uniformDist(engine));

  std::mutex myMutex;
 
  unsigned long long int add= 0;
  auto start = std::chrono::system_clock::now();
  
  for (auto i: randValues){
      std::lock_guard<std::mutex> myLockGuard(myMutex);
      add+= i;
  }
 
  std::chrono::duration<double> dur= std::chrono::system_clock::now() - start;
  std::cout << "Time for addition " << dur.count() << " seconds" << std::endl;
  std::cout << "Result: " << add << std::endl;
    
  std::cout << std::endl;

}

 

Erwartungsgemäß ist der geschützte Zugriff auf die Variable add deutlich langsamer.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
// calculateWithLock.cpp

#include <chrono>
#include <iostream>
#include <mutex>
#include <numeric>
#include <random>
#include <vector>

constexpr long long size= 100000000;

int main(){

  std::cout << std::endl;

  std::vector<int> randValues;
  randValues.reserve(size);

  // random values
  std::random_device seed;
  std::mt19937 engine(seed());
  std::uniform_int_distribution<> uniformDist(1,10);
  for ( long long i=0 ; i< size ; ++i) randValues.push_back(uniformDist(engine));

  std::mutex myMutex;
 
  unsigned long long int add= 0;
  auto start = std::chrono::system_clock::now();
  
  for (auto i: randValues){
      std::lock_guard<std::mutex> myLockGuard(myMutex);
      add+= i;
  }
 
  std::chrono::duration<double> dur= std::chrono::system_clock::now() - start;
  std::cout << "Time for addition " << dur.count() << " seconds" << std::endl;
  std::cout << "Result: " << add << std::endl;
    
  std::cout << std::endl;

}

 

Keine Optimierung

CalculateWithLockCalculateWithLock win

Maximale Optmiierung

CalculateWithLockOptCalculateWithLockOpt win

Während die nicht optimierte Variante mit dem Lock um den Faktor 4 - 12 langsamer ist, ist die maximale optimierte Variante um den Faktor 10 - 40 langsamer als ihr std::accumulate Pedant. 

Zum Abschuss folgen noch die atomaren Datentypen.

Schutz durch einen atomaren Datentyp

Die gleiche Fragestellung wie für die Locks gelten auch für die atomaren Datentypen. 

  1. Wie hoch ist de Synchronisationsaufwand, den ein atomarer Datentyp benötigt?
  2. Wie performant kann ein atomarer Datentyp maximal sein, wenn keine konkurrierenden Zugriffe auf die gemeinsame Variable stattfinden?

Darüber hinaus ist die Frage interessant, wie sich die Performanz des atomaren Datentyp gegenüber der des Locks verhält.

 

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
// calculateWithAtomic.cpp

#include <atomic>
#include <chrono>
#include <iostream>
#include <numeric>
#include <random>
#include <vector>

constexpr long long size= 100000000;

int main(){

  std::cout << std::endl;

  std::vector<int> randValues;
  randValues.reserve(size);

  // random values
  std::random_device seed;
  std::mt19937 engine(seed());
  std::uniform_int_distribution<> uniformDist(1,10);
  for ( long long i=0 ; i< size ; ++i) randValues.push_back(uniformDist(engine));
  
  std::atomic<unsigned long long> add={};
  std::cout << std::boolalpha << "add.is_lock_free(): " << add.is_lock_free() << std::endl;
  std::cout << std::endl;
 
  auto start = std::chrono::system_clock::now();
  
  for (auto i: randValues) add+= i;
 
  std::chrono::duration<double> dur= std::chrono::system_clock::now() - start;
  std::cout << "Time for addition " << dur.count() << " seconds" << std::endl;
  std::cout << "Result: " << add << std::endl;
    
  std::cout << std::endl;
  
  add=0;
  start = std::chrono::system_clock::now();
  
  for (auto i: randValues) add.fetch_add(i,std::memory_order_relaxed);
  
  dur= std::chrono::system_clock::now() - start;
  std::cout << "Time for addition " << dur.count() << " seconds" << std::endl;
  std::cout << "Result: " << add << std::endl;
    
  std::cout << std::endl;

}

 

Das Programm enthält ein paar Besonderheiten. Zum einen frage ich in Zeile 26 ab, ob der atomare Datentyp ohne Lock implementiert ist. Wäre dies der Fall, müsste ich nicht zwischen Locks und atomaren Datentypen unterscheiden. Auf allen modernen Plattformen sind atomare Datentyp ohne Locks implementiert. Zum anderen berechne ich die Summe auf zwei verschiedene Arten. In Zeile 31 verwende ich den += Operator, in Zeile 42 die Methode fetch_add. Beide Varianten besitzen im Singlethreaded Fall eine vergleichbare Performanz. Für die Methode fetch_add kann aber explizit die Speicherordnung angegeben werden. Dazu im nächsten Artikel mehr.

Nun bin ich auf die Zahlen gespannt.

Keine Optimierung

 CalculateWithAtomicCalculateWithAtomic win

Maximale Optimierung

CalculateWithAtomicOptCalculateWithAtomicOpt win

Die atomare Variante ist im Fall von Linux um den Faktor 1.5, im Fall von Windows um den Faktor 8 langsamer als der std::accmulate Algorithmus. Drastischer fällt der Unterschied für die maximale optimierten Varianten aus. So ist Linux in diesem Fall um den Faktor 15 langsamer, Windows um den Faktor 50.

Zwei Punkte möchte ich noch hervorheben.

  1. Atomare Datentypen sich um den Faktor 2-3 unter Windows und Linux schneller als ihre Lock Pendanten.
  2. Insbesondere für atomare Datentype ist Linux um den Faktor 2 schneller als Windows.

Alle Zahlen kompakt

Wer vor lauter Zahlen den Überblick verloren hat. Hier nochmals alle Zeitangaben in Sekunden.

SingleThreadedAddition

Wie geht's weiter?

Aus Singlethreaded wird Mulithreaded. Im nächsten Artikel wird die Summationsvariable add eine geteilte Variable sein, die von vier Threads verwendet wird. Den synchronisierten Zugriff werde ich durch atomare Datentypen sicherstellen.

 

 

Do you want to get the source code? Source code repository

Kommentar schreiben


Rainer Grimm

Modernes C++

Neue Beiträge zum Wochenende

Beiträge-Archiv

Sourcecode

Besucher

Heute 79

Woche 592

Monat 4174

Insgesamt 51950

Aktuell sind 335 Gäste und keine Mitglieder online