10. Parallelization
10.1. Parallelize your solver using OpenMP!
Warning
The following code is the final solution of our parallelization of the WavePropagation2D.cpp, main.cpp and netcdf.cpp In “Comparison of parallel and sequential execution” is explained how we came to this solution and what we tried before.
For this task we parallelized our x and y sweep in WavePropagation2D.cpp:
void tsunami_lab::patches::WavePropagation2d::timeStep(t_real i_scaling)
{
#pragma omp parallel for schedule(static) default(none) shared(m_h, m_hu, m_hv, m_b) firstprivate(m_xCells, m_yCells)
for (t_idx l_ce = 1; l_ce < ((m_xCells + 2) * (m_yCells + 2)); l_ce++)
{
m_h_temp[l_ce] = m_h[l_ce];
m_momentum_temp[l_ce] = m_hu[l_ce];
}
#pragma omp parallel for schedule(static) default(none) shared(m_h, m_hv, m_b, m_h_temp, m_momentum_temp) firstprivate(i_scaling, m_xCells, m_yCells, m_choice)
for (t_idx l_ex = 1; l_ex < m_xCells + 1; l_ex++)
{
for (t_idx l_ey = 1; l_ey < m_yCells + 1; l_ey++)
{
as well as the part where we set up the solver in our main.cpp:
#pragma omp parallel for reduction(max:l_hMax) default(none) shared(l_setup,l_waveProp,l_nx,l_ny,l_dxy,l_domain_start_x,l_domain_start_y)
//every variable is private when first delcared in the loop
for( tsunami_lab::t_idx l_cy = 0; l_cy < l_ny; l_cy++ ){
for( tsunami_lab::t_idx l_cx = 0; l_cx < l_nx; l_cx++ ){
tsunami_lab::t_real l_y = l_cy * l_dxy + l_domain_start_y;
tsunami_lab::t_real l_x = l_cx * l_dxy + l_domain_start_x;
// get initial values of the setup
tsunami_lab::t_real l_h = l_setup->getHeight( l_x,
l_y );
l_hMax = std::max( l_h, l_hMax );
and our makeLowerResGrid function in netcdf.cpp:
#pragma omp parallel for collapse(2) schedule(static) default(none) shared(grid,oldgrid) firstprivate(result_x,result_y,i_stride,i_k)
for (t_idx l_iy = 0; l_iy < result_y; l_iy++) // für y wert neues feld
{
for (t_idx l_ix = 0; l_ix < result_x; l_ix++) // für x wert neues feld
{
for (t_idx l_jy = 0; l_jy < i_k; l_jy++) // iterator von 0 bis k um von l_iy and zu zählen
{
for (t_idx l_jx = 0; l_jx < i_k; l_jx++) // iterator von 0 bis k um von l_ix and zu zählen
{
grid[l_iy * result_x + l_ix] += oldgrid[(l_iy * i_k + l_jy+1) * i_stride + (l_ix * i_k + l_jx+1)];
}
}
grid[l_iy * result_x + l_ix] /= (i_k * i_k);
}
}
10.2. Comparison of parallel and sequential execution
For the following tests we used the following config file with 500 seconds of simulation time:
{
"solver" : "fwave",
"dimension_x" : 2699750,
"dimension_y" : 1499750,
"setup" : "tsunamievent2d",
"nx" : 5400,
"ny" : 3000,
"k" : 5,
"hu" : 0,
"location" : 0,
"hv":0.0,
"hr": 55,
"hl": 25,
"domain_start_x" : -199875,
"domain_start_y" : -749875,
"wavepropagation" : "2d",
"endtime" : 500,
"writer" : "netcdf",
"bathfile" : "data/output/tohoku_gebco20_ucsb3_250m_bath.nc",
"disfile" : "data/output/tohoku_gebco20_ucsb3_250m_displ.nc",
"outputfilename": "simulation.nc",
"usecheckpoint" : false
}
The first goal was to find out if it is faster to parallelize the outer for loop or the inner loop in our WavePropagation2D,
or if it is fastest to parallelize both with collapse(2).
Note
Since the position and the amount of for loops we want to parallelize are the most important, we set all variables to shared for this test and use “default(none) as good practice
default(none) shared(m_h, m_hv, m_b, m_h_temp, m_momentum_temp, i_scaling, m_xCells, m_yCells, m_choice)
The first value is the SpeedUp and the second value is how much time a single cell takes on average
SpeedUp |
average_Time_per_cell |
Warning
We know that #pragma omp parallel for collapse(2) can lead to race conditions but we wanted to test it anyway
inner loop |
outer loop |
both loops |
|||||||
|---|---|---|---|---|---|---|---|---|---|
OMP_NUM_THREADS |
#pragma omp parallel for |
#pragma omp parallel for |
#pragma omp parallel for collapse(2) |
||||||
1 |
|
|
|
||||||
2 |
|
|
|
||||||
4 |
|
|
|
||||||
8 |
|
|
|
||||||
16 |
|
|
|
||||||
25 |
|
|
|
||||||
32 |
|
|
|
||||||
34 |
|
|
|
||||||
36 |
|
|
|
||||||
45 |
|
|
|
||||||
61 |
|
|
|
||||||
62 |
|
|
|
||||||
63 |
|
|
|
||||||
64 |
|
|
|
||||||
65 |
|
|
|
||||||
66 |
|
|
|
||||||
67 |
|
|
|
||||||
68 |
|
|
|
||||||
69 |
|
|
|
||||||
70 |
|
|
|
||||||
71 |
|
|
|
||||||
72 |
|
|
|
When visualizing the data, you can see that the average time is decreasing and the best performance of 8ns is achieved with 34 and 36 threads. You can also see that the parallelization of the inner for loop takes longer if you want to use more threads than you have cores. The reason for this is the large overhead you have because you have to reassign the chunks to the threads after each iteration.
The time in the outer for loop remains relatively constant, however, only at 65 and 66 threads the time drops to 7ns. We assume that this is not really an improvement because we have also looked at the total duration of the simulation and these are almost the same for all and it varies only slightly
With collapse(2), 2 for loops, one of which is inside the other, are combined into one and then parallelized, which makes sense IF both for loops are independent of each other. The values that arise when you use more threads than you have cores are difficult to explain, especially when there are jumps from 9ns->5ns->12ns->6ns->10ns->4ns.
But since there is only one overhead and one for loop is faster than 2 with the same work, the time could decrease again. Anyway, since there are race conditions, this test was just out of interest.
Summary of Task 2 and 3
Generally speaking, the outer for loop is faster than the inner for loop due to the higher overhead of the inner for loop. Furthermore, it is not worth using more threads than cores because it leads to increased overhead. If there are more threads than cores, the threads have to be distributed to the available cores, which leads to additional tasks for the scheduler and reassignment of tasks.
10.3. Study NUMA effects and use OpenMP’s first touch policy to perform NUMA-aware initializations.
Study NUMA effects
The “NUMA effects” refer to the performance differences caused by accessing local versus non-local memory. When a processor accesses non-local memory, there can be a delay known as a “NUMA penalty”. This penalty can significantly affect the performance of multiprocessor systems.
The first touch policy is a policy that ensures that the memory is allocated on the NUMA node where the thread is running.
For that we use the #pragma omp parallel for schedule(static) default(none) shared(m_h, m_hu, m_hv, m_b) firstprivate(m_xCells, m_yCells)
To perform NUMA-aware initializations we parallelize the for loop where we copy the momentum and the height into the temporary arrays.
void tsunami_lab::patches::WavePropagation2d::timeStep(t_real i_scaling)
{
#pragma omp parallel for schedule(static) default(none) shared(m_h, m_hu, m_hv, m_b) firstprivate(m_xCells, m_yCells)
for (t_idx l_ce = 1; l_ce < ((m_xCells + 2) * (m_yCells + 2)); l_ce++)
{
m_h_temp[l_ce] = m_h[l_ce];
m_momentum_temp[l_ce] = m_hu[l_ce];
}
10.4. Try different scheduling and pinning strategies.
To obtain more accurate values this time on how much time a cell takes on average, we let the simulations run for a longer duration and test it with 32 ,34 and 36 threads, where we obsered the best performance.
{ "solver" : "fwave", "dimension_x" : 2699750, "dimension_y" : 1499750, "setup" : "tsunamievent2d", "nx" : 5400, "ny" : 3000, "k" : 5, "hu" : 0, "location" : 0, "hv":0.0, "hr": 55, "hl": 25, "domain_start_x" : -199875, "domain_start_y" : -749875, "wavepropagation" : "2d", "endtime" : 7200, "writer" : "netcdf", "bathfile" : "data/output/tohoku_gebco20_ucsb3_250m_bath.nc", "disfile" : "data/output/tohoku_gebco20_ucsb3_250m_displ.nc", "outputfilename": "simulation.nc", "usecheckpoint" : false }
For this test, we have reserved 3 hours each for static, dynamic and guided and these 3 simulations must be completed in this time.
OMP_NUM_THREADS |
schedule(static) |
schedule(dynamic) |
schedule(guided) |
|---|---|---|---|
32 |
3ns |
|
4ns |
34 |
3ns |
/ |
3ns |
36 |
3ns |
/ |
4ns |
the static scheduler was the fastest, and because of NUMA aware initializations and a longer duration of the simulation (more precise average), the average time per cell is now 3ns. The dynamic scheduler did not complete the first simulation within 3 hours, so we can’t say anything about the average time per cell but we know that this scheduler is slower than the static scheduler. The guided scheduler was almost as fast as the static scheduler with 4ns per cell.
Pinning strategies
The pinning strategy is a strategy that determines which thread is assigned to which core. The default strategy is to assign the threads to the cores in a round-robin fashion.
{
"solver" : "fwave",
"dimension_x" : 2699750,
"dimension_y" : 1499750,
"setup" : "tsunamievent2d",
"nx" : 5400,
"ny" : 3000,
"k" : 5,
"hu" : 0,
"location" : 0,
"hv":0.0,
"hr": 55,
"hl": 25,
"domain_start_x" : -199875,
"domain_start_y" : -749875,
"wavepropagation" : "2d",
"endtime" : 4500,
"writer" : "netcdf",
"bathfile" : "data/output/tohoku_gebco20_ucsb3_250m_bath.nc",
"disfile" : "data/output/tohoku_gebco20_ucsb3_250m_displ.nc",
"outputfilename": "simulation.nc",
"usecheckpoint" : false
}
we tried different pinning strategies seen in the table below.
We splitted the cores into 4 groups and used close, spread and MASTER to assign the threads to the cores.
OMP_NUM_THREADS |
OMP_PROC_BIND=close OMP_PLACES=”{0:9},{9:9}, {18:9},{27:9}” |
OMP_PLACES=”{0:9},{9:9}, {18:9},{27:9}” OMP_PROC_BIND=spread |
|
|---|---|---|---|
34 |
3ns |
3ns |
20ns |
OMP_PROC_BIND=spread and OMP_PROC_BIND=close took the same time 5 minutes, 10 seconds and OMP_PROC_BIND=MASTER took 31 minutes, 5seconds with an average time of 20ns for a cell.
It doesn’t make sense to create different places if all cores are supposed to orient themselves to the master thread therefore, this long duration probably occurred.
We found that without using OMP_PLACES, both spread and bind took 20 seconds longer. However, MASTER was the fastest overall, but only by a few seconds.
To compare the unparallelized code with the fully parallelized code, we simulated Tohoku for 4500 seconds (500m cell width) again, both with and without parallelization.
without parallelization:
total duration:
duration: 7635.31 seconds = 127,26 minutes
loop duration:
Duration: 2 hours, 1 minutes, 59 seconds, 112 milliseconds, 463 microseconds, 470 nanoseconds
Station:
Duration: 0 hours, 0 minutes, 0 seconds, 0 milliseconds, 102 microseconds, 717 nanoseconds
Checkpoint:
Duration: 0 hours, 0 minutes, 0 seconds, 0 milliseconds, 0 microseconds, 0 nanoseconds
time per cell:
Duration: 0 hours, 0 minutes, 0 seconds, 0 milliseconds, 451 microseconds, 797 nanoseconds
time per iteration:
Duration: 0 hours, 0 minutes, 0 seconds, 0 milliseconds, 0 microseconds, 81 nanoseconds
with parallelization:
total duration:
duration: 342.581 seconds = 5,70968 minutes
loop duration:
Duration: 0 hours, 5 minutes, 5 seconds, 373 milliseconds, 30 microseconds, 342 nanoseconds
Station:
Duration: 0 hours, 0 minutes, 0 seconds, 0 milliseconds, 56 microseconds, 147 nanoseconds
Checkpoint:
Duration: 0 hours, 0 minutes, 0 seconds, 0 milliseconds, 0 microseconds, 0 nanoseconds
time per cell:
Duration: 0 hours, 0 minutes, 0 seconds, 0 milliseconds, 18 microseconds, 850 nanoseconds
time per iteration:
Duration: 0 hours, 0 minutes, 0 seconds, 0 milliseconds, 0 microseconds, 3 nanoseconds
The parallelized code is 22.29 times faster and the time per iteration is 27 times faster than the unparallelized code.
Ward Tammaa, Daniel Schicker Doxygen Documentation
Daniel Schicker, Mohamad Khaled Minawe, Ward Tammaa Sphnix Documentation
Daniel Schicker, Mohamad Khaled Minawe , Ward Tammaa functions implementation
Mohamad Khaled Minawe, Daniel Schicker, Ward Tammaa Unit Testing
Mohamad Khaled Minawe, Daniel Schicker Geogebra Datei(Calculations for the Unit Tests)
Ward Tammaa Hosting the code , Action runner