Case Study 1: Auditing a Coarray Reduction
"It passed on my laptop, and on the cluster it gives a different answer every time."
Executive Summary
A colleague hands you a short coarray routine that computes the squared 2-norm of a vector distributed
across images — the sum of the squares of all its elements. On their laptop, built with -fcoarray=single,
it prints the right answer every time. On the cluster, on 4 images, it prints a different answer on almost
every run, always too small. They are convinced it is a compiler or network bug. It is not: it is two
classic coarray mistakes hiding behind a test that could never expose them. This study reads their code,
diagnoses both faults precisely in the language of segments and coindexed access, and repairs it two ways
— once by hand with per-image slots and a barrier, and once by deleting the hand-rolled communication in
favour of the co_sum collective. Along the way you will see why the single-image test passed — the most
important lesson, because it is why the bug shipped. Nothing here requires a cluster; the reasoning is the
tool, and one careful read is worth more than a hundred reruns.
Skills applied: the SPMD model and private per-image data (§32.1); coindexed access a[q] (§32.2);
segments and why a remote access must be synchronized (§32.3); mutual exclusion vs the wrong single-slot
pattern (§32.3); the collective co_sum and why it is the right tool (§32.4); single-image testing with
-fcoarray=single and its blind spot (§32.5).
Background
The task is a distributed reduction: a vector's elements are spread across the images (each image holds a contiguous chunk), and we want $\sum_i x_i^2$, summed over the whole vector. To make every number checkable by hand, take the vector to be the integers $1$ through $12$, so the answer is fixed and known:
$$ \sum_{i=1}^{12} i^2 = \frac{12 \cdot 13 \cdot 25}{6} = 650. $$
A correct reduction must return 650 no matter how many images the run uses — 1, 2, 3, 4, 6, or 12 — because
the mathematics does not depend on how we chopped up the work. That image-count independence is the
acceptance test, and it is exactly the test the buggy code fails.
Phase 1 — The Symptom
Here is the routine as delivered. Each image sums the squares over its own chunk, then adds that partial into a single coarray accumulator held on image 1; image 1 reads the total:
program buggy_norm
implicit none
integer, parameter :: L = 12
integer :: me, ni, chunk, lo, hi, i, part
integer :: acc[*] ! a SINGLE accumulator slot, on image 1
me = this_image(); ni = num_images()
chunk = L / ni; lo = (me-1)*chunk + 1; hi = me*chunk
part = 0
do i = lo, hi
part = part + i*i ! this image's partial sum of squares
end do
acc[1] = acc[1] + part ! (BUG) every image reads-modifies-writes acc[1]...
sync all ! ...and the sync is AFTER the damage is done
if (me == 1) print '(a,i0)', 'sum of squares = ', acc[1]
end program buggy_norm
Built single-image, it is flawless:
$ gfortran -fcoarray=single -std=f2018 -Wall buggy_norm.f90 -o bn && ./bn
sum of squares = 650
Run on 4 images, it misbehaves — and inconsistently. A typical session:
$ cafrun -n 4 ./bn
sum of squares = 650
$ cafrun -n 4 ./bn
sum of squares = 573
$ cafrun -n 4 ./bn
sum of squares = 456
The number is different each time and never exceeds the correct 650. A value that changes from run to run is the unmistakable fingerprint of a race condition. Two distinct faults produce it.
Phase 2 — The Diagnosis
Fault 1: acc[1] is never initialized, and the accumulate races. Look at acc[1] = acc[1] + part
executed on four images at once. Each image must read acc[1], add its part, and write acc[1] back.
Nothing orders these read-modify-write sequences against each other. Two images can both read the same old
acc[1], both add their partials to it, and both write back — and the second write overwrites the first, so
one image's partial is silently lost. That is a lost update, and it is why the total is always too
small: some partials vanish. Worse, in the language of §32.3, the images are defining the coarray acc
in segments that are not ordered with respect to one another, which the standard declares simply
undefined — the program has no guaranteed meaning at all. (And acc is never set to 0, so even the
first read is of an undefined value.)
Fault 2: the sync all is in the wrong place to help. The barrier sits after the racy accumulate, so
it does nothing to order the competing writes — they have already collided by the time any image reaches it.
A barrier can only order accesses that straddle it; here both the writes and image 1's read are on the same
side of the wrong side. Placing the sync correctly would fix the read (Fault-2 half), but the
accumulate would still race (Fault 1). Both must be addressed.
Why the single-image test passed — the crucial lesson. With -fcoarray=single there is exactly one
image. There is no second image to race against, so the read-modify-write of acc[1] cannot be interleaved,
and sync all is a no-op that is trivially in the "right" place. The one-image build exercises the code path
but cannot exercise the concurrency, so it hides every synchronization bug the program has. This is the
blind spot of -fcoarray=single: it is perfect for checking logic and worthless for checking races. A
green single-image test is necessary, never sufficient.
Phase 3 — Fix A: Per-Image Slots and a Barrier
The disciplined hand-rolled fix removes the shared write entirely. Give every image its own slot to write — so no two images ever touch the same location — synchronize once, then let image 1 read and sum all the slots:
program norm_fix_a
implicit none
integer, parameter :: L = 12
integer :: me, ni, chunk, lo, hi, i, total, q
integer :: part[*] ! one slot PER IMAGE -- no shared write
me = this_image(); ni = num_images()
chunk = L / ni; lo = (me-1)*chunk + 1; hi = me*chunk
part = 0
do i = lo, hi
part = part + i*i ! each image writes ONLY its own part
end do
sync all ! all partials written before any are read
if (me == 1) then
total = 0
do q = 1, ni
total = total + part[q] ! coindexed READ of each image's own slot
end do
print '(a,i0)', 'sum of squares = ', total
end if
end program norm_fix_a
$ caf -std=f2018 -Wall norm_fix_a.f90 -o na && cafrun -n 4 ./na
sum of squares = 650
Now every image writes only part (its private copy), so there is no contention; the single sync all
cleanly separates all the writes (before) from image 1's reads (after); and image 1 sums the four slots
$14 + 77 + 194 + 365 = 650$. Run it on 1, 2, 3, or 6 images and it prints 650 every time — the acceptance
test passes. The pattern — write your own, synchronize, one image gathers — is the same one the chapter's
gather examples use, and it is always correct because no location is ever written by more than one image.
Phase 4 — Fix B: Delete the Communication, Call co_sum
Fix A is correct but it is still code you can get wrong — the slot indexing, the barrier placement, the gather loop. The reduction across images is such a common operation that Fortran 2018 gives you an intrinsic for it, and the right fix is to throw the hand-rolled version away:
program norm_fix_b
implicit none
integer, parameter :: L = 12
integer :: me, ni, chunk, lo, hi, i, ssq
me = this_image(); ni = num_images()
chunk = L / ni; lo = (me-1)*chunk + 1; hi = me*chunk
ssq = 0
do i = lo, hi
ssq = ssq + i*i ! local partial
end do
call co_sum(ssq) ! combine across ALL images, correctly, in one line
if (me == 1) print '(a,i0)', 'sum of squares = ', ssq
end program norm_fix_b
$ caf -std=f2018 -Wall norm_fix_b.f90 -o nb && cafrun -n 4 ./nb
sum of squares = 650
co_sum(ssq) combines every image's ssq and leaves the total on every image — no slots, no barrier to
place, no gather loop, no race possible, because the collective carries its own synchronization and every
image simply calls it. It is shorter, it is clearer, and it is correct by construction. This is the whole
argument for the collectives of §32.4: the patterns worth having a library for are the ones easiest to get
subtly wrong by hand.
Phase 5 — Verdict and Sanity Check
- The delivered code had two faults: an unsynchronized, single-slot accumulate (a lost-update race and, formally, undefined behaviour) and a barrier placed where it could not help. The symptom — a run-to-run varying total, always below the true value — is the textbook signature.
- The single-image test could never have caught it.
-fcoarray=singleremoves the concurrency, so it validates logic and hides races. Treat a passing one-image build as a check on what the program computes, never on whether it synchronizes. - Prefer the collective. Fix A (per-image slots + barrier) is a fine hand-rolled reduction and worth
understanding, but Fix B (
co_sum) is the code to ship: it cannot race and it cannot be indexed wrong. - Sanity check, always the same number. The acceptance test is image-count independence: $\sum_{1}^{12} i^2 = 650$ on 1, 2, 3, 4, 6, or 12 images. Any correct reduction returns it; the buggy one returned it only on 1 image, which is exactly how it fooled everyone.
Discussion Questions
- The buggy program prints
650on one image and varying wrong values on four. Which is more dangerous — a program that is always wrong, or one that is wrong only sometimes, only on some machines? Why do concurrency bugs like this survive so long in real codes? - Fix A uses
part[*]with one slot per image and reads them on image 1. Could you instead have every image write into a rank-2 coarray indexed by image number and reduce withmaxval/sum? What does the collectiveco_sumsave you over any such hand-rolled scheme? - The
sync allin the buggy code was not missing — it was misplaced. Explain, in terms of segments, why the position of a synchronization is as important as its presence, and give the general rule for where a barrier must sit relative to the writes and reads it protects.
Your Turn: Extensions
- Option A. Take the buggy program and fix it in place using a
criticalblock around theacc[1] = acc[1] + partaccumulate (plus a correctly placedsync alland an initialization ofacc[1]on image 1). Confirm it now prints650on any image count. Then argue why this "fixed hand-roll" is still inferior toco_sum. - Option B. Extend Fix B to compute the actual 2-norm $\sqrt{\sum x_i^2}$ of the vector $x_i = i,\ i = 1..12$, printing it as a real. (Answer: $\sqrt{650} \approx 25.4951$.) Which line changes, and does the reduction itself change at all?
- Option C. Instrument Fix A to also print, from image 1, each image's partial
part[q]before summing, so you can see the decomposition ($14, 77, 194, 365$ on 4 images). Then run on 2 and 3 images and confirm the partials differ but the total does not — the acceptance test, made visible.
Key Takeaways
- A run-to-run varying result is a race. Almost always it is a coarray written by several images without synchronization, or read across an unordered segment boundary — undefined behaviour, not a compiler bug.
-fcoarray=singlevalidates logic, not synchronization. One image cannot race; a passing single-image test is necessary but never sufficient. Reason about segments, or run truly parallel, to catch races.- Never let two images write the same location unguarded. Give each image its own slot (then gather), use
critical/atomicfor a genuine shared accumulator, or — best — call a collective. co_sumis the right reduction. It is shorter than any hand-rolled version, carries its own synchronization, and is correct by construction. The acceptance test for any reduction is image-count independence.