Parallelization
In computer programming parallelization is a way to speed a program up by spreading the calculations over more compute resources. The idea itself is rather old. For example, Amdahl published his paper suggesting his famous law about the most benefit you can get from parallelization in 1967[1], and Kuck et al. published a paper analyzing Fortran IV codes regarding the potential for parallel execution in 1972[2]. Nevertheless, Moore’s law[3], from 1965, meant that for a considerable amount of time one could just wait for computers to become faster rather than spend the effort to parallelize programs.
Still in the 1980s efforts towards parallel programming started that resulted in the Parallel Virtual Machine (PVM)[4] and Message Passing Interface (MPI)[5] approaches in the early 1990s. Today MPI still is the leading technology for distributed data parallel computing.
Another realization early on was that compilers can do parallelization for you. This realization led to efforts such as High Performance Fortran[6]. In principle compilers can even generate distributed data parallel codes. In practice having compilers write good MPI codes is highly non-trivial, and even Virtual Shared Memory models tend to have performance issues due to communication latencies[7]. Other problems include that static code analysis cannot resolve which loops are good targets for parallelization, and data dependencies can be difficult to analyze[8]. In addition successful porting efforts to parallel platforms typically require significant code rewrites to express the parallelism on a much higher level in the code design. The need to bring programmer knowledge into the code to direct the compiler’s parallelization efforts led to directive based approaches such as OpenMP[9]. The latest developments with OpenMP focus on offloading computation to accelerator devices such as GPUs[10].
Introducing the matrix-matrix multiplication
Given the more than 50 year long history of parallel computing it is a valid question to ask “where to start?”. This page illustrates currently leading approaches based on a matrix-matrix multiplication example. Matrix-matrix multiplication is a good example because:
The basic algorithm is simple and easily understood
It is an important step in many engineering applications
It requires a substantial amount of computation so parallelization is a meaningful thing to do
The algorithm is not completely trivial so some generally relevant issues will come up
The implementations shown here are available as mini apps[11]. The codes are written in Fortran, mainly because Fortran
supports convenient ways of using multi-dimensional arrays. There is no
fundamental reason why these examples could not be expressed in C or C++
(Python is a different story because the interpreter model imposes additional
restrictions, but MPI is available in through mpi4py[12].)
Finally, the codes provided here are truly examples. In a real application you should call an optimized library routine for matrix-matrix multiplication. The BLAS[13] collection of routines is a good place to find suitable routines. Here the matrix-matrix multiplication is explicitly coded so that the implementation can be modified.
In terms of matrices the calculation can be expressed as C := C + A * B. The
corresponding code is
do jj = 1, nsize
do kk = 1, nsize
do ii = 1, nsize
c(ii,jj) = c(ii,jj) + a(ii,kk)*b(kk,jj)
enddo
enddo
enddo
There are a few things to note here:
First of all, the three matrices are each \(O(N^2)\) in size, i.e. if the dimension of the matrix is
nsizethen you neednsize * nsizenumbers to store it.Second, the algorithm has a computational complexity of \(O(N^3)\). I.e. it takes
nsize * nsize * nsizeoperations to perform this calculation.Third , the loops are put in a particular order because of the way Fortran lays a matrix out in memory. In Fortran the distance in memory between
c(ii,jj)andc(ii+1,jj)is1, whereas the distance betweenc(ii,jj)andc(ii,jj+1)isnsize. The loops as shown here ensure the algorithm takes the smallest steps in memory which is typically beneficial for performance as it maximizes cache reuse. Note that in C/C++ the memory layout is the other way around, and you would want the inner loop to run overjjand the outer loop overii.
Distributed Data Parallelization with MPI
In the collection of Mini Apps the case called mpi-mxm
demonstrates a distributed data parallelization of the matrix-matrix
multiplication using MPI. Distributed data refers to the spreading of data
across all available compute resources. This way if you have \(P\) processors then
each processor only needs to hold about \(1/P\) of the data of the problem. At
the same time you can bring the compute power of \(P\) processors to bear on
the problem.
In order to split the problem over a potentially large number of processors
it is not sufficient to partition the matrices over just 1 index. Doing that
would limit the amount of processors the compute can be shared with to the
number of elements along one dimension, i.e. nsize in our case example. If
the matrices are split into blocks then, in principle, the compute could be
shared over nsize * nsize processors. To keep the algorithm simple it is
assumed that the number of processors is the square of an integer \(p\). If it isn’t
a square then the remaining processors will simply not be used. In addition,
if \(P = p^2\) we also assume that \(\mathrm{nsize} = n p\) where \(n\) is some whole
number. This means that all matrices can be broken down into \(p^2\) blocks that
are each \(n\)-by-\(n\) elements large.
Thus the matrices are partitioned as shown below. This example shows the distribution on a 4-by-4 processor grid (i.e. \(p=4\))
block-beta
columns 3
block
columns 1
block
C11["C<sub>0,0</sub>"] C21["C<sub>0,1</sub>"] C31["C<sub>0,2</sub>"] C41["C<sub>0,3</sub>"]
end
block
C12["C<sub>1,0</sub>"] C22["C<sub>1,1</sub>"] C32["C<sub>1,2</sub>"] C42["C<sub>1,3</sub>"]
end
block
C13["C<sub>2,0</sub>"] C23["C<sub>2,1</sub>"] C33["C<sub>2,2</sub>"] C43["C<sub>2,3</sub>"]
end
block
C14["C<sub>3,0</sub>"] C24["C<sub>3,1</sub>"] C34["C<sub>3,2</sub>"] C44["C<sub>3,3</sub>"]
end
end
block
columns 1
block
A11["A<sub>0,0</sub>"] A21["A<sub>0,1</sub>"] A31["A<sub>0,2</sub>"] A41["A<sub>0,3</sub>"]
end
block
A12["A<sub>1,0</sub>"] A22["A<sub>1,1</sub>"] A32["A<sub>1,2</sub>"] A42["A<sub>1,3</sub>"]
end
block
A13["A<sub>2,0</sub>"] A23["A<sub>2,1</sub>"] A33["A<sub>2,2</sub>"] A43["A<sub>2,3</sub>"]
end
block
A14["A<sub>3,0</sub>"] A24["A<sub>3,1</sub>"] A34["A<sub>3,2</sub>"] A44["A<sub>3,3</sub>"]
end
end
block
columns 1
block
B11["B<sub>0,0</sub>"] B21["B<sub>0,1</sub>"] B31["B<sub>0,2</sub>"] B41["B<sub>0,3</sub>"]
end
block
B12["B<sub>1,0</sub>"] B22["B<sub>1,1</sub>"] B32["B<sub>1,2</sub>"] B42["B<sub>1,3</sub>"]
end
block
B13["B<sub>2,0</sub>"] B23["B<sub>2,1</sub>"] B33["B<sub>2,2</sub>"] B43["B<sub>2,3</sub>"]
end
block
B14["B<sub>3,0</sub>"] B24["B<sub>3,1</sub>"] B34["B<sub>3,2</sub>"] B44["B<sub>3,3</sub>"]
end
end
In algorithm we need to choose how to distribute the compute. For this I have chosen to do all the compute associated with a block of matrix C on the processor that holds that block. You could make different choices here, for example, do everything associated with the local block of A on the processor that holds it. To compute the local block of C we need pairs of blocks from rows of A and columns of B as shown below.
block-beta
columns 3
block
columns 1
block
C11["C<sub>0,0</sub>"] C21["C<sub>0,1</sub>"] C31["C<sub>0,2</sub>"] C41["C<sub>0,3</sub>"]
end
block
C12["C<sub>1,0</sub>"] C22["C<sub>1,1</sub>"] C32["C<sub>1,2</sub>"] C42["C<sub>1,3</sub>"]
end
block
C13["C<sub>2,0</sub>"] C23["C<sub>2,1</sub>"] C33["C<sub>2,2</sub>"] C43["C<sub>2,3</sub>"]
end
block
C14["C<sub>3,0</sub>"] C24["C<sub>3,1</sub>"] C34["C<sub>3,2</sub>"] C44["C<sub>3,3</sub>"]
end
end
block
columns 1
block
A11["A<sub>0,0</sub>"] A21["A<sub>0,1</sub>"] A31["A<sub>0,2</sub>"] A41["A<sub>0,3</sub>"]
end
block
A12["A<sub>1,0</sub>"] A22["A<sub>1,1</sub>"] A32["A<sub>1,2</sub>"] A42["A<sub>1,3</sub>"]
end
block
A13["A<sub>2,0</sub>"] A23["A<sub>2,1</sub>"] A33["A<sub>2,2</sub>"] A43["A<sub>2,3</sub>"]
end
block
A14["A<sub>3,0</sub>"] A24["A<sub>3,1</sub>"] A34["A<sub>3,2</sub>"] A44["A<sub>3,3</sub>"]
end
end
block
columns 1
block
B11["B<sub>0,0</sub>"] B21["B<sub>0,1</sub>"] B31["B<sub>0,2</sub>"] B41["B<sub>0,3</sub>"]
end
block
B12["B<sub>1,0</sub>"] B22["B<sub>1,1</sub>"] B32["B<sub>1,2</sub>"] B42["B<sub>1,3</sub>"]
end
block
B13["B<sub>2,0</sub>"] B23["B<sub>2,1</sub>"] B33["B<sub>2,2</sub>"] B43["B<sub>2,3</sub>"]
end
block
B14["B<sub>3,0</sub>"] B24["B<sub>3,1</sub>"] B34["B<sub>3,2</sub>"] B44["B<sub>3,3</sub>"]
end
end
style C23 stroke-width:4px
style A13 stroke-width:4px
style A23 stroke-width:4px
style A33 stroke-width:4px
style A43 stroke-width:4px
style B21 stroke-width:4px
style B22 stroke-width:4px
style B23 stroke-width:4px
style B24 stroke-width:4px
So the pseudo code becomes for a processor at coordinates \(p_{row}\), \(p_{col}\) on the processor grid with dimension \(p\)
MPI parallel matrix-matrix multiplication
allocate local A-block buffer A_buf
allocate local B-block buffer B_buf
do pp = 0, p-1
if (pp == p_col) then
A_buf = Ap_row,p_col
end if
MPI_Bcast(A_buf,communicator(p_row))
if (pp == p_row) then
B_buf = Bp_row,p_col
end if
MPI_Bcast(B_buf,communicator(p_col))
do jj = 1, nsize_block
do kk = 1, nsize_block
do ii = 1, nsize_block
C-block(ii,jj) = C-block(ii,jj) + A_buf(ii,kk) * B_buf(kk,jj)
end do
end do
end do
end do
deallocate A_buf, B_buf
Where communicator(p_row) is the MPI communicator that includes all processors with the same
value for p_row. I.e. it is a communicator for all processors in the row p_row. Similarly
communicator(p_col) is the communicator for the column p_col.
MPI + OpenMP Parallelization
We saw that we can use MPI to parallelize across nodes. A remaining issue is that in an MPI code the number of messages we need to send increases with the number MPI processes. For example, if I run the matrix-matrix multiplication MPI code on a single CPU then I don’t need to do any communication, but I have to do \(\mathrm{nsize}^3\) multiplications. If I ran the code on \(\mathrm{nsize}^2\) CPUs then every CPU would do \(\mathrm{nsize}\) multiplications, but also \(2*\mathrm{nsize}\) MPI broad casts. So, for a matrix-matrix multiplication, if I distribute larger tasks I have less of a communication overhead, but at the same time each task involves more compute and therefore takes longer to complete.
One way to resolve this issue is instead of running a single MPI process per CPU to run a single
MPI process per node, for example. The time it takes to do the resulting large tasks can be reduced
by using OpenMP to accelerate the MPI processes. As MPI and OpenMP parallelization are independent
of eachother all we need to do is to put the two approaches together as shown below
(see also mpi-openmp-mxm)
MPI+OpenMP parallel matrix-matrix multiplication
allocate local A-block buffer A_buf
allocate local B-block buffer B_buf
do pp = 0, p-1
if (pp == p_col) then
A_buf = Ap_row,p_col
end if
MPI_Bcast(A_buf,communicator(p_row))
if (pp == p_row) then
B_buf = Bp_row,p_col
end if
MPI_Bcast(B_buf,communicator(p_col))
!$omp parallel do collapse(2) private(kk)
do jj = 1, nsize_block
do ii = 1, nsize_block
do kk = 1, nsize_block
C-block(ii,jj) = C-block(ii,jj) + A_buf(ii,kk) * B_buf(kk,jj)
end do
end do
end do
$!omp end parallel do
end do
deallocate A_buf, B_buf
One note worthy restriction on this approach is that MPI communicates between processes, it cannot communicate between threads. Therefore, you cannot place MPI calls inside an OpenMP parallel region! You will need to keep that in mind in your algorithm design.
MPI + OpenMP GPU off-loading
Sofar parallelization of matrix-matrix multiplications over CPUs has been discussed in various options. On the lastest high performance computing systems the CPUs unfortunately are the slowest resources available. Instead, the most compute power comes from accelerators such as GPUs. Hence it would be very beneficial if we could move most of the compute to the accelerator devices. One aspect to keep in mind though is that with current architectures the bandwidth between the host’s main memory and the device memory is relatively low. So moving data from the host to the device or from the device to the host is typically slow. Over time this is likely to become less important, in particular as improved device-to-device communications tend to reduce the need to communicate data with the host. At present, nevertheless, it is not worthwhile to move small tasks to the device. Conversely, when you have all the required data on the device already, it may also not be worthwhile to move small amounts of compute to the host, even if the compute is not suited to GPUs.
In recent times off loading compute to devices has been greatly facilitated by additions to the OpenMP
standard. In order to understand these additions it helps to know a little bit more about the
architecture of GPUs. Like any multi-core processor a GPU consists of a number of compute cores. On
regular multi-core processors every core is a fully fletched CPU. The down side of this is that a lot
of silicon and hence a lot of energy is spent on the control infrastructure that manages the
scheduling and execution of instructions. On GPUs, this has been addressed by having multiple cores
share their control infrastructure. On Nvidia GPUs 32 cores share their control infrastructure and
this unit is referred to as a warp. On AMD GPUs there are 64 cores to a warp. Intel GPUs use
wavefronts in a similar manner, except that you can control the number of cores per wavefront to some
extent. The important restriction that comes from sharing control infrastructure is that all cores
in a warp or wavefront must execute the same instructions. Therefore, the matrix-matrix
multiplication example discussed here is an excellent candidate because for every pair of ii and jj
the same dot-product (the loop over kk) is executed.
Algorithms with if-statements are less well suited. Consider the following code snippet:
do ii = 1, huge_number
if (d(ii) < 0.0) then
c(ii) = a(ii) * b(ii)
else
c(ii) = a(ii) + b(ii)
end if
end do
Now dependent on the values of d some cores in a warp may have to perform a multiplication and
others an addition. But because all cores in a warp have to do the same operation this situation has
to be resolved in a special way. The way this done is that all cores will do both instructions.
For example, all cores in the warp do the multiplication first and then the result on all cores where
d(ii) >= 0.0 is thrown away. Then all cores do the addition and the result on all cores where
d(ii) < 0.0 is thrown away. Clearly, this means that if your code contains if-statements that require
that different cores to do different computations (also referred to as branch-divergence) this becomes
very costly. The larger the number of branches, and the more compute there is to do in each branch,
the higher the impact on performance on GPUs. This is something to keep in mind when selecting
code sections to off-load to a GPU.
When it comes to off-loading compute to GPUs with OpenMP the directives do refer in some extent
to the GPU architecture. Instead of the usual parallel do we have to indicate that we are targeting
GPUs for the execution. In OpenMP the target construct is used for this purpose and its clauses
can be used to manage the data migration to and from the device. In addition there is the teams
construct to group threads into “teams” where each team executes on a warp. Put together using
OpenMP to off-load a matrix-matrix multiplication to a single GPU looks like
!$omp target map(tofrom: c) map(to: a, b)
!$omp teams distribute parallel do collapse(2) private(kk)
do jj = 1, nsize
do ii = 1, nsize
do kk = 1, nsize
c(ii,jj) = c(ii,jj) + a(ii,kk) * b(kk,jj)
end do
end do
end do
!$omp end teams distribute parallel do
!$omp end target
Here the map clauses are the main new thing. The tofrom clause indicates that c has to be
moved from the host to the device, then it can be changed on the device, and the result needs to be
moved back to the host. The other two matrices only need to be moved to the device, as indicated
with the to clause but as they don’t change there is no need to move those matrices back.
If you need an intermediate data structure on the device you can create it on the device
with alloc and destroy it when you’re done with delete. For example, a matrix transformation
\(C = B^T A B\) can be rewritten as \(D = A B; C = B^T D\) to lower the computational complexity
and off-loaded to the GPU as (see also openmp-offload-mxmxm
!$omp target enter data map(to: a, b) map(alloc: c, d)
!$omp target map(to: a, b, d)
!$omp teams distribute parallel do collapse(2) private(kk)
do jj = 1, nsize
do ii = 1, nsize
d(ii,jj) = 0.0
do kk = 1, nsize
d(ii,jj) = d(ii,jj) + a(ii,kk) * b(kk,jj)
end do
end do
end do
!$omp end teams distribute parallel do
!$omp end target
!$omp target map(to: b, d) map(from: c)
!$omp teams distribute parallel do collapse(2) private(kk)
do jj = 1, nsize
do ii = 1, nsize
c(ii,jj) = 0.0
do kk = 1, nsize
c(ii,jj) = c(ii,jj) + b(kk,ii) * d(kk,jj)
end do
end do
end do
!$omp end teams distribute parallel do
!$omp end target
!$omp target exit data map(delete: c, d)
Here target enter data and target exit data constructs have been added. These constructs allow
to move data to and from the device outside of OpenMP parallel regions. For example, it is possible
to have code to be executed on the host between target enter data and target, such as MPI calls
to move data between compute nodes.
In the target enter data construct there is an map alloc clause that arranges memory on the
GPU for matrices c and d. The following target construct has a map to clause that includes
d. Clearly, that is strange. Why do we need to tell OpenMP to move d to the device when it is
actually first calculated on the device? This is a consequence of the design fundamentals of OpenMP.
OpenMP uses compiler directives embedded in comments. An OpenMP capable compiler can translate these
directives and generate the code we want, but a compiler that is unaware of OpenMP should be able
to ignore these directives and still generate a correctly working code. This requirement that a
compiler that is not OpenMP aware or only supports part of an OpenMP standard still produces working
code requires special rules. One such rule is that there has to be a map clause for every variable
that is used in a device kernel. If no map clause is given then this is resolved by the compiler
implicitly inserting a map tofrom clause. So if we would have written
!$omp target enter data map(to: a, b) map(alloc: c, d)
!$omp target map(to: a, b)
then the compiler would read this as if we had written
!$omp target enter data map(to: a, b) map(alloc: c, d)
!$omp target map(to: a, b) map(tofrom: d)
because tofrom is the safe choice with respect to program correctness if the compiler ignores
part of OpenMP.
But this would mean that the program would move the data from d back to the host at the end
of the parallel do construct. As d is not needed on the host we don’t want the code to do that.
Therefore we are forced to explicitly provide a map clause that says something about d. When we say
map(to: d) then the code will check whether d is already on the device, which it is because we
have just created it there, and then decide that no data transfer is needed. At the end of the
parallel do no data transfer will happen either because we have said explicitly that we don’t
want d to be moved to the host (there is no from part in the map clause related to d).
So now, the data in d won’t be moved, which is what we wanted even
if we had to say this in a bit of a strange way.
Putting the OpenMP off-loading and the MPI framework together we get
(see also mpi-openmp-offload-mxm)
MPI+OpenMP parallel matrix-matrix multiplication
allocate local A-block buffer A_buf
allocate local B-block buffer B_buf
!$omp target enter data map(to: C-block)
do pp = 0, p-1
if (pp == p_col) then
A_buf = Ap_row,p_col
end if
MPI_Bcast(A_buf,communicator(p_row))
if (pp == p_row) then
B_buf = Bp_row,p_col
end if
MPI_Bcast(B_buf,communicator(p_col))
!$omp target map(to: A_buf, B_buf)
!$omp teams distribute parallel do collapse(2) default(shared) &
!$omp private(jj,ii,kk)
do jj = 1, nsize_block
do ii = 1, nsize_block
do kk = 1, nsize_block
C-block(ii,jj) = C-block(ii,jj) + A_buf(ii,kk) * B_buf(kk,jj)
end do
end do
end do
!$omp end teams distribute parallel do
!$omp end target
end do
!$omp target exit data map(from: C-block)
deallocate A_buf, B_buf
Note the use of target enter data and target exit data to move C-block to the device
before the loop over processors starts, keep it on the device throughout the processor loop,
and bring it back to the host after the loop has finished. Without this C-block would be moved
to and from the device for every off-load region.
OpenMP off-loading to multiple GPUs
The common approach with MPI+OpenMP off-loading is to run one MPI-process per device. This process
then uses OpenMP to off-load compute to the device. What if we want to use OpenMP on a node and
create a thread per device and off-load from those threads to the devices? That way we should be
able to use a single (MPI) process and use OpenMP to off-load to all the devices in a node. Naively,
one might expect this to work fully analogously to MPI+OpenMP off-loading, in that you simply use OpenMP
threads instead of MPI processes to off-load from. In practice this is slightly more complicated.
The reason is that MPI processes are separate processes that are independent from eachother except
for the MPI communication between them. Within OpenMP the threads are created in the context of an
overarching process, and the threads share a lot of the process state. As a result if one device
changes data in a variable for which a map tofrom clause was specified this triggers a
synchronization between the off-loaded kernels. At that moment the benefits of using multiple GPUs
is lost.
To avoid the problem described above we need to explicitly suppress synchronization by off-loading
the kernels asynchronously by using the nowait clause. The resulting code is
(see also openmp-offload-mxm)
num_devices = 1
#ifdef _OPENMP
num_devices = omp_get_num_devices()
#endif
num_per_thread = (nsize-1+num_devices)/num_devices
!$omp parallel do private(ijlo, ijhi)
do k_dev = 0, num_devices-1
ijlo = k_dev*num_per_thread+1
ijhi = min((k_dev+1)*num_per_thread,nsize)
!
! Nowait on the next line is essential to suppress synchronization between the threads that drive the GPUs
!$omp target device(k_dev) map(tofrom: c(:,ijlo:ijhi)) map(to: a, b(:,ijlo:ijhi), ijlo, ijhi, nsize) nowait
!$omp teams distribute parallel do collapse(2) default(shared) firstprivate(ijlo, ijhi, nsize) private(jj,ii,kk)
do jj = ijlo, ijhi
do ii = 1, nsize
do kk = 1, nsize
c(ii,jj) = c(ii,jj) + a(ii,kk)* &
b(kk,jj)
enddo
enddo
enddo
!$omp end teams distribute parallel do
!$omp end target
end do
!$omp end parallel do
!$omp taskwait
Note that we have to use the omp_get_num_devices() OpenMP API call to get the number of
devices available in the node. If we are not using an OpenMP capable compiler this function will
not be available. The #ifdef statement checks for the presence of the _OPENMP macro that
is set by OpenMP capable compilers. As a result this function will only be called when the
compiler supports it. To ensure code correctness for compilers that do not support OpenMP
we must initialize num_devices explicitly to 1.
In the parallelization of the loop over devices we break matrices c and b into blocks
by the second index. That way we off-load contiguous blocks of a data. Nevertheless we are
parallelizing only over one index, whereas before in the MPI case we had argued for parallelizing
over two indeces. The reason both approaches are reasonable is that with MPI you might create
thousands of MPI processes and therefore you want collectively enough iterations to parallelize
over that many processes. With OpenMP you parallelize just over the devices within a node. At
present the number of devices per node tends to be limited to about 8 (this is anno 2025, if Moore’s
law applies to the number of GPUs per node then there might be 256 GPUs per node by 2035, which
is not as crazy as it seems given that amplitUDE has 112 cores per node and one could envisage an
architecture where you have one GPU per CPU core). For most matrices partitioning them reasonably into
8 pieces is certainly feasible.
Another new clause here is the device clause. Every process has a default device. Therefore,
unless we state otherwise all OpenMP threads would connect to the same device. With the device
clause we can tell every thread to connect to its own device.
Finally, the nowait clause asynchronously off-loads the compute to the device. Nowait explicitly
removes all synchronization so that the device kernels run entirely independently from eachother.
As a side effect the code would immediately continue beyond the parallel do construct. Hence we
need the taskwait directive to tell OpenMP to wait until all off-loaded kernels have finished.
With this we have a single proces using OpenMP off-loading to multiple GPUs.
MPI + OpenMP off-loading to multiple GPUs
As a final step we might combine MPI with OpenMP off-loading but now using OpenMP to off-load
to multiple GPUs. The reason to want to do such a thing is the same that we have seen before in
section MPI+OpenMP. When we break a job up into fewer but larger MPI tasks then we need less
communication. Larger tasks tend to take longer to complete which tends to make load-balancing
worse.
We can mitigate the length of larger tasks by parallelizing those tasks over multiple threads.
The key difference with respect to OpenMP + Offloading to multiple devices is that in this
scenario we want to keep the parts of c on the device between MPI communications and successive
off-loads.
The resulting algorithm can be sketched as
(see also mpi-openmp-multiple-offload-mxm)
MPI+OpenMP parallel matrix-matrix multiplication
allocate local A-block buffer A_buf
allocate local B-block buffer B_buf
num_per_thread = (nsize_block-1+num_devices)/num_devices
!$omp parallel do private(ijlo, ijhi)
do k_dev = 0, num_devices-1
ijlo = k_dev*num_per_thread+1
ijhi = min((k_dev+1)*num_per_thread,nsize_block)
!$omp target enter data device(k_dev) map(to: c(:,ijlo:ijhi)) nowait
enddo
!$omp end parallel do
!$omp taskwait
do pp = 0, p-1
if (pp == p_col) then
A_buf = Ap_row,p_col
end if
MPI_Bcast(A_buf,communicator(p_row))
if (pp == p_row) then
B_buf = Bp_row,p_col
end if
MPI_Bcast(B_buf,communicator(p_col))
!$omp parallel do private(ijlo, ijhi)
do k_dev = 0, num_devices-1
ijlo = k_dev*num_per_thread+1
ijhi = min((k_dev+1)*num_per_thread,nsize_block)
!$omp target device(k_dev) map(to: A_buf, B_buf(:,ijlo:ijhi))
!$omp teams distribute parallel do collapse(2) default(shared) &
!$omp private(jj,ii,kk)
do jj = ijlo, ijhi
do ii = 1, nsize_block
do kk = 1, nsize_block
C-block(ii,jj) = C-block(ii,jj) + A_buf(ii,kk) * B_buf(kk,jj)
end do
end do
end do
!$omp end teams distribute parallel do
!$omp end target
end do
!$omp end parallel do
!$taskwait
end do
!$omp parallel do private(ijlo, ijhi)
do k_dev = 0, num_devices-1
ijlo = k_dev*num_per_thread+1
ijhi = min((k_dev+1)*num_per_thread,nsize_block)
!$omp target exit data device(k_dev) map(from: c(:,ijlo:ijhi)) nowait
enddo
!$omp end parallel do
!$omp taskwait
deallocate A_buf, B_buf
With this we have explored every combination of MPI and OpenMP, apart from a mixed model. In a mixed model one could imagine using OpenMP threads to off-load to multiple devices and combine that with further OpenMP threads to do the compute on CPUs. Such a model would need a way to address the difference in the compute power between devices and CPUs. Addressing this issue might prove non-trivial. Furthermore the compute power of modern devices is so much larger than that of CPUs that this model might not make a lot of sense regardless.