OpenMP for Multi-core Programming
OpenMP for Multi-core Programming
Chapter Summary
Of many different parallel and distributed systems, multi-core and shared memory
multiprocessors are most likely the easiest to program if only the right approach is
taken. In this chapter, programming such systems is introduced using OpenMP, a
widely used and ever-expanding application programming interface well suited for
the implementation of multithreaded programs. It is shown how the combination
of properly designed compiler directives and library functions can provide a pro-
gramming environment where the programmer can focus mostly on the program and
algorithms and less on the details of the underlying computer system.
Fig. 3.2 A parallel system with two quad-core CPUs supporting simultaneous multithreading con-
tains 16 logical cores all connected to the same memory
Fig. 3.3 Two examples of a race condition when two threads attempt to increase the value at the
same location in the main memory
Fig. 3.4 Preventing race conditions as illustrated in Fig. 3.3 using locking
as illustrated in Fig. 3.3. In such situations, the result is both incorrect and undefined:
in either case, the value in the memory will be increased by either 1 or 2 but not by
1 and 2.
To avoid the race condition, exclusive access to the shared address in the main
memory must be ensured using some mechanism like locking using semaphores or
atomic access using read-modify-write instructions. If locking is used, each thread
must lock the access to the shared memory location before modifying it and unlock
it afterwards as illustrated in Fig. 3.4. If a thread attempts to lock something that the
other thread has already locked, it must wait until the other thread unlocks it. This
approach forces one thread to wait but guarantees the correct result.
The peripheral devices are not shown in Figs. 3.1 and 3.2. It is usually assumed
that all threads can access all peripheral devices but it is again up to software to
resolve which thread can access each device at any given time.
One such thing is OpenMP, a parallel programming environment best suitable for
writing parallel programs that are to be run on shared memory systems. It is not
yet another programming language but an add-on to an existing language, usually
Fortran or C/C++. In this book, OpenMP atop of C will be used.
The application programming interface (API) of OpenMP is a collection of
• compiler directives,
• supporting functions, and
• shell variables.
OpenMP compiler directives tell the compiler about the parallelism in the source
code and provide instructions for generating the parallel code, i.e., the multi-
threaded translation of the source code. In C/C++, directives are always expressed
as #pragmas. Supporting functions enable programmers to exploit and control the
parallelism during the execution of a program. Shell variables permit tunning of
compiled programs to a particular parallel system.
To illustrate different kinds of OpenMP API elements, we will start with a simple
program in Listing 3.1.
This program starts as a single thread that first prints out the salutation. Once
the execution reaches the omp parallel directive, several additional threads are
created alongside the existing one. All threads, the initial thread and the newly created
threads, together form a team of threads. Each thread in the newly established team of
threads executes the statement immediately following the directive: in this example
it just prints out its unique thread number obtained by calling OpenMP function
omp_get_thread_num. When all threads have done that threads created by the
omp parallel directive are terminated and the program continues as a single
thread that prints out a single new line character and terminates the program by
executing return 0.
To compile and run the program shown in Listing 3.1 using GNU GCC C/C++
compiler, use the command-line option -fopenmp as follows:
3.2 Using OpenMP to Write Multithreaded Programs 51
Hello, world: 2 5 1 7 6 0 3 4
Without OMP_NUM_THREADS being set, the program would set the number of
threads to match the number of logical cores threads can run on. For instance, on a
CPU with 2 cores and hyper-threading, 4 threads would be used and a permutation
of numbers from 0 to 3 would be printed out.
Once the threads are started, it is up to a particular OpenMP implementation and
especially the underlying operating system to carry out scheduling and to resolve
competition for the single standard output the permutation is printed on. Hence, if
the program is run several times, a different permutation of thread numbers will most
likely be printed out each time. Try it.
52 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
During the design, development, and debugging of parallel programs reasoning about
parallel algorithms and how to encode them better rarely suffices. To understand how
an OpenMP program actually runs on a multi-core system, it is best to monitor and
measure the performance of the program. Even more, this is the simplest and the
most reliable way to know how many cores your program actually runs on.
Let us use the program in Listing 3.2 as an illustration. The program starts several
threads, each of them printing out one Fibonacci number computed using the naive
and time-consuming recursive algorithm.
On most operating systems, it is usually easy to measure the running time of a
program execution. For instance, compiling the above program and running it using
time utility on Linux as
yields some Fibonacci numbers, and then as the last line of output, the information
about the program’s running time:
3.2 Using OpenMP to Write Multithreaded Programs 53
(See Appendix A for instructions on how to measure time and monitor the execution
of a program on Linux, macOS and MS Windows.)
The user and system time amount to the total time that all logical cores together
spent executing the program. In the example above, the sum of the user and system
time is bigger than the real time, i.e., the elapsed or wall-clock time. Hence, various
parts of the program must have run on several logical cores simultaneously.
Most operating systems provide system monitors that among other metrics show
the amount of computation performed by individual cores. This might be very infor-
mative during OpenMP program development, but be careful as most system monitor
reports the overall load on an individual logical core, i.e., load of all programs running
on a logical core.
Using a system monitor while the program shown in Listing 3.2 is run on an
otherwise idle system, one can observe the load on individual logical cores during
program execution. As threads finish one after another, one can observe how the
load on individual logical cores drops as the execution proceeds. Toward the end of
execution, with only one thread remaining, it can be seen how the operating system
occasionally migrates the last thread from one logical core to another.
Listing 3.3 Printing out all integers from 1 to max in no particular order.
The program in Listing 3.3 starts as a single initial thread. The value max is read
and stored in variable max. The execution then reaches the most important part of
the program, namely, the for loop which actually prints out the numbers (each
preceded by the number of a thread that prints it out). But the omp parallel
for directive in line 6 specifies that the for loop must be executed in parallel, i.e.,
its iterations must be divided among and executed by multiple threads running on
all available processing units. Hence, a number of slave threads is created, one per
each available processing unit or as specified explicitly (minus one that the initial
thread runs on). The initial thread becomes the master thread and together with the
newly created slave threads the team of threads is formed. Then,
• iterations of the parallel for loop are divided among threads where each iteration
is executed by the thread it has been assigned to, and
• once all iterations have been executed, all threads in the team are synchronized
at the implicit barrier at the end of the parallel for loop and all slave threads are
terminated.
Finally, the execution proceeds sequentially and the master thread terminates the
program by executing return 0. The execution of the program in Listing 3.3 is
illustrated in Fig. 3.5.
Several observations must be made regarding the program in Listing 3.3 (and exe-
cution of parallel for loops in general). First, the program in Listing 3.3 does not
specify how the iterations should be divided among threads (as explicit scheduling
of iterations will be described later). In such cases, most OpenMP implementations
divide the entire iteration space into chunks where each chunk containing a subin-
terval of all iterations is executed by one thread. Note, however, that this must not
be the case as if left unspecified, it is up to a particular OpenMP implementation to
do as it likes.
3.3 Parallelization of Loops 55
Fig. 3.5 Execution of the program for printing out integers as implemented in Listing 3.3
56 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
Second, once the iteration space is divided into chunks, all iterations of an indi-
vidual chunk are executed sequentially, one iteration after another. And third, the
parallel for loop variable i is made private in each thread executing a chunk of
iterations as each thread must have its own copy of i. On the other hand, variable
max can be shared by all threads as it is set before and is only read within the parallel
region.
However, the most important detail that must be paid attention to is that the overall
task of printing out all integers from 1 to max in no particular order can be divided
into N totally independent subtasks of almost the same size. In such cases, the
parallelization is trivial.
As the access to the standard output is serialized, printing out integers does not
happen as parallel as it might seem. Therefore, an example of truly parallel compu-
tation follows.
The structure of function vectAdd is very similar to the program for printing
out integers shown in Listing 3.3: a simple parallel for loop where the result of one
iteration is completely independent of the results produced by other loops. Even more,
different iterations access different array elements, i.e., they read from and write to
completely different memory locations. Hence, no race conditions can occur.
1 d o u b l e * v e c t A d d ( d o u b l e * c , d o u b l e * a , d o u b l e * b , int n ) {
2 # p r a g m a omp p a r a l l e l for
3 for ( int i = 0; i < n ; i ++)
4 c [ i ] = a [ i ] + b [ i ];
5 return c;
6 }
Consider now printing out all pairs of integers from 1 to max in no particular
order, something that calls for two nested for loops. As all iterations of both nested
loops are independent, either loop can be parallelized while the other is not. This is
achieved by placing the omp parallel for directive in front of the loop targeted
for parallelization. For instance, the program with the outer for loop parallelized is
shown in Listing 3.5.
Listing 3.5 Printing out all pairs of integers from 1 to max in no particular order by parallelizing
the outermost for loop only.
Assume all pairs of integers from 1 to max are arranged in a square table. If 4
threads are used and max = 6, each iteration of the parallelized outer for loop prints
out a few lines of the table as illustrated in Fig. 3.6a. Note that the first two threads
are assigned twice as much work than the other two threads which, if run on 4 logical
cores, will have to wait idle until the first two complete as well.
However, there are two other ways of parallelizing nested loops. First, the two
nested for loops can be collapsed in order to be parallelized together using clause
collapse(2) as shown in Listing 3.6.
Because of the clause collapse(2) in line 6, the compiler merges the two
nested for loops into one and parallelizes the resulting single loop. The outer for
loop running from 1 to max and max inner for loops running from 1 to max as
well, are replaced by a single loop running from 1 to max 2 . All max 2 iterations are
divided among available threads together. As only one loop is parallelized, i.e., the
58 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
Fig. 3.6 Partition of the problem domain when all pairs of integers from 1 to 6 must be printed
using 4 threads: a if only the outer for loop is parallelized, b if both for loops are parallelized
together, and c if both for loops are parallelized separately
Listing 3.6 Printing out all pairs of integers from 1 to max in no particular order by parallelizing
both for loops together.
one that comprises iterations of both nested for loops, the execution of the program
in Listing 3.6 still follows the pattern illustrated in Fig. 3.5. For instance, if max = 6,
all 36 iterations of the collapsed single loop are divided among 4 thread as shown
in Fig. 3.6b. Compared with the program in Listing 3.5, the work is more evenly
distributed among threads.
The other method of parallelizing nested loops is by parallelizing each for loop
separately as shown in Listing 3.7.
Listing 3.7 Printing out all pairs of integers from 1 to max in no particular order by parallelizing
each nested for loop separately.
3.3 Parallelization of Loops 59
To have one parallel region within the other as shown in Listing 3.7 active at the
same time, nesting of parallel regions must be enabled first. This is achieved by calling
omp_set_nested(1) before mtxMul is called or by setting OMP_NESTED to
true. Once nesting is activated, iterations of both loops are executed in parallel sep-
arately as illustrated in Fig. 3.7. Compare Figs. 3.5 and 3.7 and note how many more
threads are created and terminated in the latter, i.e., if nested loops are parallelized
separately.
Fig. 3.7 The execution of the program for printing out all pairs of integers using separately paral-
lelized nested loops as implemented in Listing 3.7
60 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
1 d o u b l e ** m t x M u l ( d o u b l e ** c , d o u b l e ** a , d o u b l e ** b , int n ) {
2 # p r a g m a omp p a r a l l e l for c o l l a p s e (2)
3 for ( int i = 0; i < n ; i ++)
4 for ( int j = 0; j < n ; j ++) {
5 c [ i ][ j ] = 0.0;
6 for ( int k = 0; k < n ; k ++)
7 c [ i ][ j ] = c [ i ][ j ] + a [ i ][ k ] * b [ k ][ j ];
8 }
9 return c;
10 }
Listing 3.8 Matrix multiplication where the two outermost loops are parallelized together.
1 d o u b l e ** m t x M u l ( d o u b l e ** c , d o u b l e ** a , d o u b l e ** b , int n ) {
2 # p r a g m a omp p a r a l l e l for
3 for ( int i = 0; i < n ; i ++)
4 # p r a g m a omp p a r a l l e l for
5 for ( int j = 0; j < n ; j ++) {
6 c [ i ][ j ] = 0.0;
7 for ( int k = 0; k < n ; k ++)
8 c [ i ][ j ] = c [ i ][ j ] + a [ i ][ k ] * b [ k ][ j ];
9 }
10 return c;
11 }
Listing 3.9 Matrix multiplication where the two outermost loops are parallelized separately.
3.3 Parallelization of Loops 61
Fig. 3.8 Conway’s Game of Life: a particular initial population turns into an oscillating one
Writing functions for matrix multiplication where only one of the two outermost
forloops is parallelized, either outer of inner, is left as an exercise.
• each live cell with fewer than two neighbors dies of underpopulation,
• each live cell with two or three neighbors lives on,
• each live cell with more than three neighbors dies of overpopulation, and
• each dead cell with three neighbors becomes a live cell.
It is assumed that each cell has eight neighbors, four along its sides and four on its
corners.
Once the initial generation is set, all the subsequent generations can be computed.
Sometimes the population of live cells die out, sometimes it turns into a static colony,
other times it oscillates forever. Even more sophisticated patterns can appear includ-
ing traveling colonies and colony generators. Figure 3.8 shows an evolution of an
oscillating colony on the 10 × 10 plane.
The program for computing Convay’s Game of life is too long to be included
entirely, but its core is shown in Listing 3.10. To understand it, observe the following:
Except for the omp parallel for directive, the code in Listing 3.10 is the
same as if it was written for the sequential execution: the (outermost) while loop
62 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
runs over all generations to be computed while the inner two loops are used to
compute the next generation and store it in aux_plane given the current generation
in plane. More precisely, the rules of the game are implemented in the switch
statement in lines 6–11: the case for plane[i][j]==0 implements the rule for
dead cells and the case for plane[i][j]==1 implements the rules for live cells.
Once the new generation has been computed, the arrays are swapped so that the
generation just computed becomes the current one.
The omp parallel for directive in line 2 is used to specify that the iterations
of the two for loops in lines 3–12 can be performed simultaneously. By inspecting
the code, it becomes clear that just like in matrix multiplication every iteration of the
outer for loop computes one row of the plane representing the next generation and
that every iteration of the inner loop computes a single cell of the next generation.
As array plane is read only and the (i, j)-th iteration of the collapsed loop is the
only one writing to the (i, j)-th cell of array aux_plane, there can be no race
conditions and there are no dependencies among iterations.
The implicit synchronization at the end of the parallelized loop nest is crucial.
Without synchronization, if the master thread performed the swap in line 13 before
other threads finished the computation within both for loops, it would cause all
other threads to mess up the computation profoundly.
Finally, instead of parallelizing the two for loops together it is also possible
to parallelize them separately just like in matrix multiplication. But the outermost
loop, i.e., while loop, cannot be parallelized as every iteration (except the first one)
depends on the result of the previous one.
In most cases, however, individual loop iterations aren’t entirely independent as they
are used to solve a single problem together and thus each iteration contributes its part
to the combined solution. Most often then not partial results of different iterations
must be combined together.
1 w h i l e ( gens - - > 0) {
2 # p r a g m a omp p a r a l l e l for c o l l a p s e (2)
3 for ( int i = 0; i < size ; i ++)
4 for ( int j = 0; j < size ; j ++) {
5 int n e i g h s = n e i g h b o r s ( plane , size , i , j ) ;
6 s w i t c h ( p l a n e [ i ][ j ]) {
7 case 0: a u x _ p l a n e [ i ][ j ] = ( n e i g h s == 3) ;
8 break ;
9 case 1: a u x _ p l a n e [ i ][ j ] = ( n e i g h s == 2) || ( n e i g h s == 3) ;
10 break ;
11 }
12 }
13 char ** t m p _ p l a n e = a u x _ p l a n e ; a u x _ p l a n e = p l a n e ; p l a n e = t m p _ p l a n e ;
14 }
If integers from the given interval are to be added instead of printed out, all subtasks
must somehow cooperate to produce the correct sum. The first parallel solution that
comes to mind is shown in Listing 3.11. It uses a single variable sum where the
result is to be accumulated.
Listing 3.11 Summation of integers from a given interval using a single shared variable — wrong.
Again, iterations of the parallel for loop are divided among multiple threads. In
all iterations, threads use the same shared variable sum on both sides of assignment
in line 8, i.e., they read from and write to the same memory location. As illustrated
in Fig. 3.9 where every box containing =+ denotes the assignment sum = sum +
i, the accesses to variable sum overlap and the program is very likely to encounter
race conditions illustrated in Fig. 3.3.
Indeed, if this program is run multiple times using several threads, it is very likely
that it will not always produce the same result. In other words, from time to time it
will produce the wrong result. Try it.
To avoid race conditions, the assignment sum = sum + i can be put inside a
critical section — a part of a program that is performed by at most one thread at
a time. This is achieved by the omp critical directive which is applied to the
statement or a block immediately following it. The program using critical sections
is shown in Listing 3.12.
The program works correctly because the omp critical directive performs
locking around the code it contains, i.e., the code that accesses variable sum, as
64 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
Listing 3.12 Summation of integers from a given interval using a critical section — slow.
illustrated in Fig. 3.4 and thus prevents race conditions. However, the use of critical
sections in this program makes the program slow because at every moment at most
one thread performs the addition and assignment while all other threads are kept
waiting as illustrated in Fig. 3.10.
It is worth comparing the running times of the programs shown in Listings
3.11 and 3.12. On a fast multi-core processor, a large value for max possibly causing
an overflow is needed so that the difference can be observed.
Another way to avoid race conditions is to use atomic access to variables as shown
in Listing 3.13.
Although sum is a single variable shared by all threads in the team, the program
computes the correct result as the omp atomic directive instructs the compiler
Listing 3.13 Summation of integers from a given interval using a atomic variable access — faster.
To prevent race conditions and to avoid locking or explicit atomic access to vari-
ables at the same time, OpenMP provides a special operation called reduction. Using
it, the program in Listing 3.11 is rewritten to the program shown in Listing 3.14.
Listing 3.14 Summation of integers from a given interval using reduction — fast.
66 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
√
Fig. 3.12 Integrating y = 1 − x 2 numerically from 0 to 1
OpenMP: reduction
Technically, reduction is yet another data sharing attribute specified by
reduction(reduction-identifier :list )
clause.
For each variable in the list, a private copy is created in each thread of a parallel
region, and initialized to a value specified by the reduction-identifier. At the end
of a parallel region, the original variable is updated with values of all private
copies using the operation specified by the reduction-identifier.
The reduction-identifier may be +, -, &, |, ˆ, &&, ||, min, and max. For * and
&& the initial value is 1; for min and max the initial value is the largest and the
smallest value of the variable’s type, respectively; for all other operations, the
initial value is 0.
rectangle, i.e., 1/N ,multiplied by the function value computed in the left-end point
of the interval, i.e., 1 − (i/N )2 . Thus,
⎛ ⎞
1
N −1 2
1 − x 2d x ≈ ⎝1 1−
1
i ⎠
0 N N
i=0
1 double x = 0.0;
2 # p r a g m a omp p a r a l l e l for r e d u c t i o n (+: i n t e g r a l )
3 for ( int i = 0; i < i n t e r v a l s ; i ++) {
4 d o u b l e fx = s q r t (1.0 - x * x ) ;
5 i n t e g r a l = i n t e g r a l + fx * dx ;
6 x = x + dx ;
7 }
√
Listing 3.16 Computing π by integrating 1 − x 2 from 0 to 1 using a non-paralellizable loop.
This works well if the program is run by only one thread (set OMP_NUM_THREADS
to 1), but produces the wrong result if multiple threads are used. The reason is that the
iterations are no longer independent: the value of x is propagated from one iteration
3.3 Parallelization of Loops 69
Fig. 3.13 Computing π by random shooting: different threads shoot independently, but the final
result is a combination of all shots
to another so the next iteration cannot be performed until the previous has been fin-
ished. However, the omp parallel for directive in line 2 of Listing 3.16 states
that the loop can and should be parallelized. The programmer unwisely requested
the parallelization and took the responsibility for ensuring the loop can indeed be
parallelized too lightly.
From the parallel programming view, the program in Listing 3.17 is basically
simple: num_shots are shot within the parallel for loop in lines 17–23 and
the number of hits is accumulated in variable num_shots . Furthermore, it also
resembles the program in Listing 3.14: the results of independent iterations combined
together to yield the final result (Fig. 3.14).
The most intricate part of the program is generating random shots. The usual
random generators, i.e., rand or random, are not reentrant or thread-safe: they
should not be called in multiple threads because they use a single hidden state that
is modified on each call regardless of the thread the call is made in. To avoid this
problem, function rnd has been written: it takes a seed, modifies it, and returns a
random value in the interval [0, 1). Hence, a distinct seed for each thread is created in
lines 12–14 where OpenMP function omp_get_max_threads is used to obtain
the number of future threads that will be used in the parallel for loop later on. Using
these seeds, the program contains one distinct random generator for each thread.
The rate of convergence toward π is much lower than if random shooting is used
instead of numerical integration. However, this example shows how simple it is to
implement a wide class of Monte Carlo methods if random generator is applied
correctly: one must only run all random based individual experiments, e.g., shots
into [0, 1] × [0, 1] in lines 18–20, and aggregate the results, e.g., count the number
of hits within the unit circle.
As long as the number of individual experiments is known in advance and the
complexity of individual experiments is approximately the same, the approach is pre-
sented in Listing 3.17 suffices. Otherwise, a more sophisticated approach is needed,
but more about that later.
Before proceeding, we can rewrite the program in Listing 3.17 to a simpler one.
By splitting the omp parallel and omp for we can define the thread-local seed
inside the parallel region as shown in Listing 3.19.
Let us demonstrate that computing π by random shooting into [0, 1] × [0, 1] and
counting the shots inside the unit circle can also be encoded differently as shown in
Listing 3.19, but at its core it stays the same.
Namely, the parallel regions, one per each available thread, specified by the omp
parallel directive in line 13 are used instead of the parallel for loop (see also
Listings 3.1 and 3.2). Within each parallel region, the seed for the thread-local random
generator is generated in lines 15. Then, the number of shots that must be carried
out by the thread is computed in lines 16–18 and finally all shots are performed in
a thread-local sequential while loop in lines 19–23. Unlike the iterations of the
parallel par loop in Listing 3.18, the iterations of the while loop do not contain a
call of function omp_get_thread_num . However, the aggregation of the results
obtained by the parallel regions, i.e., the number of hits, is done using the reduction
in the same way as in Listing 3.18.
72 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
So far no attention has been paid on how iterations of a parallel loop, or of a several
collapsed parallel loops, are distributed among different threads in a single team
of threads. However, OpenMP allows the programmer to specify several different
iteration scheduling strategies.
Consider computing the sum of integers from a given interval again using the
program shown in Listing 3.14. This time, however, the program will be modified
as shown in Listing 3.20. First, the schedule(runtime) clause is added to the
omp for directive in line 8. It allows the iteration schedule strategy to be defined
once the program is started using the shell variable OMP_SCHEDULE . Second, in
line 10, each iteration prints out the the number of thread that executes it. And third,
different iterations take different time to execute as specified by the argument of
function sleep in line 11: iterations 1, 2, and 3 require 2, 3, and 4 s, respectively,
while all other iterations require just 1 second.
Listing 3.20 Summation of integers from a given interval where iteration scheduling strategy is
determined in runtime.
1 s. Hence, thread T0 finishes much later than all other threads as can be seen in
Fig. 3.15.
• If OMP_SCHEDULE=static,1 or OMP_SCHEDULE=static,2, the itera-
tions are divided into chunks containing 1 or 2 iterations, respectively. Chunks
are then assigned to threads in a round-robin fashion as
or
Fig. 3.14 A distribution of 14 iterations among 4 threads where iterations 1, 2 and 3 require more
time than other iterations, using static iteration scheduling strategy
Fig. 3.15 A distribution of 14 iterations among 4 threads where iterations 1, 2 and 3 require more
time than other iterations, using static,1 (left) and static,2 (right) iteration scheduling
strategies
Fig. 3.16 A distribution of 14 iterations among 4 threads where iterations 1, 2 and 3 require more
time than other iterations, using dynamic,1 (left) and dynamic,2 (right) iteration scheduling
strategies
or
The scheduling of iterations is illustrated in Fig. 3.16: the overall running is further
reduced to 5 or 6 s, again depending on the chunk size. The overall running time
of 5 s is the minimal possible as each thread performs the same amount of work.
3.3 Parallelization of Loops 75
To produce Fig. 3.17, max_iters has been set to 100. Each point of the black
region, i.e., within the Mandelbrot set, takes 100 iterations to compute. However, each
point within the dark gray region requires more than 10 yet less than 100 iterations.
Likewise, each point within the light gray region requires more than 5 and less than
10 iterations and all the rest, i.e., points colored white, require at most 5 iterations
each. As different points and thus different iterations of the collapsed for loops
in lines 2 and 3 require significantly different amount of computation, it matters
what iteration scheduling strategy is used. Namely, if static,100 is used instead
of simply static, the running time is reduced by approximately 30 percent; the
choice of dynamic,100 reduces the running time even more. Run the program
and measure its running time under different iteration scheduling strategies.
The parallel for loop and reduction operation are so important in OpenMP pro-
gramming that they should be studied and understood in detail.
Let us return to the program for computing the sum of integers from 1 to max as
shown in Listing 3.14. If it assumed that T , the number of threads, divides max and
the static iteratin scheduling startegy is used, the program can be rewritten into
the one shown in Listing 3.22. (See exercises for the case when T does not divide
max.)
Listing 3.22 Implementing efficient summation of integers by hand using simple reduction.
The initial thread first obtains T , the number of threads available (using OpenMP
function omp_get_max_threads), and creates an array sums of variables used
for summation within each thread. Although the array sums is going to be shared
by all threads, each thread will access only one of its T elements.
3.3 Parallelization of Loops 77
Fig. 3.18 Computing the reduction in time O(log2 T ) using T /2 threads when T = 12
Reaching omp parallel region the master thread creates (T − 1) slave threads
to run alongside the master thread. Each thread, master or slave, first computes its
subinterval (lines 11–12), initializes its local summation variable to 0 (line 13), and
then executes its thread-local sequential for loop (line 14–15). Once all threads
have finished computing local sums, only the master thread is left alive. It adds the
local summation variables and prints the result. The overall execution is performed
as shown in Fig. 3.9. However, no race conditions appear because each thread uses
its own summation variable, i.e., the t-th thread uses the t-th element sums[t] of
array sums.
From the implementation point of view, the program in Listing 3.22 uses array
sums instead of thread-local summation variables and performs the reduction by
the master thread only. Array sums is created by the master thread before creating
slave threads so that the explicit reduction, which is performed in line 18 after the
slave threads have been terminated and their local variables (t, lo, hi, and n) have
been lost, can be implemented.
Furthermore, the reduction is performed by adding local summation variables,
i.e., elements of sums, one after another to variable sum. This takes O(T ) time
and works fine if the number of threads is small, e.g., T = 4 or T = 8. However, if
there are a few hundred threads, a solution shown in Listing 3.23 that works in time
O(log2 T ) and produces the result in sums[0], is often preferred (unless the target
system architecture requires even more sophisticated method).
Listing 3.23 Implementing efficient summation of integers by hand using simple reduction.
The idea behind the code shown in Listing 3.23 is illustrated in Fig. 3.18. In List-
ing 3.23 variable, d contains the distance between elements of array sums being
added, and as it doubles in each iteration, there are log2 T iterations of the outer
loop. Variable t denotes the left element of each pair being added in the inner loop.
But as the inner loop is performed in parallel by at least T /2 threads which operate
on distinct elements of array sums, all additions in the inner loop are performed
simultaneously, i.e., in time O(1).
Note that either method used for computing the reduction uses (T − 1) additions.
However, in the first method (line 18 of Listing 3.22) additions are performed one
78 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
after another while in the second method (Listing 3.23) certain additions can be
performed simultaneously.
Although most parallel programs spend most of their time running parallel loops,
this is not always the case. Hence, it is worth exploring how a program consisting of
different tasks can be parallelized.
As above, where parallelization of loops that need not combine the results of its
iterations was explained first, we start with explanation of tasks where cooperation
is not needed.
Consider computing the sum of integers from 1 to max one more time. At the end
of a previous section, it was shown how iterations of a single parallel for loop are
distributed among threads. This time, however, the interval from 1 to max is split
into a number of mutually disjoint subintervals. For each subinterval, a task that
first computes the sum of all integers of a subinterval and then adds the sum of the
subinterval to the global sum, is used.
The idea is implemented as the program in Listing 3.24. For the sake of simplicity,
it is assumed that T , denoting the number of tasks and stored in variable tasks,
divides max.
Computing the sum is performed in the parallel block in lines 9–25. The for
loop in line 12 creates all T tasks where each task is defined by the code in lines
13–23. Once the tasks are created, it is more or less up to OpenMP’s runtime system
to schedule tasks and execute them.
The important thing, however, is that the for loop in line 12 is executed by only
one thread as otherwise each thread would create its own set of T tasks. This is
achieved by placing the for loop in line 12 under the OpenMP directive single.
The OpenMP directive task in line 13 specifies that the code in lines 14–23 is
to be executed as a single task. The local sum is initialized to 0 and the subinterval
bounds are computed from the task number, i.e., t. The integers of the subinterval
are added up and the local sum is added to the global sum using atomic section to
prevent a race condition between two different tasks.
Note that when a new task is created, the execution of the task that created the
new task continues without delay; once created, the new task has a life of its own.
Namely, when the master thread in Listing 3.24 executes the for loop, it creates
one new task in each iteration, but the iterations (and thus creation of new tasks) are
executed one after another without waiting for the newly created tasks to finish (in
fact, it would make no sense at all to wait for them to finish). However, all tasks must
3.4 Parallel Tasks 79
finish before the parallel region can end. Hence, once the global sum is printed
out in line 26 of Listing 3.24, all tasks has already finished.
The difference between the approaches taken in the previous and this section can
be told in yet another way. Namely, when iterations of a single parallel for loop
are distributed among threads, tasks, one per thread, are created implicitly. But when
a number of explicit tasks is used, the loop itself is split among tasks that are then
distributed among threads.
OpenMP: tasks
A task is declared using the directive
#pragma omp task [clause [[ ,] clause] …]
structured-block
The task directive creates a new task that executes structured-block. The new
task can be executed immediately or can be deferred. A deferred task can be
later executed by any thread in the team.
The task directive can be further refined by a number of clauses, the most
important being the following ones:
• final(scalar-logical-expression) causes, if scalar-logical-expression eval-
uates to true, that the created task does not generate any new tasks any more,
i.e., the code of would-be-generated new subtasks is included in and thus
executed within this task;
• if([ task:]scalar-logical-expression) causes, if scalar-logical-expression
evaluates to false, that an undeferred task is created, i.e., the created task
suspends the creating task until the created task is finished.
For other clauses see OpenMP specification.
Converting a parallel for loop into a set of tasks is not very interesting and in
most cases does not help either. The real power of tasks, however, can be appreciated
when the number and the size of individual tasks cannot be known in advance. In
3.4 Parallel Tasks 81
Listing 3.25 Computing Fibonacci numbers using OpenMP’s tasks: smaller tasks, i.e., for smaller
Fibonacci numbers are created first.
other words, when the problem or the algorithm demands that tasks are created
dynamically.
Listing 3.26 Computing Fibonacci numbers using OpenMP’s tasks: smaller tasks, i.e., for smaller
Fibonacci numbers are created last.
Listing 3.27 The parallel implementation of the Quicksort algorithm where each recursive call is
performed as a new task.
The partition part of the algorithm, implemented in lines 4–14 of Listing 3.27,
is the same as in the sequential version. The recursive calls, though, are modified
82 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
because they can be performed independently, i.e., at the same time. Each of the two
recursive calls is therefore executed as its own task.
However, no matter how efficient creating new tasks is, it takes time. Creating a
new task only makes sense if a part of the table that must be sorted using a recursive
call is big enough. In Listing 3.27, the clause final in lines 15 and 17 is used to
prevent creating new tasks for parts of table that contain less than 1000 elements. The
threshold 1000 has been chosen by experience; choosing the best threshold depends
on many factors (the number of elements, the time needed to compare two elements,
the implementation of OpenMP’s tasks, …). The experimental way of choosing it
shall be, to some extent, covered in the forthcoming chapters.
There is an analogy with the sequential algorithm: recursion takes time as well
and to speed up the sequential Quicksort algorithm, the insertion sort is used once
the number of elements falls below a certain threshold.
There should be no confusion about the arguments for function par_qsort .
However, function par_qsort must be called within a parallel region by
exactly one thread as shown in Listing 3.28.
1 # p r a g m a omp p a r a l l e l
2 # p r a g m a omp s i n g l e
3 p a r _ q s o r t ( strings , 0 , n u m _ s t r i n g s - 1 , c o m p a r e ) ;
Listing 3.28 The call of the parallel implementation of the Quicksort algorithm.
As the Quicksort algorithm itself is rather efficient, i.e., it runs in time O(n log n),
a sufficient number of elements must be used to see that the parallel version actually
outperforms the sequential one. The comparison of running times is summarized in
Table 3.1. By comparing the running times of the sequential version with the parallel
version running within a single thread, one can estimate the time needed to create
and destroy OpenMP’s threads.
Using 4 or 8 threads the parallel version is definitely faster, although the speed
us consider the Quicksort algorithm up is not proportional to the number of threads
used. Note that the partition of the table in lines 4–14 of Listing 3.27 is performed
sequentially and recall the Amdahl law.
Table 3.1 The comparison of the running time of the sequential and parallel version of the Quick-
sort algorithm when sorting n random strings of max length 64 using a quad-core processor with
multithreading
n seq par
(1 thread) (4 threads) (8 threads)
105 0.05 s 0.07 s 0.04 s 0.04 s
106 0.79 s 0.99 s 0.44 s 0.32 s
107 11.82 s 12.47 s 4.27 s 3.57 s
108 201.13 s 218.14 s 71.90 s 61.81 s
Counting swaps during the partition phase in a sequential program is trivial. For
instance, as shown in Listing 3.29, three new variables can be introduced, namely
count, locount, and hicount that contain the number of swaps in the current
partition phase and the total numbers of swaps in recursive calls, respectively. (In
the sequential program, this could be done with a single counter, but having three
counters instead is more appropriate for the developing of the parallel version.)
Listing 3.29 The call of the parallel implementation of the Quicksort algorithm.
In the parallel version, the modification is not much harder, but a few things must
be taken care of. First, as recursive calls in lines 16 and 18 of Listing 3.27 change
to assignment statements in lines 19 in 21 of Listing 3.29, the values of variables
locount and hicount are set in two newly created tasks and must, therefore, be
shared among the creating and the created tasks. This is achieved using shared
clause in lines 18 and 20.
84 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
Second, remember that once the new tasks in lines 18–19 and 20–21 are created,
the task that has just created them continues. To prevent it from computing the sum of
all three counters and returning the result when variables locount and hicount
might not have been set yet, the taskwait directive is used. It represents an explicit
barrier: all tasks created by the task executing it must finish before that task can
continue.
At the end of the parallel section is an implicit barrier before all tasks
created within the parallel section must finish just like all iterations of a parallel
loop must. Hence, in Listing 3.24, there is no need for an explicit barrier using
taskwait.
Exercises
1. Modify the program in Listing 3.1 so that it uses a team of 5 threads within the par-
allel region by default. Investigate how shell variables OMP_NUM_THREADS and
OMP_THREAD_LIMIT influence the execution of the original and modified
program.
2. If run with one thread per logical core, threads started by the program in List-
ings 3.1 print out their thread numbers in random order while threads started
by the program in Listing 3.2 always print out their results in the same order.
Explain why.
3. Suppose two 100 × 100 matrices are to be multiplied using 8 threads. How many
dot products, i.e., operations performed by the innermost for loop, must each
thread compute if different approaches to parallelizing the two outermost for
loops of matrix multiplication illustrated in Fig. 3.6 are used?
4. Draw a 3D graph with the size of the square matrix along one independent axis,
e.g., from 1 to 100, and the number of available threads, e.g., from 1 to 16, along
the other showing the ratio between the number of dot products computed by
the most and the least loaded thread for different approaches to parallelizing the
two outermost for loops of matrix multiplication illustrated in Fig. 3.6.
3.5 Exercises and Mini Projects 85
5. Modify the programs for matrix multiplication based on different loop paral-
lelization methods to compute C = A · B T instead of C = A · B. Compare the
running time of the original and modified programs.
6. Suppose 4 threads are being used when the program in Listing 3.20 and max =
20. Determine which iteration will be performed by which thread if static,1,
static,2 or static,3 is used as a iteration scheduling strategy. Try without
running the program first. (Assume that iterations 1, 2 and 3 require 2, 3 and 4
units of time while all other iterations require just 1 unit of time.)
7. Suppose 4 threads are being used when the program in Listing 3.20 and
max = 20. Determine which iteration will be performed by which thread if
dynamic,1, dynamic,2 or dynamic,3 is used as a iteration scheduling
strategy. Is the solution uniquely defined? (Assume that iterations 1, 2 and 3
require 2, 3 and 4 units of time while all other iterations require just 1 unit of
time.)
8. Modify lines 12 and 13 in Listing 3.22 so that the program works correctly even
if T , the number of threads, does not divide max. The number of iterations of
the for loop in lines 15 and 16 should not differ by more than 1 for any two
threads.
9. Modify the program in Listing 3.22 so that the modified program implements
static,c iteration scheduling strategy instead of static as is the case in
Listing 3.22. The chunk size c must be a constant declared in the program.
10. Modify the program in Listing 3.22 so that the modified program implements
dynamic,c iteration scheduling strategy instead of static as is the case in
Listing 3.22. The chunk size c must be a constant declared in the program.
Hint: Use a shared counter of iterations that functions as a queue of not yet
scheduled iterations outside the parallel section.
11. While computing the sum of all elements of sums in Listing 3.23, the program
creates new threads within every iteration of the outer loop. Rewrite the code so
that creation of new threads in every iteration of the outer loop is avoided.
12. Try rewriting the programs in Listings 3.25 and 3.26 using parallel for loops
instead of OpenMP’s tasks to mimic the behavior of the original program as
close as possible. Find out which iteration scheduling strategy should be used.
Compare the running time of programs using parallel for loops with those that
use OpenMP’s tasks.
13. Modify the program in Listing 3.27 so that it does not use final but works in
the same way.
14. Check the OpenMP specification and rewrite the program in Listing 3.24 using
the taskloop directive.
Mini Projects
P1. Write a multi-core program that uses CYK algorithm [13] to parse a string of
symbols. The inputs are a context-free grammar G in Chomsky Normal Form
and a string of symbols. At the end, the program should print yes if the string
of symbols can be derived by the rules of the grammar and no otherwise.
86 3 Programming Multi-core and Shared Memory Multiprocessors Using OpenMP
Write a sequential program (no OpenMP directives at all) as well. Compare the
running time of the sequential program with the running time of the multi-core
program and compute the speedup for different grammars and different string
lengths.
Hint: Observe that in the classical formulation of CYK algorithm the iterations
of the outermost loop must be performed one after another but that iterations
of the second outermost loop are independent and offer a good opportunity for
parallelization.
P2. Write a multi-core program for the “all-pairs shortest paths” problem [5]. The
input is a weighted graph with no negative cycles and the expected output are
lengths of the shortest paths between all pairs of vertices (where the length of a
path is a sum of weights along the edges that the path consists of).
Write a sequential program (no OpenMP directives at all) as well. Compare the
running time of the sequential program with the running time of the multi-core
program and compute the speedup achieved
1. for different number of cores and different number of threads per core, and
2. for different number of vertices and different number of edges.
Hint 1: Take the Bellman–Ford algorithm for all-pairs shortest paths [5] and
consider its matrix multiplication formulation. For a graph G = V, E your
program should achieve at least time O(|V |4 ), but you can do better and achieve
time O(|V |3 log2 |V |). In neither case should you ignore the cache performance:
allocate matrices carefully.
Hint 2: Instead of using the Bellman–Ford algorithm, you can try parallelizing
the Floyd–Warshall algorithm that runs in time O(|V |3 ) [5]. How fast is the
program based on the Floyd–Warshall algorithm compared with the one that
uses the O(|V |4 ) or O(|V |3 log2 |V |) Bellman–Ford algorithm?
The primary source of information including all details of OpenMP API is available
at OpenMP web site [20] where the complete specification [18] and a collection of
examples [19] are available. OpenMP version 4.5 is used in this book as version 5.0
is still being worked on by OpenMP Architecture Review Board. The summary card
for C/C++ is also available at OpenMP web site.
As standards and specifications are usually hard to read, one might consider some
book wholly dedicated to programming using OpenMP. Although relatively old and
thus lacking the most of the modern OpenMP features, the book by Rohit Chandra
et al. [4] provides a nice introduction to underlying ideas upon which OpenMP is
based upon and the basic OpenMP constructs. A more recent and comprehensive
description of OpenMP, version 4.5, can be found in the book by Ruud van der Pas
et al. [21].
In OpenMP, it is crucial for loop variables to be private to each thread to prevent race conditions and ensure that each thread has its own instance of the loop variable. This isolation allows threads to execute independently without interfering with one another's execution paths, ensuring correct program output .
The independence of iteration results is critical in parallelizing loops with OpenMP because it ensures that no dependencies exist between iterations, preventing race conditions. When iterations are independent, they can be executed simultaneously without needing synchronization. This allows for direct and efficient parallel execution, as each thread can process its assigned iterations without waiting on or affecting other threads' computations .
Improperly parallelizing a loop in OpenMP can lead to incorrect program behavior if the loop's iterations are not truly independent. For example, in a loop where the value of a variable is carried over from one iteration to the next, parallel execution can result in race conditions, causing incorrect results. This occurs because simultaneous updates to the shared variable by multiple threads can lead to inconsistencies .
A drawback of using the default method of load distribution in parallelized nested loops is that it might lead to unequal workloads across threads. For example, if only the outer loop is parallelized, threads assigned to early loop iterations may complete their tasks quickly, causing them to wait idly while others finish. This inefficiency can lead to suboptimal utilization of processing resources .
By default, OpenMP implementations divide the iteration space of a loop into chunks, where each chunk is assigned to a thread. The specifics of chunk division, such as whether chunks are evenly distributed or their size, are left to the implementation's discretion if not specified by the programmer .
The collapse clause in OpenMP allows nested loops to be merged into a single loop, enabling them to be parallelized as a combined entity. This can balance workload distribution across threads more evenly compared to only parallelizing the outer loop. By treating the nested iterations as one loop, it reduces idle time and improves resource utilization .
An implicit barrier in OpenMP may be unnecessary when subsequent operations do not depend on the completion of all parallelized iterations. To eliminate this synchronization overhead, the nowait clause can be added to a parallel for directive. This allows threads to proceed independently to the next computation phase without waiting for others to finish their iterations .
The reduction clause in an OpenMP parallel loop is used to perform a specified associative operation across multiple iterations, accumulating results safely and efficiently. Each thread computes a partial result during its iterations, and these partial results are combined into a single final result at the end of the parallel region. Reduction helps handle dependencies that accumulate a variable, such as sums or products, without introducing race conditions .
A significant consideration when using random number generators in multi-threaded OpenMP programs is ensuring reentrancy and thread safety. Standard random generators such as rand are not thread-safe because they use a single, shared state that can lead to race conditions when accessed by multiple threads concurrently. To avoid this, each thread should have its own independent instance or seed for the random number generator, as implemented with a custom function where each thread modifies its local seed .
The random generator function in the Monte Carlo π calculation is made thread-safe in OpenMP by using a thread-local seed for each thread. Each thread maintains its own seed, which is modified independently of other threads. The function takes a pointer to the thread-specific seed, ensuring that state changes do not interfere with other threads' random number generation .