Everything your solver has done in parallel so far has happened inside a single computer. Coarrays in
Prerequisites
- 5
- 8
- 24
- 31
- 32
- 33
Learning Objectives
- Explain the MPI process/communicator/rank model and write the launch-and-teardown skeleton (`mpi_init`, `mpi_comm_rank`, `mpi_comm_size`, `mpi_finalize`) with the `ierr` last-argument convention.
- Exchange data between two processes with `mpi_send` and `mpi_recv`, get the buffer/count/datatype/dest/tag/comm argument order exactly right, and recognise and fix the classic send/recv deadlock.
- Choose the right collective operation — `mpi_bcast`, `mpi_reduce`, `mpi_allreduce`, `mpi_gather`, `mpi_scatter` — for a communication pattern instead of hand-rolling it from point-to-point calls.
- Decompose the heat plate across processes with a 1D domain decomposition, add ghost (halo) rows, and exchange them each step deadlock-free with `mpi_sendrecv` (and understand the non-blocking `mpi_isend`/`mpi_irecv`/`mpi_wait` variant).
- Build and launch an MPI program with `mpif90` and `mpirun -np N`, and say what MPI-IO and hybrid MPI+OpenMP add.
In This Chapter
- Overview
- Learning Paths
- 34.1 The MPI Model: Processes, Communicators, and Ranks
- 34.2 Point-to-Point Communication: Send, Receive, and Deadlock
- 34.3 Collective Communication: Broadcast, Reduce, Gather, Scatter
- 34.4 Domain Decomposition and Halo Exchange
- 34.5 Running MPI Programs: mpif90, mpirun, and a Note on MPI-IO
- Project Checkpoint
- Summary
- Spaced Review
- What's Next
Chapter 34: MPI — Distributed-Memory Parallelism for Cluster Computing
"MPI is the assembly language of parallel computing." — a common saying among HPC programmers
Overview
Everything your solver has done in parallel so far has happened inside a single computer. Coarrays in Chapter 32 split the plate across images, and OpenMP in Chapter 33 turned the update loop loose on the threads of one node — both models where, ultimately, there is one shared pool of memory that the parallel workers can reach. That model has a hard ceiling: a single node, however fat, holds only so many cores and so much RAM. When the weather service runs a global forecast, or the astrophysicist evolves a galaxy, the computation does not fit on one machine and never will. It runs across a cluster — hundreds or thousands of separate computers, each with its own private memory, wired together by a fast network. To program that machine you need a way for those separate computers to cooperate, and for thirty years the answer, across essentially every supercomputer on Earth, has been one thing: MPI, the Message Passing Interface.
This is the chapter where your solver leaves the single node behind and becomes a genuine cluster code. It is also, not coincidentally, the model that runs the production Fortran named in Chapter 1 — WRF, CESM, and the quantum-chemistry and astrophysics codes that consume most of the cycles on the TOP500 machines are, overwhelmingly, Fortran plus MPI. Learning MPI is learning the lingua franca of real high-performance computing. It is more work than OpenMP — there is no sprinkling a directive on a loop; you must decide who owns which data and write every exchange by hand — but that explicitness is exactly what lets it scale to a million cores when shared-memory models cannot. Fortran is not dead; it is running, right now, on the largest machines humanity has built, and MPI is how it talks to itself across them.
The intellectual core of the chapter is a single pattern — domain decomposition with halo exchange — that you have been quietly prepared for since Chapter 24. The five-point stencil there reaches only to a cell's four nearest neighbours. Cut the plate into strips, give each process one strip, and the only thing a process needs from its neighbours is the single row of cells along each shared edge. Exchange those edge rows each step and every process can march its own strip forward as if it owned the whole plate. That is the whole idea, and it is how a stencil code — the workhorse of computational physics — is distributed across a cluster.
In this chapter, you will learn to:
- Describe the MPI model — many processes, each with private memory, coordinated by messages — and write
the
mpi_init…mpi_finalizeskeleton that every MPI program shares. - Send and receive data between processes with point-to-point calls, get the argument order exactly right, and diagnose and cure the notorious send/recv deadlock.
- Reach for the right collective — broadcast, reduce, all-reduce, gather, scatter — instead of building those patterns by hand.
- Decompose the heat plate across processes, wrap each piece in ghost cells, and exchange halos
each step with
mpi_sendrecv, deadlock-free — the Project Checkpoint that makes your solver a cluster code. - Compile with
mpif90, launch withmpirun -np N, and place MPI-IO and hybrid MPI+OpenMP on the map.
Learning Paths
How to read this chapter by track. - ⚡ HPC ("I need cluster code") — this is the chapter you came for; read every section. §34.4 (domain decomposition and halo exchange) and the Project Checkpoint are the pattern behind essentially every distributed stencil code in production. Hand-trace the small example; that is where the understanding lives. - 🔬 Scientist ("my simulation outgrew one machine") — read §34.1 for the model, §34.4 for how a grid is split and stitched, and the Checkpoint. Skim §34.2–34.3 for the vocabulary you will meet in others' code. - 📖 Standard — read straight through. MPI is a library, not part of the Fortran standard, so this chapter is about a binding — how the Fortran interface expresses the message-passing model. - 🔧 Legacy ("I inherited an MPI code") — §34.1–34.3 decode the
mpi_*calls you are staring at, and the ⚠️ deadlock pitfall in §34.2 explains the hang you may have hit. §34.5 covers theuse mpivsmpi_f08interface question you will face when modernising it.
34.1 The MPI Model: Processes, Communicators, and Ranks
Start with what is different about this model, because it is the source of everything else. In OpenMP, several threads shared one address space: a variable written by one thread could be read by another, and the whole difficulty was keeping them from colliding. MPI is the opposite world. Here the parallel workers are not threads but processes — separate running programs, each with its own private memory — and no process can touch another's variables at all. If process 0 has a value that process 1 needs, process 0 must package it up and send it, and process 1 must receive it. There is no shared state; there is only communication. This is the distributed-memory model from Chapter 31, and MPI is its dominant expression.
Definition (MPI). MPI — the Message Passing Interface — is a standardised library for distributed-memory parallel programming, in which many independent processes, each with private memory, coordinate by explicitly sending and receiving messages. It is not part of any language; it is a specification (first standardised in 1994) with bindings for Fortran, C, and C++, implemented by libraries such as Open MPI and MPICH. A Fortran program uses it by
use mpiand by callingmpi_*procedures. MPI is the substrate of nearly all large-scale scientific computing: when a code runs across the thousands of nodes of a cluster, MPI is almost always what carries the data between them.
Every MPI program follows the same shape. You start the MPI runtime, ask it who you are and how many processes there are, do your work with that knowledge, and shut MPI down. Here is that skeleton — the "hello, world" of message passing — and it already introduces the four calls you will write in every MPI program you ever build.
program mpi_hello
use mpi
implicit none
integer :: ierr, rank, nprocs
call mpi_init(ierr) ! start the MPI runtime
call mpi_comm_rank(MPI_COMM_WORLD, rank, ierr) ! which process am I? (0-based)
call mpi_comm_size(MPI_COMM_WORLD, nprocs, ierr) ! how many of us are there?
print '(a,i0,a,i0)', 'Hello from rank ', rank, ' of ', nprocs
call mpi_finalize(ierr) ! shut the MPI runtime down
end program mpi_hello
$ mpif90 -std=f2018 -Wall -O2 example-01-hello.f90 -o hello && mpirun -np 4 ./hello
Hello from rank 0 of 4
Hello from rank 1 of 4
Hello from rank 2 of 4
Hello from rank 3 of 4
Several things in those eleven lines deserve unpacking, because they define the whole model.
One program, many processes. You wrote one program, but mpirun -np 4 launched four copies of it,
running at the same time, each in its own memory. Every copy executes the identical source — the same
print statement runs four times — yet they are not clones, because each one gets a different answer from
mpi_comm_rank. This is the SPMD model: Single Program, Multiple Data. One program text, run by
many processes, each specialising its behaviour based on which process it is. Almost all MPI programs are
SPMD; the if (rank == 0) branch that does something special on one process is the model's signature move.
Definition (rank). A rank is a process's integer identity within a group of MPI processes, numbered from
0tonprocs - 1. It is how a process knows which of the running copies it is, and how other processes address it: to send data "to rank 2" is to name its rank.mpi_comm_rankreturns the calling process's own rank;mpi_comm_sizereturns how many processes are in the group. Between them, those two numbers are the entire basis on which an SPMD program divides its work — rank 0 takes the first slice, rank 1 the next, and so on.
Communicators name the group. Notice MPI_COMM_WORLD in both queries. That is a communicator, and
it is MPI's way of naming a set of processes that can talk to each other.
Definition (communicator). A communicator is an MPI object identifying a group of processes and providing a private communication context for them. Every message is sent within a communicator, and a rank is only meaningful relative to one — "rank 2" means rank 2 in this communicator. The predefined
MPI_COMM_WORLDcontains every process the program was launched with, and it is the only communicator most programs ever need. You can create sub-communicators to split the processes into cooperating teams (say, one per subdomain), but that is an advanced move; for this chapter, communicator meansMPI_COMM_WORLD. In theuse mpiinterface a communicator is just a defaultintegerhandle.
The ierr last-argument convention. Every mpi_* call ends with an extra integer argument, ierr,
into which MPI writes a status code (MPI_SUCCESS on success). This is the Fortran binding's universal
convention and it is not optional in the use mpi interface — leave it off and the call is wrong. It
looks alien beside modern Fortran's intent(out) results, and it is: MPI is a C-flavoured library from 1994,
and the Fortran binding mirrors the C functions' integer return codes as a trailing argument. In practice
almost nobody checks ierr (a failed MPI call usually aborts the whole job anyway), but you must always
pass it. We will meet the more modern mpi_f08 interface, which makes ierr genuinely optional, in §34.5.
⚠️ Common Pitfall — forgetting
ierr, or checking the wrong thing. The single most common beginner error in Fortran MPI is omitting the trailingierrargument —call mpi_init()will not compile against theuse mpiinterface, andcall mpi_comm_rank(MPI_COMM_WORLD, rank)is missing its last argument. Everympi_*subroutine in this chapter takesierrlast, without exception. A subtler trap: the valuerankis written into the second-to-last argument andierrinto the last, sompi_comm_rank(comm, rank, ierr)— get those two in the wrong order and you will "successfully" read the error code into your rank variable and wonder why every process thinks it is rank 0.📜 From History: how MPI came to be. Before 1994 every parallel-computer vendor shipped its own incompatible message-passing library, so a code written for one machine had to be rewritten for the next — a catastrophe for scientific software meant to outlive the hardware. The MPI Forum, a consortium of vendors, national labs, and academics, met through 1993–94 to agree on a single portable interface, and MPI-1 was the result. The bet paid off spectacularly: three decades later, the same MPI calls compile and run on a laptop and on the largest supercomputer in the world. It is the same standardisation instinct that keeps fifty-year-old Fortran running (Chapter 1's ⭐ From History) — the unglamorous act of agreeing on an interface is what lets scientific software accumulate instead of evaporate.
🔄 Check Your Understanding. 1. In the MPI model, can process 1 read a variable that process 0 declared and set? How does it obtain the value if it needs it? 2. What do
mpi_comm_rankandmpi_comm_sizereturn, and why does an SPMD program need both? 3. Why doescall mpi_init()fail to compile against theuse mpiinterface?
Answers
(1) No — each process has private memory; there is no shared state. Process 0 mustmpi_sendthe value and process 1 mustmpi_recvit. (2)mpi_comm_rankreturns the calling process's own rank (its identity, 0-based);mpi_comm_sizereturns the total number of processes. An SPMD program runs one identical text on all processes, so each must learn which it is (rank) and how many there are (size) to take its own slice of the work. (3) Everympi_*call requires the trailingierrstatus argument;mpi_init()is missing it. The correct call iscall mpi_init(ierr).
34.2 Point-to-Point Communication: Send, Receive, and Deadlock
With processes launched and each knowing its rank, the first real question is how two of them exchange data. The most basic operation is point-to-point communication: one named process sends a message, one named process receives it.
Definition (point-to-point communication). Point-to-point communication is the exchange of a message between exactly two processes — one calls a send operation naming the receiver, the other calls a matching receive operation naming the sender. It is the fundamental building block of MPI; every more elaborate pattern can, in principle, be built from it. The core pair is
mpi_sendandmpi_recv.
Here is the archetype: rank 0 packages an array and sends it to rank 1, which receives it and works with it.
program point_to_point
use mpi
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer :: ierr, rank, nprocs
integer :: status(MPI_STATUS_SIZE) ! receive status (use mpi interface)
real(dp) :: buf(3)
call mpi_init(ierr)
call mpi_comm_rank(MPI_COMM_WORLD, rank, ierr)
call mpi_comm_size(MPI_COMM_WORLD, nprocs, ierr)
if (rank == 0) then
buf = [10.0_dp, 20.0_dp, 30.0_dp]
call mpi_send(buf, 3, MPI_DOUBLE_PRECISION, 1, 0, MPI_COMM_WORLD, ierr)
! buf count datatype dest tag comm ierr
else if (rank == 1) then
call mpi_recv(buf, 3, MPI_DOUBLE_PRECISION, 0, 0, MPI_COMM_WORLD, status, ierr)
! buf count datatype src tag comm status ierr
print '(a,f6.1)', 'rank 1 received, sum = ', sum(buf)
end if
call mpi_finalize(ierr)
end program point_to_point
$ mpif90 -std=f2018 -Wall -O2 example-02-send-recv.f90 -o pp && mpirun -np 2 ./pp
rank 1 received, sum = 60.0
The two calls are the heart of MPI, so learn their arguments cold. mpi_send takes, in order: the
buffer (the data), the count (how many elements), the datatype, the destination rank, a
tag, the communicator, and ierr. mpi_recv takes the same first three, then the source
rank, the tag, the communicator, a status, and ierr. Six things must line up for a message to be
delivered, and getting any of them wrong is the source of most MPI bugs:
| Argument | Role | Rule |
|---|---|---|
| buffer | the data itself | any type; passed by reference |
| count | number of elements | the send count and recv count must be compatible (recv buffer at least as large) |
| datatype | the element type | MPI_DOUBLE_PRECISION for real(dp), MPI_INTEGER for integer, MPI_REAL for default real |
| dest / source | the other rank | send names where to, recv names where from |
| tag | a label on the message | send and recv tags must match (or recv uses MPI_ANY_TAG) |
| communicator | the process group | send and recv must use the same communicator |
The datatype deserves a word, because it is where Fortran meets MPI's C heritage. MPI does not know your
Fortran kinds; you must tell it, by hand, what kind of element you are sending, using one of its named
constants. Our project's reals are real(dp) with dp = selected_real_kind(15, 307), i.e. IEEE double
precision, so the matching MPI datatype is MPI_DOUBLE_PRECISION. Send an array of real(dp) and
declare it MPI_INTEGER, and MPI will faithfully copy the wrong bytes and hand the receiver garbage — with
no error, because MPI trusts you. This is the first of several places where MPI is powerful and unforgiving
in equal measure.
💡 Intuition: think of
mpi_send/mpi_recvas posting a parcel. The buffer is what is in the box; the count and datatype are the customs declaration ("three double-precision reals"); the destination is the address; the tag is a reference number written on the outside so the recipient can tell this parcel apart from others you sent them; and the communicator is the postal system you are using. The parcel is delivered only when all of these agree between sender and receiver. The status the receiver gets back is the delivery slip — it records who really sent it and under what tag, useful when you accepted a parcel fromMPI_ANY_SOURCE.
The deadlock trap
Now the mistake that every MPI programmer makes exactly once, and never forgets. Suppose two neighbouring processes each need to give the other a row of data — precisely the halo exchange your solver will need. The obvious code has each process send its row first, then receive the neighbour's:
! BOTH rank 0 and rank 1 run this -- and it can HANG FOREVER
call mpi_send(mine, n, MPI_DOUBLE_PRECISION, other, 0, MPI_COMM_WORLD, ierr)
call mpi_recv(yours, n, MPI_DOUBLE_PRECISION, other, 0, MPI_COMM_WORLD, status, ierr)
This looks symmetric and correct, and it is a deadlock waiting to happen.
⚠️ Common Pitfall — the send/recv deadlock (and why it "works" in testing). When both processes call
mpi_sendfirst, each blocks inside its send waiting for the other to receive — but neither can reach itsmpi_recv, because both are stuck inmpi_send. Each waits for the other forever; the program hangs with no error. The vicious part is that it often runs fine in testing.mpi_sendis "standard mode," which means MPI may copy small messages into an internal buffer and return immediately (the "eager" protocol), so for a few elements both sends complete, both recvs run, and everything works. Cross the implementation's buffer threshold — a large enough row, which real problems always have — andmpi_sendswitches to the "rendezvous" protocol, blocking until the matching recv is posted, and the code deadlocks in production after passing every small test. Correctness must never depend on MPI buffering it for you. There are three clean fixes:```fortran ! Fix 1 -- order the calls: one side sends-then-receives, the other receives-then-sends. if (mod(rank,2) == 0) then call mpi_send(mine, n, MPI_DOUBLE_PRECISION, other, 0, MPI_COMM_WORLD, ierr) call mpi_recv(yours, n, MPI_DOUBLE_PRECISION, other, 0, MPI_COMM_WORLD, status, ierr) else call mpi_recv(yours, n, MPI_DOUBLE_PRECISION, other, 0, MPI_COMM_WORLD, status, ierr) call mpi_send(mine, n, MPI_DOUBLE_PRECISION, other, 0, MPI_COMM_WORLD, ierr) end if
! Fix 2 -- ONE call that does both, which MPI guarantees will not deadlock: call mpi_sendrecv(mine, n, MPI_DOUBLE_PRECISION, other, 0, & ! send half yours, n, MPI_DOUBLE_PRECISION, other, 0, & ! recv half MPI_COMM_WORLD, status, ierr) ```
mpi_sendrecvexists for exactly this situation, so prefer it: it is one call, it cannot deadlock, and it reads as what it is — an exchange. (Fix 3, non-blockingmpi_isend/mpi_irecv, comes in §34.4.) The project's halo exchange usesmpi_sendrecv.
mpi_sendrecv bundles a send and a receive into a single deadlock-free call. Its argument list is just
mpi_send's followed by mpi_recv's, sharing one communicator and status at the end: send buffer, count,
type, dest, sendtag; then recv buffer, count, type, source, recvtag; then comm, status, ierr. It is the
right tool for any structured pairwise exchange, and it is the backbone of stencil halo swapping.
🐛 Find the Bug. A colleague's two-process exchange runs perfectly on their laptop with a test grid of 100 cells per row, then hangs on the cluster with 4000 cells per row. They swear "nothing changed but the problem size." What is happening, and what is the one-line change that fixes it for good?
Answer
It is the send-first/recv-second deadlock above. At 100 elements the message fit under the MPI implementation's eager-buffer threshold, sompi_sendreturned immediately and the code worked; at 4000 elements the message exceeds the threshold,mpi_sendswitches to rendezvous mode and blocks until a matching recv is posted, and since both ranks are stuck inmpi_send, they wait forever. The fix is to replace the separate send-then-recv with a singlempi_sendrecv(or to order the calls so one side receives first). The bug was always there; the larger problem merely exposed it — which is why you never rely on buffering for correctness.🔄 Check Your Understanding. 1. List the seven arguments of
mpi_send, in order. 2. Why can a send-then-receive exchange pass every small test and then deadlock on a large production run? 3. What single call replaces a paired send-and-receive and is guaranteed not to deadlock?
Answers
(1) buffer, count, datatype, destination rank, tag, communicator,ierr. (2)mpi_sendis standard mode: for small messages MPI may buffer the data and return immediately, so both sends complete and the recvs run; for large messages it blocks until the matching recv is posted, and if both ranks send first, neither ever posts a recv — deadlock. Correctness must not depend on buffering. (3)mpi_sendrecv.
34.3 Collective Communication: Broadcast, Reduce, Gather, Scatter
Point-to-point is the foundation, but a great deal of what parallel programs actually do is not one process
talking to one other — it is all the processes cooperating in a pattern: everyone needs the same
configuration value; everyone contributes a partial sum that must be totalled; one process holds an array
that must be dealt out to all. You could build these from loops of mpi_send/mpi_recv, and it would be
tedious, slow, and buggy. MPI provides them directly, as collective operations.
Definition (collective communication). A collective operation is one that all processes in a communicator call together, cooperating in a single communication pattern — broadcasting, combining, gathering, or scattering data across the whole group. Every process must make the matching call (a collective that some processes skip will hang), and in return MPI implements the pattern efficiently, often with clever tree algorithms that no hand-rolled loop of point-to-point calls would match. Collectives are both clearer (one call states the whole intent) and faster than doing it yourself.
The five you will use constantly:
| Collective | What it does | Data flow |
|---|---|---|
mpi_bcast |
one process's value is copied to all | one → all (same value) |
mpi_scatter |
one process's array is split into pieces, one per process | one → all (different pieces) |
mpi_gather |
each process's piece is collected into one process's array | all → one |
mpi_reduce |
each process's value is combined (sum, max, …) into one process | all → one (combined) |
mpi_allreduce |
like reduce, but every process gets the combined result | all → all (combined) |
Here is a program using the two most common — a broadcast to hand everyone the same starting value, and an all-reduce to combine a per-process contribution into a global total that everyone then knows:
program collectives
use mpi
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer :: ierr, rank, nprocs
real(dp) :: base, mine, total, biggest
call mpi_init(ierr)
call mpi_comm_rank(MPI_COMM_WORLD, rank, ierr)
call mpi_comm_size(MPI_COMM_WORLD, nprocs, ierr)
if (rank == 0) base = 100.0_dp ! only the root has a meaningful value...
call mpi_bcast(base, 1, MPI_DOUBLE_PRECISION, 0, MPI_COMM_WORLD, ierr) ! ...now everyone does
mine = real(rank + 1, dp) ! each rank contributes rank+1
call mpi_allreduce(mine, total, 1, MPI_DOUBLE_PRECISION, MPI_SUM, MPI_COMM_WORLD, ierr)
call mpi_allreduce(mine, biggest, 1, MPI_DOUBLE_PRECISION, MPI_MAX, MPI_COMM_WORLD, ierr)
if (rank == 0) then
print '(a,f6.1)', 'broadcast base = ', base
print '(a,f6.1)', 'sum of (rank+1) over all ranks = ', total
print '(a,f6.1)', 'max of (rank+1) over all ranks = ', biggest
end if
call mpi_finalize(ierr)
end program collectives
$ mpif90 -std=f2018 -Wall -O2 example-03-collectives.f90 -o coll && mpirun -np 4 ./coll
broadcast base = 100.0
sum of (rank+1) over all ranks = 10.0
max of (rank+1) over all ranks = 4.0
Work the output by hand to trust it, and note which parts depend on the process count. mpi_bcast copies
rank 0's base = 100.0 to every process, so all four now hold 100.0. Each rank sets mine = rank + 1, so
the four contributions are 1, 2, 3, 4; mpi_allreduce with MPI_SUM totals them to 1+2+3+4 = 10, and
with MPI_MAX returns the largest, 4. With -np N the sum would be N(N+1)/2 and the max would be N
— those two numbers are process-count-dependent; the broadcast value 100.0 is not. The crucial word is
all-reduce: after the call, every process holds total and biggest, not just rank 0. Had we used
mpi_reduce (which takes an extra root argument), only the named root would hold the answer and the others
would have garbage — the right choice when only one process needs the result, e.g. to print it.
The argument shapes are worth fixing in memory, because they differ in small but important ways:
mpi_bcast(buffer, count, datatype, root, comm, ierr)— one buffer, played fromrootto all. No separate send/recv buffers: the root'sbufferis the source, everyone else'sbufferis the destination.mpi_reduce(sendbuf, recvbuf, count, datatype, op, root, comm, ierr)— separate in/out buffers, an operation (MPI_SUM,MPI_MAX,MPI_MIN,MPI_PROD, …), and aroot; the result lands only onroot.mpi_allreduce(sendbuf, recvbuf, count, datatype, op, comm, ierr)— identical to reduce but with noroot, because everyone gets the result.
⚡ Performance Note — collectives are not just convenient, they are fast. A naive "sum everyone's value onto rank 0" written by hand sends
N - 1messages one after another to rank 0, taking time proportional toN. A good MPI library implementsmpi_reduceas a tree: pairs combine, then pairs of pairs, and so on, finishing in time proportional to $\log_2 N$ — for 1024 processes, 10 steps instead of 1023. The library also knows the machine's network topology and can route the combination along it. This is the same lesson as Chapter 21's "don't hand-roll what a tuned library does better": performance is not accidental, and a collective hands the implementation the whole pattern to optimise, exactly as a whole-array operation hands the compiler the whole loop. Always prefer a collective to a hand-built equivalent.🐍 Python Comparison. The same patterns exist in mpi4py, the Python binding:
comm.bcast,comm.reduce,comm.allreduce,comm.Scatter,comm.Gather. mpi4py is genuinely useful for orchestration and for the parts of a workflow that are not performance-critical — and, true to this book's refrain, the productive pattern is Fortran and Python better together: run the heavy stencil in Fortran+MPI and script the launch, the parameter sweep, or the post-processing in Python. But for the inner loop of a real simulation, the Fortran binding wins decisively, because there is no interpreter between your code and the network, and because the data is already in the contiguous, column-major arrays MPI wants to send. Python is the conductor; Fortran+MPI is the orchestra.🔄 Check Your Understanding. 1. What is the difference between
mpi_reduceandmpi_allreduce, and when would you choose each? 2. In the program above, if you launch with-np 8, what will the sum and max lines print? 3. Why must every process in the communicator call a collective, even one that seems to only "receive"?
Answers
(1)mpi_reducedelivers the combined result to a singlerootprocess only;mpi_allreducedelivers it to every process. Use reduce when one process needs the answer (say, to print or write it); use allreduce when every process needs it to proceed (say, a global convergence test each step). (2) Sum $= 1+2+\dots+8 = N(N+1)/2 = 36$; max $= N = 8$ — both scale with the process count. (3) A collective is a single cooperative operation across the whole group; MPI's algorithm (e.g. a tree) needs every process to participate at its point in the pattern. If one process skips its call, the others wait for it forever — the collective hangs.
34.4 Domain Decomposition and Halo Exchange
Now we assemble the pieces into the pattern that distributes a stencil code — and it is the same pattern whether the code is your heat solver, a global weather model, or a galaxy simulation. The idea is domain decomposition: cut the physical domain into pieces, give one piece to each process, and let each process compute its own piece while exchanging just enough boundary data to keep the stencil fed.
Definition (domain decomposition). Domain decomposition is the strategy of dividing a simulation's spatial domain into subdomains, assigning one subdomain to each process, and having each process compute the update for its own subdomain. It is the dominant way to parallelise grid-based physics on distributed memory. Because a finite-difference stencil couples only nearby points, a subdomain's update needs data from other subdomains only along its shared boundaries — so the communication is small (boundaries) relative to the computation (interiors), which is exactly why the approach scales.
Recall the geometry. Your plate is a 2D array; its five-point stencil (Chapter 24) computes each interior cell from its four nearest neighbours. Cut the plate into horizontal strips, one per process — rank 0 takes the top strip, rank 1 the next, and so on down. Each process now owns a block of rows and can update almost all of them from data it already holds. The only problem is the cells along each cut: to update its topmost owned row, a process needs the row just above it, which lives on the neighbour above; to update its bottom row, it needs the row just below, on the neighbour below. Those two borrowed rows are the whole of the communication.
The standard device for holding borrowed boundary data is the ghost cell.
Definition (ghost cell). A ghost cell (or halo cell) is a grid cell a process stores but does not own — a copy of a neighbouring subdomain's boundary data, kept so the stencil can reach "across" the subdomain edge without special-casing it. A process surrounds its owned block with a one-cell-thick layer of ghost cells (a halo), fills that layer each step with the neighbour's current edge values, and then runs the ordinary interior update over its owned cells — the stencil reads the ghosts exactly as if they were real neighbours. The act of refreshing the halo each step is the halo exchange (or halo swap).
Here is the layout for one process's strip. We store it as u(nx, 0:nloc+1): the first index runs across the
full plate width (nx columns), and the second index is the process's local row number, with owned rows
1..nloc bracketed by a ghost row at 0 (above) and nloc+1 (below):
one process's strip, stored as u(nx, 0:nloc+1)
+---------------------------------------------+
| u(:,0) GHOST row (copy of neighbour ↑) | <- filled by halo exchange
+---------------------------------------------+
| u(:,1) owned row 1 \ |
| u(:,2) owned row 2 | the rows |
| ... | I update |
| u(:,nloc) owned row nloc / |
+---------------------------------------------+
| u(:,nloc+1) GHOST row (copy of neighbour ↓) | <- filled by halo exchange
+---------------------------------------------+
🚪 Threshold Concept — a process computes as if it owned the whole grid. The power of the ghost-cell idea is that it localises the parallelism. Once the halo is filled, the interior update on a process is byte-for-byte the same serial stencil from Chapter 24 — it reads
u(i,k-1)andu(i,k+1)without ever knowing or caring that some of those rows are ghosts copied from another machine. All the distributed-memory complexity is quarantined into one routine, the halo exchange, that runs before each update; the physics kernel never changes. This is why the same solver survives every parallel model in this part: coarrays, OpenMP, and MPI all reduce to "get the neighbour's edge data into place, then run the ordinary update." Once you see a parallel stencil code as serial-update-plus-halo-exchange, every such code — including the ones running the world's weather — reads as a variation on this one structure.
Why store the strip with the split (row) direction as the second index, and the full width as the first?
Because of column-major order (Chapter 5). In
Fortran the first index varies fastest through memory, so a whole row in this layout — u(:, k), the thing
we exchange — is a block of nx contiguous reals. That means each halo message is a plain contiguous
buffer we can hand straight to mpi_sendrecv with count = nx, no derived MPI datatypes, no packing. This
is arrays as Fortran's superpower in a distributed setting: laying the field out to respect the memory
order makes the communication trivial. (In C, which is row-major, you would split the other way, into
column strips, for the same reason — the memory layout decides the decomposition.)
Now the exchange itself. Each process has an "up" neighbour (rank − 1) and a "down" neighbour (rank + 1),
except the top and bottom strips, which run into the physical edge of the plate. MPI has a beautiful device
for the edges: MPI_PROC_NULL, a null rank. A send to or receive from MPI_PROC_NULL is a no-op that
returns immediately — so if we set the missing neighbour to MPI_PROC_NULL, the same exchange code runs on
every process with no if for the boundaries, and the physical-edge ghost rows (holding the fixed Dirichlet
boundary) are simply never overwritten:
subroutine exchange_halos(u, nx, nloc, up, down, comm)
use mpi
use, intrinsic :: iso_fortran_env, only: dp => real64
real(dp), intent(inout) :: u(nx, 0:nloc+1) ! owned rows 1..nloc, ghosts 0 and nloc+1
integer, intent(in) :: nx, nloc, up, down, comm
integer :: ierr, status(MPI_STATUS_SIZE)
! send my top owned row up; receive my top ghost from up (one tag: source rank disambiguates)
call mpi_sendrecv(u(:,1), nx, MPI_DOUBLE_PRECISION, up, 0, &
u(:,0), nx, MPI_DOUBLE_PRECISION, up, 0, &
comm, status, ierr)
! send my bottom owned row down; receive my bottom ghost from down
call mpi_sendrecv(u(:,nloc), nx, MPI_DOUBLE_PRECISION, down, 0, &
u(:,nloc+1), nx, MPI_DOUBLE_PRECISION, down, 0, &
comm, status, ierr)
end subroutine exchange_halos
Two mpi_sendrecv calls — one swapping with the neighbour above, one with the neighbour below — and every
process's halo is current. We use a single tag (0) for all four messages: because each call names both a
specific destination and a specific source rank, the rank already tells each message apart, so no separate
tags are needed here. (Tags earn their keep when several messages of different kinds flow between the same
pair of ranks — then a distinct tag on each keeps them from being mismatched.) Because mpi_sendrecv cannot
deadlock, this is safe for any strip size; because MPI_PROC_NULL neutralises the edges, there is not a
single boundary special-case. The neighbours are
computed once at setup:
up = rank - 1; if (rank == 0) up = MPI_PROC_NULL
down = rank + 1; if (rank == nprocs - 1) down = MPI_PROC_NULL
💡 Intuition — surface versus volume, and why this scales. A strip's computation is proportional to its area (all the cells it owns); its communication is proportional to its perimeter (the edge rows it swaps). As you use more processes on a fixed plate, each strip gets thinner, and the ratio of perimeter to area grows — you spend relatively more time talking and less computing. This surface-to-volume ratio is the fundamental economics of domain decomposition: it scales well as long as each subdomain stays fat enough that its interior work dwarfs its boundary exchange. Cut the plate into too many too-thin strips and communication overtakes computation — precisely the "rising overhead" Case Study 1 of Chapter 31 diagnosed with the Karp–Flatt metric. The cure is 2D decomposition (square tiles have less perimeter per unit area than thin strips), but 1D strips are the right place to learn the pattern, and they scale well to the modest process counts most runs use.
Non-blocking exchange: overlapping communication with computation
mpi_sendrecv is blocking: the process waits inside it until the exchange is done, doing nothing useful
meanwhile. On a large run, the network latency of that wait is pure overhead. The remedy is non-blocking
communication: start the sends and receives, go compute something that does not depend on the halo (the
strip's deep interior), and only wait for the exchange to finish when you finally need the ghost rows (for
the edge rows of the update).
Definition (non-blocking communication). A non-blocking MPI operation —
mpi_isend,mpi_irecv(theiis for immediate) — initiates a send or receive and returns at once, handing you a request handle instead of waiting for completion. The data is not safe to use until you complete the operation withmpi_wait(ormpi_waitallfor several requests). The point is overlap: between thei-call and thewait, the process is free to do independent work, so the communication happens underneath the computation instead of stalling it — often hiding the network cost entirely.
The pattern for the halo, sketched:
integer :: reqs(4), stats(MPI_STATUS_SIZE, 4), ierr
call mpi_irecv(u(:,0), nx, MPI_DOUBLE_PRECISION, up, 1, comm, reqs(1), ierr) ! ghost from up
call mpi_irecv(u(:,nloc+1), nx, MPI_DOUBLE_PRECISION, down, 0, comm, reqs(2), ierr) ! ghost from down
call mpi_isend(u(:,1), nx, MPI_DOUBLE_PRECISION, up, 0, comm, reqs(3), ierr) ! my top row up
call mpi_isend(u(:,nloc), nx, MPI_DOUBLE_PRECISION, down, 1, comm, reqs(4), ierr) ! my bottom row down
call update_deep_interior(u) ! rows 2..nloc-1 need no ghosts -- compute NOW, overlapping
call mpi_waitall(4, reqs, stats, ierr) ! now the ghosts have arrived...
call update_edge_rows(u) ! ...update rows 1 and nloc, which needed them
Post the receives first (so the incoming data has somewhere to land the instant it arrives), post the
sends, compute the part of the update that needs no ghosts, then mpi_waitall and finish the edges. On a
big enough strip the interior work fully hides the exchange, and the halo becomes almost free. The Project
Checkpoint uses the simpler mpi_sendrecv; the exercises build this non-blocking version, which is the form
production codes use.
⚠️ Common Pitfall — using a non-blocking buffer before
mpi_wait. The buffer you hand tompi_isendormpi_irecvis off-limits until the matchingmpi_waitreturns: reading a receive buffer early gives you stale data, and modifying a send buffer early can corrupt the message in flight. The whole point of non-blocking is that the transfer happens later, so touching the data before you have waited is a race. Do independent work between thei-call and thewait; never touch the halo buffers themselves until aftermpi_waitall.🔗 Connection — this is how real models run. The halo exchange you just wrote is, in structure, identical to what happens inside WRF, CESM, and every other production grid code from Chapter 1 — decompose the globe into tiles, give each MPI rank a tile, exchange halos of atmospheric state each timestep. The coarray version you built in Chapter 32 expresses the same decomposition with the same halo, but reaches the neighbour's data through a coindexed reference
u(:, :)[q]instead of an explicit message; the physics is identical, only the delivery mechanism differs. Understanding one is most of understanding the other — and understanding this pattern is most of understanding how distributed scientific computing works at all.🔄 Check Your Understanding. 1. What is a ghost cell, and why does adding a halo let a process run the unmodified serial stencil? 2. Why is
MPI_PROC_NULLuseful at the top and bottom strips? 3. What does non-blocking communication (mpi_isend/mpi_irecv+mpi_wait) buy you overmpi_sendrecv, and what must you not do before thewait?
Answers
(1) A ghost cell is a stored copy of a neighbour subdomain's boundary data that the process does not own. With a one-cell halo filled each step, the interior update readsu(i,k±1)uniformly — some of those are ghosts, but the stencil neither knows nor cares — so the exact serial Chapter 24 update runs unchanged. (2) A send/recv toMPI_PROC_NULLis a no-op, so setting the missing neighbour of the top and bottom strips to it lets the identical exchange code run everywhere with no boundaryif, and leaves the physical-edge ghost rows (holding the fixed Dirichlet values) untouched. (3) Overlap: you can compute the ghost-independent interior while the exchange is in flight, hiding the network cost. You must not read or write the halo buffers passed tompi_isend/mpi_irecvuntil the matchingmpi_wait/mpi_waitallreturns.
34.5 Running MPI Programs: mpif90, mpirun, and a Note on MPI-IO
MPI is a library, so building an MPI program means compiling and linking against it. You could invoke
gfortran with the right -I include path and -l link flags, but every MPI installation ships a
compiler wrapper that does it for you.
Definition (
mpif90).mpif90(alsompifort) is the MPI Fortran compiler wrapper: it calls your underlying Fortran compiler — usuallygfortran— with all the MPI include and library flags added automatically. You use it exactly as you wouldgfortran, passing the same flags:mpif90 -std=f2018 -Wall -O2 mycode.f90 -o mycode. It is not a different compiler, justgfortranwith MPI's paperwork filled in, so everything you know about optimisation flags (Chapter 30) still applies.
To run it, you do not launch the executable directly — you launch it through mpirun (or mpiexec),
which starts the requested number of process copies and wires up their communication:
$ mpif90 -std=f2018 -Wall -O2 project-checkpoint.f90 -o heat_mpi
$ mpirun -np 4 ./heat_mpi
-np 4 launches four processes. On a real cluster you would not type mpirun at a shell at all; you would
submit a batch script to a scheduler (Slurm's srun, PBS, LSF), which places the processes across the
allocated nodes for you — but the program is identical, and mpirun -np N on your laptop is the right way to
develop and test it. A subtlety worth stating: -np can exceed your physical core count (the processes
time-share), which is how you test an 8-process decomposition on a 4-core laptop — useful, though of course
you will not see a speedup that way.
⚡ Performance Note — MPI ranks and physical cores, and the hybrid model. For real performance you want roughly one MPI process per physical core (or per node, in the hybrid model below), and you want ranks that exchange halos placed near each other on the network. The scheduler handles placement, but the principle matters: MPI's cost is the network, so a decomposition that keeps neighbours physically close and messages large-and-few beats one that scatters them and chatters. This is the distributed-memory face of the same truth as cache locality on one core — performance is not accidental; it comes from respecting where the data physically lives, whether "far" means a different cache line or a different rack.
Hybrid MPI + OpenMP. The two models you have learned compose. A modern cluster node has many cores
sharing memory; a cluster has many such nodes. The scalable pattern is hybrid: run one MPI process per
node (which handles the between-node halo exchange), and inside each process use OpenMP
(Chapter 33) to spread the strip's update loop across that node's cores.
MPI crosses the nodes; OpenMP fills each node. Your solver is already most of the way there — the OpenMP
step from Chapter 33 and the MPI halo exchange from this chapter drop into the same program, MPI around the
outside and OpenMP inside. The capstone in
Chapter 38 assembles exactly this.
A note on MPI-IO. One serial bottleneck remains: output. If every process sends its strip to rank 0 to write one file, rank 0 becomes a funnel and the write is serial — the 2% that Chapter 31's Amdahl estimate warned would cap your speedup.
Definition (MPI-IO). MPI-IO is the part of the MPI standard for parallel file I/O: it lets all the processes write to (or read from) a single shared file concurrently, each into its own region, so the output does not funnel through one rank. Combined with a parallel file system, it keeps I/O from becoming the serial bottleneck on large runs. In practice you rarely call raw MPI-IO; you use it through a self-describing library — parallel HDF5 or parallel NetCDF (Chapter 25) — which is built on MPI-IO and gives you portable, metadata-rich files as well as parallel writes. For the project, gathering to rank 0 is fine at small scale; MPI-IO (via parallel HDF5) is the answer when it is not.
🔗 Connection — where MPI comes from. MPI is not something you install per project; it is a system library — Open MPI or MPICH — that a cluster provides, and that you can install on a workstation from your package manager. It is one of the pillars of the Fortran-adjacent numerical ecosystem named in Chapter 16, alongside LAPACK, FFTW, and NetCDF/HDF5. The full MPI reference for this book — the calls, datatypes, and the
use mpiversusmpi_f08question — is collected in Appendix G, the parallel-programming reference.🔧 Modern vs Legacy —
use mpiversusmpi_f08. You have three ways to access MPI from Fortran, in ascending modernity. The ancientinclude 'mpif.h'is a raw text include with no compile-time checking — you will see it in old codes; avoid it in new ones. Theuse mpimodule (which this chapter uses) is the F90-style module: handles are plainintegers,ierris mandatory, and the compiler checks argument counts. The modernuse mpi_f08module (from MPI-3, mirroring Fortran 2008) is the one to prefer in new code: handles become real derived types —type(MPI_Comm),type(MPI_Datatype),type(MPI_Status),type(MPI_Request)— so the compiler catches a communicator used where a datatype was meant, andierrbecomes a genuinely optional final argument. The same skeleton inmpi_f08:
fortran program hello_f08 use mpi_f08 ! modern interface: typed handles, optional ierr implicit none integer :: rank, nprocs call mpi_init() ! ierr is OPTIONAL now -- omit it call mpi_comm_rank(MPI_COMM_WORLD, rank) call mpi_comm_size(MPI_COMM_WORLD, nprocs) print '(a,i0,a,i0)', 'Hello from rank ', rank, ' of ', nprocs call mpi_finalize() end program hello_f08We teach with
use mpibecause it is what you will most often encounter — the overwhelming majority of existing Fortran MPI code, and most tutorials, use it — and because theierrconvention it forces on you is worth seeing plainly once. For code you write fresh, reach formpi_f08. Modern Fortran is a modern language, and its MPI binding has kept pace.
Project Checkpoint
This is the checkpoint the whole part has been aiming at: your solver becomes a distributed program that runs across MPI processes, each owning a strip of the plate and exchanging halos each step. The physics does not change — the five-point stencil and the FTCS update are exactly Chapter 24's — and, crucially, neither does the answer: a distributed run computes the identical numbers a single-process run would, because the halo exchange simply reconstructs, by message, the neighbour rows a serial program has for free.
We build a self-contained MPI heat solver. Each process stores its strip as u(nx, 0:nloc+1) with ghost
rows at 0 and nloc+1; the global top edge (rank 0's upper ghost) is held at 100, the bottom edge
(rank nprocs−1's lower ghost) and the two side columns at 0 — Dirichlet everywhere, matching the plate
of Chapter 1. Each step is: exchange halos, then run the ordinary interior update. Here is the core (the full
program, with setup and a gather-to-rank-0 for output, is in code/project-checkpoint.f90):
do step = 1, nsteps
call exchange_halos(u, nx, nloc, up, down, MPI_COMM_WORLD) ! refresh ghost rows (§34.4)
u_new = u
do k = 1, nloc ! owned rows
do i = 2, nx-1 ! interior columns (column-major: i inner)
u_new(i,k) = u(i,k) + r*( u(i-1,k) + u(i+1,k) & ! east + west
+ u(i,k-1) + u(i,k+1) & ! north + south (may be ghosts)
- 4.0_dp*u(i,k) ) ! - 4*centre (identical to Ch.24)
end do
end do
u(:,1:nloc) = u_new(:,1:nloc) ! commit owned rows; ghosts refreshed next step
end do
The update loop is Chapter 24's stencil verbatim; the only addition is the exchange_halos call that
precedes it. That is the entire distributed-memory cost, and it is quarantined in one routine.
A hand-computed run. Take a plate 5 columns wide and 6 rows tall (top row 100, all other edges 0),
$\alpha = 1$, $\Delta x = \Delta y = 1$, so $r = \alpha\,\Delta t/h^2 = 0.2$ with $\Delta t = 0.2$ (safely
under the CFL limit $r \le 1/4$). Run on 2 processes, so each owns 2 interior rows: rank 0 owns global
rows 2–3, rank 1 owns global rows 4–5. Marching three steps, the assembled interior (gathered to rank 0 for
printing) is:
after step 1 after step 2 after step 3
100 100 100 100 100 100 100 100 100 100 100 100 100 100 100
0 20 20 20 0 0 28 32 28 0 0 32.8 38.4 32.8 0 <- rank 0
0 0 0 0 0 0 4 4 4 0 0 7.2 8.8 7.2 0 <- rank 0
0 0 0 0 0 0 0 0 0 0 0 0.8 0.8 0.8 0 <- rank 1 (halo!)
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 <- rank 1
0 0 0 0 0 0 0 0 0 0 0 0 0 0 0
Trace the moment the halo earns its keep. Through steps 1 and 2, heat has spread only within rank 0's rows;
rank 1's rows are still all zero, and — note — rank 0's step-2 interior, 28 32 28 and 4 4 4, is the same
pattern Chapter 24 computed for its plate, now produced by two cooperating processes. At the start of step 3,
exchange_halos sends rank 0's bottom owned row (global row 3, now 0 4 4 4 0) down into rank 1's upper
ghost. Rank 1's update of global row 4 then reads that ghost: for its centre column,
$0 + 0.2\,(4 + 0 + 0 + 0 - 0) = 0.8$. That 0.8 exists only because the halo carried rank 0's row across
the process boundary — without the exchange, rank 1 would see a zero ghost and its row would stay cold,
silently computing the wrong physics. The full program also calls mpi_allreduce with MPI_MAX each step on
the maximum cell change, giving every process a global convergence measure — a second, honest use of a
collective.
Honesty note. The temperatures above are exact and deterministic: run on 1, 2, or 3 processes, this solver prints the identical field, because domain decomposition changes how the work is divided, not what is computed. What is not deterministic, and what this book never presents as measured, is timing and the interleaving of process output — those depend on the machine and the run. The result is physics; the schedule is illustrative.
This is the distributed heat solver. Chapter 35 offers the optional GPU variant, and the Chapter 38 capstone assembles the fully hybrid MPI+OpenMP solver, validates it, and presents it as a paper. Your solver now runs on a cluster.
Summary
This chapter took the running solver off a single node and onto a distributed-memory cluster with MPI — the model that runs essentially all large-scale scientific computing.
| Idea | The short version |
|---|---|
| MPI model | Many processes, each with private memory, coordinating by explicit messages. SPMD: one program, run by all, specialised by rank. |
| The skeleton | mpi_init(ierr) → mpi_comm_rank(MPI_COMM_WORLD, rank, ierr) / mpi_comm_size(…, nprocs, ierr) → work → mpi_finalize(ierr). Every mpi_* call ends in ierr. |
| Communicator / rank | A communicator (MPI_COMM_WORLD) names a process group; a rank (0…N−1) is a process's identity within it. |
| Point-to-point | mpi_send(buf, count, type, dest, tag, comm, ierr) and mpi_recv(buf, count, type, src, tag, comm, status, ierr). Six things must match. |
| Deadlock | Both ranks mpi_send-first can hang (works for small messages, deadlocks for large). Fix with mpi_sendrecv or by ordering the calls. |
| Collectives | mpi_bcast (one→all), mpi_scatter (deal out), mpi_gather (collect), mpi_reduce (combine→root), mpi_allreduce (combine→all). Faster than hand-rolling; all ranks must call. |
| Domain decomposition | Cut the domain into subdomains, one per process; each computes its own, exchanging only shared boundaries. |
| Ghost cell / halo | A stored copy of a neighbour's edge data; refresh it each step (halo exchange) and the unmodified serial stencil runs. |
| Halo exchange | Two mpi_sendrecv calls (up and down), MPI_PROC_NULL for the physical edges — deadlock-free, no boundary special-case. |
| Non-blocking | mpi_isend/mpi_irecv + mpi_wait(all) overlap communication with interior computation; do not touch the buffers before the wait. |
| Build & run | mpif90 (a gfortran wrapper) compiles; mpirun -np N launches N processes. Hybrid: one MPI rank per node × OpenMP within. MPI-IO for parallel output. |
The two things to memorize. First, the mpi_send argument order — buffer, count, datatype,
destination, tag, communicator, ierr — and that mpi_recv inserts a status before ierr; getting this
wrong is the most common MPI bug, and the deadlock cure is mpi_sendrecv. Second, the shape of a distributed
stencil code: decompose into strips, wrap each in ghost cells, exchange the halo each step, then run the
ordinary serial update — the pattern that scales a finite-difference solver from your laptop to a
supercomputer, unchanged in its physics.
Spaced Review
Retrieval practice on the two parallel models this chapter joins — coarrays (Chapter 32) and OpenMP (Chapter 33). Answer before expanding.
-
(Chapter 32.) Coarrays and MPI both distribute the plate with the same 1D decomposition and the same halo. In one sentence each, how does a process reach its neighbour's edge row in each model, and which is part of the Fortran language itself?
Answer
In **coarrays**, a process (image) reaches the neighbour's data directly through a *coindexed reference* — e.g. `u(:, :)[q]` — and the compiler/runtime turns that into a local read or a network transfer; coarrays are part of the Fortran *standard* (since Fortran 2008). In **MPI**, the process sends and receives the edge row with an explicit *message* (`mpi_sendrecv`); MPI is an external *library*, not part of the language. Same decomposition and same halo, different delivery mechanism — implicit coindexing versus explicit message. -
(Chapter 33.) OpenMP parallelised the update loop with a directive and no change to the data layout, whereas MPI required you to split the array across processes and add ghost cells. What is the one property of shared versus distributed memory that forces that difference?
Answer
**Shared memory.** Under OpenMP all threads see the *same* single copy of the field in one address space, so parallelising the loop needs no data partitioning — every thread already has every cell. MPI processes have *private* memory and cannot see each other's arrays at all, so the field must be physically split, and any cell a process needs but does not own must be *copied in* as a ghost and refreshed by message. Distributed memory is what forces decomposition and halos; shared memory does not. -
(Chapters 32 & 33.) A colleague asks why they should not just use OpenMP for everything, since it is so much less code than MPI. Give the one-sentence hardware reason MPI exists, and name the hybrid scheme that uses both.
Answer
OpenMP is confined to the shared memory of a *single node* — it cannot use more cores or more RAM than one machine has — so when a problem needs many nodes of a cluster, you must cross to distributed memory, which is MPI's domain. The scalable combination is the **hybrid MPI+OpenMP** model: one MPI process per node handles the between-node halo exchange, and OpenMP threads fill each node's cores with the update loop. -
(Chapter 33.) In OpenMP you feared a data race when two threads wrote the same cell; the MPI solver in this chapter has no such fear inside a process. Why does the distributed-memory model make data races on the field impossible — and what replaces that worry?
Answer
Because each process has *private* memory and updates only its *own* owned rows, no two processes ever write the same cell — there is nothing shared to race over. What replaces the data-race worry is the *communication* correctness worry: you must exchange halos at the right time (before each update) and match every send with a receive, or a process computes from stale or missing neighbour data. Shared memory trades communication for race conditions; distributed memory trades race conditions for communication.
What's Next
Your solver now spans a cluster, but there is one more class of hardware it has not touched: the GPU, a processor with thousands of tiny cores built to do the same operation on enormous quantities of data at once — which is exactly what your stencil sweep is. Chapter 35 closes Part VIII by offloading the update to a graphics processor with OpenACC directives and, for the adventurous, CUDA Fortran — the last parallel model in the book, and the one that packs the most arithmetic into the least silicon. Same solver, same stencil, a different kind of chip. Let's put a GPU to work.