Speedup mit tbb::concurrent_unordered_set

Die C++ Standard-Klassen std::set und std::map bzw. ihre unsortierten Varianten
std::unordered_set und std::unordered_map (assoziative Container) sind nicht thread-safe. Eine thread-safe Alternative sind die entsprechenden Container (tbb::concurrent_set, tbb::concurrent_unordered_set, …) aus der frei verfügbaren Intel-Bibliothek Threading Building Blocks (TBB).

Dazu ein Beispiel: für eine FEM-Berechnung sei ein 3D-Modells mittels Tetraeder trianguliert. Üblicherweise wird das Gitter durch eine Liste mit den 3D-Punkten

0  -0.3420   0.3420  0.0020
1  -0.3420  -0.3420  0.0020
2  -0.3420   0.3420  0.6339
3  -0.3420  -0.3420  0.6339
...

und einer zweiten Liste für die Tetraeder mit Punktindizes

0 524    51485  523    104970 
1 104887 52572  120512 120102 
2 89182  9528   104369 104369
3 168514 166744 84550  168926 
...

gegeben. In diesem Beispiel definiert das Dreieck zu den Punkten 524, 51485 und 523 zusammen mit dem vierten Punkt zum Index 104970 Tetraeder 0.
Dementsprechend enthält die C++-Klasse der Tetraeder auch diese vier Indizes:

class Tetra
{  
    public: 
          array<int,4> v;     // Indizes der Knoten
   ...
}
...
vector<Tetra>  Cells; 

Zur Aufstellung des Gleichungssystems ist es zweckmäßig, auch die “umgekehrte” Information zu kennen: Zu welchen Tetraedern gehört ein bestimmter Punkt? Dazu sei

typedef  std::set<int> IntSet;
vector<IntSet> VertexStar(Points.size());

D.h. zu jedem Punkt gibt es eine Menge, die die Indizes der umgebenen Tetraeder enthält. Ein einfacher for-loop über alle Cells liefert das Gewünschte.

Serielles Verfahren

const uint n = Cells.size(); 
//---------------  Algorithmus-1 -----------------------------
for(uint i=0; i < n; ++i) {
 const auto u = Cells[i].v;     // Indizes der Punkte zu Cell i 
 VertexStar[ u[0] ].insert(i);  // cell-Index i --> VertexStar[ u[0] ]
 VertexStar[ u[1] ].insert(i);
 VertexStar[ u[2] ].insert(i);
 VertexStar[ u[3] ].insert(i);
};

Wendet man Algorithmus-1 auf ein real-world Beispiel

+----------------------------------------------------------+
|                  tetra-mesh information                  |
+----------------------------------------------------------+
|       Tetraeder-Cells          5721840
|       Points                   1103378
|       map of connected sets is empty 

mit 5.7 Mio Elementen an, so beträgt die Rechenzeit 9.88 s.

Eine kleine Verbesserung ergibt sich, wenn auf eine Sortierung der Menge verzichtet wird. Statt std::set<int> kann std::unordered_set<int> verwendet werden, denn nur die Indizes der beteiligten Tetraeder sind interessant. Eine Reihenfolge ist hier nicht wichtig. Die Rechenzeit reduziert sich auf 8.92 s.

OpenMp-Parallelisierung

Der Versuch die for-Schleife mittels OpenMp zu parallelisieren

//---------------  Algorithmus-2 -----------------------------
#pragma omp parallel for 
for(uint i=0; i < n; ++i) {
 const auto u = Cells[i].v;     // Indizes der Punkte zu Cell i 
 VertexStar[ u[0] ].insert(i);  //  i --> VertexStar[ u[0] ]
 VertexStar[ u[1] ].insert(i);
 VertexStar[ u[2] ].insert(i);
 VertexStar[ u[3] ].insert(i);
};

endet mit einem Absturz

|       Tetra4Mesh::SetSparseMatrixPattern()
Speicherzugriffsfehler (Speicherabzug geschrieben)

Das Einfügen einer Zahl in die Menge std::set<int> ist nicht thread-safe. Die Barriere #pragma omp critical(Insert) vor jedem insert-Aufruf “rettet” zwar den Algorithmus-2, bringt aber keinen Fortschritt bzgl. der Rechenzeit, da dann faktisch wieder Verfahren 1 vorliegt.

Parallelisierung mit tbb

Jetzt wird std::set<int> durch tbb::concurrent_unordered_set<int> ersetzt und die for-Schleife mittels tbb::parallel_for parallelisiert.

#include "tbb.h"
...
//---------------  Algorithmus-3 -----------------------------
  typedef tbb::concurrent_unordered_set<int> IntSet;           
  vector<IntSet>  VertexStar(Points.size());

  tbb::parallel_for(static_cast<uint> (0), n, [&](uint i) {
     const auto u = Cells[i].v;
     VertexStar[ u[0] ].insert(i);
     VertexStar[ u[1] ].insert(i);
     VertexStar[ u[2] ].insert(i);
     VertexStar[ u[3] ].insert(i);
  });

Der insert-Aufruf ist nun thread-safe und die Variante nutzt alle 16 Kerne der CPU. Die Rechenzeit beträgt nur noch erstauliche 0.6562 s. Das ist ein Speedup von 15 (!) und eine fast perfekte Skalierung.

tbb::concurrent_unordered_set mit fast optimalem Speedup

Alle Rechnungen wurden auf einem AMD Ryzen 9 5950X (16 cores) durchgeführt.

Links zu dem Thema: