Case Study 2: Building a Distributed Heat Rod
"A parallel program is correct when it gives the serial answer — and useful when it decides, together, that it is done."
Executive Summary
The Project Checkpoint distributed the 2D plate; here you build, from scratch, the one-dimensional
sibling — a heated rod split across images — and take it two steps further than the checkpoint did. First you
make the images agree on a global stopping criterion: instead of a fixed number of steps, they iterate
until the largest temperature change anywhere on the rod drops below a tolerance, which requires combining
a per-image quantity into a global one with the co_max collective. Second you analyze the design: you
prove the parallel answer equals the serial one, you work out the communication-to-computation ratio and
what it says about scaling, and you identify the one optimization — overlapping communication with
computation — that the next chapter will make real. The result is a small but complete piece of
distributed-memory science: decomposition, halo exchange, a collective reduction for convergence, a
validation, and a scaling argument, all in one program you can hand-trace.
Skills applied: SPMD decomposition and per-image ownership (§32.1); allocatable coarrays and coindexed
halo reads (§32.2); segment ordering and the two-barrier step (§32.3); the co_max collective for a global
reduction (§32.4); building and validating with -fcoarray=single then caf/cafrun (§32.5); the
communication-cost reasoning that connects to strong/weak scaling (Chapter 31).
Background
The 1D heat equation on a rod is the plate's stencil with the two $y$-neighbours dropped (Chapter 24): $u_i^{n+1} = u_i^n + r\,(u_{i-1}^n - 2u_i^n + u_{i+1}^n)$, stable for $r = \alpha\Delta t/\Delta x^2 \le \tfrac12$. Take a rod of six nodes, the two ends held Dirichlet — the left cold at $0$, the right hot at $100$ — the four interior nodes starting cold, and $r = 0.25$. Marched serially, it warms from the hot end leftward, and after three steps the interior reads $0,\ 0,\ 1.5625,\ 12.5,\ 45.3125$ (with the ends $0$ and $100$), eventually settling to the linear steady state $0, 20, 40, 60, 80, 100$. Those exact dyadic fractions are our validation target: the distributed version must reproduce them to the bit.
Phase 1 — Design: One Rod, Many Images
The decomposition mirrors the plate's, one dimension simpler. The four interior nodes are split into equal contiguous segments, one per image. Each image's local array carries its owned nodes plus one halo node on each side, holding a copy of its neighbour's edge node:
global rod: [1] 2 3 4 5 [6] ends [1]=0 (cold), [6]=100 (hot)
| \___/ \___/ |
2 images: img1 owns 2,3 img2 owns 4,5
img1 local: ( 1 ) 2 3 ( 4 ) local 1 = left halo (=node 1, cold end)
\__ local 4 = right halo <- img2's node 4
img2 local: ( 1 ) 4 5 ( 6 ) local 4 = right halo (=node 6, hot end)
\__ local 1 = left halo <- img1's node 3
Owned nodes are local indices 2 : nloc+1; halos are local 1 and nloc+2. The physical ends are simply
the halos of the end images, set once and never exchanged (image 1's left halo stays $0$; the last image's
right halo stays $100$). The interior halos — image 1's right, image 2's left — are refreshed every step by
coindexed reads. The invariant that makes it all work: the coarray's local shape is identical on every
image (nloc+2 nodes), as coarrays require, so u(2)[me+1] means the same slot on every image.
Phase 2 — Build: Halo, Stencil, and a Collective Convergence Test
The new idea beyond the checkpoint is the global stopping test. Each image can compute the largest change
over its own nodes this step, but "are we converged?" is a question about the whole rod — so we combine
the per-image maxima into one global maximum with co_max, and every image learns the same residual. Here is
the complete program:
program rod_caf
use, intrinsic :: iso_fortran_env, only: dp => real64
implicit none
integer, parameter :: mglob = 6, nint = mglob - 2 ! 6 nodes, 4 interior
real(dp), parameter :: r = 0.25_dp ! <= 1/2 in 1D: stable (Ch.24)
real(dp), allocatable :: u(:)[:] ! local segment + 2 halos (COARRAY)
real(dp), allocatable :: u_new(:)
integer :: me, ni, nloc, it, q, lo
real(dp) :: change
real(dp), allocatable :: rod(:) ! image 1 assembles the full rod
me = this_image(); ni = num_images()
if (mod(nint, ni) /= 0) then
if (me == 1) print '(a)', 'error: interior nodes must divide evenly across images'
error stop 1
end if
nloc = nint / ni
allocate(u(nloc+2)[*]); allocate(u_new(nloc+2))
u = 0.0_dp
if (me == ni) u(nloc+2) = 100.0_dp ! hot right end (last image's right halo)
do it = 1, 3 ! real code: do while (change > tol)
sync all
if (me > 1) u(1) = u(nloc+1)[me-1] ! left halo <- left neighbour's last node
if (me < ni) u(nloc+2) = u(2)[me+1] ! right halo <- right neighbour's first node
sync all
u_new(2:nloc+1) = u(2:nloc+1) + r*( u(1:nloc) - 2.0_dp*u(2:nloc+1) + u(3:nloc+2) )
change = maxval(abs(u_new(2:nloc+1) - u(2:nloc+1))) ! this image's largest change
call co_max(change) ! GLOBAL residual: max over ALL images
u(2:nloc+1) = u_new(2:nloc+1) ! commit
if (me == 1) print '(a,i0,a,f10.4)', 'step ', it, ' global residual = ', change
end do
! gather the whole rod onto image 1 and print it
sync all
if (me == 1) then
allocate(rod(mglob)); rod = 0.0_dp; rod(mglob) = 100.0_dp
do q = 1, ni
lo = (q-1)*nloc + 2
rod(lo:lo+nloc-1) = u(2:nloc+1)[q] ! coindexed read of each segment
end do
print '(a, 6f9.4)', 'rod after 3 steps: ', rod
end if
end program rod_caf
$ gfortran -fcoarray=single -std=f2018 -Wall rod_caf.f90 -o rod && ./rod
step 1 global residual = 25.0000
step 2 global residual = 12.5000
step 3 global residual = 7.8125
rod after 3 steps: 0.0000 0.0000 1.5625 12.5000 45.3125 100.0000
The identical output appears under caf -std=f2018 -Wall rod_caf.f90 -o rod && cafrun -n 2 ./rod — same
residuals, same rod — which is the point of Phase 3.
Phase 3 — Validate: Parallel Equals Serial
Correctness first, always. Trace the residuals by hand on two images. Image 1 owns nodes 2–3; image 2 owns
4–5. At step 1 only node 5 sees the hot end, changing $0 \to 0.25 \times 100 = 25$; every other interior
node is still surrounded by zeros, so image 1's local maximum change is $0$ and image 2's is $25$. co_max
combines them: global residual $= \max(0, 25) = 25$. At step 2, node 4 changes to $6.25$ and node 5 to
$37.5$ (a change of $12.5$), while image 1's nodes barely move; global residual $= 12.5$. At step 3, node
3 finally warms to $1.5625$ because image 1's right halo now holds node 4's value $6.25$ from image 2 — the
halo doing its job across the strip boundary — and node 5's change is $7.8125$, the new global maximum.
The assembled rod, $0,\ 0,\ 1.5625,\ 12.5,\ 45.3125,\ 100$, is exactly the serial result. Two images, halo exchange, and a collective convergence test reproduce the single-core answer to the bit — and would on 1, 2, or 4 images, since $4$ interior nodes divide all three. That image-count independence is the proof of correctness, the same acceptance test as Case Study 1: the physics cannot depend on how we cut the rod.
Phase 4 — Analyze: Communication Cost and the One Optimization
Now the engineering. Two questions decide whether a decomposition is worth it: how much does each image communicate relative to how much it computes, and can the communication be hidden.
Communication-to-computation ratio. Per step, an interior image of the rod computes $\sim M/P$ node updates (its segment) and communicates exactly $2$ halo nodes — a ratio of $2P/M$, which shrinks as the rod $M$ grows for fixed $P$. That is excellent: a long rod is almost all computation, a sliver of communication. The plate is the more instructive case. A strip image computes $\sim N^2/P$ cell updates and communicates $\sim 2N$ halo cells, a ratio $\sim 2P/N$ — still shrinking as the grid $N$ grows. This is the general and important truth of halo methods: the work is a volume ($N^2$) and the communication is a surface ($N$), so refining the grid improves the ratio. It is precisely why structured-grid codes weak-scale so well (Chapter 31 §31.4): grow the problem with the cores and each core stays compute-bound, its halo a rounding error.
| Decomposition | Compute / image / step | Communicate / image / step | Ratio |
|---|---|---|---|
| 1D rod, $P$ images | $\sim M/P$ | $2$ nodes | $\sim 2P/M$ |
| 2D plate, strips | $\sim N^2/P$ | $\sim 2N$ cells | $\sim 2P/N$ |
| 3D box, slabs | $\sim N^3/P$ | $\sim 2N^2$ cells | $\sim 2P/N$ |
The one optimization: overlap. Look at the two sync all barriers per step. Between them the images do
nothing but communicate — the compute waits for the halo. But most of each segment's update (every node
except the two touching a halo) needs only local data and could run while the halo is in flight. The
optimization is to split the update: fire off the halo exchange, immediately compute the segment's interior
interior (the nodes not adjacent to a halo) using data already in hand, and only then, once the halo has
landed, compute the two edge nodes. Done well, the communication cost disappears behind computation that had
to happen anyway. Coarrays with sync all express the simple version; the overlapped version wants
one-sided or non-blocking communication, which is exactly the mpi_isend/mpi_irecv machinery of
Chapter 34. We name the optimization here and cash it there.
Phase 5 — The Result
Read it as an engineer would write it up:
Distributed heat rod (v1). Model: 1D FTCS, $r = 0.25$, 6 nodes, ends $0$/$100$ Dirichlet. Decomposition: interior nodes split into equal contiguous segments, one per image; local shape identical across images; two halo nodes per image, exchanged each step by coindexed reads bracketed by
sync all. Convergence: per-imagemaxvalof the change, combined byco_maxinto a global residual; iterate until below tolerance. Validation: reproduces the serial rod ($0,0,1.5625,12.5,45.3125,100$ after 3 steps) exactly, on any image count dividing the 4 interior nodes; residuals $25 \to 12.5 \to 7.8125$. Cost: communication/computation $\sim 2P/M$, shrinking with problem size — weak-scales well. Next optimization: overlap the halo exchange with the interior update (Chapter 34, non-blocking).
That is a complete distributed solver in one page: decomposition, communication, a collective decision, a proof of correctness, and a cost model. The 2D plate of the Project Checkpoint is the same recipe with one more dimension of halo, and the capstone runs it at scale.
Discussion Questions
- The convergence test uses
co_maxso every image learns the same global residual and they all stop together. What would go wrong if each image instead decided to stop based only on its own segment's maximum change? - Phase 4 argues the communication/computation ratio $\sim 2P/N$ shrinks as the grid grows. Explain why this makes weak scaling (grow the problem with the cores) far easier than strong scaling (fixed problem, more cores) for this solver, connecting to Amdahl and Gustafson from Chapter 31.
- The overlap optimization computes the "interior interior" while the halo is in transit. What is the risk if you get the split wrong and accidentally use a halo value before it has arrived — and which chapter's tools (and which property of the update) let you do the overlap safely?
Your Turn: Extensions
- Option A. Replace the fixed
do it = 1, 3withdo while (change > tol)(tolerance $10^{-3}$) and a guard against runaway iteration, so the rod runs to steady state. Predict (no code) the steady-state rod and confirm it is the straight line $0, 20, 40, 60, 80, 100$. Roughly how does the residual behave as it converges? - Option B. Extend the rod to $M = 10$ nodes (8 interior) and run on 1, 2, and 4 images. Confirm the assembled rod is identical across all three, and that the first-step global residual is the same regardless of image count. Which line of code enforces the "divides evenly" precondition, and how would you relax it?
- Option C. Add a second collective: after convergence, use
co_sumto compute the rod's total thermal energy $\sum_i u_i$ (a conserved-ish diagnostic) across all images, and print it from image 1. Verify by hand against the assembled rod.
Key Takeaways
- A distributed solver is decomposition + halo + a collective decision. Split the domain, exchange edges
each step with coindexed reads and two barriers, and combine per-image quantities (a residual, an energy)
with a collective like
co_max/co_sum. - Correctness is proven by reproducing the serial answer — bit-for-bit, and independent of the image count. If the parallel rod does not equal the serial rod, the halo or the synchronization is wrong.
- Halo methods weak-scale because work is volume and communication is surface. The ratio $\sim 2P/N$ shrinks as the grid grows, so bigger problems are more efficient — the deep reason structured-grid codes dominate HPC.
- The next win is overlap. Most of each update needs only local data and can run while the halo is in flight; hiding communication behind computation is the optimization Chapter 34's non-blocking messages make real.