Exercises: Why Fortran Is Fast
This is the chapter where performance stops being a slogan and becomes something you can predict, explain, and measure. The exercises reflect that: some ask you to reason about memory and the compiler, some ask you to port or fix code, and a few ask you to estimate — the back-of-the-envelope arithmetic that separates people who guess about performance from people who know. Do the estimates even when they feel imprecise; an order-of-magnitude answer you can defend beats a precise number you cannot.
Difficulty: ⭐ warm-up · ⭐⭐ standard · ⭐⭐⭐ deeper. Solutions: worked solutions to the daggered (†)
and odd-numbered problems are in appendices/answers-to-selected.md; the compilable ones are in
code/exercise-solutions.f90. Never run a "predict the output" program before you have predicted it —
that is the whole exercise. And remember the chapter's honesty rule: any timing you produce is yours to
measure; the outputs you can compute by hand.
Part A — Warm-ups ⭐
27.1 † In one line each, say what -O0, -O2, -O3, and -Ofast do, and name the single most
important thing that changes between -O2 and -O3, and between -O3 and -Ofast.
27.2 For a Fortran 2D array a(:,:), which loop index belongs on the inner loop, and in one sentence,
why?
27.3 † State the no-aliasing advantage in one sentence, from the compiler's point of view.
27.4 What does -Ofast add on top of -O3, and give one reason to be cautious about it in numerical
code. (Link your answer to Chapter 20.)
27.5 † Name the gfortran flag that lists the loops the compiler successfully vectorized, and the one that lists loops it failed to vectorize with a reason.
27.6 True or false, with a one-sentence justification: "At -O3, gfortran unrolls all your loops
automatically."
Part B — Memory and Loop Order ⭐⭐
27.7 † A cache line is 64 bytes and a real(dp) is 8 bytes. For an array a(512, 512) of real(dp):
(a) how many bytes is one column, and how many cache lines does it span? (b) When you loop across a row
(inner loop over the last index), roughly how many cache lines do you touch to read one full row of 512
elements, and why is that the slow pattern?
27.8 Write the cache-friendly (with-the-grain) triple-nested loop that initializes a 3D array
u(nx, ny, nz) of real(dp) to zero using explicit indices. Which index is innermost, and which outermost?
27.9 † Explain what "memory-bound" means, and why, for a memory-bound loop, reducing the number of arithmetic operations may not make it any faster. What would make it faster?
27.10 Predict which of these two nests is faster on a large a(n,n), and state the direction of the
factor (you need not give an exact number):
! Nest A
do i = 1, n
do j = 1, n
a(i,j) = a(i,j) * 2.0_dp
end do
end do
! Nest B
do j = 1, n
do i = 1, n
a(i,j) = a(i,j) * 2.0_dp
end do
end do
Part C — The No-Aliasing Advantage and pure/elemental ⭐⭐
27.11 † Why can a Fortran compiler vectorize c = a + b (on distinct assumed-shape arrays) with no
runtime check, while a C compiler compiling the same loop over pointers may have to insert one — or
serialize entirely? What C keyword narrows the gap, and why is it weaker than Fortran's guarantee?
27.12 Which of the following procedures can legally be declared pure? For each, say yes or no and why.
(a) a function returning x*x + 1; (b) a subroutine that writes a value into an intent(out) argument;
(c) a function that prints a debug line and returns a sum; (d) a function that reads and updates a module
counter each call.
27.13 † Take this scalar function and make it elemental, then show the one-line call that applies it to
a whole array t(:):
function to_kelvin(celsius) result(k)
real(dp), intent(in) :: celsius
real(dp) :: k
k = celsius + 273.15_dp
end function to_kelvin
27.14 In your own words, list two distinct optimizations a compiler may perform on a call to a pure
function that it could not perform on an otherwise-identical impure function, and give a one-line reason
for each.
Part D — Find the Bug ⭐⭐
27.15 † This program compiles and may even print a "reasonable" answer at -O0, yet it is broken. Find
the defect, name the rule it violates, and explain why the printed result may change when you recompile
at -O3.
pure subroutine shift_avg(a, b) ! b(i) := average of a's two neighbors
real(dp), intent(in) :: a(:)
real(dp), intent(out) :: b(:)
integer :: i
b = a
do i = 2, size(a) - 1
b(i) = 0.5_dp * (a(i-1) + a(i+1))
end do
end subroutine shift_avg
! ...called as:
call shift_avg(v, v)
27.16 This loop will not vectorize no matter how you compile it. Identify the reason (name the kind of dependence), and say whether it is a fixable coding issue or an inherent property of the algorithm:
do i = 2, n
x(i) = x(i-1) + h * f(i) ! a running (prefix) sum
end do
27.17 † A colleague reports that a loop they expected to vectorize did not, and shows you the report
line missed: not vectorized: possible dependence between data-refs. Give two distinct plausible causes and,
for each, one thing they could check or change.
Part E — Port It ⭐⭐
27.18 Here is a pure-Python daxpy loop. Port it to a pure Fortran subroutine with intent on every
argument, and add a one-sentence note on why the Fortran version vectorizes where the Python loop cannot.
def axpy(a, x, y): # y <- a*x + y, elementwise
for i in range(len(y)):
y[i] = a * x[i] + y[i]
return y
27.19 † Here is a NumPy five-point smoother of a 2D array. Port the interior update to Fortran using array sections (a whole-array statement), and separately as an explicit loop nest in the cache-friendly order. Then estimate the class of speedup you would expect over a pure-Python double loop doing the same thing (order of magnitude, framed as "typically…").
new[1:-1, 1:-1] = 0.25 * (u[:-2, 1:-1] + u[2:, 1:-1] + u[1:-1, :-2] + u[1:-1, 2:])
Part F — Back of the Envelope ⭐⭐⭐
Order-of-magnitude estimates. Show your reasoning; the exact number is not the point, the reasoning is.
27.20 A real(dp) array a(4000, 4000). (a) What is its total size in bytes, and in mebibytes? (b) A
typical L1 data cache is 32 KB. Does one row (4000 elements) fit in L1? Does one column? (c) What does
your answer imply about which loop order will keep the most reuse in cache?
27.21 † Suppose vectorization performs 4 real(dp) operations per instruction, but your loop is
memory-bound — limited by how fast the array streams from memory, not by arithmetic. Will turning on
vectorization give you a 4× speedup? Explain what actually bounds the loop, and what would have to be true
for the full 4× to appear.
27.22 Estimate the arithmetic intensity (floating-point operations performed per byte of data moved)
of one five-point-stencil update u(i,j) = u(i,j) + r*(u(i-1,j)+u(i+1,j)+u(i,j-1)+u(i,j+1)-4*u(i,j)). Count
the flops and the real(dp) values read/written per interior point (assume no caching for the estimate),
then argue whether the stencil is likely memory-bound or compute-bound.
27.23 † Your solver runs in 5 hours. You discover its hot loop is in the wrong (against-the-grain) order and fixing it — a two-line change taking 30 minutes — gives a 10× speedup on that loop, which is 80% of the runtime. Estimate the new total runtime (use Amdahl-style reasoning: only 80% speeds up 10×). After how many runs has the 30-minute fix paid for itself?
Part G — Design It and Read the Report ⭐⭐ / ⭐⭐⭐
27.24 Extend the Project Checkpoint: add a third variant of the stencil sweep written as a single
whole-array statement using array sections (unew(2:nx-1,2:ny-1) = u(2:nx-1,2:ny-1) + r*( … )). Predict how
its speed should compare to the explicit i-inner loop, and explain your prediction in terms of what the
compiler sees.
27.25 † Design a minimal experiment, using system_clock, that demonstrates the -O0-versus--O3
difference on a single vectorizable loop. Say (a) what your program computes, (b) what it prints, (c) why
you must print a checksum (or otherwise consume the result), and (d) how you would compile it twice to make
the comparison.
27.26 On Compiler Explorer (godbolt.org) you compile a c = a + b kernel at -O3 -march=native and
see instructions named vaddpd. At -O2 you see addsd. In one or two sentences, explain what each name
tells you about whether the loop vectorized.
Part H — Interleaved ⭐⭐
Reaching back to earlier chapters.
27.27 † (Chapter 5 + 27.) The Chapter 5 whole-array Laplacian is written with array sections:
lap(2:n-1,2:n-1) = u(1:n-2,2:n-1) + u(3:n,2:n-1) + u(2:n-1,1:n-2) + u(2:n-1,3:n) - 4*u(2:n-1,2:n-1). In
what memory order does evaluating this statement traverse u, and is that traversal cache-friendly? How
does writing it as sections (versus a hand loop) affect what the compiler may assume about aliasing?
27.28 (Chapter 20 + 27.) You rebuild your validated solver with -Ofast and the final printed
temperature changes in its sixth decimal place. Is this a bug? Explain what -Ofast did, connect it to
Chapter 20's point that
floating-point addition is not associative, and say how you would decide whether the change is acceptable.
Solutions to the daggered and odd-numbered problems are in appendices/answers-to-selected.md; the
compilable ones (27.8, 27.13, 27.18, 27.19, 27.24) are in code/exercise-solutions.f90. Every "expected
output" in those solutions was computed by hand — compile them and check, and if your machine disagrees,
you have found something worth reporting.