Case Study 2: A Convergence-Driven, Non-Blocking Distributed Solver
"A good parallel program does two things at once: it computes, and it hides the fact that it is also communicating."
Executive Summary
The Project Checkpoint gave you a distributed heat solver that marches a fixed number of steps and exchanges
halos with the simple, blocking mpi_sendrecv. That is the right place to start and the wrong place to stop.
Real solvers do not march a guessed number of steps; they run until the physics settles — until the plate
reaches steady state — and they decide collectively, because a distributed simulation has no single process
that can see the whole field. And real solvers do not sit idle inside a blocking exchange while the network
works; they overlap the communication with computation that does not need it. This study builds both. You
will design a global convergence test using mpi_allreduce — so every process stops on the same step,
together — and then restructure the halo exchange to be non-blocking, splitting the update so the strip's
deep interior is computed while its edge rows are still in flight. The result is a solver that is both
self-terminating and latency-hiding: the shape of code that runs on real clusters. Where Case Study 1
repaired a broken solver, this one designs a better one, one tier up.
Skills applied: mpi_allreduce for a global reduction every process needs (§34.3); why all-reduce, not
reduce, for a stopping decision (§34.3); non-blocking mpi_irecv/mpi_isend/mpi_waitall and
computation/communication overlap (§34.4); the surface-to-volume argument for when overlap pays (§34.4, 💡);
verifying determinism against process count (Project Checkpoint honesty note).
Background
We start from the Checkpoint solver: each process owns a strip u(nx, 0:nloc+1), exchanges halos, and applies
the Chapter 24 stencil. Two design goals drive the redesign:
- Stop at steady state, not at a guessed step count. The heat equation relaxes toward a steady field where nothing changes; the natural stopping rule is "halt when the largest temperature change anywhere on the plate, in one step, falls below a tolerance." The catch: anywhere on the plate spans every process, so no single process can evaluate the rule alone.
- Hide the halo behind computation. Inside a blocking
mpi_sendrecv, the process waits on the network. If instead it starts the exchange, computes the rows that do not depend on the incoming ghosts, and only then waits, the network latency disappears under useful work — at least when there is enough interior work to hide it.
Phase 1 — A Global Convergence Test with mpi_allreduce
Each process can compute its own largest cell change in a step, local_max = maxval(abs(u_new − u)) over
its owned rows. The stopping rule needs the global maximum over all strips — and, crucially, every
process must learn it, because every process runs the time loop and every process must decide, identically,
whether to take another step. If only one process knew, the others would march on and the program would
diverge into ranks doing different numbers of steps — a deadlock waiting to happen at the next collective.
This is precisely the case mpi_allreduce exists for: combine a value across all processes and give the
result to all of them.
local_max = maxval(abs(u_new(:,1:nloc) - u(:,1:nloc))) ! my biggest change this step
call mpi_allreduce(local_max, global_max, 1, MPI_DOUBLE_PRECISION, &
MPI_MAX, MPI_COMM_WORLD, ierr) ! every rank now holds the global max
if (global_max < tol) exit ! ...so every rank exits TOGETHER
Using mpi_reduce here would be a bug: only the root would hold global_max, so only the root would see the
exit condition, and the others would loop forever waiting at the next mpi_allreduce for a root that has
already left. The stopping decision must be unanimous, so the reduction must be an all-reduce. This is
the distributed-memory face of a simple truth: a collective decision needs collective information.
Phase 2 — The Convergence Loop, and What It Prints
Here is the redesigned time loop (the full program is a direct extension of code/project-checkpoint.f90,
adding the tolerance test and a step cap for safety):
do step = 1, max_steps
call exchange_halos(u, nx, nloc, up, down) ! ghosts first (Case Study 1's lesson)
call apply_stencil(u, u_new, nx, nloc, r) ! Chapter 24 update over owned rows
local_max = maxval(abs(u_new(:,1:nloc) - u(:,1:nloc)))
call mpi_allreduce(local_max, global_max, 1, MPI_DOUBLE_PRECISION, MPI_MAX, MPI_COMM_WORLD, ierr)
u(:,1:nloc) = u_new(:,1:nloc)
if (rank == 0) print '(a,i0,a,f6.1)', 'step ', step, ' global max change = ', global_max
if (global_max < tol) then
if (rank == 0) print '(a,i0,a)', 'converged at step ', step, ' (max change below tolerance)'
exit
end if
end do
Run it on the Checkpoint's $5 \times 6$ plate (top edge 100, $r = 0.2$), on 2 processes, with a tolerance of
1.0 and a generous step cap. The global max change is the same sequence you hand-computed for the
Checkpoint — 20.0, then 12.0, then 6.4 — decreasing monotonically as the plate approaches steady state:
$ mpif90 -std=f2018 -Wall -O2 heat_converge.f90 -o hc && mpirun -np 2 ./hc
step 1 global max change = 20.0
step 2 global max change = 12.0
step 3 global max change = 6.4
... (continues; each step's change is the global MAX across both strips, via allreduce)
converged at step NN (max change below tolerance)
The first three numbers are exact and hand-checkable (they are the Checkpoint's); the step NN at which the
change finally drops below 1.0 depends on running the full relaxation, which we do not tabulate here because
the point is the mechanism, not the count. What matters is that the change is a single global number every
process agrees on, computed by mpi_allreduce, so all processes stop on the same step. Change to -np 1 or
-np 4 and the printed sequence of global max changes is identical — the reduction combines the same
per-cell changes regardless of how the strips are divided.
Honesty note. We present the per-step max change (a computed quantity we can verify) but never a per-step time. How long each step takes, and how the two processes interleave, depends on the machine and is not something this study measured. The physics is deterministic; the schedule is not.
Phase 3 — Non-Blocking Halo Exchange
Now the latency-hiding redesign. The blocking exchange_halos makes the process wait on the network before
it does any update. But observe which rows actually need the ghosts: only the edge rows (local rows 1
and nloc) read u(:,0) and u(:,nloc+1). The deep interior rows (2 through nloc-1) read only owned
data — they can be updated the instant the step begins, without waiting for anything. So we split the work:
! post the receives first (landing spots ready), then the sends -- none of these block
call mpi_irecv(u(:,0), nx, MPI_DOUBLE_PRECISION, up, 1, MPI_COMM_WORLD, reqs(1), ierr)
call mpi_irecv(u(:,nloc+1), nx, MPI_DOUBLE_PRECISION, down, 0, MPI_COMM_WORLD, reqs(2), ierr)
call mpi_isend(u(:,1), nx, MPI_DOUBLE_PRECISION, up, 0, MPI_COMM_WORLD, reqs(3), ierr)
call mpi_isend(u(:,nloc), nx, MPI_DOUBLE_PRECISION, down, 1, MPI_COMM_WORLD, reqs(4), ierr)
call update_rows(u, u_new, 2, nloc-1, r) ! DEEP interior: needs no ghosts -- compute while halo flies
call mpi_waitall(4, reqs, stats, ierr) ! now the ghosts have landed...
call update_rows(u, u_new, 1, 1, r) ! ...top edge row, which needed the upper ghost
call update_rows(u, u_new, nloc, nloc, r) ! ...bottom edge row, which needed the lower ghost
The tags follow §34.4's direction convention (up-going messages tag 0, down-going tag 1), so each isend
matches the neighbour's irecv. Between the isends and the mpi_waitall, the process computes its deep
interior; on a fat strip that is most of the work, and it runs while the network moves the edge rows. When
mpi_waitall returns, the ghosts are current and the two edge rows finish the step. The communication has
been hidden behind computation.
⚠️ The rule you must not break: do not read or write the halo buffers
u(:,0)/u(:,nloc+1), or the send buffersu(:,1)/u(:,nloc), between thei-calls and thempi_waitall. The transfer is in flight; touching those rows early is a race. The deep-interior update is safe precisely because it touches none of them.
Phase 4 — When Does the Overlap Actually Pay?
Be honest about the payoff, because it is not free and it is not always worth it. The deep interior has
nloc − 2 rows; the edges are always 2 rows. On the Checkpoint's tiny strips, nloc = 2, so the deep
interior is empty — there is nothing to overlap, and the non-blocking version buys nothing but complexity.
The overlap pays only when nloc is large enough that the deep-interior update takes longer than the halo
transfer, so the exchange finishes "for free" underneath it. This is the surface-to-volume argument of
§34.4 wearing work clothes: computation scales with the strip's area (nloc × nx), communication with its
perimeter (2 × nx), so the fatter the strip, the more thoroughly its interior work hides its boundary
exchange.
Strip rows nloc |
Deep-interior rows to overlap | Overlap worthwhile? |
|---|---|---|
| 2 | 0 | No — both rows are edges; use blocking mpi_sendrecv |
| 10 | 8 | Marginal — some hiding |
| 1000 | 998 | Yes — the edge exchange all but disappears under the interior |
The design lesson is to keep both exchange routines and choose by scale: blocking mpi_sendrecv for its
simplicity when strips are thin or the code is young, non-blocking overlap when the strips are fat and the
run is large enough that hiding the halo matters. Reaching for the complex version too early is a
premature optimization; never reaching for it at scale leaves performance on the table.
Phase 5 — Verify, Then Reason About Scaling
The correctness check is the same discipline as always: the convergence-driven, non-blocking solver must
print the identical field and the identical global-max-change sequence as the simple Checkpoint solver, on
any process count, because none of these changes touches the per-cell arithmetic. Run all three — Checkpoint
(blocking, fixed steps), convergence (blocking, allreduce stop), and overlap (non-blocking) — on the
$5 \times 6$ plate and confirm the step-1–3 max changes are 20.0, 12.0, 6.4 in every case. If they diverge,
a communication bug has crept in, and the deterministic reference has caught it.
With correctness pinned, the redesign is what lets the solver scale honestly: it stops when the science says
to (not when a guess says to), and it hides its communication when the strips are fat enough to do so. Both
are prerequisites for the weak-scaling studies of the Chapter 38
capstone, where the plate grows with the process count and the per-step allreduce and halo exchange must
stay a small fraction of the step. You have built the two mechanisms that keep them small.
Discussion Questions
- Why must the convergence test use
mpi_allreducerather thanmpi_reducefollowed by a broadcast of the result? (Both would give every rank the value — so is it only about convenience, or is there a correctness difference if a rank misses the broadcast?) - The non-blocking version computes the deep interior between the
isends and thewaitall. Sketch what goes wrong if a programmer, trying to "do more work," also updates an edge row in that window. - The overlap buys nothing at
nloc = 2and a great deal atnloc = 1000. Given a fixed plate and a target process count, how would you decide at run time which exchange routine to call? What single quantity would you branch on?
Your Turn: Extensions
- Option A. Add a second reduced quantity to the convergence report: alongside the global max change,
compute the global sum of the field (total heat) each step with a second
mpi_allreduceusingMPI_SUM, and watch it approach the steady-state total. What does a non-monotone total tell you about a bug? - Option B. Implement both
exchange_halos_blockingandexchange_halos_nonblockingbehind oneexchange_halosthat dispatches onnloc(blocking below a threshold, non-blocking above). Justify your threshold in terms of the surface-to-volume table. - Option C. Extend the convergence loop to also stop if
global_maxrises between steps (a sign of CFL instability — Chapter 24), printing a clear diagnostic. Why is a rising global max change a reliable distributed detector of an unstable timestep, and why doesallreducemake it cheap to check?
Key Takeaways
- A distributed stopping decision needs a global reduction every process sees: compute each strip's local
max change, combine with
mpi_allreduce/MPI_MAX, and let every rank exit on the same step.mpi_reducewould strand the non-root ranks. - Non-blocking exchange hides the halo behind computation: post
irecv/isend, update the ghost-independent deep interior,mpi_waitall, then finish the edge rows — but never touch the in-flight buffers before the wait. - Overlap pays by surface-to-volume: it helps only when the strip is fat enough that its interior work outlasts its boundary transfer. Keep the simple blocking version for thin strips.
- Determinism is the check: every redesign must reproduce the reference field and max-change sequence on any process count, because none of it alters the per-cell arithmetic.